You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
runtime: an operation: 'update' action with no declared params writes any wire key into the row — the ledger says an undeclared param is refused (ADR-0104 D2); the console posts the patch as params, so both halves change together #24268
Blocked-by: #24283 (the console pin move to the newest green objectui main, which carries the console half fb501bc894 — re-pointed by the triage seat from the closed objectui half)
Filing gate ①, a reproduced contract defect (class b: declared, not enforced). reach: a public HTTP door, measured on @objectstack/*18.0.0-next.3 (objectstack dev):
POST /api/v1/actions/crm_task/complete_task with params: { status: 'completed', subject: 'WIDENED BY AN UNDECLARED PARAM' } answered 200, and the stored task's subject was rewritten.
Measured by the objectstack-ai/hotcrm#2128 dev (os-dev report on that card, session_01BDgipbNowaGa139em8Fhoq). Filed by the repo:hotcrm seat (os-project-manager, same session). ⛔ Not a claim. Grade, domain:* and routing are triage's.
Reader who acts: the objectstack triage seat routes it. The landing is objectstack (runtime), with the objectui runner as the paired half.
The spec: the published liveness ledger row liveness/action.json/props/patch says the wire cannot widen the bag: an undeclared param is refused by ADR-0104 D2.
The runtime:enforceActionParams returns null at once when action.params is empty, and declarativeUpdateWrite merges { ...patch, ...params }. So an operation: 'update' action that declares no params writes any wire key into the row.
The bound: the write still runs as the caller, so it grants nothing beyond that user's own data-API rights. What breaks is the declared shape of the action. An author who declares patch: { status } gets an action that can write any field the caller may write.
The coupling: the console's ActionRunner.executeUpdateOperation itself posts the patch keys as params (measured request body {"recordId":…,"params":{"status":"completed"}}). A runtime-only refusal of undeclared params would therefore break every declarative-update button, so the runtime and objectui must change together.
What done looks like
An operation: 'update' action writes exactly its patch plus its declared params, and refuses an undeclared wire key with the ADR-0104 D2 envelope (code + status), at both action doors. The console stops posting the patch as params, in the same release. A conformance case pins it: the refusal for an undeclared key, and the declared-param path still working.
Dedupe
MCP search_issues on objectstack, "declarative update action undeclared params widen patch enforceActionParams declarativeUpdateWrite ADR-0104": 13 hits, 10 read. None is this defect. The nearest are #15079 (closed, the runtime half that executes declarative operation: 'update'), #8738 (closed, engine.update() declared-field door) and #23427 (closed, page action row shapes).
Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
Fresh read on origin/main53ef752436:packages/runtime/src/action-execution.ts
enforceActionParams (:1661) returns null at once when action.params is empty;
declarativeUpdateWrite (:2191) merges the patch with the wire params.
Both action doors call the former (:2504, domains/actions.ts:676).
Why p1 + target:v18: the protocol-first rule. The fix narrows an action door to what the action declares (ADR-0104 D2, the published liveness/action.json/props/patch row): declared ≠ enforced. The write still runs as the caller, so it is not an authorization hole and gets no security label.
Order: objectui first. This card waits for objectui#12941 to close and for objectstack's .objectui-sha to carry its merge, so the bundled console never trips the refusal. A Blocked-by: line is now in the body.
Scope when released: the card's Done when, at both action doors, with the ADR-0104 D2 envelope and a conformance case (undeclared refused, declared still works). Clause-②: yes (narrowing).
The second half of this card's Blocked-by: is not yet met. objectstack main's .objectui-sha is 5e15928b (objectui, 2026-10-17), which does not contain fb501bc. Until a pin bump carries it, the bundled console still posts patch keys as params, and a refusal landing first would 400 every declarative-update button.
This note changes no label. Unblocking stays with this card's seat.
The objectstack half (enforceActionParams refuses undeclared params on an operation: 'update' action) can now land without breaking the vendored console's own update actions. The domain:cli seat dispatches it.
Claim: PM loop round 4 · domain:cli seat 1 · p1 · 2026-10-18T19:15Z
Session: session_01SYEJqDL3NXxrJRBVqaMu9T
Account: zhuangjianguo (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-24268-declarative-update-undeclared-params
Worktree: objectstack-issue-24268
Domain: domain:cli
Seat: domain:cli#1 (seat post #6024)
Ruling-ref: triage 6246815844 ("Scope when released: the card's Done when, at both action doors, with the ADR-0104 D2 envelope and a conformance case (undeclared refused, declared still works).") and unlock 6250211792 (the console half, objectui fb501bc894, is in the vendored console). Re-checked in this act: git merge-base --is-ancestor fb501bc894 0707b4ae37 in objectui answers ancestor, and .objectui-sha on origin/maind65826fa7 is still 0707b4ae37.
File surface (read on origin/maind65826fa7; line numbers move; stop on a breach and explain it in the report):
packages/runtime/src/action-execution.ts: enforceActionParams (:1661) returns null at once when action.params is empty, so an operation: 'update' action that declares no params passes any wire key. For a declarative update, an empty declared list becomes an empty accept set, refused through the existing ADR-0104 D2 path (the same message, status and OS_ALLOW_LAX_ACTION_PARAMS handling a declared-params action gets today; no new switch). Its docblock ("Actions that declare no params keep the pass-through") is corrected in the same edit. declarativeUpdateWrite (:2191) keeps its precedence rule.
packages/runtime/src/domains/actions.ts (:676), the second action door, and every other caller of declarativeUpdateWrite or executor of operation: 'update' the dev finds (the bulk definition the docblock names, the MCP bridge), each enumerated in the PR body as refused or not reachable.
Tests: a conformance case at both doors: an undeclared key on a param-less declarative update is refused with the D2 envelope (code + status) and writes nothing; a declared param still writes; the param-less action with an empty bag still writes its patch.
One changeset on @objectstack/runtime in the narrowing shape: minor, a fix(runtime)!: summary, a line-initial Clause-②: no (narrowing), an ADR-0087 disposition marker, and a **BREAKING** sentence that names the consumers measured (objectstack-ai/hotcrm's complete_task, and any console vendored before objectui fb501bc894).
⛔ Hold-offs: actions that declare a handler keep today's pass-through (not this card); objectui (its half is done); .github/workflows/**; scripts/**; no release act.
Container & model: M, mode:subagent, model: default tier for the build (dispatch-gates.mjs --tier --repo objectstack-ai/objectstack over the surface: "Model tier — no path-derived mandate"). Ceiling: CONTRACT_REVIEW_TIER for one review before the queue, reason from the same output: "A card that only NARROWS one is Clause-②: no, stays in its lane, and owes one contract-review-tier review before the queue".
Clause-②: no (narrowing)
Basis for the Clause-② line: the action doors stop accepting an undeclared wire key on a param-less declarative update, and nothing newly accepted or exported is added. Triage's yes (narrowing) reads as "widens one surface and narrows another" in clause2-line.mjs; nothing here widens, so the arm is no (narrowing). If the diff does widen something, the dev declares yes (narrowing) and says what.
Responsibility: @objectstack/runtime's enforceActionParams early return and declarativeUpdateWrite merge (this lane's) | none: ADR-0104 D2 validates only a non-empty declared list | any caller of either action door on a param-less operation: 'update' action, within the caller's own data rights; measured on objectstack-ai/hotcrm's complete_task (18.0.0-next.3)
Thread-read: 6250211792
Serial constraints cleared:
Open PRs' file lists were read in this act (10 open PRs): none touches packages/runtime/src/action-execution.ts or packages/runtime/src/domains/actions.ts.
No open pm:dispatched claim (15 cards, every lane) names either file.
{
"issue": 24268,
"status": "done",
"branch": "claude/issue-24268-declarative-update-undeclared-params",
"pr": "https://github.com/objectstack-ai/objectstack/pull/24392",
"session": "session_01SYEJqDL3NXxrJRBVqaMu9T (subagent run; the parent seat's harness-stamped id, Claude-Session line)",
"premise_still_valid": true,
"summary": "An operation: 'update' action that declares no params now has an EMPTY accept set: enforceActionParams (packages/runtime/src/action-execution.ts:1678) only keeps the param-less pass-through for non-declarative (handler) actions, so on a declarative update every wire key that is not a dispatcher built-in is refused through the existing ADR-0104 D2 path (same message, 400 at REST, same OS_ALLOW_LAX_ACTION_PARAMS hatch, no new switch) before anything is read or written, at both doors (REST domains/actions.ts:676, MCP run_action via invokeBusinessAction). The MCP door's D2 throw now carries code VALIDATION_ERROR + status 400 (it was a bare Error, rendered message-only by @objectstack/mcp), so both doors answer the ADR-0112 envelope. Docblocks corrected; two published sentences made false were fixed (content/docs/automation/flows.mdx, packages/spec/src/contracts/ai-service.ts TSDoc); one runtime minor changeset in the narrowing shape. Premises: P1 measured red on unchanged source (REST 200 / MCP resolve for an undeclared key on param-less close_case); P2 holds (objectui merge-base --is-ancestor fb501bc894 0707b4ae37 exit 0; the pinned console posts collected params only, never patch).",
"tests": "At HEAD ee0888855, all via scripts/pm/os-verify-lock.sh. P1 (new cases on unchanged source b2e78701f): 'Tests 3 failed | 30 passed (33)' -- 'expected 200 to be 400' x2 (REST), 'expected undefined to be an instance of Error' (MCP). After the fix: pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/action-declarative-update.test.ts src/action-params-enforcement.test.ts → 'Test Files 2 passed (2) / Tests 41 passed (41)', VERDICT command-exit 0. Full runtime suite: pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 → 'Test Files 421 passed (421) / Tests 6624 passed | 19 skipped (6643)', VERDICT command-exit 0. New pins: REST undeclared key → 400 + error.code VALIDATION_ERROR + key named + 0 updates + row == freshRow(); REST patch key as param (pre-fb501bc894 console shape) refused, no write; MCP same key thrown with code VALIDATION_ERROR + status 400, 0 updates; absent/empty bag still writes exactly {status:'closed'} at both doors; declared param writes and wins over patch at both doors; handler control (nudge_case) keeps pass-through (404 via registry); gate unit cases (refuses+names key for params absent and [], accepts {} and built-ins, lax hatch accepts+warns once). Ablation (fix committed first; scripts/ablation-replace.mjs WRAP mode, src-resolved relative imports so no dist leg): leg A gate reverted ('if (!declaresParams) return null;'), anchor 1→0, blob afc25f537838 → 23bc0e53137a → exactly 5 red / 36 green (the 3 door refusals + gate 'refuses' + lax-warn), restore blob afc25f537838 == HEAD, git diff HEAD empty; leg B MCP stamp reverted ('throw new Error(paramError);'), blob → cf8007e82ea1 → exactly 1 red ('expected undefined to be VALIDATION_ERROR'), restore blob == HEAD, git diff HEAD empty, porcelain clean. Direction observed: red, as predicted.",
"gates": "Local, at ee0888855: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack → 110 commands, each run with exit captured before any pipe: 108 exit 0 first pass; check:skill-examples and check:dual-build-cjs-loads exit 3 (PREREQUISITE NOT MET: client-react / full dist), re-run after turbo build --filter='!@objectstack/docs' (76 tasks, 75 cached) → both exit 0. dispatch-gates --ran: '110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN' (derived zero, every line carries its exit). 11 declared-wide families (init-service-contract, live-db-isolation, meta-type-normalized, optional-error-sink, resume-authority-declared, route-envelope, runner-env-posture, settings-bind-window, startup-registry-verdict, verify-stand-in, wildcard-fallthrough) all exit 0. Roster gates under touched dirs (check-changeset-fixed, spec check:meta-url-spelling, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:route-ledger-census) all exit 0. pnpm --filter @objectstack/runtime run typecheck exit 0 (test files in tsconfig.test.json program, check:test-typecheck OK); pnpm --filter @objectstack/spec run typecheck exit 0; eslint --no-inline-config over the 4 changed .ts files exit 0; node scripts/check-changeset-no-major.mjs exit 0; node scripts/check-adr-0087-registration.mjs exit 0 ('1 declared-breaking changeset ... not-required (no-migration-prescription)'). CI on PR #24392 head ee0888855 at report time: 'No other open PR may claim the same issue' success, 'Packed-tarball smoke (opt-in)' skipped, the other 22 check-runs (Lint ·, Type Check ·, Check Changeset, Governed Surface Queue Guard, Spec property liveness, ...) in_progress -- not waited on per contract; Test Core / Build Core not yet listed.",
"mcp_calls": "0",
"api_writes": "2 -- both through the fleet-write relay as objectstack-fleet[bot]: (1) POST /repos/objectstack-ai/objectstack/dispatches (request fw-20261018T201052Z-8840fd, run 39162127868 success) executing POST /repos/objectstack-ai/objectstack/pulls (draft #24392) + POST /repos/objectstack-ai/objectstack/issues/24392/assignees (zhuangjianguo; read-back: 15726 bytes sent == stored); (2) POST /repos/objectstack-ai/objectstack/dispatches executing POST /repos/objectstack-ai/objectstack/issues/24268/comments (this os-dev-report). git push x2 (empty branch probe, then ee0888855) are not REST writes. Labels: zero written (none named by the dispatch; skip-changeset not applicable, a changeset ships).",
"deviations": [
"MCP envelope: the dispatch asked both for 'the existing D2 path ... a declared-params action gets today' and for the D2 envelope (code + status) at BOTH doors; at the MCP door the existing D2 throw was a bare Error (message-only tool error), so the two could not both hold. Chose the envelope: the throw now carries code standardErrorCodeForHttpStatus(400) + status 400, which also applies to every D2 refusal at run_action (declared-params handler actions included). Pinned by ablation leg B. Seat to confirm it stays Clause-② no (narrowing): nothing newly accepted, a refusal gains members.",
"Base is origin/main b2e78701f, one commit past the claim's read d65826fa7 (#24373; touches neither runtime file nor .objectui-sha). origin/main later moved to 1eae21f97 (5 commits, none touching this diff's files or behaviour; git merge-tree clean); not merged, per the claim's merge rule.",
"Cross-lane files edited only at the sentence this PR made false: content/docs/automation/flows.mdx (domain:devx) and packages/spec/src/contracts/ai-service.ts (domain:spec, TSDoc comment only, no .describe, no generated artifact) -- the seat owes the cross-lane declaration on #6017 / #6023.",
"Declared to CI, not run locally: test:repo (vitest repo project; diff touches none of its tests), the workspace typecheck lanes, pnpm lint (repo-wide scan), and @objectstack/spec's own test suite (spec diff is one TSDoc sentence; its typecheck and every derived spec family ran).",
"First launch of the full runtime suite used 'pnpm --filter @objectstack/runtime run test -- --maxWorkers=2' (the bare -- trap: vitest received '-- --maxWorkers=2'); stopped after ~15 s by the recorded PIDs of that tree only, no reading taken from it, relaunched as 'exec vitest run --project local --maxWorkers=2'.",
"Commit trailer: the model-free pair (Claude-Session + Co-authored-by: Claude) per AGENTS.md; the harness reminder's model-named Co-Authored-By form was not used."
],
"open_questions": [
{
"question": "ADR-0104 D2 states the boundary this PR narrows: lines 195-196 'Actions declaring no params keep today's pass-through (there is nothing to check against), so existing param-less actions are untouched' and lines 941-942 'Actions declaring no params keep their pass-through -- that boundary is unchanged from D2's landing'. Both predate declarative updates (#14092). Prime Directive #13 asks for an amended status line or addendum when a recorded decision changes; docs/adr is Tier H and outside this claim, so it is untouched. How should the ADR record it?",
"options": [
"A: a separate docs-only PR adds a dated ADR-0104 addendum (one paragraph): the param-less pass-through is scoped to actions with a handler; an operation: 'update' action's empty or absent params list is an empty accept set (#24268). Cost: one Tier H landing (maintainer click), ~1 paragraph; #24392 lands in its lane meanwhile.",
"B: add that addendum to #24392 itself. Cost: the whole PR becomes Tier H, so this p1 waits for the maintainer's approval.",
"C: no ADR edit -- read D2's sentence as written for handler actions only. Cost: the ADR keeps a sentence that the code contradicts for one action class, which is the PD#13 'changeset that quietly does the opposite' shape."
],
"recommendation": "A, because it satisfies PD#13 without pulling a p1 runtime fix into Tier H, and the narrowing already matches the published liveness row /props/patch ('an undeclared param is refused by ADR-0104 D2')."
},
{
"question": "Page action rows (action:button / action:icon) may carry operation: 'update' with an OBJECT params of static values, which the spec's row judge counts as values to write (action-declarative-update.ts staticParams; row operation describe: 'a patch or params gives the values to write'). The console forwards them as wire params; after this PR the route writes them only if the action it resolves by name declares them. The row's own patch has not reached the route since objectui fb501bc894 (the route writes its declaration's patch). Zero such rows in this repo. Which way should the row surface go?",
"options": [
"A: a spec-lane card narrows the row prose (and, if wanted, the row judge) to what the route honours: a row's operation: 'update' runs the declared action of that name; its patch is not written and its static params are written only where that action declares them. Cost: a describe edit + gen:schema/gen:docs regeneration, small.",
"B: retire patch / static-params write semantics on page action rows (ADR-0049 enforce-or-remove) with a tombstone and ADR-0087 entry. Cost: a spec retirement kit (~14 surfaces), medium.",
"C: leave it. Cost: an author (or AI) can write row metadata that is silently dropped (patch) or refused at runtime (static params) while os validate passes."
],
"recommendation": "A first, because it is cheap and makes the spec stop promising a write the route does not perform; B only if the row form is judged to have no use once A is written down."
}
],
"out_of_scope_findings": [
"carrier: none (承接者:无) · noted in PR #24392 Acceptance notes 2, not filed · class c candidate without reach: page action row operation: 'update' -- row patch never sent since objectui fb501bc894, row static object params refused unless the resolved action declares them; evidence read-only (spec action-declarative-update.ts staticParams + component.zod.ts row operation describe; objectui 0707b4ae37 ActionRunner.executeUpdateOperation; route resolveRouteActionDeclaration), zero in-repo instances, no public-door measurement · dedupe words: page action row operation update patch, action:button static params update, row patch dropped declarative update",
"carrier: none (承接者:无) · noted in PR #24392 Acceptance notes 4, not filed · read-only inference: declarativeUpdateRefusal (no recordId / nothing to write) throws status only, while @objectstack/mcp thrownRefusalEnvelope needs a string code, so at run_action those refusals render message-only despite the docblock's 'agree by construction'; not measured over /mcp · dedupe words: declarativeUpdateRefusal MCP code, run_action refusal message-only, thrownRefusalEnvelope status-only",
"carrier: none (承接者:无) · noted in PR #24392 Acceptance notes 5, not filed · comment drift: packages/services/service-automation/src/engine.ts (~8601) and screen-input-contract.ts (~64) cite enforceActionParams' param-less pass-through as their model; still true for handler actions · dedupe words: screen input contract pass-through analogy, enforceActionParams param-less comment"
]
}
REWORK (patch round 1) → PR #24392 comment 6254699772 · domain:cli seat 1 · session session_01SYEJqDL3NXxrJRBVqaMu9T · 2026-10-18T20:22Z. Two small items: the changeset names page action rows as consumers, and the MCP refusal envelope of declarativeUpdateRefusal is measured and then fixed or dropped. Open questions answered: the ADR-0104 record is filed as #24393 (Tier H, Blocked-by: #24268); page action rows are filed as #24394 (spec lane through triage, Blocked-by: #24268). Cross-lane: #60176254685225, #60236254691036.
{
"issue": 24268,
"patch_round": 1,
"status": "done",
"branch": "claude/issue-24268-declarative-update-undeclared-params",
"pr": "https://github.com/objectstack-ai/objectstack/pull/24392",
"head": "6466a6c05",
"session": "session_01SYEJqDL3NXxrJRBVqaMu9T (subagent run; the parent seat's harness-stamped id, Claude-Session line)",
"premise_still_valid": true,
"summary": "Review 6254699772, both items done in one commit on the same branch, 6466a6c05 (on top of ee0888855). Item 1: the changeset's BREAKING sentence now also names page action rows (action:button / action:icon with operation: 'update') whose static object params the console forwards to a param-less declarative update action. There are none in this repo, and it cites #24394. The MCP paragraph now also names the executor's own refusals. Item 2 was MEASURED over the real bridge and found message-only, then fixed. The measurement booted bootStack + MCPServerPlugin, called POST /mcp tools/call run_action, and the throw was rendered by @objectstack/mcp. On ee0888855, run_action with no recordId and with nothing to write each answered isError:true with the bare message and no envelope, while the D2 control answered {error:{code:VALIDATION_ERROR,...,status:400}}. declarativeUpdateRefusal (action-execution.ts:2221) now stamps code: standardErrorCodeForHttpStatus(status) beside the status. Its docblock's 'No code literal is stamped here ... agree by construction' is replaced with the measured reason. The D2 throw's comment ('derived from the status, never spelled, as declarativeUpdateRefusal does') is therefore true at 6466a6c05. On 6466a6c05, after a runtime rebuild, all three refusals answer the coded envelope. A pin at the MCP door was added, and a one-leg ablation went exactly 1 red with the restore proven. The REST answers are unchanged, because the REST door already derived the same code. out_of_scope_findings[1] is resolved by this fix, so it is dropped from findings. out_of_scope_findings[2] is already Acceptance note 5 in the body. The PR body was not patched: body_edits carries 19 exact old/new pairs.",
"tests": "Real-bridge measurement, a throwaway file packages/qa/dogfood/test/zz-measure-24268-mcp-declarative-refusal.test.ts, run then deleted and never committed. It booted bootStack + MCPServerPlugin with object mz_case and actions mz_close (patch only) and mz_set_note (params only), then sent POST /mcp tools/call run_action as admin. Before, on ee0888855 dist: 'MEASURE[no recordId] isError=true json=false text=Action ... no `recordId` was supplied...' and 'MEASURE[nothing to write] isError=true json=false text=Action ... has nothing to write...'. The control was 'MEASURE[D2 undeclared (control)] isError=true json=true text={\"error\":{\"code\":\"VALIDATION_ERROR\",...,\"status\":400}}'. After, on 6466a6c05 with the runtime rebuilt: no recordId and nothing to write both returned json=true {\"error\":{\"code\":\"VALIDATION_ERROR\",\"message\":...,\"status\":400}}, and the success control wrote. Pin: action-declarative-update.test.ts, describe '#15079 point 7', 'the MCP door answers the same two refusals with `code` + `status`, and writes nothing'. It covers close_case with no recordId, plus a new params-only fixture action note_case run with params {}, and asserts code VALIDATION_ERROR, status 400, the message subject, 0 updates and the row unchanged. Runs at 6466a6c05 through os-verify-lock: the two touched files gave 'Test Files 2 passed (2) / Tests 42 passed (42)', VERDICT command-exit 0. The full runtime suite (exec vitest run --project local --maxWorkers=2) gave 'Test Files 421 passed (421) / Tests 6625 passed | 19 skipped (6644)', VERDICT command-exit 0. Ablation leg C, with the fix committed first, used scripts/ablation-replace.mjs in WRAP mode. Tests import the source by relative path, so there was no dist leg. The anchor was '{ code: standardErrorCodeForHttpStatus(status), status }', replaced by '{ status }', 1→0. The blob went f379ccfb9e51 → 257cb3d3a474. The result was exactly 1 red / 41 green, 'expected undefined to be VALIDATION_ERROR' on the new MCP pin. The REST point-7 cases stayed green, as predicted. Restore: blob f379ccfb9e51 == HEAD, git diff HEAD empty, porcelain clean.",
"gates": "Local, at 6466a6c05. dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived the same 110 commands as at ee0888855. After a turbo build --filter='!@objectstack/docs' (76 tasks, 63 cached), all 110 exited 0, each captured before any pipe. dispatch-gates --ran gave '110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN', a derived zero. The 11 declared-wide families all exited 0. The 6 roster gates under touched dirs all exited 0. pnpm --filter @objectstack/runtime run typecheck exited 0 (check:test-typecheck OK). eslint --no-inline-config over the 2 changed .ts files exited 0. check-changeset-no-major.mjs exited 0. check-adr-0087-registration.mjs exited 0 ('1 declared-breaking changeset ... not-required (no-migration-prescription)'). Spec typecheck was not re-run because round 1 does not touch packages/spec; it exited 0 at ee0888855. CI read before pushing: on ee0888855 there was no red (30 success, 5 skipped); its six Test Core shards were cancelled by the push of 6466a6c05, a superseded run, not a failure. CI on 6466a6c05 at report time: 33 success (Lint & Repo Gates, Build Core, all four Type Check lanes, Check Changeset, Governed Surface Queue Guard among them), 5 skipped, and Test Core 3/6 and 4/6 in_progress. Per the contract I did not wait on them. origin/main is 584b4225e, 6 commits past the base. None touches this diff's paths, and git merge-tree is clean. Not merged.",
"mcp_calls": "0",
"api_writes": "1 this round: POST /repos/objectstack-ai/objectstack/dispatches through the fleet-write relay, executing POST /repos/objectstack-ai/objectstack/issues/24268/comments (this os-dev-report). git push x1 (ee0888855..6466a6c05) is not a REST write. The PR body was not written, per os-dev.md:64; the seat applies body_edits. Reads: issue comment 6254699772 and check-runs on ee0888855 and 6466a6c05, all via gh api GET.",
"deviations": [
"The real-bridge measurement is a throwaway dogfood file. It booted the same bootStack + MCPServerPlugin shape as flow-door-elevated-start.dogfood.test.ts, because runtime does not depend on @objectstack/mcp and packages/mcp does not depend on runtime. It was run before and after the fix, then deleted and not committed. The committed pin is at the runtime MCP door (invokeBusinessAction) and asserts the two members thrownRefusalEnvelope reads, which is the D2 pin's own level. A committed end-to-end dogfood pin would add a boot-sized file to the main battery outside the claim's surface. The seat can ask for it.",
"The worktree had been removed after round 0. It was re-created from the existing local branch claude/issue-24268-declarative-update-undeclared-params at ee0888855 (== origin), with no merge of main.",
"The fix also covers the third declarativeUpdateRefusal shape, the object-less action key, because it is the same function. That shape was not separately measured over /mcp or pinned at the MCP door. Its REST pin (point 7, 'an OBJECT-LESS action key') stays green."
],
"body_edits": [
{
"section": "opening (Clause-② basis)",
"old": "The one envelope change (the MCP door's ADR-0104 D2 refusal now carries `code` + `status`) adds members to a refusal that already refused; it accepts nothing new.",
"new": "The envelope changes at the MCP door (the ADR-0104 D2 refusal, and the declarative executor's own refusals, now carry `code` + `status`) add members to refusals that already refused; they accept nothing new."
},
{
"section": "What changed",
"old": "- **MCP door envelope** (`action-execution.ts:2535`, in `invokeBusinessAction`). The D2 throw was a bare `Error`, which `@objectstack/mcp`'s `thrownRefusalEnvelope` renders message-only, because it reads a string `code`. It is now thrown with `code: standardErrorCodeForHttpStatus(400)` (`VALIDATION_ERROR`, the code the REST door's `deps.error(paramError, 400)` derives) and `status: 400`. This applies to every D2 refusal at that door, not only the new one (see Acceptance notes).",
"new": "- **MCP door envelope** (`action-execution.ts:2542`, in `invokeBusinessAction`). The D2 throw was a bare `Error`, which `@objectstack/mcp`'s `thrownRefusalEnvelope` renders message-only, because it reads a string `code`. It is now thrown with `code: standardErrorCodeForHttpStatus(400)` (`VALIDATION_ERROR`, the code the REST door's `deps.error(paramError, 400)` derives) and `status: 400`. This applies to every D2 refusal at that door, not only the new one (see Acceptance notes).\n- **The declarative executor's own refusals** (`declarativeUpdateRefusal`, `action-execution.ts:2221`: no `recordId`, nothing to write, an object-less action key). They threw `status` only, so over the real `/mcp` bridge they rendered message-only (measured; see Tests). They now carry `code: standardErrorCodeForHttpStatus(status)` beside the status, as the D2 throw does. The REST door already derived the same code from the status, so its answers are unchanged."
},
{
"section": "What changed",
"old": "- **Docblocks that became false or incomplete:** `declarativeUpdateWrite`, and the `invokeBusinessAction` input comment (\"silently accepted on one that declares none\").",
"new": "- **Docblocks that became false or incomplete:** `declarativeUpdateWrite`; the `invokeBusinessAction` input comment (\"silently accepted on one that declares none\"); and `declarativeUpdateRefusal`'s (\"No `code` literal is stamped here ... agree by construction\"), which the measurement disproved."
},
{
"section": "What changed",
"old": "- **Changeset:** `.changeset/24268-declarative-update-undeclared-params-refused.md`, `@objectstack/runtime: minor`, `fix(runtime)!:`, line-initial `Clause-②: no (narrowing)`, ADR-0087 `not-required (no-migration-prescription)`, and a `**BREAKING**` sentence naming the consumers.",
"new": "- **Changeset:** `.changeset/24268-declarative-update-undeclared-params-refused.md`, `@objectstack/runtime: minor`, `fix(runtime)!:`, line-initial `Clause-②: no (narrowing)`, ADR-0087 `not-required (no-migration-prescription)`, and a `**BREAKING**` sentence naming the consumers: objectstack-ai/hotcrm's `complete_task`, consoles vendored before objectui `fb501bc894`, and page action rows that forward static object `params` to a param-less declarative update (none in this repo; the row surface is #24394)."
},
{
"section": "Consumers, measured",
"old": "- **Out of repo (from the card):** objectstack-ai/hotcrm's `complete_task`, called with params, and any console vendored before objectui `fb501bc894`. Both are named in the changeset.",
"new": "- **Out of repo:** objectstack-ai/hotcrm's `complete_task`, called with params, and any console vendored before objectui `fb501bc894` (both from the card); and page action rows (`action:button` / `action:icon` with `operation: 'update'`) whose static object `params` the console forwards to a param-less declarative update action, zero of them in this repo (the row surface is #24394). All three are named in the changeset."
},
{
"section": "Tests",
"old": " - Control: a param-less handler action (`nudge_case`) with the same undeclared key still reaches the registry (404 not-found, `executeAction` called). The pass-through is kept.",
"new": " - Control: a param-less handler action (`nudge_case`) with the same undeclared key still reaches the registry (404 not-found, `executeAction` called). The pass-through is kept.\n- `packages/runtime/src/action-declarative-update.test.ts`, describe `#15079 point 7` (patch round 1): the MCP door answers the no-`recordId` refusal (`close_case`) and the nothing-to-write refusal (`note_case`, a new params-only fixture action) with `code` `VALIDATION_ERROR` + `status` 400, and writes nothing. Those are the two members `thrownRefusalEnvelope` reads."
},
{
"section": "Tests",
"old": "Runs at `ee0888855`, each through `scripts/pm/os-verify-lock.sh`:",
"new": "Real-bridge measurement (patch round 1; a throwaway dogfood file, not committed): `bootStack` + `MCPServerPlugin`, `POST /mcp` `tools/call run_action` on a fixture object, so the runtime's `buildMcpBridge` throw is rendered by `@objectstack/mcp`. On `ee0888855`, no `recordId` and nothing-to-write each answered `isError: true` with the bare message and no JSON envelope, while the D2 control answered `{\"error\":{\"code\":\"VALIDATION_ERROR\",...,\"status\":400}}`. On `6466a6c05`, with runtime rebuilt, all three answered `{\"error\":{\"code\":\"VALIDATION_ERROR\",\"message\":...,\"status\":400}}`, and the success control wrote.\n\nRuns at `6466a6c05`, each through `scripts/pm/os-verify-lock.sh`:"
},
{
"section": "Tests",
"old": "- The two files: `Test Files 2 passed (2)`, `Tests 41 passed (41)`, `VERDICT command-exit 0`.",
"new": "- The two files: `Test Files 2 passed (2)`, `Tests 42 passed (42)`, `VERDICT command-exit 0`."
},
{
"section": "Tests",
"old": "- The full runtime suite (`pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2`): `Test Files 421 passed (421)`, `Tests 6624 passed | 19 skipped (6643)`, `VERDICT command-exit 0`.",
"new": "- The full runtime suite (`pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2`): `Test Files 421 passed (421)`, `Tests 6625 passed | 19 skipped (6644)`, `VERDICT command-exit 0`."
},
{
"section": "Reverse verification",
"old": "The fix was committed first (`ee0888855`). Each leg went through `node scripts/ablation-replace.mjs` in WRAP mode, so the restore is armed on exit, INT and TERM against the absolute path.",
"new": "The fix was committed first (`ee0888855` for legs A and B, `6466a6c05` for leg C). Each leg went through `node scripts/ablation-replace.mjs` in WRAP mode, so the restore is armed on exit, INT and TERM against the absolute path."
},
{
"section": "Reverse verification",
"old": "- Observed direction: red, the expected direction in both legs. `git status --porcelain` was empty after both.",
"new": "- **Leg C, the executor refusal's code dropped** (patch round 1: `declarativeUpdateRefusal` back to `Object.assign(new Error(message), { status })`). The blob went from `f379ccfb9e51` to `257cb3d3a474`. **Exactly 1 red, 41 green**: the new MCP point-7 case, with `expected undefined to be 'VALIDATION_ERROR'`. The REST point-7 cases stayed green, because the REST door derives the code from the status. Restore: blob `f379ccfb9e51` equals HEAD, and `git diff HEAD` is empty.\n- Observed direction: red, the expected direction in all three legs. `git status --porcelain` was empty after each."
},
{
"section": "Gates",
"old": "## Gates (local, at `ee0888855`)",
"new": "## Gates (local, at `6466a6c05`)"
},
{
"section": "Gates",
"old": "- `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 110 commands. 108 exited 0 on the first pass. 2 exited 3 (`PREREQUISITE NOT MET`): `check:skill-examples` needed a `client-react` build, and `check:dual-build-cjs-loads` needed a full build. After `turbo run build --filter='!@objectstack/docs'` (76 tasks, 75 cached), both exited 0.",
"new": "- `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 110 commands, the same set as at `ee0888855`. After `turbo run build --filter='!@objectstack/docs'` (76 tasks, 63 cached), all 110 exited 0 on the first pass. (At `ee0888855`, `check:skill-examples` and `check:dual-build-cjs-loads` first exited 3, `PREREQUISITE NOT MET`, and exited 0 after the same build.)"
},
{
"section": "Gates",
"old": "- `pnpm --filter @objectstack/runtime run typecheck` exited 0, and so did `pnpm --filter @objectstack/spec run typecheck`. Both new test files are in `tsconfig.test.json`'s program (`--listFiles`), and `check:test-typecheck` is OK.",
"new": "- `pnpm --filter @objectstack/runtime run typecheck` exited 0 at `6466a6c05`. `pnpm --filter @objectstack/spec run typecheck` exited 0 at `ee0888855`; round 1 does not touch `packages/spec`. Both new test files are in `tsconfig.test.json`'s program (`--listFiles`), and `check:test-typecheck` is OK."
},
{
"section": "Gates",
"old": "- `eslint --no-inline-config` over the 4 changed TypeScript files exited 0.",
"new": "- `eslint --no-inline-config` over the 4 changed TypeScript files exited 0; in round 1, over the 2 it touches, exit 0."
},
{
"section": "Gates",
"old": "- `origin/main` has since moved to `1eae21f97` (5 commits). None touches this diff's files or behaviour, and `git merge-tree` is clean. Not merged, per the claim: a merge happens only for a conflict, a main-caused red or a dequeue.",
"new": "- `origin/main` has since moved to `584b4225e` (6 commits past the base). None touches this diff's files or behaviour, and `git merge-tree` is clean. Not merged, per the claim: a merge happens only for a conflict, a main-caused red or a dequeue.\n- CI on `ee0888855` showed no red. Its six `Test Core` shards were cancelled when `6466a6c05` was pushed, and they rerun on that head."
},
{
"section": "Acceptance notes",
"old": "This is raised for the seat in the report's open questions.",
"new": "The seat filed the ADR record as #24393 (Tier H, its own PR, `Blocked-by: #24268`)."
},
{
"section": "Acceptance notes",
"old": "This needs a decision on the row surface and is reported, not changed.",
"new": "The seat filed the row surface for the spec lane as #24394. This PR only names the rows in the changeset."
},
{
"section": "Acceptance notes",
"old": "4. **MCP envelope scope.** The `code` + `status` stamp covers every D2 refusal at `run_action`, including declared-params handler actions, which used to render message-only. The declarative executor's own refusals (`declarativeUpdateRefusal`: no `recordId`, nothing to write) still throw `status` only. `packages/mcp`'s renderer reads `code`, so at the MCP door those still render message-only, even though that function's docblock says the two doors \"agree by construction\". This was read, not measured over `/mcp`, and it is not changed here.",
"new": "4. **MCP envelope scope.** The `code` + `status` stamp covers every D2 refusal at `run_action`, including declared-params handler actions, which used to render message-only. The declarative executor's own refusals (`declarativeUpdateRefusal`: no `recordId`, nothing to write, an object-less key) were measured message-only over the real `/mcp` bridge and now carry the derived code too (patch round 1; see What changed)."
}
],
"open_questions": [],
"out_of_scope_findings": [
"carrier: none (承接者:无) · Acceptance note 5 in PR #24392 body (already present), not filed · comment drift: packages/services/service-automation/src/engine.ts (~8601) and screen-input-contract.ts (~64) cite enforceActionParams' param-less pass-through as their model; still true for handler actions · dedupe words: screen input contract pass-through analogy, enforceActionParams param-less comment",
"carrier: #24394 (seat-filed, spec lane) · page action row operation: 'update' surface, round 0's findings[0]; named in the changeset this round",
"resolved in this PR, round 1 (no longer a finding): round 0's findings[1], declarativeUpdateRefusal message-only at the MCP door, measured and fixed in 6466a6c05"
]
}
ACCEPT (lane review) → PR #24392 comment 6255104464 · head 6466a6c05 · patch round 1 done: the changeset names page action rows (#24394), and the MCP door's executor refusals were measured message-only and now carry code + status. ⛔ Not queueable yet: a CONTRACT_REVIEW_TIER review is running in an isolated reviewer, and needs:contract-review is on the PR. The seat wrote the PR body's corrections.
Contract review → PR #24392 comment 6255264902: CONTRACT_REVIEW_TIER, head 6466a6c05, PASS. needs:contract-review is removed. The PR lands through the queue once the pre-checks read clean. Carried from the record: #24393 (the ADR-0104 record) unblocks when this card closes, and the maintainer lands it by hand. #24394 (page action rows) is for the spec lane within v18.
What landed: an operation: 'update' action that declares no params refuses every undeclared wire key at both action doors, through the existing ADR-0104 D2 path (the same 400, the same OS_ALLOW_LAX_ACTION_PARAMS hatch). Handler actions keep the pass-through. At the MCP run_action door, the D2 refusal and the declarative executor's own refusals now carry code + status. @objectstack/runtime ships it as minor (fix(runtime)!:, the narrowing shape).
Filed from this card, both now unblocked (Blocked-by: #24268 closed):
Activity
objectstack-fleet commentedon Oct 18, 2026
Triage:
pm:blockedon objectui#12941 + the console pin · p1 ·target:v18·area:api·domain:cli· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-18T06:56ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
origin/main53ef752436:packages/runtime/src/action-execution.tsenforceActionParams(:1661) returnsnullat once whenaction.paramsis empty;declarativeUpdateWrite(:2191) merges the patch with the wire params.:2504,domains/actions.ts:676).target:v18: the protocol-first rule. The fix narrows an action door to what the action declares (ADR-0104 D2, the publishedliveness/action.json/props/patchrow): declared ≠ enforced. The write still runs as the caller, so it is not an authorization hole and gets nosecuritylabel.operation: 'update'action posts its declaredpatchas wireparams, so the runtime cannot refuse undeclared params without breaking every declarative-update button (objectstack#24268 paired half) objectui#12941 (p1,target:v18,domain:ui). The console posts the patch asparams, so a runtime refusal landing first would break every declarative-update button..objectui-shato carry its merge, so the bundled console never trips the refusal. ABlocked-by:line is now in the body.Clause-②: yes (narrowing).Generated by Claude Code
objectstack-fleet commentedon Oct 18, 2026
objectui half landed ·
domain:uiseat 1 ·session_0188TsgPV8kQXfuRuKmkj2ks· 2026-10-18T08:05Zoperation: 'update'action posts its declaredpatchas wireparams, so the runtime cannot refuse undeclared params without breaking every declarative-update button (objectstack#24268 paired half) objectui#12941 closedcompletedthrough PR fix(core): anoperation: 'update'action posts only its collected params, never itspatch(objectui#12941) objectui#12944, merged as objectuifb501bc894.ActionRunner.executeUpdateOperationnow posts the collected params alone. The declaredpatchnever rides on the wire, anddeclarativeUpdateWritestill applies it from the declaration.Blocked-by:is not yet met. objectstackmain's.objectui-shais5e15928b(objectui, 2026-10-17), which does not containfb501bc. Until a pin bump carries it, the bundled console still posts patch keys asparams, and a refusal landing first would 400 every declarative-update button.Generated by Claude Code
objectstack-fleet commentedon Oct 18, 2026
Blocked-by:re-pointed to #24283 ·pm:blockedstays · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-18T09:19ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
operation: 'update'action posts its declaredpatchas wireparams, so the runtime cannot refuse undeclared params without breaking every declarative-update button (objectstack#24268 paired half) objectui#12941 is closed. Its fix is objectuifb501bc894(objectui PR ci(lint): run os-regen-merge's --self-test, mirroring the verify-lock step #12944), asdomain:uiseat 1 noted at6247443727.domain:devx). The maintainer's word there is 「要升级就升最新」, and objectuimainat03f1d65289carriesfb501bc894.Blocked-by:line now names chore(objectui): move the console pin from 5e15928b4631 to a green objectui main commit at or after 32b492b67 — it carries the PDF chain's three print fixes (#8346) and the line-items column hydration (#24190) #24283, and the unlock scan follows the pin, not the closed half.Generated by Claude Code
objectstack-fleet commentedon Oct 18, 2026
Unlock:
pm:blocked→pm:queue· p1 ·target:v18·domain:cli· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-18T12:59ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
f5e98a35d1(11:34Z).origin/main.objectui-shais now0707b4ae3712.git merge-base --is-ancestor fb501bc894 0707b4ae37(objectui) → ancestor. So the console half (objectui PR ci(lint): run os-regen-merge's --self-test, mirroring the verify-lock step #12944:ActionRunnerposts the collected params alone) is in the consolemainvendors.enforceActionParamsrefuses undeclared params on anoperation: 'update'action) can now land without breaking the vendored console's own update actions. Thedomain:cliseat dispatches it.Generated by Claude Code
objectstack-fleet commentedon Oct 18, 2026
Claim: PM loop round 4 ·
domain:cliseat 1 · p1 · 2026-10-18T19:15ZSession:
session_01SYEJqDL3NXxrJRBVqaMu9TAccount:
zhuangjianguo(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24268-declarative-update-undeclared-paramsWorktree:
objectstack-issue-24268Domain:
domain:cliSeat:
domain:cli#1(seat post #6024)Ruling-ref: triage
6246815844("Scope when released: the card's Done when, at both action doors, with the ADR-0104 D2 envelope and a conformance case (undeclared refused, declared still works).") and unlock6250211792(the console half, objectuifb501bc894, is in the vendored console). Re-checked in this act:git merge-base --is-ancestor fb501bc894 0707b4ae37in objectui answers ancestor, and.objectui-shaonorigin/maind65826fa7is still0707b4ae37.File surface (read on
origin/maind65826fa7; line numbers move; stop on a breach and explain it in the report):packages/runtime/src/action-execution.ts:enforceActionParams(:1661) returnsnullat once whenaction.paramsis empty, so anoperation: 'update'action that declares no params passes any wire key. For a declarative update, an empty declared list becomes an empty accept set, refused through the existing ADR-0104 D2 path (the same message, status andOS_ALLOW_LAX_ACTION_PARAMShandling a declared-params action gets today; no new switch). Its docblock ("Actions that declare noparamskeep the pass-through") is corrected in the same edit.declarativeUpdateWrite(:2191) keeps its precedence rule.packages/runtime/src/domains/actions.ts(:676), the second action door, and every other caller ofdeclarativeUpdateWriteor executor ofoperation: 'update'the dev finds (the bulk definition the docblock names, the MCP bridge), each enumerated in the PR body as refused or not reachable.code+status) and writes nothing; a declared param still writes; the param-less action with an empty bag still writes itspatch.packages/spec/src/**(thepatch.describe) andcontent/docs/**are other lanes' files: declared on [PM seat] domain:spec — ⏳ vacant #6017 or [PM seat] domain:devx @ objectstack — 🟢 os-warren · session_01FM29ZqLTrnPNXxijioZwMp #6023 in the act that touches them.@objectstack/runtimein the narrowing shape:minor, afix(runtime)!:summary, a line-initialClause-②: no (narrowing), an ADR-0087 disposition marker, and a**BREAKING**sentence that names the consumers measured (objectstack-ai/hotcrm'scomplete_task, and any console vendored before objectuifb501bc894)..github/workflows/**;scripts/**; no release act.Container & model:
M,mode:subagent,model: default tierfor the build (dispatch-gates.mjs --tier --repo objectstack-ai/objectstackover the surface: "Model tier — no path-derived mandate"). Ceiling:CONTRACT_REVIEW_TIERfor one review before the queue, reason from the same output: "A card that only NARROWS one isClause-②: no, stays in its lane, and owes one contract-review-tier review before the queue".Clause-②: no (narrowing)
Basis for the Clause-② line: the action doors stop accepting an undeclared wire key on a param-less declarative update, and nothing newly accepted or exported is added. Triage's
yes (narrowing)reads as "widens one surface and narrows another" inclause2-line.mjs; nothing here widens, so the arm isno (narrowing). If the diff does widen something, the dev declaresyes (narrowing)and says what.Responsibility:
@objectstack/runtime'senforceActionParamsearly return anddeclarativeUpdateWritemerge (this lane's) | none: ADR-0104 D2 validates only a non-empty declared list | any caller of either action door on a param-lessoperation: 'update'action, within the caller's own data rights; measured on objectstack-ai/hotcrm'scomplete_task(18.0.0-next.3)Thread-read: 6250211792
Serial constraints cleared:
packages/runtime/src/action-execution.tsorpackages/runtime/src/domains/actions.ts.pm:dispatchedclaim (15 cards, every lane) names either file.packages/restimport door, in the merge queue), PR test(cli): runServe() takes a kernel-assigned port and returns the bound one; the released-port pin widens to every CLI spawn #24386 (packages/cli/test/**) and PR fix(verify): the dashboard date-window family also proves sums and derived measures, and says why an average stays out (#15953 slice 8) #24385 (verify: classify hotcrm's "platform-semantics pins" — each becomes a derived proof family in@objectstack/verifyor a platform regression test in dogfood, never an app test (epic hotcrm#1579, step 5c) #15953 slice 8, docs). Batch 2: this and verify: classify hotcrm's "platform-semantics pins" — each becomes a derived proof family in@objectstack/verifyor a platform regression test in dogfood, never an app test (epic hotcrm#1579, step 5c) #15953 slice 8.Generated by Claude Code
objectstack-fleet commentedon Oct 18, 2026
os-dev-report
{ "issue": 24268, "status": "done", "branch": "claude/issue-24268-declarative-update-undeclared-params", "pr": "https://github.com/objectstack-ai/objectstack/pull/24392", "session": "session_01SYEJqDL3NXxrJRBVqaMu9T (subagent run; the parent seat's harness-stamped id, Claude-Session line)", "premise_still_valid": true, "summary": "An operation: 'update' action that declares no params now has an EMPTY accept set: enforceActionParams (packages/runtime/src/action-execution.ts:1678) only keeps the param-less pass-through for non-declarative (handler) actions, so on a declarative update every wire key that is not a dispatcher built-in is refused through the existing ADR-0104 D2 path (same message, 400 at REST, same OS_ALLOW_LAX_ACTION_PARAMS hatch, no new switch) before anything is read or written, at both doors (REST domains/actions.ts:676, MCP run_action via invokeBusinessAction). The MCP door's D2 throw now carries code VALIDATION_ERROR + status 400 (it was a bare Error, rendered message-only by @objectstack/mcp), so both doors answer the ADR-0112 envelope. Docblocks corrected; two published sentences made false were fixed (content/docs/automation/flows.mdx, packages/spec/src/contracts/ai-service.ts TSDoc); one runtime minor changeset in the narrowing shape. Premises: P1 measured red on unchanged source (REST 200 / MCP resolve for an undeclared key on param-less close_case); P2 holds (objectui merge-base --is-ancestor fb501bc894 0707b4ae37 exit 0; the pinned console posts collected params only, never patch).", "tests": "At HEAD ee0888855, all via scripts/pm/os-verify-lock.sh. P1 (new cases on unchanged source b2e78701f): 'Tests 3 failed | 30 passed (33)' -- 'expected 200 to be 400' x2 (REST), 'expected undefined to be an instance of Error' (MCP). After the fix: pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/action-declarative-update.test.ts src/action-params-enforcement.test.ts → 'Test Files 2 passed (2) / Tests 41 passed (41)', VERDICT command-exit 0. Full runtime suite: pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 → 'Test Files 421 passed (421) / Tests 6624 passed | 19 skipped (6643)', VERDICT command-exit 0. New pins: REST undeclared key → 400 + error.code VALIDATION_ERROR + key named + 0 updates + row == freshRow(); REST patch key as param (pre-fb501bc894 console shape) refused, no write; MCP same key thrown with code VALIDATION_ERROR + status 400, 0 updates; absent/empty bag still writes exactly {status:'closed'} at both doors; declared param writes and wins over patch at both doors; handler control (nudge_case) keeps pass-through (404 via registry); gate unit cases (refuses+names key for params absent and [], accepts {} and built-ins, lax hatch accepts+warns once). Ablation (fix committed first; scripts/ablation-replace.mjs WRAP mode, src-resolved relative imports so no dist leg): leg A gate reverted ('if (!declaresParams) return null;'), anchor 1→0, blob afc25f537838 → 23bc0e53137a → exactly 5 red / 36 green (the 3 door refusals + gate 'refuses' + lax-warn), restore blob afc25f537838 == HEAD, git diff HEAD empty; leg B MCP stamp reverted ('throw new Error(paramError);'), blob → cf8007e82ea1 → exactly 1 red ('expected undefined to be VALIDATION_ERROR'), restore blob == HEAD, git diff HEAD empty, porcelain clean. Direction observed: red, as predicted.", "gates": "Local, at ee0888855: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack → 110 commands, each run with exit captured before any pipe: 108 exit 0 first pass; check:skill-examples and check:dual-build-cjs-loads exit 3 (PREREQUISITE NOT MET: client-react / full dist), re-run after turbo build --filter='!@objectstack/docs' (76 tasks, 75 cached) → both exit 0. dispatch-gates --ran: '110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN' (derived zero, every line carries its exit). 11 declared-wide families (init-service-contract, live-db-isolation, meta-type-normalized, optional-error-sink, resume-authority-declared, route-envelope, runner-env-posture, settings-bind-window, startup-registry-verdict, verify-stand-in, wildcard-fallthrough) all exit 0. Roster gates under touched dirs (check-changeset-fixed, spec check:meta-url-spelling, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:route-ledger-census) all exit 0. pnpm --filter @objectstack/runtime run typecheck exit 0 (test files in tsconfig.test.json program, check:test-typecheck OK); pnpm --filter @objectstack/spec run typecheck exit 0; eslint --no-inline-config over the 4 changed .ts files exit 0; node scripts/check-changeset-no-major.mjs exit 0; node scripts/check-adr-0087-registration.mjs exit 0 ('1 declared-breaking changeset ... not-required (no-migration-prescription)'). CI on PR #24392 head ee0888855 at report time: 'No other open PR may claim the same issue' success, 'Packed-tarball smoke (opt-in)' skipped, the other 22 check-runs (Lint ·, Type Check ·, Check Changeset, Governed Surface Queue Guard, Spec property liveness, ...) in_progress -- not waited on per contract; Test Core / Build Core not yet listed.", "mcp_calls": "0", "api_writes": "2 -- both through the fleet-write relay as objectstack-fleet[bot]: (1) POST /repos/objectstack-ai/objectstack/dispatches (request fw-20261018T201052Z-8840fd, run 39162127868 success) executing POST /repos/objectstack-ai/objectstack/pulls (draft #24392) + POST /repos/objectstack-ai/objectstack/issues/24392/assignees (zhuangjianguo; read-back: 15726 bytes sent == stored); (2) POST /repos/objectstack-ai/objectstack/dispatches executing POST /repos/objectstack-ai/objectstack/issues/24268/comments (this os-dev-report). git push x2 (empty branch probe, then ee0888855) are not REST writes. Labels: zero written (none named by the dispatch; skip-changeset not applicable, a changeset ships).", "deviations": [ "MCP envelope: the dispatch asked both for 'the existing D2 path ... a declared-params action gets today' and for the D2 envelope (code + status) at BOTH doors; at the MCP door the existing D2 throw was a bare Error (message-only tool error), so the two could not both hold. Chose the envelope: the throw now carries code standardErrorCodeForHttpStatus(400) + status 400, which also applies to every D2 refusal at run_action (declared-params handler actions included). Pinned by ablation leg B. Seat to confirm it stays Clause-② no (narrowing): nothing newly accepted, a refusal gains members.", "Base is origin/main b2e78701f, one commit past the claim's read d65826fa7 (#24373; touches neither runtime file nor .objectui-sha). origin/main later moved to 1eae21f97 (5 commits, none touching this diff's files or behaviour; git merge-tree clean); not merged, per the claim's merge rule.", "Cross-lane files edited only at the sentence this PR made false: content/docs/automation/flows.mdx (domain:devx) and packages/spec/src/contracts/ai-service.ts (domain:spec, TSDoc comment only, no .describe, no generated artifact) -- the seat owes the cross-lane declaration on #6017 / #6023.", "Declared to CI, not run locally: test:repo (vitest repo project; diff touches none of its tests), the workspace typecheck lanes, pnpm lint (repo-wide scan), and @objectstack/spec's own test suite (spec diff is one TSDoc sentence; its typecheck and every derived spec family ran).", "First launch of the full runtime suite used 'pnpm --filter @objectstack/runtime run test -- --maxWorkers=2' (the bare -- trap: vitest received '-- --maxWorkers=2'); stopped after ~15 s by the recorded PIDs of that tree only, no reading taken from it, relaunched as 'exec vitest run --project local --maxWorkers=2'.", "Commit trailer: the model-free pair (Claude-Session + Co-authored-by: Claude) per AGENTS.md; the harness reminder's model-named Co-Authored-By form was not used." ], "open_questions": [ { "question": "ADR-0104 D2 states the boundary this PR narrows: lines 195-196 'Actions declaring no params keep today's pass-through (there is nothing to check against), so existing param-less actions are untouched' and lines 941-942 'Actions declaring no params keep their pass-through -- that boundary is unchanged from D2's landing'. Both predate declarative updates (#14092). Prime Directive #13 asks for an amended status line or addendum when a recorded decision changes; docs/adr is Tier H and outside this claim, so it is untouched. How should the ADR record it?", "options": [ "A: a separate docs-only PR adds a dated ADR-0104 addendum (one paragraph): the param-less pass-through is scoped to actions with a handler; an operation: 'update' action's empty or absent params list is an empty accept set (#24268). Cost: one Tier H landing (maintainer click), ~1 paragraph; #24392 lands in its lane meanwhile.", "B: add that addendum to #24392 itself. Cost: the whole PR becomes Tier H, so this p1 waits for the maintainer's approval.", "C: no ADR edit -- read D2's sentence as written for handler actions only. Cost: the ADR keeps a sentence that the code contradicts for one action class, which is the PD#13 'changeset that quietly does the opposite' shape." ], "recommendation": "A, because it satisfies PD#13 without pulling a p1 runtime fix into Tier H, and the narrowing already matches the published liveness row /props/patch ('an undeclared param is refused by ADR-0104 D2')." }, { "question": "Page action rows (action:button / action:icon) may carry operation: 'update' with an OBJECT params of static values, which the spec's row judge counts as values to write (action-declarative-update.ts staticParams; row operation describe: 'a patch or params gives the values to write'). The console forwards them as wire params; after this PR the route writes them only if the action it resolves by name declares them. The row's own patch has not reached the route since objectui fb501bc894 (the route writes its declaration's patch). Zero such rows in this repo. Which way should the row surface go?", "options": [ "A: a spec-lane card narrows the row prose (and, if wanted, the row judge) to what the route honours: a row's operation: 'update' runs the declared action of that name; its patch is not written and its static params are written only where that action declares them. Cost: a describe edit + gen:schema/gen:docs regeneration, small.", "B: retire patch / static-params write semantics on page action rows (ADR-0049 enforce-or-remove) with a tombstone and ADR-0087 entry. Cost: a spec retirement kit (~14 surfaces), medium.", "C: leave it. Cost: an author (or AI) can write row metadata that is silently dropped (patch) or refused at runtime (static params) while os validate passes." ], "recommendation": "A first, because it is cheap and makes the spec stop promising a write the route does not perform; B only if the row form is judged to have no use once A is written down." } ], "out_of_scope_findings": [ "carrier: none (承接者:无) · noted in PR #24392 Acceptance notes 2, not filed · class c candidate without reach: page action row operation: 'update' -- row patch never sent since objectui fb501bc894, row static object params refused unless the resolved action declares them; evidence read-only (spec action-declarative-update.ts staticParams + component.zod.ts row operation describe; objectui 0707b4ae37 ActionRunner.executeUpdateOperation; route resolveRouteActionDeclaration), zero in-repo instances, no public-door measurement · dedupe words: page action row operation update patch, action:button static params update, row patch dropped declarative update", "carrier: none (承接者:无) · noted in PR #24392 Acceptance notes 4, not filed · read-only inference: declarativeUpdateRefusal (no recordId / nothing to write) throws status only, while @objectstack/mcp thrownRefusalEnvelope needs a string code, so at run_action those refusals render message-only despite the docblock's 'agree by construction'; not measured over /mcp · dedupe words: declarativeUpdateRefusal MCP code, run_action refusal message-only, thrownRefusalEnvelope status-only", "carrier: none (承接者:无) · noted in PR #24392 Acceptance notes 5, not filed · comment drift: packages/services/service-automation/src/engine.ts (~8601) and screen-input-contract.ts (~64) cite enforceActionParams' param-less pass-through as their model; still true for handler actions · dedupe words: screen input contract pass-through analogy, enforceActionParams param-less comment" ] }Generated by Claude Code
objectstack-fleet commentedon Oct 18, 2026
REWORK (patch round 1) → PR #24392 comment
6254699772·domain:cliseat 1 · sessionsession_01SYEJqDL3NXxrJRBVqaMu9T· 2026-10-18T20:22Z. Two small items: the changeset names page action rows as consumers, and the MCP refusal envelope ofdeclarativeUpdateRefusalis measured and then fixed or dropped. Open questions answered: the ADR-0104 record is filed as #24393 (Tier H,Blocked-by: #24268); page action rows are filed as #24394 (spec lane through triage,Blocked-by: #24268). Cross-lane: #60176254685225, #60236254691036.Generated by Claude Code
objectstack-fleet commentedon Oct 18, 2026
os-dev-report
{ "issue": 24268, "patch_round": 1, "status": "done", "branch": "claude/issue-24268-declarative-update-undeclared-params", "pr": "https://github.com/objectstack-ai/objectstack/pull/24392", "head": "6466a6c05", "session": "session_01SYEJqDL3NXxrJRBVqaMu9T (subagent run; the parent seat's harness-stamped id, Claude-Session line)", "premise_still_valid": true, "summary": "Review 6254699772, both items done in one commit on the same branch, 6466a6c05 (on top of ee0888855). Item 1: the changeset's BREAKING sentence now also names page action rows (action:button / action:icon with operation: 'update') whose static object params the console forwards to a param-less declarative update action. There are none in this repo, and it cites #24394. The MCP paragraph now also names the executor's own refusals. Item 2 was MEASURED over the real bridge and found message-only, then fixed. The measurement booted bootStack + MCPServerPlugin, called POST /mcp tools/call run_action, and the throw was rendered by @objectstack/mcp. On ee0888855, run_action with no recordId and with nothing to write each answered isError:true with the bare message and no envelope, while the D2 control answered {error:{code:VALIDATION_ERROR,...,status:400}}. declarativeUpdateRefusal (action-execution.ts:2221) now stamps code: standardErrorCodeForHttpStatus(status) beside the status. Its docblock's 'No code literal is stamped here ... agree by construction' is replaced with the measured reason. The D2 throw's comment ('derived from the status, never spelled, as declarativeUpdateRefusal does') is therefore true at 6466a6c05. On 6466a6c05, after a runtime rebuild, all three refusals answer the coded envelope. A pin at the MCP door was added, and a one-leg ablation went exactly 1 red with the restore proven. The REST answers are unchanged, because the REST door already derived the same code. out_of_scope_findings[1] is resolved by this fix, so it is dropped from findings. out_of_scope_findings[2] is already Acceptance note 5 in the body. The PR body was not patched: body_edits carries 19 exact old/new pairs.", "tests": "Real-bridge measurement, a throwaway file packages/qa/dogfood/test/zz-measure-24268-mcp-declarative-refusal.test.ts, run then deleted and never committed. It booted bootStack + MCPServerPlugin with object mz_case and actions mz_close (patch only) and mz_set_note (params only), then sent POST /mcp tools/call run_action as admin. Before, on ee0888855 dist: 'MEASURE[no recordId] isError=true json=false text=Action ... no `recordId` was supplied...' and 'MEASURE[nothing to write] isError=true json=false text=Action ... has nothing to write...'. The control was 'MEASURE[D2 undeclared (control)] isError=true json=true text={\"error\":{\"code\":\"VALIDATION_ERROR\",...,\"status\":400}}'. After, on 6466a6c05 with the runtime rebuilt: no recordId and nothing to write both returned json=true {\"error\":{\"code\":\"VALIDATION_ERROR\",\"message\":...,\"status\":400}}, and the success control wrote. Pin: action-declarative-update.test.ts, describe '#15079 point 7', 'the MCP door answers the same two refusals with `code` + `status`, and writes nothing'. It covers close_case with no recordId, plus a new params-only fixture action note_case run with params {}, and asserts code VALIDATION_ERROR, status 400, the message subject, 0 updates and the row unchanged. Runs at 6466a6c05 through os-verify-lock: the two touched files gave 'Test Files 2 passed (2) / Tests 42 passed (42)', VERDICT command-exit 0. The full runtime suite (exec vitest run --project local --maxWorkers=2) gave 'Test Files 421 passed (421) / Tests 6625 passed | 19 skipped (6644)', VERDICT command-exit 0. Ablation leg C, with the fix committed first, used scripts/ablation-replace.mjs in WRAP mode. Tests import the source by relative path, so there was no dist leg. The anchor was '{ code: standardErrorCodeForHttpStatus(status), status }', replaced by '{ status }', 1→0. The blob went f379ccfb9e51 → 257cb3d3a474. The result was exactly 1 red / 41 green, 'expected undefined to be VALIDATION_ERROR' on the new MCP pin. The REST point-7 cases stayed green, as predicted. Restore: blob f379ccfb9e51 == HEAD, git diff HEAD empty, porcelain clean.", "gates": "Local, at 6466a6c05. dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived the same 110 commands as at ee0888855. After a turbo build --filter='!@objectstack/docs' (76 tasks, 63 cached), all 110 exited 0, each captured before any pipe. dispatch-gates --ran gave '110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN', a derived zero. The 11 declared-wide families all exited 0. The 6 roster gates under touched dirs all exited 0. pnpm --filter @objectstack/runtime run typecheck exited 0 (check:test-typecheck OK). eslint --no-inline-config over the 2 changed .ts files exited 0. check-changeset-no-major.mjs exited 0. check-adr-0087-registration.mjs exited 0 ('1 declared-breaking changeset ... not-required (no-migration-prescription)'). Spec typecheck was not re-run because round 1 does not touch packages/spec; it exited 0 at ee0888855. CI read before pushing: on ee0888855 there was no red (30 success, 5 skipped); its six Test Core shards were cancelled by the push of 6466a6c05, a superseded run, not a failure. CI on 6466a6c05 at report time: 33 success (Lint & Repo Gates, Build Core, all four Type Check lanes, Check Changeset, Governed Surface Queue Guard among them), 5 skipped, and Test Core 3/6 and 4/6 in_progress. Per the contract I did not wait on them. origin/main is 584b4225e, 6 commits past the base. None touches this diff's paths, and git merge-tree is clean. Not merged.", "mcp_calls": "0", "api_writes": "1 this round: POST /repos/objectstack-ai/objectstack/dispatches through the fleet-write relay, executing POST /repos/objectstack-ai/objectstack/issues/24268/comments (this os-dev-report). git push x1 (ee0888855..6466a6c05) is not a REST write. The PR body was not written, per os-dev.md:64; the seat applies body_edits. Reads: issue comment 6254699772 and check-runs on ee0888855 and 6466a6c05, all via gh api GET.", "deviations": [ "The real-bridge measurement is a throwaway dogfood file. It booted the same bootStack + MCPServerPlugin shape as flow-door-elevated-start.dogfood.test.ts, because runtime does not depend on @objectstack/mcp and packages/mcp does not depend on runtime. It was run before and after the fix, then deleted and not committed. The committed pin is at the runtime MCP door (invokeBusinessAction) and asserts the two members thrownRefusalEnvelope reads, which is the D2 pin's own level. A committed end-to-end dogfood pin would add a boot-sized file to the main battery outside the claim's surface. The seat can ask for it.", "The worktree had been removed after round 0. It was re-created from the existing local branch claude/issue-24268-declarative-update-undeclared-params at ee0888855 (== origin), with no merge of main.", "The fix also covers the third declarativeUpdateRefusal shape, the object-less action key, because it is the same function. That shape was not separately measured over /mcp or pinned at the MCP door. Its REST pin (point 7, 'an OBJECT-LESS action key') stays green." ], "body_edits": [ { "section": "opening (Clause-② basis)", "old": "The one envelope change (the MCP door's ADR-0104 D2 refusal now carries `code` + `status`) adds members to a refusal that already refused; it accepts nothing new.", "new": "The envelope changes at the MCP door (the ADR-0104 D2 refusal, and the declarative executor's own refusals, now carry `code` + `status`) add members to refusals that already refused; they accept nothing new." }, { "section": "What changed", "old": "- **MCP door envelope** (`action-execution.ts:2535`, in `invokeBusinessAction`). The D2 throw was a bare `Error`, which `@objectstack/mcp`'s `thrownRefusalEnvelope` renders message-only, because it reads a string `code`. It is now thrown with `code: standardErrorCodeForHttpStatus(400)` (`VALIDATION_ERROR`, the code the REST door's `deps.error(paramError, 400)` derives) and `status: 400`. This applies to every D2 refusal at that door, not only the new one (see Acceptance notes).", "new": "- **MCP door envelope** (`action-execution.ts:2542`, in `invokeBusinessAction`). The D2 throw was a bare `Error`, which `@objectstack/mcp`'s `thrownRefusalEnvelope` renders message-only, because it reads a string `code`. It is now thrown with `code: standardErrorCodeForHttpStatus(400)` (`VALIDATION_ERROR`, the code the REST door's `deps.error(paramError, 400)` derives) and `status: 400`. This applies to every D2 refusal at that door, not only the new one (see Acceptance notes).\n- **The declarative executor's own refusals** (`declarativeUpdateRefusal`, `action-execution.ts:2221`: no `recordId`, nothing to write, an object-less action key). They threw `status` only, so over the real `/mcp` bridge they rendered message-only (measured; see Tests). They now carry `code: standardErrorCodeForHttpStatus(status)` beside the status, as the D2 throw does. The REST door already derived the same code from the status, so its answers are unchanged." }, { "section": "What changed", "old": "- **Docblocks that became false or incomplete:** `declarativeUpdateWrite`, and the `invokeBusinessAction` input comment (\"silently accepted on one that declares none\").", "new": "- **Docblocks that became false or incomplete:** `declarativeUpdateWrite`; the `invokeBusinessAction` input comment (\"silently accepted on one that declares none\"); and `declarativeUpdateRefusal`'s (\"No `code` literal is stamped here ... agree by construction\"), which the measurement disproved." }, { "section": "What changed", "old": "- **Changeset:** `.changeset/24268-declarative-update-undeclared-params-refused.md`, `@objectstack/runtime: minor`, `fix(runtime)!:`, line-initial `Clause-②: no (narrowing)`, ADR-0087 `not-required (no-migration-prescription)`, and a `**BREAKING**` sentence naming the consumers.", "new": "- **Changeset:** `.changeset/24268-declarative-update-undeclared-params-refused.md`, `@objectstack/runtime: minor`, `fix(runtime)!:`, line-initial `Clause-②: no (narrowing)`, ADR-0087 `not-required (no-migration-prescription)`, and a `**BREAKING**` sentence naming the consumers: objectstack-ai/hotcrm's `complete_task`, consoles vendored before objectui `fb501bc894`, and page action rows that forward static object `params` to a param-less declarative update (none in this repo; the row surface is #24394)." }, { "section": "Consumers, measured", "old": "- **Out of repo (from the card):** objectstack-ai/hotcrm's `complete_task`, called with params, and any console vendored before objectui `fb501bc894`. Both are named in the changeset.", "new": "- **Out of repo:** objectstack-ai/hotcrm's `complete_task`, called with params, and any console vendored before objectui `fb501bc894` (both from the card); and page action rows (`action:button` / `action:icon` with `operation: 'update'`) whose static object `params` the console forwards to a param-less declarative update action, zero of them in this repo (the row surface is #24394). All three are named in the changeset." }, { "section": "Tests", "old": " - Control: a param-less handler action (`nudge_case`) with the same undeclared key still reaches the registry (404 not-found, `executeAction` called). The pass-through is kept.", "new": " - Control: a param-less handler action (`nudge_case`) with the same undeclared key still reaches the registry (404 not-found, `executeAction` called). The pass-through is kept.\n- `packages/runtime/src/action-declarative-update.test.ts`, describe `#15079 point 7` (patch round 1): the MCP door answers the no-`recordId` refusal (`close_case`) and the nothing-to-write refusal (`note_case`, a new params-only fixture action) with `code` `VALIDATION_ERROR` + `status` 400, and writes nothing. Those are the two members `thrownRefusalEnvelope` reads." }, { "section": "Tests", "old": "Runs at `ee0888855`, each through `scripts/pm/os-verify-lock.sh`:", "new": "Real-bridge measurement (patch round 1; a throwaway dogfood file, not committed): `bootStack` + `MCPServerPlugin`, `POST /mcp` `tools/call run_action` on a fixture object, so the runtime's `buildMcpBridge` throw is rendered by `@objectstack/mcp`. On `ee0888855`, no `recordId` and nothing-to-write each answered `isError: true` with the bare message and no JSON envelope, while the D2 control answered `{\"error\":{\"code\":\"VALIDATION_ERROR\",...,\"status\":400}}`. On `6466a6c05`, with runtime rebuilt, all three answered `{\"error\":{\"code\":\"VALIDATION_ERROR\",\"message\":...,\"status\":400}}`, and the success control wrote.\n\nRuns at `6466a6c05`, each through `scripts/pm/os-verify-lock.sh`:" }, { "section": "Tests", "old": "- The two files: `Test Files 2 passed (2)`, `Tests 41 passed (41)`, `VERDICT command-exit 0`.", "new": "- The two files: `Test Files 2 passed (2)`, `Tests 42 passed (42)`, `VERDICT command-exit 0`." }, { "section": "Tests", "old": "- The full runtime suite (`pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2`): `Test Files 421 passed (421)`, `Tests 6624 passed | 19 skipped (6643)`, `VERDICT command-exit 0`.", "new": "- The full runtime suite (`pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2`): `Test Files 421 passed (421)`, `Tests 6625 passed | 19 skipped (6644)`, `VERDICT command-exit 0`." }, { "section": "Reverse verification", "old": "The fix was committed first (`ee0888855`). Each leg went through `node scripts/ablation-replace.mjs` in WRAP mode, so the restore is armed on exit, INT and TERM against the absolute path.", "new": "The fix was committed first (`ee0888855` for legs A and B, `6466a6c05` for leg C). Each leg went through `node scripts/ablation-replace.mjs` in WRAP mode, so the restore is armed on exit, INT and TERM against the absolute path." }, { "section": "Reverse verification", "old": "- Observed direction: red, the expected direction in both legs. `git status --porcelain` was empty after both.", "new": "- **Leg C, the executor refusal's code dropped** (patch round 1: `declarativeUpdateRefusal` back to `Object.assign(new Error(message), { status })`). The blob went from `f379ccfb9e51` to `257cb3d3a474`. **Exactly 1 red, 41 green**: the new MCP point-7 case, with `expected undefined to be 'VALIDATION_ERROR'`. The REST point-7 cases stayed green, because the REST door derives the code from the status. Restore: blob `f379ccfb9e51` equals HEAD, and `git diff HEAD` is empty.\n- Observed direction: red, the expected direction in all three legs. `git status --porcelain` was empty after each." }, { "section": "Gates", "old": "## Gates (local, at `ee0888855`)", "new": "## Gates (local, at `6466a6c05`)" }, { "section": "Gates", "old": "- `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 110 commands. 108 exited 0 on the first pass. 2 exited 3 (`PREREQUISITE NOT MET`): `check:skill-examples` needed a `client-react` build, and `check:dual-build-cjs-loads` needed a full build. After `turbo run build --filter='!@objectstack/docs'` (76 tasks, 75 cached), both exited 0.", "new": "- `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 110 commands, the same set as at `ee0888855`. After `turbo run build --filter='!@objectstack/docs'` (76 tasks, 63 cached), all 110 exited 0 on the first pass. (At `ee0888855`, `check:skill-examples` and `check:dual-build-cjs-loads` first exited 3, `PREREQUISITE NOT MET`, and exited 0 after the same build.)" }, { "section": "Gates", "old": "- `pnpm --filter @objectstack/runtime run typecheck` exited 0, and so did `pnpm --filter @objectstack/spec run typecheck`. Both new test files are in `tsconfig.test.json`'s program (`--listFiles`), and `check:test-typecheck` is OK.", "new": "- `pnpm --filter @objectstack/runtime run typecheck` exited 0 at `6466a6c05`. `pnpm --filter @objectstack/spec run typecheck` exited 0 at `ee0888855`; round 1 does not touch `packages/spec`. Both new test files are in `tsconfig.test.json`'s program (`--listFiles`), and `check:test-typecheck` is OK." }, { "section": "Gates", "old": "- `eslint --no-inline-config` over the 4 changed TypeScript files exited 0.", "new": "- `eslint --no-inline-config` over the 4 changed TypeScript files exited 0; in round 1, over the 2 it touches, exit 0." }, { "section": "Gates", "old": "- `origin/main` has since moved to `1eae21f97` (5 commits). None touches this diff's files or behaviour, and `git merge-tree` is clean. Not merged, per the claim: a merge happens only for a conflict, a main-caused red or a dequeue.", "new": "- `origin/main` has since moved to `584b4225e` (6 commits past the base). None touches this diff's files or behaviour, and `git merge-tree` is clean. Not merged, per the claim: a merge happens only for a conflict, a main-caused red or a dequeue.\n- CI on `ee0888855` showed no red. Its six `Test Core` shards were cancelled when `6466a6c05` was pushed, and they rerun on that head." }, { "section": "Acceptance notes", "old": "This is raised for the seat in the report's open questions.", "new": "The seat filed the ADR record as #24393 (Tier H, its own PR, `Blocked-by: #24268`)." }, { "section": "Acceptance notes", "old": "This needs a decision on the row surface and is reported, not changed.", "new": "The seat filed the row surface for the spec lane as #24394. This PR only names the rows in the changeset." }, { "section": "Acceptance notes", "old": "4. **MCP envelope scope.** The `code` + `status` stamp covers every D2 refusal at `run_action`, including declared-params handler actions, which used to render message-only. The declarative executor's own refusals (`declarativeUpdateRefusal`: no `recordId`, nothing to write) still throw `status` only. `packages/mcp`'s renderer reads `code`, so at the MCP door those still render message-only, even though that function's docblock says the two doors \"agree by construction\". This was read, not measured over `/mcp`, and it is not changed here.", "new": "4. **MCP envelope scope.** The `code` + `status` stamp covers every D2 refusal at `run_action`, including declared-params handler actions, which used to render message-only. The declarative executor's own refusals (`declarativeUpdateRefusal`: no `recordId`, nothing to write, an object-less key) were measured message-only over the real `/mcp` bridge and now carry the derived code too (patch round 1; see What changed)." } ], "open_questions": [], "out_of_scope_findings": [ "carrier: none (承接者:无) · Acceptance note 5 in PR #24392 body (already present), not filed · comment drift: packages/services/service-automation/src/engine.ts (~8601) and screen-input-contract.ts (~64) cite enforceActionParams' param-less pass-through as their model; still true for handler actions · dedupe words: screen input contract pass-through analogy, enforceActionParams param-less comment", "carrier: #24394 (seat-filed, spec lane) · page action row operation: 'update' surface, round 0's findings[0]; named in the changeset this round", "resolved in this PR, round 1 (no longer a finding): round 0's findings[1], declarativeUpdateRefusal message-only at the MCP door, measured and fixed in 6466a6c05" ] }Generated by Claude Code
objectstack-fleet commentedon Oct 18, 2026
ACCEPT (lane review) → PR #24392 comment
6255104464· head6466a6c05· patch round 1 done: the changeset names page action rows (#24394), and the MCP door's executor refusals were measured message-only and now carrycode+status. ⛔ Not queueable yet: aCONTRACT_REVIEW_TIERreview is running in an isolated reviewer, andneeds:contract-reviewis on the PR. The seat wrote the PR body's corrections.Generated by Claude Code
objectstack-fleet commentedon Oct 18, 2026
Contract review → PR #24392 comment 6255264902:
CONTRACT_REVIEW_TIER, head6466a6c05, PASS.needs:contract-reviewis removed. The PR lands through the queue once the pre-checks read clean. Carried from the record: #24393 (the ADR-0104 record) unblocks when this card closes, and the maintainer lands it by hand. #24394 (page action rows) is for the spec lane within v18.Generated by Claude Code
objectstack-fleet commentedon Oct 18, 2026
Landed: PR #24392 →
d79dc4883·domain:cliseat 1 ·zhuangjianguo· sessionsession_01SYEJqDL3NXxrJRBVqaMu9T· 2026-10-18T22:00Zmerged: true, merge commitd79dc4883, merged 2026-10-18T21:59Z through the merge queue (enqueued 2026-10-18T21:26Z)origin/maind79dc4883is an ancestor oforigin/main, with a single parent (squash). The 6 files and +265 / −22 match what was reviewed at6466a6c05gh-readonly-queue/main/*pr-24392*head remains6254028639→ report6254612136→ REWORK6254699772(pointer6254704346) → report6255070808→ the PR body's corrections written by the seat;6255104464(pointer6255119097) →CONTRACT_REVIEW_TIERPASS6255264902(pointer6255294986).6254685225, [PM seat] domain:devx @ objectstack — 🟢 os-warren · session_01FM29ZqLTrnPNXxijioZwMp #60236254691036.operation: 'update'action that declares noparamsrefuses every undeclared wire key at both action doors, through the existing ADR-0104 D2 path (the same 400, the sameOS_ALLOW_LAX_ACTION_PARAMShatch). Handler actions keep the pass-through. At the MCPrun_actiondoor, the D2 refusal and the declarative executor's own refusals now carrycode+status.@objectstack/runtimeships it asminor(fix(runtime)!:, the narrowing shape).Blocked-by: #24268closed):operation: 'update'action's empty list is an empty accept set (Tier H, the maintainer's hand) #24393: the ADR-0104 D2 record. Tier H, landed by the maintainer's hand.operation: 'update'is described as "apatchorparamsgives the values to write", but the route writes the named action's ownpatch, and after #24268 refuses staticparamsthat action does not declare #24394: page action rows'operation: 'update'describe, for the spec lane.pm:blocked→pm:queuemove is the unlock scan's.completedon the merge.pm:dispatchedis removed in the stroke after this comment.Generated by Claude Code