Repository navigation
fix(runtime)!: a param-less declarative update refuses undeclared wire params at both action doors - #24392
Conversation
…e params at both action doors An `operation: 'update'` action that declares no `params` skipped the ADR-0104 D2 param check, so `declarativeUpdateWrite` merged any wire key over `patch` and wrote it to the row. Its declared list is its whole wire write set, so an empty or absent list is now an empty accept set, refused through the existing D2 path (same message, 400, OS_ALLOW_LAX_ACTION_PARAMS hatch). Handler actions keep the param-less pass-through. The MCP run_action door's D2 throw now carries code + status, so both doors answer the ADR-0112 envelope. Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 148 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4e14b58f0ab14f0861085ef71e2cdd3c328a530d && git checkout 4e14b58f0ab14f0861085ef71e2cdd3c328a530d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 584b4225e1fcbaf5e8bed14d9c280d6347a897ff 6466a6c05730d09192484342f74310a84fb89863 && git checkout -B drift-repro 584b4225e1fcbaf5e8bed14d9c280d6347a897ff && git merge --no-ff 6466a6c05730d09192484342f74310a84fb89863
node scripts/docs-audit/affected-docs.mjs --json 584b4225e1fcbaf5e8bed14d9c280d6347a897ff |
席位核对 — REWORK (patch round 1: two small items; both open questions answered)
Accepted as it stands (read on the diff):
Owed in this round:
Your open questions, answered:
Generated by Claude Code |
…ed code to the MCP door declarativeUpdateRefusal (no recordId, nothing to write, object-less key) threw `status` only; @objectstack/mcp's thrownRefusalEnvelope reads a string `code`, so over the real /mcp bridge those refusals rendered message-only. The throw now carries standardErrorCodeForHttpStatus(status) beside the status, as the D2 throw does; the docblock's "agree by construction" claim is corrected. Pinned at the MCP door. The changeset's BREAKING sentence also names page action rows that forward static object params to a param-less declarative update (none in this repo; the row surface is #24394). Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
席位核对ACCEPT (lane review; ⛔ not yet queueable: a
Generated by Claude Code |
Contract reviewServed-tier: Read-only, adversarial to the dispatching seat's conclusions ( ① Derived judgmentsGate verdicts on the head: all five required contexts are
Nothing found that moves the accept set or the public surface beyond the narrowing and the envelope above. ② Semver level
③ Boundary flagsDev flags (reports
Open questions (round 0), answered by the seat in
Governance: none of the six paths is a governed surface ( Implemented-by: VERDICT: PASS |
Fixes #24268
Clause-②: no (narrowing). Basis: the two action doors stop accepting an undeclared wire param on a param-less
operation: 'update'action, and nothing newly accepted or exported is added. The envelope changes at the MCP door (the ADR-0104 D2 refusal, and the declarative executor's own refusals, now carrycode+status) add members to refusals that already refused; they accept nothing new. Owes oneCONTRACT_REVIEW_TIERreview before the queue.Dispatched by the
domain:cliseat 1 (session_01SYEJqDL3NXxrJRBVqaMu9T, claim comment 6254028639); this dev ran as a subagent of that session. Branched fromorigin/mainb2e78701f(one commit past the claim's readd65826fa7: #24373, which touches neither runtime file nor.objectui-sha).Measured before writing
b2e78701f,enforceActionParamsreturnednullat once whenaction.paramswas empty or absent, anddeclarativeUpdateWritemerged{ ...patch, ...params }. Measured with this PR's conformance cases run against the unchanged source: 3 of 33 red. The REST door answered 200 (expected 400) forparams: { note: ... }and forparams: { status: 'reopened' }on the param-lessclose_case(patch: { status: 'closed' }). The MCPrun_actiondoor resolved instead of refusing.git merge-base --is-ancestor fb501bc894 0707b4ae37exits 0 (ancestor). Exit 0 proves it even though that checkout is shallow (--is-shallow-repositoryanswerstrue)..objectui-shaon this branch's base is0707b4ae37. At that sha,ActionRunner.executeUpdateOperationposts{ ...collected }and neverpatch.serverActionHandlerstrips_rowRecordand POSTs{ recordId, params }. So the vendored console does not post the patch as params, and this change does not break its declarative-update buttons.What changed
enforceActionParams(packages/runtime/src/action-execution.ts:1678). The early return now applies only to a param-less action that is not a declarative update. ForisDeclarativeUpdateAction(action), the declared list resolves to[], and the existing validator then refuses every key that is not a dispatcher built-in (recordId,objectName,_selectedIds). The route is the existing D2 path: the same message (Invalid action params: Unknown action param "KEY" ...), the same 400 at the REST door, and the sameOS_ALLOW_LAX_ACTION_PARAMS=1escape hatch, which warns and accepts. No new switch, permission, capability or config key. The docblock sentence "Actions that declare noparamskeep the pass-through" is replaced by a section saying which param-less actions keep it (those with a handler) and why a declarative update does not.action-execution.ts:2542, ininvokeBusinessAction). The D2 throw was a bareError, which@objectstack/mcp'sthrownRefusalEnveloperenders message-only, because it reads a stringcode. It is now thrown withcode: standardErrorCodeForHttpStatus(400)(VALIDATION_ERROR, the code the REST door'sdeps.error(paramError, 400)derives) andstatus: 400. This applies to every D2 refusal at that door, not only the new one (see Acceptance notes).declarativeUpdateRefusal,action-execution.ts:2221: norecordId, nothing to write, an object-less action key). They threwstatusonly, so over the real/mcpbridge they rendered message-only (measured; see Tests). They now carrycode: 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.declarativeUpdateWrite; theinvokeBusinessActioninput comment ("silently accepted on one that declares none"); anddeclarativeUpdateRefusal's ("Nocodeliteral is stamped here ... agree by construction"), which the measurement disproved.content/docs/automation/flows.mdx: "the same way an action with noparamsis untouched". It now names the declarative-update exception.packages/spec/src/contracts/ai-service.ts: the TSDoc onAI_ACTION_CONFIRMATION_MEMBER, "On an action declaring no params that check is a pass-through". This is a comment only; there is no schema,.describeor generated artifact.patch.describe(the precedence is unchanged), andliveness/action.json/props/patch("the wire cannot widen the bag (an undeclared param is refused by ADR-0104 D2)"), which this PR makes true for the param-less case too.content/docs/protocol/objectui/actions.mdxandcontent/docs/getting-started/build-with-claude-code.mdxdescribe param-less updates invoked with no params; those still write..changeset/24268-declarative-update-undeclared-params-refused.md,@objectstack/runtime: minor,fix(runtime)!:, line-initialClause-②: no (narrowing), ADR-0087not-required (no-migration-prescription), and a**BREAKING**sentence naming the consumers: objectstack-ai/hotcrm'scomplete_task, consoles vendored before objectuifb501bc894, and page action rows that forward static objectparamsto a param-less declarative update (none in this repo; the row surface is spec(ui): a page action row'soperation: '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).Family: every door that executes a declarative update
git grepoverpackages/**forexecuteDeclarativeUpdateAction,declarativeUpdateWrite,isDeclarativeUpdateAction,invokeBusinessActionandrunAction(non-test sources).POST /actions/OBJECT/ACTION[/ID](all three Hono mounts and the fallback seam reachhandleActionsRequest)domains/actions.ts:676enforceActionParams, then:728executeDeclarativeUpdateActionVALIDATION_ERROR, nothing written (pinned)run_action: the runtime bridgedomains/mcp.tsrunAction, and@objectstack/mcpmcp-http-tools.tsbridge.runActioninvokeBusinessAction, thenenforceActionParams, thenexecuteDeclarativeUpdateActioncodeVALIDATION_ERROR+status400, nothing written (pinned)resume_run(domains/mcp.tsresumeActionRun)action.type === 'flow' && !isDeclarativeUpdateAction(action)bulkActionDefsoperation: 'update'(the defdeclarativeUpdateWrite's docblock names)useBulkExecutorwrites throughdataSource.update/bulkUpdate(the data API) at0707b4ae37declarativeUpdateWrite. Its collected values come from the def's own strictparams[].executeDeclarativeUpdateActionhas exactly the two callers above.objectql'saction-governance.tsandlint'svalidate-ai-tool-references.tsonly readoperationfor inventory and linting.Consumers, measured
git grep -E "operation: ?['\"]update['\"]"overexamples/**,packages/apps/**,packages/qa/**and every test, atee0888855:packages/runtime/src/action-declarative-update.test.ts(close_case, standaloneescalate_case,flag_case,global_update),packages/runtime/src/dispatcher-unique-violation-wire-code.test.ts(take_email, posted with a{}body), andpackages/mcp/src/mcp-action-refusal-details.test.ts(close_case, behind a fake bridge, no params). Two docs examples are invoked with no params:resolve_ticket(getting-started) andtask_complete(protocol/objectui/actions). There are zero inexamples/**,packages/apps/**andpackages/qa/**: every hit there is an RLS policy, abulkActionDefsentry or a batch op.paramsto one: zero in this repo before this PR. Every fixture invocation sends noparamsor an empty bag. No test or dogfood pin broke: the full runtime suite is green (below), so no fixture needed a fix.complete_task, called with params, and any console vendored before objectuifb501bc894(both from the card); and page action rows (action:button/action:iconwithoperation: 'update') whose static objectparamsthe console forwards to a param-less declarative update action, zero of them in this repo (the row surface is spec(ui): a page action row'soperation: '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). All three are named in the changeset.Tests
New cases, all on the real doors:
HttpDispatcher.handleActionsfor REST, andinvokeBusinessActionwired to the realcallDatafor MCP. The rig is the file's existing row-scoped, identity-honest engine double.packages/runtime/src/action-declarative-update.test.ts, describe#24268:close_casegives 400,error.codeVALIDATION_ERROR, a message naming"note", zero updates, and the row is equal tofreshRow().patchkey sent as a param (status, the console shape beforefb501bc894) is refused the same way, with no write.codeVALIDATION_ERROR,status400, the key named, zero updates, and the row unchanged.{}and{ params: {} }at REST;{ recordId }and{ recordId, params: {} }at MCP) still writes exactly{ status: 'closed' }.patchat both doors:{ status: 'triaged', priority: 'urgent' }.nudge_case) with the same undeclared key still reaches the registry (404 not-found,executeActioncalled). The pass-through is kept.packages/runtime/src/action-declarative-update.test.ts, describe#15079 point 7(patch round 1): the MCP door answers the no-recordIdrefusal (close_case) and the nothing-to-write refusal (note_case, a new params-only fixture action) withcodeVALIDATION_ERROR+status400, and writes nothing. Those are the two membersthrownRefusalEnvelopereads.packages/runtime/src/action-params-enforcement.test.ts: the gate itself. A param-less update, withparamsabsent or[], refuses an undeclared key and names it. It accepts{}and the built-ins (the positive controls).OS_ALLOW_LAX_ACTION_PARAMS=1accepts the key and warns once. The existing handler pass-through case was retitled and kept.Real-bridge measurement (patch round 1; a throwaway dogfood file, not committed):
bootStack+MCPServerPlugin,POST /mcptools/call run_actionon a fixture object, so the runtime'sbuildMcpBridgethrow is rendered by@objectstack/mcp. Onee0888855, norecordIdand nothing-to-write each answeredisError: truewith the bare message and no JSON envelope, while the D2 control answered{"error":{"code":"VALIDATION_ERROR",...,"status":400}}. On6466a6c05, with runtime rebuilt, all three answered{"error":{"code":"VALIDATION_ERROR","message":...,"status":400}}, and the success control wrote.Runs at
6466a6c05, each throughscripts/pm/os-verify-lock.sh:Test Files 2 passed (2),Tests 42 passed (42),VERDICT command-exit 0.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.Reverse verification
The fix was committed first (
ee0888855for legs A and B,6466a6c05for leg C). Each leg went throughnode scripts/ablation-replace.mjsin WRAP mode, so the restore is armed on exit, INT and TERM against the absolute path.if (!declaresParams && !isDeclarativeUpdateAction(action)) return null;replaced byif (!declaresParams) return null;). The anchor went from 1 hit to 0, and the blob fromafc25f537838to23bc0e53137a. Exactly 5 red, 36 green. Red were the three door refusals (REST undeclared, REST patch-key, MCP) and two gate cases (refuses an undeclared key; the lax switch's warn). Green were the empty-bag, declared-param and handler-control cases, as predicted. Restore: blobafc25f537838equals HEAD, andgit diff HEADis empty.throw new Error(paramError);). The blob went fromafc25f537838tocf8007e82ea1. Exactly 1 red: the MCP refusal case, withexpected undefined to be 'VALIDATION_ERROR'. Restore: blob equals HEAD, andgit diff HEADis empty.declarativeUpdateRefusalback toObject.assign(new Error(message), { status })). The blob went fromf379ccfb9e51to257cb3d3a474. Exactly 1 red, 41 green: the new MCP point-7 case, withexpected undefined to be 'VALIDATION_ERROR'. The REST point-7 cases stayed green, because the REST door derives the code from the status. Restore: blobf379ccfb9e51equals HEAD, andgit diff HEADis empty.git status --porcelainwas empty after each.Gates (local, at
6466a6c05)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 110 commands, the same set as atee0888855. Afterturbo run build --filter='!@objectstack/docs'(76 tasks, 63 cached), all 110 exited 0 on the first pass. (Atee0888855,check:skill-examplesandcheck:dual-build-cjs-loadsfirst exited 3,PREREQUISITE NOT MET, and exited 0 after the same build.)dispatch-gates --ran:110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN(a derived zero: every line carries its exit code).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.check-changeset-fixed,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 typecheckexited 0 at6466a6c05.pnpm --filter @objectstack/spec run typecheckexited 0 atee0888855; round 1 does not touchpackages/spec. Both new test files are intsconfig.test.json's program (--listFiles), andcheck:test-typecheckis OK.eslint --no-inline-configover the 4 changed TypeScript files exited 0; in round 1, over the 2 it touches, exit 0.check-changeset-no-major(no major) andcheck-adr-0087-registration(1 declared-breaking changeset ... not-required (no-migration-prescription)) both exit 0.test:repo, the full workspace typecheck lanes, andpnpm lint(the repo-wide scans). The spec package's own test suite was also not run: the spec diff is one TSDoc sentence, its typecheck and every derived spec family ran, and CI'sTest Coreruns the suite.origin/mainhas since moved to584b4225e(6 commits past the base). None touches this diff's files or behaviour, andgit merge-treeis clean. Not merged, per the claim: a merge happens only for a conflict, a main-caused red or a dequeue.ee0888855showed no red. Its sixTest Coreshards were cancelled when6466a6c05was pushed, and they rerun on that head.Acceptance notes
paramskeep today's pass-through (there is nothing to check against), so existing param-less actions are untouched". Lines 941-942 say "Actions declaring noparamskeep their pass-through — that boundary is unchanged from D2's landing". Both were written before declarative updates existed (ruling The most common action in any app — set a field on the current record — has no declarative form for a ROW action, while the BULK form is fully declarative #14092 came later), when every action had a handler. This PR keeps that boundary for handler actions and narrows it foroperation: 'update'. Prime Directive [WIP] Add Chinese version of the documentation #13 asks for an amended status line or an addendum on the ADR when a recorded decision changes.docs/adr/**is Tier H and outside this claim's surface, so it is untouched here. The seat filed the ADR record as docs(adr): ADR-0104 D2 still says every action that declares no params keeps the pass-through — since #24268 a declarativeoperation: 'update'action's empty list is an empty accept set (Tier H, the maintainer's hand) #24393 (Tier H, its own PR,Blocked-by: #24268).action:button/action:iconrow withoperation: 'update'may carry an OBJECTparamsof static values. The spec's row judge counts those as values to write (action-declarative-update.ts,staticParams; the rowoperationdescribe says "apatchorparamsgives the values to write"). The console forwards them as wire params. After this PR, they are written only when the action the route resolves by name declares them; a param-less declaration refuses them. The row's ownpatchhas not reached the route since objectuifb501bc894: the route writes its declaration'spatch. There are zero page rows withoperation: 'update'in this repo. The seat filed the row surface for the spec lane as spec(ui): a page action row'soperation: '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. This PR only names the rows in the changeset.recordId,objectName,_selectedIds) stay allowlisted by the D2 validator and still reachdeclarativeUpdateWrite's bag. The engine's undeclared-field write door then refuses them asINVALID_FIELDunless the object declares such a field. This is unchanged here.code+statusstamp covers every D2 refusal atrun_action, including declared-params handler actions, which used to render message-only. The declarative executor's own refusals (declarativeUpdateRefusal: norecordId, nothing to write, an object-less key) were measured message-only over the real/mcpbridge and now carry the derived code too (patch round 1; see What changed).packages/services/service-automation/src/engine.ts(about line 8601) andscreen-input-contract.ts(about line 64) citeenforceActionParams' param-less pass-through as their model. They remain accurate for handler actions and were not edited.paramskeep the pass-through. They are out of this card's scope.Generated by Claude Code