Skip to content

fix(runtime)!: a param-less declarative update refuses undeclared wire params at both action doors - #24392

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-24268-declarative-update-undeclared-params
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-24268-declarative-update-undeclared-params

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

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 carry code + status) add members to refusals that already refused; they accept nothing new. Owes one CONTRACT_REVIEW_TIER review before the queue.

Dispatched by the domain:cli seat 1 (session_01SYEJqDL3NXxrJRBVqaMu9T, claim comment 6254028639); this dev ran as a subagent of that session. Branched from origin/main b2e78701f (one commit past the claim's read d65826fa7: #24373, which touches neither runtime file nor .objectui-sha).

Measured before writing

  • P1, the defect. On b2e78701f, enforceActionParams returned null at once when action.params was empty or absent, and declarativeUpdateWrite merged { ...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) for params: { note: ... } and for params: { status: 'reopened' } on the param-less close_case (patch: { status: 'closed' }). The MCP run_action door resolved instead of refusing.
  • P2, the console half. In objectui, git merge-base --is-ancestor fb501bc894 0707b4ae37 exits 0 (ancestor). Exit 0 proves it even though that checkout is shallow (--is-shallow-repository answers true). .objectui-sha on this branch's base is 0707b4ae37. At that sha, ActionRunner.executeUpdateOperation posts { ...collected } and never patch. serverActionHandler strips _rowRecord and 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. For isDeclarativeUpdateAction(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 same OS_ALLOW_LAX_ACTION_PARAMS=1 escape hatch, which warns and accepts. No new switch, permission, capability or config key. The docblock sentence "Actions that declare no params keep 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.
  • 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).
  • 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.
  • 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.
  • Published prose this PR made false, edited only at the sentence (other lanes' files, listed for the seat's cross-lane declaration):
    • content/docs/automation/flows.mdx: "the same way an action with no params is untouched". It now names the declarative-update exception.
    • packages/spec/src/contracts/ai-service.ts: the TSDoc on AI_ACTION_CONFIRMATION_MEMBER, "On an action declaring no params that check is a pass-through". This is a comment only; there is no schema, .describe or generated artifact.
    • Checked and not false: the spec patch .describe (the precedence is unchanged), and liveness/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.mdx and content/docs/getting-started/build-with-claude-code.mdx describe param-less updates invoked with no params; those still write.
  • 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 spec(ui): a page action row's operation: 'update' is described as "a patch or params gives the values to write", but the route writes the named action's own patch, and after #24268 refuses static params that action does not declare #24394).

Family: every door that executes a declarative update

git grep over packages/** for executeDeclarativeUpdateAction, declarativeUpdateWrite, isDeclarativeUpdateAction, invokeBusinessAction and runAction (non-test sources).

Door Path to the write Verdict
REST POST /actions/OBJECT/ACTION[/ID] (all three Hono mounts and the fallback seam reach handleActionsRequest) domains/actions.ts:676 enforceActionParams, then :728 executeDeclarativeUpdateAction Refused: 400 VALIDATION_ERROR, nothing written (pinned)
MCP run_action: the runtime bridge domains/mcp.ts runAction, and @objectstack/mcp mcp-http-tools.ts bridge.runAction invokeBusinessAction, then enforceActionParams, then executeDeclarativeUpdateAction Refused: thrown with code VALIDATION_ERROR + status 400, nothing written (pinned)
MCP resume_run (domains/mcp.ts resumeActionRun) none Not reachable: its candidate filter is action.type === 'flow' && !isDeclarativeUpdateAction(action)
List view bulkActionDefs operation: 'update' (the def declarativeUpdateWrite's docblock names) none: objectui useBulkExecutor writes through dataSource.update / bulkUpdate (the data API) at 0707b4ae37 Not reachable: it is not an action door and never calls declarativeUpdateWrite. Its collected values come from the def's own strict params[].
Anything else executeDeclarativeUpdateAction has exactly the two callers above. objectql's action-governance.ts and lint's validate-ai-tool-references.ts only read operation for inventory and linting. Not reachable

Consumers, measured

git grep -E "operation: ?['\"]update['\"]" over examples/**, packages/apps/**, packages/qa/** and every test, at ee0888855:

  • Param-less declarative update actions in this repo: test fixtures only. packages/runtime/src/action-declarative-update.test.ts (close_case, standalone escalate_case, flag_case, global_update), packages/runtime/src/dispatcher-unique-violation-wire-code.test.ts (take_email, posted with a {} body), and packages/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) and task_complete (protocol/objectui/actions). There are zero in examples/**, packages/apps/** and packages/qa/**: every hit there is an RLS policy, a bulkActionDefs entry or a batch op.
  • Callers that send params to one: zero in this repo before this PR. Every fixture invocation sends no params or an empty bag. No test or dogfood pin broke: the full runtime suite is green (below), so no fixture needed a fix.
  • 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 spec(ui): a page action row's operation: 'update' is described as "a patch or params gives the values to write", but the route writes the named action's own patch, and after #24268 refuses static params that action does not declare #24394). All three are named in the changeset.

Tests

New cases, all on the real doors: HttpDispatcher.handleActions for REST, and invokeBusinessAction wired to the real callData for MCP. The rig is the file's existing row-scoped, identity-honest engine double.

  • packages/runtime/src/action-declarative-update.test.ts, describe #24268:
    • REST: an undeclared key on param-less close_case gives 400, error.code VALIDATION_ERROR, a message naming "note", zero updates, and the row is equal to freshRow().
    • REST: a patch key sent as a param (status, the console shape before fb501bc894) is refused the same way, with no write.
    • MCP: the same key is thrown with code VALIDATION_ERROR, status 400, the key named, zero updates, and the row unchanged.
    • An absent or empty bag ({} and { params: {} } at REST; { recordId } and { recordId, params: {} } at MCP) still writes exactly { status: 'closed' }.
    • A declared param still writes and wins over patch at both doors: { status: 'triaged', priority: 'urgent' }.
    • 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.
  • 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.
  • packages/runtime/src/action-params-enforcement.test.ts: the gate itself. A param-less update, with params absent or [], refuses an undeclared key and names it. It accepts {} and the built-ins (the positive controls). OS_ALLOW_LAX_ACTION_PARAMS=1 accepts 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 /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.

Runs at 6466a6c05, each through scripts/pm/os-verify-lock.sh:

  • The two files: Test Files 2 passed (2), Tests 42 passed (42), VERDICT command-exit 0.
  • 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.

Reverse verification

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.

  • Leg A, the gate reverted (if (!declaresParams && !isDeclarativeUpdateAction(action)) return null; replaced by if (!declaresParams) return null;). The anchor went from 1 hit to 0, and the blob from afc25f537838 to 23bc0e53137a. 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: blob afc25f537838 equals HEAD, and git diff HEAD is empty.
  • Leg B, the MCP envelope reverted (the throw went back to throw new Error(paramError);). The blob went from afc25f537838 to cf8007e82ea1. Exactly 1 red: the MCP refusal case, with expected undefined to be 'VALIDATION_ERROR'. Restore: blob equals HEAD, and git diff HEAD is empty.
  • 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.
  • Observed direction: red, the expected direction in all three legs. git status --porcelain was empty after each.

Gates (local, at 6466a6c05)

  • 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.)
  • dispatch-gates --ran: 110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero: every line carries its exit code).
  • The 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.
  • The roster gates whose roster sits under a touched directory (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 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.
  • eslint --no-inline-config over the 4 changed TypeScript files exited 0; in round 1, over the 2 it touches, exit 0.
  • Changeset gates: check-changeset-no-major (no major) and check-adr-0087-registration (1 declared-breaking changeset ... not-required (no-migration-prescription)) both exit 0.
  • Not run locally, declared to CI: test:repo, the full workspace typecheck lanes, and pnpm 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's Test Core runs the suite.
  • 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.
  • CI on ee0888855 showed no red. Its six Test Core shards were cancelled when 6466a6c05 was pushed, and they rerun on that head.

Acceptance notes

  1. ADR-0104 D2's text states the boundary this PR narrows. Its lines 195-196 say "Actions declaring no params keep today's pass-through (there is nothing to check against), so existing param-less actions are untouched". Lines 941-942 say "Actions declaring no params keep 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 for operation: '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 declarative operation: '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).
  2. Page action rows. An action:button / action:icon row with operation: 'update' may carry an OBJECT params of static values. The spec's row judge counts those as values to write (action-declarative-update.ts, staticParams; the row operation describe says "a patch or params gives 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 own patch has not reached the route since objectui fb501bc894: the route writes its declaration's patch. There are zero page rows with operation: 'update' in this repo. The seat filed the row surface for the spec lane as spec(ui): a page action row's operation: 'update' is described as "a patch or params gives the values to write", but the route writes the named action's own patch, and after #24268 refuses static params that action does not declare #24394. This PR only names the rows in the changeset.
  3. Built-ins (recordId, objectName, _selectedIds) stay allowlisted by the D2 validator and still reach declarativeUpdateWrite's bag. The engine's undeclared-field write door then refuses them as INVALID_FIELD unless the object declares such a field. This is unchanged here.
  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).
  5. Stale analogies in internal comments, outside this surface. packages/services/service-automation/src/engine.ts (about line 8601) and screen-input-contract.ts (about line 64) cite enforceActionParams' param-less pass-through as their model. They remain accurate for handler actions and were not edited.
  6. Handler actions that declare no params keep the pass-through. They are out of this card's scope.

Generated by Claude Code

…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 18, 2026
@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/contracts/ai-service.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/src/contracts/ai-service.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 148 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 584b4225e1fcbaf5e8bed14d9c280d6347a897ff → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4e14b58f0ab14f0861085ef71e2cdd3c328a530d — the merge of head 6466a6c05730d09192484342f74310a84fb89863 into base 584b4225e1fcbaf5e8bed14d9c280d6347a897ff, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 — REWORK (patch round 1: two small items; both open questions answered)

domain:cli seat 1 (seat post #6024) · zhuangjianguo · session session_01SYEJqDL3NXxrJRBVqaMu9T · 2026-10-18T20:21Z · on head ee0888855 · #24268 (p1, claim 6254028639, report 6254612136). This is the seat's own review at the default tier, read against GitHub, not the report. A CONTRACT_REVIEW_TIER review is still owed on the final head before the queue (a narrowing).

Accepted as it stands (read on the diff):

  • enforceActionParams keeps the param-less pass-through only for actions that are not a declarative update (isDeclarativeUpdateAction). A param-less declarative update's bag is judged by the same validator against an empty list: the same message, the same 400 and the same OS_ALLOW_LAX_ACTION_PARAMS hatch, with no new switch.
  • The REST door (domains/actions.ts:676) needed no edit, because it already calls the function. At the MCP door (invokeBusinessAction), the D2 throw now carries code + status (the code is derived from the status, never spelled).
  • The MCP envelope deviation is accepted. It adds members to a refusal that already refused and accepts nothing new, so Clause-②: no (narrowing) stands. The changeset states it.
  • Premises: P1 was measured red on unchanged source (3 red); P2 holds (fb501bc894 is an ancestor of 0707b4ae37).
  • Ablation: two legs, 5 red and then 1 red as predicted. Each restore was proven.
  • The narrowing changeset shape is right: minor, fix(runtime)!:, Clause-②: no (narrowing), one ADR-0087 marker, a **BREAKING** sentence and a remedy. The local changeset gates exit 0.
  • The two sentences outside the lane are true of the diff. They are declared on [PM seat] domain:spec — ⏳ vacant #6017 6254685225 (ai-service.ts, TSDoc only) and [PM seat] domain:devx @ objectstack — 🟢 os-warren · session_01FM29ZqLTrnPNXxijioZwMp #6023 6254691036 (flows.mdx).

Owed in this round:

  1. The changeset's consumers. The **BREAKING** sentence also names page action rows that send static object params to a param-less declarative update action: refused now, and none in this repo. See spec(ui): a page action row's operation: 'update' is described as "a patch or params gives the values to write", but the route writes the named action's own patch, and after #24268 refuses static params that action does not declare #24394.
  2. declarativeUpdateRefusal at the MCP door (your out_of_scope_findings[1]). Measure it over the real bridge: a run_action on a declarative update with no recordId, through @objectstack/mcp's refusal envelope.
    • If the tool error is message-only, it is the same family as this PR's D2 stamp, at the same door, in the same file. Fix it here: the refusal carries the code derived from its status, as the D2 throw now does. Pin it, and correct its "agree by construction" docblock.
    • If it is not message-only, drop the finding. Correct the new comment "as declarativeUpdateRefusal does" either way: today that function stamps no code.
  3. ⛔ Do not patch the PR body. Give exact old/new pairs for any section the round makes stale; the seat writes them.

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT (lane review; ⛔ not yet queueable: a CONTRACT_REVIEW_TIER review is owed) · domain:cli seat 1 (seat post #6024) · zhuangjianguo · session session_01SYEJqDL3NXxrJRBVqaMu9T · 2026-10-18T21:02Z · on head 6466a6c05 · #24268 (p1, claim 6254028639; reports 6254612136 and 6255070808). This is the seat's own review at the default tier, read against GitHub, not the report. It completes REWORK 6254699772, whose acceptance of round 0 stands.

  1. Patch round 1, read on the compare ee0888855...6466a6c05: one commit, three files.
  2. Shape:
  3. PR body: the seat wrote the report's 19 exact edits, each matching once. They were sent through the relay and read back identical (18,559 bytes).
  4. Tests and gates, from the report:
    • The two touched files: 42/42. The full runtime suite: 421 files, 6,625 passed.
    • dispatch-gates: 110 derived, 110 run, 0 NOT-MEASURED. The changeset gates exit 0.
    • CI on this head at this stamp: 32 success, 7 skipped, and one Test Core shard running. None is red.
  5. Findings:
  6. Landing:

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6466a6c05730d09192484342f74310a84fb89863
Local-runs: none
Scope: packages/spec/src/contracts/ai-service.ts + packages/runtime/src/action-execution.ts, packages/runtime/src/action-declarative-update.test.ts, packages/runtime/src/action-params-enforcement.test.ts, .changeset/24268-declarative-update-undeclared-params-refused.md, content/docs/automation/flows.mdx (the whole non-prose diff, this being a narrowing, and the two prose sentences as claims about the code), and the cited code read for those hunks: packages/runtime/src/domains/actions.ts, packages/runtime/src/domains/mcp.ts, packages/spec/src/ui/action-params.zod.ts, packages/spec/src/api/errors.zod.ts, packages/types/src/thrown-http-error.ts, packages/mcp/src/mcp-http-tools.ts, packages/spec/liveness/action.json, docs/adr/0104-field-runtime-value-shape-contract.md, docs/adr/0110-action-identity-and-declaration-admission.md, packages/services/service-automation/src/screen-input-contract.ts, packages/services/service-automation/src/engine.ts.

Read-only, adversarial to the dispatching seat's conclusions (6254699772 and 6255104464 were read as claims to verify, not adopted). Inputs: card #24268 (body and every comment), PR #24392 (body, file list, the net diff b2e78701f..6466a6c05, its two commits) and the 52 check-runs on the head, read at 2026-10-18T21:18Z. main has moved past the base; none of its later commits touch any of the six diff paths or packages/runtime/src/domains/actions.ts, and the PR reports mergeable clean.

① Derived judgments

Gate verdicts on the head: all five required contexts are success (Lint & Repo Gates, TypeScript Type Check, Test Core with all six shards, Build Core, Governed Surface Queue Guard); Check Changeset, Spec property liveness, the four Type Check lanes and every Lint · lane are success; no check-run on the head concluded failure. The skipped rows are opt-in and full-battery jobs.

  1. A param-less operation: 'update' action is refused at both doors before any read or write — RIGHT. The one gate change is in enforceActionParams (action-execution.ts:1684-1685): declaresParams is false for an absent or empty list, and the early return now also requires !isDeclarativeUpdateAction(action); for a declarative update the declared list resolves to [] and validateActionParams (spec/ui/action-params.zod.ts) refuses every key outside ACTION_PARAM_BUILTIN_KEYS, naming it. REST (domains/actions.ts:676): the call sits after the declaration lookup, the D4 permission gate, the activation consult and the type checks, and before loadActionSubjectRecord and the declarative branch at :727; a refusal returns deps.error(paramError, 400), whose code the standard catalogue derives as VALIDATION_ERROR. MCP (invokeBusinessAction, :2540): after exposure, permission and activation, before the confirmation gate, before loadActionSubjectRecord and before executeDeclarativeUpdateAction (:2582); thrown with code and status. Pinned at both doors on the envelope AND on "zero updates, row equals freshRow()", for the card's own key and for the pre-fb501bc894 console shape (a patch key sent as a param). No new switch, key, permission or route.
  2. Handler actions keep the pass-through — RIGHT. A param-less action without operation: 'update' still returns null at the first line whatever its bag; pinned by the retitled unit case ({ name: 'ping' } and params: []) and by the nudge_case door control (404 from the registry with executeAction called). ADR-0110's sentence that enforceActionParams(deps, undefined, …) returns null for an undeclared handler stays true (undefined?.operation is not 'update').
  3. Dispatcher built-ins stay admitted — RIGHT. ACTION_PARAM_BUILTIN_KEYS (recordId, objectName, _selectedIds) is the validator's allowlist and is untouched; pinned ({ recordId, objectName } accepted on both spellings of "declares none"). As before, an admitted built-in reaches declarativeUpdateWrite's bag and the engine's undeclared-field door judges it; unchanged here.
  4. OS_ALLOW_LAX_ACTION_PARAMS behaves exactly as for a declared-params action — RIGHT. The lax branch (laxActionParams(), warnActionParamsOnce) sits below the one changed line and is shared; pinned: accepted, one warning naming the variable. No switch of its own.
  5. The MCP envelope stamps accept nothing new — RIGHT. The D2 throw (:2542) and declarativeUpdateRefusal (:2235, all three shapes at 400) now carry code: standardErrorCodeForHttpStatus(status) beside status; the code is derived through spec/api/errors.zod.ts:205, never spelled. @objectstack/mcp's errorResultFromThrown renders a throw with no string code as errorResult(messageOf(err)), so these refusals were message-only there and now ride the { code, message, status } envelope the same door already answers for the D4, activation and confirmation refusals. REST is unchanged: resolveThrownHttpError (packages/types) answers a registered .code or the status-derived member, the same VALIDATION_ERROR either way, and the pre-existing REST point-7 pins stayed green on this head. The stamp also reaches D2 refusals on declared-params handler actions at run_action: a refusal that already refused gains members; the changeset says so.
  6. No other caller changes behaviour — RIGHT. enforceActionParams has exactly two call sites in non-test source (domains/actions.ts:676, action-execution.ts:2540); declarativeUpdateWrite exactly one (executeDeclarativeUpdateAction, :2354), itself reached only from those two doors. resume_run excludes declarative updates by predicate (domains/mcp.ts:956); the list_actions walk (:754) only enumerates; bulkActionDefs is a list-view surface the console drives through the data API, not an action door. Nothing outside packages/ calls either function.
  7. The TSDoc and docs sentences are true of the code — RIGHT. ai-service.ts (the hits path): a confirm inside params passes through on a handler action that declares none and is refused on a declarative update that declares none; both halves hold. flows.mdx:1195-1197: the exception it names is the code. The enforceActionParams, declarativeUpdateWrite, declarativeUpdateRefusal and invokeBusinessAction docblocks match the lines beneath them, and the REWORK's item on "as declarativeUpdateRefusal does" is true at this head. Each claim in the changeset (same message, same 400, same hatch, patch still written on an absent or empty bag, declared param wins, handler pass-through unchanged, MCP envelope) has a pin. The published liveness row liveness/action.json /props/patch ("an undeclared param is refused by ADR-0104 D2") becomes true for the param-less case too. A hand re-read of content/docs for other sentences on the pass-through finds none besides the edited one. Two internal comments (service-automation screen-input-contract.ts:64, engine.ts:8601) still cite the pass-through as a model; true for handler actions, not published, and noted by the PR (Acceptance note 5): acceptable.

Nothing found that moves the accept set or the public surface beyond the narrowing and the envelope above.

② Semver level

  • The changeset .changeset/24268-declarative-update-undeclared-params-refused.md carries '@objectstack/runtime': minor, a fix(runtime)!: summary, a line-initial Clause-②: no (narrowing), exactly one adr-0087: not-required (no-migration-prescription) marker, a **BREAKING** sentence naming the measured consumers (hotcrm's complete_task, consoles vendored before objectui fb501bc894, page action rows forwarding static params) and "shipped as minor under the launch-window convention", then a FROM/TO narrative and a "What to do" remedy. The shape matches the three precedents (41c740536, 2f569a9de, 741aa85da) line for line.
  • Clause-② reads the same on both carriers. readClause2Line (scripts/pm/clause2-line.mjs) takes the PR body's Clause-②: no (narrowing). Basis: … as declared, value no, arm narrowing: readArmToken matches the exact arm and leaves the trailing sentence as reasoning; the changeset's line reads identically. reviewFace over the file list decides the schema face on packages/spec/src/contracts/ai-service.ts, hence this tier; the declaration would decide narrowing at the same tier.
  • no is right: nothing is newly accepted or exported — no key, export, env var, route or error code (VALIDATION_ERROR is the standard catalogue's member). The enriched MCP refusal envelope is the reading precedent 741aa85da took for its error.details on a refusal; read as yes (narrowing) instead, the gate consequences (minor, narrowing face, this review) would be identical. (narrowing) is right: two doors accept fewer wire values. minor is right: a narrowing is BREAKING and the launch window forbids major (check-changeset-no-major.mjs).
  • The ADR-0087 disposition not-required (no-migration-prescription) is the correct category: no authorable key, stored shape, export or code is added, removed, renamed or converted, and a request bag is not metadata. check-adr-0087-registration.mjs and check-changeset-no-major.mjs both ran in Check Changeset on this head: success.
  • packages/spec/src/contracts/ai-service.ts changes one TSDoc paragraph on an unchanged export: no .describe, no schema, no generated artifact (TypeScript Type Check, which runs every spec check:* gate, is success). No spec changeset is judged acceptable: the fixed group versions @objectstack/spec with the runtime minor regardless, and the behaviour the comment now states is the runtime changeset's own text.

③ Boundary flags

Dev flags (reports 6254612136 and 6255070808), each judged:

  • MCP envelope deviation (round 0): accepted on the reading in ①.5; declared in the changeset.
  • Base b2e78701f, main since moved: none of main's later commits touch the diff's paths or domains/actions.ts; mergeable clean; the queue rebuilds on current main.
  • Cross-lane files (flows.mdx, ai-service.ts): both sentences are true of the code (①.7). The seat records them as declared on [PM seat] domain:spec — ⏳ vacant #6017 6254685225 and [PM seat] domain:devx @ objectstack — 🟢 os-warren · session_01FM29ZqLTrnPNXxijioZwMp #6023 6254691036; those seat posts are outside this review's inputs and were not read here; the verdict does not turn on them.
  • Not run locally (test:repo, workspace typecheck, pnpm lint, the spec suite): covered on the head by Lint & Repo Gates, the four Type Check lanes, Test Core 6/6 and Build Core, all success.
  • Vitest bare -- trap: a process note; no reading was taken from the aborted run.
  • Commit trailers: both commits carry the model-free pair and no model identifier (read on the branch).
  • Round 1: the real-bridge measurement file was not committed (the file list is the six paths and nothing else); the committed pin asserts the two members thrownRefusalEnvelope reads, at the door that throws — the D2 pin's own level, adequate. The object-less-key shape is the same one-line change and keeps its REST pin; acceptable without a separate MCP pin.

Open questions (round 0), answered by the seat in 6254699772, each judged:

Governance: none of the six paths is a governed surface (Governed Surface Queue Guard is success); the head repo equals the base repo; 265 additions, 22 deletions; draft, auto_merge unset, needs:contract-review on; Fixes #24268, and the card's newest Claim: names this branch (that check-run is success). .objectui-sha at the head is 0707b4ae37, the pin the unlock 6250211792 measured as carrying objectui fb501bc894; not re-measured here.

Implemented-by: claude/issue-24268-declarative-update-undeclared-params
Reviewed-by: session_01SYEJqDL3NXxrJRBVqaMu9T

VERDICT: PASS

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants