fix(runtime,mcp): enforce ai.requiresConfirmation at the AI-facing action door, and grow run_action the member that satisfies it - #17486
Conversation
…tion door The runtime half of the confirmation gate, plus the MCP door member the gate is unsatisfiable without. Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt Co-authored-by: Claude <noreply@anthropic.com>
…n-action-confirmation-gate
Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 31 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 16550e30ca8eb622db13db2dc5201497cfb83dab && git checkout 16550e30ca8eb622db13db2dc5201497cfb83dab
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fa23d69875d1d75b55afc485790fb3df3c9fb712 e19fe3a78dcb5a6ab5e70e50175f3fb010cb37d6 && git checkout -B drift-repro fa23d69875d1d75b55afc485790fb3df3c9fb712 && git merge --no-ff e19fe3a78dcb5a6ab5e70e50175f3fb010cb37d6
node scripts/docs-audit/affected-docs.mjs --json fa23d69875d1d75b55afc485790fb3df3c9fb712
|
Fixes #15942
action.ai.requiresConfirmationwas a safety-shaped flag with no execution path: read once, projected once into thelist_actionssummary, never consulted byrun_action. This lands the runtime half of decision batch #54 (option A, maintainer verbatim 「同意」) — the pre-dispatch refusal — together with the MCP door member without which the gate would be unsatisfiable.The contract half is
@objectstack/spec#16293 / PR #16531, merged 2026-09-07.packages/specis untouched here: it is the contract this implements against.What changed
packages/runtime/src/action-execution.tsactionConfirmationRefusal()— the gate producer — plus its call site ininvokeBusinessAction, after the param contract and before the subject-record load. The door's request shape mixes inAIActionConfirmationfrom the contract.packages/runtime/src/domains/mcp.tsrunActioninput type carries the member (it already forwarded the whole request object).packages/mcp/src/mcp-http-tools.tsrun_actioninputSchemagrows the member, the handler forwards it, anderrorResultFromThrownkeeps an ADR-0112 envelope'scode/status/detailsinstead of flattening the throw to a sentence.packages/runtime/src/http-dispatcher.tspackages/mcp/src/skill-md.tsrun_actionsignature and the sentence that makes the member usable.list_actionsis unchanged. There is no queue, no parking, no stored pending-approval record and no resume path.The member's spelling is never hand-written: both packages import
AI_ACTION_CONFIRMATION_MEMBERand use it as the schema key, the forward key and the echoeddetails.confirmationMember.The trap this delivery had to clear
The
domain:specseat recorded on the card that enforcing only ininvokeBusinessActionwould make every gated action un-invokable, because the MCP door strips the member twice over. Both legs were re-measured here rather than taken on trust.Leg 2 — the handler forward. Confirmed by symbol on the merge base. The coordinates had moved:
:761/:769when the block was recorded at 11:00Z,:816/:824in the claim comment at 15:43Z. PR #17332 edited this file. Both readings were snapshots; the destructure and the forward are what was located, not the line numbers.Leg 1 — the SDK's unknown-key drop. This is the leg the dispatching seat did not re-measure, so it was measured here, on the unmodified tree, through a real JSON-RPC
tools/call:The client sent
{ actionName, recordId: 'r1', confirm: true }. The call was not rejected — it succeeded, and the bridge receivedrecordIdand noconfirm.recordIdsurviving the identical round trip is the lit control: the transport works and only the undeclared member was lost. So the strip is a silent drop, not an error, and the two-layer claim holds.Acceptance notes
The end-to-end drive, both directions
examples/app-todo/test/mcp-actions.e2e.tsgains Step 7. It is the one drive that spans the whole path: a realObjectKernel, the real ObjectQL engine on a real SQLite driver, app-todo's real registered handlers, the real principal-bound bridge fromHttpDispatcher.buildMcpBridge, and the realMCPServerRuntimeanswering JSON-RPC — both strip layers included. Run ate19fe3a7:The record itself is the evidence in both directions:
not_startedafter the refusal,completedafter the confirmed retry. Every refusal case asserts thecodeand thestatus; none of them asserts only that something threw.Reverse verification — the door half is load-bearing
The read and the forward were ablated from
mcp-http-tools.ts,@objectstack/mcprebuilt, the marker confirmed on disk and indist/index.js, and the drive re-run. It reproduces the seat's warning exactly:The door-level pin goes red on the same ablation (2 failed / 4 passed). Restored with
git checkout HEAD -- packages/mcp/src/mcp-http-tools.ts;git diff HEADempty and the blob hash equal toHEAD's (948ad578f436b489f585ed4f191d8edf94805d97), anddistrebuilt clean (marker count 0).An earlier ablation of the forward alone failed the DTS build with
TS6133: 'confirm' is declared but its value is never read— a second, compile-time tie between the two halves.CI-visible coverage
packages/runtime/src/action-confirmation-gate.test.ts— 9 tests. The refusal assertscode+status+detailsand thatexecuteActionwas never called and thatcallData(the subject-record read) was never called. Flow-typed actions gated ahead of the type branch, with a lit control that the same action does dispatch once confirmed.'true',1,{},false,nullare each refused. Both heuristic cases pinned: amode: 'delete'action whose author declared nothing is NOT gated (withactionLooksDestructiveassertedtrueon the same fixture, so the case cannot pass vacuously), and an explicitfalseon avariant: 'danger'action is honoured.packages/mcp/src/mcp-action-confirmation-member.test.ts— 6 tests through the real JSON-RPC door: the member is advertised on the tool schema as a boolean, a gated call is refused with the full envelope, the retry carrying the member reaches the bridge astrue,recordIdandparamsride unchanged beside it, a truthy string never reaches the bridge at all, and an uncoded throw still flattens to its message.Measurements
pnpm --filter @objectstack/runtime testpnpm --filter @objectstack/mcp testpnpm --filter @objectstack/mcp --filter @objectstack/runtime typecheckpnpm lint(whole repo,eslint . --no-inline-config)pnpm --filter @objectstack/spec check:generatedscripts/pm/dispatch-gates.mjscheck:dual-build-cjs-loadsandcheck:type-check-debtfirst answeredPREREQUISITE NOT MET(exit 3 — explicitly not a pass and not a finding); both were re-run to exit 0 after the full workspace build.packages/spec's two pins that read the edited runtime file —action-requires-confirmation-docblock.pin.test.tsandaction-confirmation-contract.pin.test.ts— pass (12 tests).actionLooksDestructiveis untouched, which is what the first of those holds.One pre-existing pin moved with the surface:
skill-md.test.tspinned therun_actionsignature string, so it now pins the new one plus the two sentences that make the member actionable.Notes for review
confirm: trueis an unverifiable caller claim. An agent that always sends it bypasses the gate entirely; the ruling accepted that model. The gate makes forgetting loud — it does not prove a human, and the code says so where someone would otherwise assume otherwise.The enforced set is bounded to the doors that enforce
ai.exposed— todayinvokeBusinessActionvia MCPrun_action. REST/actionsis notai.exposed-gated (noactionAiExposureErrorindomains/actions.ts), so an API-key agent there sits outside this gate rather than being silently assumed inside it. Widening that set is its own decision.Clause-②: yes
This narrows the accept set on a published door: a
run_actioncall on an action declaringai.requiresConfirmation: truethat succeeds today starts answering428 ACTION_CONFIRMATION_REQUIRED. The diff carries two opposite directions and both belong in the declaration — adding the member to the door'sinputSchemawidens whatrun_actionaccepts (a request that was rejected-by-silent-drop is now a first-class member), while the refusal narrows what succeeds. The narrowing is deliberately as small as the author's own declaration: onlyai.requiresConfirmation === true, only the booleantrueas an attestation, only the AI-facing doors. Review of record is this seat's default-tier review plus the gates (PR #17294); build stayed default tier.The changeset is
minoron both packages and spells the break with its FROM → TO.🤖 Generated with Claude Code
https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt
Generated by Claude Code