spec: define the confirmation contract behind action.ai.requiresConfirmation - #16531
Conversation
…irmation` Declares the request-side member, the refusal code and the refusal detail shape, and rewrites the two passages that described a queue the open framework path does not have. - `AIActionConfirmation` / `AI_ACTION_CONFIRMATION_MEMBER` / `ActionConfirmationRequiredDetails` in `contracts/ai-service.ts`. - `ACTION_CONFIRMATION_REQUIRED` in `ERROR_CODE_LEDGER` under `@objectstack/runtime`, answered 428, registered ahead of its producer. - The refusal is gated on the DECLARED `ai.requiresConfirmation === true`, never on `actionLooksDestructive`'s heuristic fallback. Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno Co-authored-by: Claude <noreply@anthropic.com>
api-surface / export-origins gain exactly the three new contract exports; the ledger's reference page and ApiErrorSchema's code enum gain the one new code; spec-changes.json and the upgrade guide pick up the rewritten ADR-0049 entry prose. No removals in any of them. Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 10 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 — 130 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 7dfa88cca71afdaf94f7243bb4b6bbe2da18e493 && git checkout 7dfa88cca71afdaf94f7243bb4b6bbe2da18e493
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ce8caba91403c8f160cb7764c63b08371a13db99 10abca6c2825c3b91e113be6f56ceb3183568adc && git checkout -B drift-repro ce8caba91403c8f160cb7764c63b08371a13db99 && git merge --no-ff 10abca6c2825c3b91e113be6f56ceb3183568adc
node scripts/docs-audit/affected-docs.mjs --json ce8caba91403c8f160cb7764c63b08371a13db99
|
Contract review at
|
…/A7) A1: the ledger row cited UNIQUE_SCOPE_CONFIRMATION_REQUIRED as registered beside the same standard member; it answers 409 and so sits beside RESOURCE_CONFLICT. Citation corrected; 428 unchanged. A3: the producerless row now cites #16293, #15942 and decision batch #54. A4: the retirement prescription and the migration entry asserted present-tense enforcement the runtime does not perform yet, and told an author to invoke a destructive action without the confirmation member "and observe the call refused". Both are now contract-referential and warn that such a call RUNS until the door lands. A5: dropped the non-existent flow `call action` node from the placement rationale and added the stronger reason -- `params` is strict by default (enforceActionParams, ADR-0104 D2), so an undeclared `confirm` there is rejected, not merely a collision. The two destination request shapes are now named. A6: added the missing pin -- ErrorCode admission, the ledger row, the standard-synonym reading and a compile witness for confirmationMember. A7: the contract no longer re-lists `mode: 'delete'` / `variant: 'danger'`; it references actionLooksDestructive, leaving one enumeration site under the #13865 pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
… A4 tense fix The A4 rationale rides in a comment INSIDE the guidance literal rather than above the const: placed above it, it detached the const's own JSDoc and the doc generators re-read that block as the module summary, rewriting content/docs/references/ai/tool.mdx and skills/objectstack-ai/references/_index.md. Both are untouched again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
The changeset body is the release-time carrier of the same claim the guidance and the migration entry were carrying, so leaving it present-tense would publish exactly the sentence the repair pass removed. It now says the contract is a declaration, that no door performs the refusal yet, and it takes the A5 correction on the `params` placement reason. Grade unchanged: minor, additive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
Delta re-review at
|
Fixes #16293
Clause-②: yes
Executes decision batch #54 (maintainer 「同意」 on recommendation A). This is the spec half only — it defines the contract and registers the refusal code. The runtime enforcement in
invokeBusinessActionis #15942, which remains open and is not addressed here.Contract review at
CONTRACT_REVIEW_TIER(claude-fable-5-1): PASS — no blocking findings (comment 5567317321). Six advisories were held for a repair pass; all six have landed and are itemised under The repair pass below. The load-bearing decision was re-confirmed by that review and is untouched: the refusal gates on the DECLAREDaction.ai.requiresConfirmation === true, never onactionLooksDestructive, and the runtime is not in this diff.The four deliverables
1. The request-side contract —
packages/spec/src/contracts/ai-service.ts, directly underAIToolDefinition:AI_ACTION_CONFIRMATION_MEMBER = 'confirm'— one exported constant fixing the member's spelling, so the door that refuses and the client that retries read the same symbol instead of each hand-spelling it.AIActionConfirmation— the closed boolean member, declared once and mixed into each door's request shape rather than restated by it.It rides at the top level of the action request, and the contract now NAMES the two request shapes it is destined for rather than gesturing at them: the MCP
run_actiontool input (actionName/objectName/recordId/params,packages/mcp/src/mcp-http-tools.ts) and the runtime action door's own request object — theinputargument ofinvokeBusinessAction(objectName/recordId/params,packages/runtime/src/action-execution.ts), which the MCP bridge builds that object from.Two placements were considered and rejected, and the reasons are written into the contract:
params. That bag is CLOSED against the author's own declared input vocabulary:enforceActionParams(ADR-0104 D2, strict by default since 17.0) rejects any key that is neither a declared param nor a built-in, so on an action that declares params a platformconfirmriding there is REFUSED as an unknown action param — a 400 raised before the confirmation gate is ever reached. On an action declaring no params that check is a pass-through, so the same member would be silently accepted instead: one placement, two opposite behaviours, which on its own disqualifies it for a safety member.2. The refusal —
ACTION_CONFIRMATION_REQUIRED, registered inERROR_CODE_LEDGERunder@objectstack/runtime(the package that owns both action doors, beside itsACTION_DISABLEDsibling), answered 428.error.detailsisActionConfirmationRequiredDetails: the action name, its object, and the exact member to set, so an agent builds the retry mechanically instead of re-parsing the prose it was handed.3. The two misleading passages —
ai/tool.zod.ts'srequiresConfirmationretirement guidance and the ADR-0049 semantic entry'sreplacement/acceptanceCriteria— rewritten so they no longer describe an approval queue, and (after the review) so they no longer assert enforcement that does not exist yet. See A4 below: the wording is contract-referential and says plainly that the flag stops nothing until the door lands.4. Changeset
minoron@objectstack/spec. Additive: three new exports, one new union member, zero removals —check:api-surfacereports the surface unchanged apart from the additions.Which predicate gates the refusal, and why
The DECLARED flag:
action.ai.requiresConfirmation === true. NeveractionLooksDestructive.The MCP
list_actionsprojection atpackages/runtime/src/action-execution.ts:1014readsand
actionLooksDestructive(:979) returns the declared flag when the author set it and otherwise falls through to a destructiveness heuristic, per the #7828 Option A ruling. So the listing reports declared-flag-or-guess.The ruling says "an action declaring
ai.requiresConfirmation: true". Writing the refusal againstactionLooksDestructivewould make an action whose author declared nothing start refusing on a guess — well past what was ruled, and in the direction that breaks working callers.The two are not the same predicate and the contract says so explicitly. They also answer different questions, so both are honoured without contradiction:
truetruefalsefalsetrue(heuristic)The member is accepted on every AI-facing action call and required only on the declared-gated ones. So a client that confirms whenever a listing says
requiresConfirmation: trueis always correct — over-confirming is free — while inferring "no door will ever refuse" from a listing'sfalseis sound precisely because the listing's predicate is the wider of the two.list_actionsis unchanged.Why 428, and why a ledger code beside
PRECONDITION_REQUIREDThe card rules 4xx: the request is valid but incomplete, and the caller can fix it. 428 is the platform's own reading of exactly that condition —
packages/cli/src/utils/secret-reference-union.ts:189already records it as "the standard catalog's 'request is missing a required precondition'". 409 was rejected: there is no state conflict, and the identical call with the member set succeeds against unchanged server state. 422 was rejected: the definition is fine.The code is not a re-spelling of the standard member.
PRECONDITION_REQUIREDsays some precondition is missing, which leaves a caller unable to tell a confirmation gate from a missing conditional header and unable to build the retry; this code says which precondition, on which action — the same discrimination its*_DISABLEDandFLOW_*neighbours make. Measured:standardSynonymOf('ACTION_CONFIRMATION_REQUIRED')returnsundefined, withCONFLICTandFORBIDDENlit as positive controls in the same run — now a standing pin rather than a one-off probe.Registered ahead of its producer by design — the
FLOW_INPUT_SCHEMA_INVALIDsplit shape the ledger already sanctions — and the row cites the cards that make it a split rather than a residue (#16293 for this half, #15942 for the producer, decision batch #54 for the ruling that split them).Deliberately not done here
confirmmember added to the live MCPrun_actioninputSchema(packages/mcp/src/mcp-http-tools.ts). Adding it before MCPrun_actionnever enforcesai.requiresConfirmation— it is surfaced inlist_actionsonly, while@objectstack/specguidance tells authors it stops execution #15942 enforces it would ship a member every LLM client can see and the runtime accepts and ignores — a safety flag that is merely accepted, which is precisely the defecttool.requiresConfirmationwas retired for under ADR-0049. The spec declares the member; each door grows it in the same change that enforces it, so no window exists where it is accepted and ignored. The hand-off is recorded on MCPrun_actionnever enforcesai.requiresConfirmation— it is surfaced inlist_actionsonly, while@objectstack/specguidance tells authors it stops execution #15942 (comment 5567321513).ui/action.zod.ts'saiguidance, are untouched. The card assigns the first to MCPrun_actionnever enforcesai.requiresConfirmation— it is surfaced inlist_actionsonly, while@objectstack/specguidance tells authors it stops execution #15942; the second sits underaction-requires-confirmation-docblock.pin.test.ts.content/docs/ai/actions-as-tools.mdxis accurate today and becomes incomplete only once the server refuses — MCPrun_actionnever enforcesai.requiresConfirmation— it is surfaced inlist_actionsonly, while@objectstack/specguidance tells authors it stops execution #15942's job, already on the drift bot's list.The repair pass — the six advisories held from the review
All measurements below were taken in a worktree pinned to merge-base
a5eccf9257, at final head10abca6c28.UNIQUE_SCOPE_CONFIRMATION_REQUIREDas "registered beside the same standard member for the same reason". Measured: that gate answers 409 (packages/cloud-connection/src/marketplace-install-local-plugin.ts:829closes}, 409); its own ledger row says 409;HttpStatusErrorCodeMapmaps409toRESOURCE_CONFLICTand428toPRECONDITION_REQUIRED). It is therefore a sibling in KIND only — the platform's other confirmation gate that registers its own code — and no precedent for choosing 428 over 409. 428 itself is unchanged.[#16293]heads the row in the sibling convention, and the producerless paragraph names MCPrun_actionnever enforcesai.requiresConfirmation— it is surfaced inlist_actionsonly, while@objectstack/specguidance tells authors it stops execution #15942 and decision batch Prepare v0.2.0 release for ObjectStack packages #54, with one sentence saying why the numbers are the point: a producerless row with no card behind it is the "registered but unemittable" retirement class, and this one is a split.acceptanceCriteriatold an author to "invoke it WITHOUT the confirmation member and observe the call refused" — an instruction that, before MCPrun_actionnever enforcesai.requiresConfirmation— it is surfaced inlist_actionsonly, while@objectstack/specguidance tells authors it stops execution #15942, executes the destructive action. All three sites now describe what the contract DECLARES, say plainly that the door which performs the refusal has not landed, and explicitly warn against that verification: "until that door ships, such a call is not refused, it RUNS". The queue language is NOT restored — it stays deleted, exactly as the ruling requires. Nothing here relies on release ordering.minor, additive).check:doc-authoringreds on an internal issue id inside a customer-facing prescription (a hoistedguidanceconst is one of its recognised positions; ADR ids stay,#NNNNdoes not). The migration entry'sreplacement/acceptanceCriteriaare not swept — measured against dozens of sibling semantic entries that carry#NNNNin those same two fields today, on a green tree — so both name MCPrun_actionnever enforcesai.requiresConfirmation— it is surfaced inlist_actionsonly, while@objectstack/specguidance tells authors it stops execution #15942 directly.call actionnodes" is gone:FlowNodeAction(packages/spec/src/automation/flow.zod.ts:27) enumeratesscript/subflow/connector_actionand no action-call node (control:subflowis present in the same enum). Added, as the decisive reason the contract was missing:paramsis strict by default, so an undeclaredconfirmthere is REJECTED rather than merely colliding — with the asymmetry spelled out, sinceenforceActionParamsis a pass-through for an action that declares no params.git grep -lover*.test.ts/*.spec.tsreturned 0 files for each ofAI_ACTION_CONFIRMATION_MEMBER,AIActionConfirmation,ActionConfirmationRequiredDetailsandACTION_CONFIRMATION_REQUIRED, while the same glob lights 26 files forERROR_CODE_LEDGERand the same terms light non-test files.packages/spec/src/contracts/action-confirmation-contract.pin.test.tsnow carries the feat(spec): register FLOW_INPUT_SCHEMA_INVALID — the never-dispatched ADR-0112 code for the definition-level input-schema refusal #12611 shape — an accepts case throughErrorCode.parse, the ledger row under@objectstack/runtime(plus a control that no other owner holds it),standardSynonymOf(...)undefined against two lit controls — and the compile witness:confirmationMembertyped totypeof AI_ACTION_CONFIRMATION_MEMBER, with a near-miss spelling and a stringconfirmeach behind a@ts-expect-error. Those two directives are LIT rather than decorative: an unused directive is TS2578, this file carries no entry intest-typecheck-debt.json, andcheck:test-typecheckis green — which is only possible if both are consumed.actionLooksDestructiveby name and says the signals are named once, beside the authorable key inui/action.zod.ts. Why removal over extending the pin: the enumeration is a RUNTIME implementation detail, andActionAiSchema.requiresConfirmationdocblock still namesconfirmTextas a destructive signal — the #7828 ruling removed that leg #13865's pin exists to hold ONE authoring surface against the classifier. Extending it to a second site would freeze the same list in two places and make both rot-prone; deleting the restatement leaves nothing to rot and keeps the pin's subject singular. The changeset keeps its one mention, deliberately: it is frozen intoCHANGELOG.mdat release as a record of the decision, not a live authoring surface.Accepted as-is per the review's rulings: A8 (the
acceptanceCriteriarewrite is in-spirit and kept, with the A4 tense fix), A9 (minoris correct — not re-graded), A10 (actions-as-tools.mdxis #15942's).Verification — all readings on
10abca6c28, merge-basea5eccf9257node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ...reconciles clean at this head. 100 green; 2 NOT MEASURED, both exit 3 (PREREQUISITE NOT MET, which the scripts themselves say is neither a pass nor a finding) and both needing a whole-tree build this container cannot fit in one foreground window:check:dual-build-cjs-loads(no new module and no new import edge hides behind it) andcheck:type-check-debt(its one moved package,@objectstack/spec, is separately green below, source + scripts + test layers). Both are declared to CI. Four further gates first returned exit 3 or a build-first exit 1 and were re-run to a real green after building the@objectstack/lint,@objectstack/clientand@objectstack/client-reactclosures.pnpm --filter @objectstack/spec check:generated— all 15 generated artifacts up to date, aftergen:migration-registryand then exactly the four--fixproved stale (gen:spec-changes,gen:upgrade-guide,gen:skill-refs,gen:docs) — never the whole set. Every regenerated diff was read: the registry,spec-changes.jsonand the upgrade guide carry the corrected prose and nothing else.//block aboveTOOL_RETIRED_KEY_GUIDANCE, which detached that const's own JSDoc and made the doc generators re-read it as the module summary — rewritingcontent/docs/references/ai/tool.mdxandskills/objectstack-ai/references/_index.md. The comment now sits INSIDE the object literal beside the key it explains, and both files are untouched in this diff.pnpm --filter @objectstack/spec test— 484 files / 13142 tests passed (from 483 / 13136: the new pin).pnpm --filter @objectstack/spec typecheckgreen acrosstsc --noEmit,check:scripts-typecheckandcheck:test-typecheck(54 files / 261 errors / 145 pinned signatures held, unchanged — the new file adds none).eslint . --no-inline-configover 6274 files — 0 errors, 0 warnings, run at this head (6273 before; the delta is the new test file).check:nul-bytesgreen; plus an independent control-byte sweep of the 13 changed paths (grep -naPover the control ranges), no hits.check:changeset-gate-self-tests,check:objectui-changeset,check:doc-authoring,check:keyed-text-bounds,check:nul-bytes,check:error-code-casing,check:comment-mask-adoption,check:comment-mask-corpus,check:closing-keyword-parity,check:cross-package-test-inputs,check:test-source-alias,check:type-check-coverage,check:dispatcher-error-vocabulary,check:error-code-provenance.dispatch-gatesreports this tree as at least 24 commits behindorigin/main, with five family-defining files (two workflows,scripts/nightly-tiers.*,check-skill-line-ratchet.mjs) changed across that range. The derivation is therefore against THIS branch's copies of those files. The change set itself is anchored to the literal merge-basea5eccf9257, never to a moving ref.Review posture
needs:contract-reviewstays on this PR and on #16293. The PR stays draft — not flipped ready, not enqueued, no auto-merge — per the reviewing seat's instruction.Authored by Claude Code in session
session_01T6HeZvT9wdSJD1ZxJb5Eno(durable attribution, kept in prose).