From 31163527e256047db986349d6e2d4a24c3798eae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 06:41:56 +0000 Subject: [PATCH 1/5] spec: define the confirmation contract behind `action.ai.requiresConfirmation` 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 --- .changeset/action-confirmation-contract.md | 14 +++ packages/spec/src/ai/tool.zod.ts | 11 +- .../spec/src/api/error-code-ledger.zod.ts | 30 +++++ packages/spec/src/contracts/ai-service.ts | 116 ++++++++++++++++++ .../17.tool-requires-confirmation-retired.ts | 13 +- 5 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 .changeset/action-confirmation-contract.md diff --git a/.changeset/action-confirmation-contract.md b/.changeset/action-confirmation-contract.md new file mode 100644 index 0000000000..05792c9354 --- /dev/null +++ b/.changeset/action-confirmation-contract.md @@ -0,0 +1,14 @@ +--- +"@objectstack/spec": minor +--- + +`action.ai.requiresConfirmation` gets a real contract: an AI-facing call on an action that declares it must carry an explicit confirmation, and the refusal tells the caller how to retry. + +The flag has always read as a safety gate and has only ever filled one field of the MCP `list_actions` summary. Two of the spec's own passages went further and told authors it "actually stops execution" through an HITL approval queue — a queue the open framework path does not have (the server-side queue is an ObjectOS layer over these same actions). This change defines the gate the flag was always claimed to be. It is additive and defines the contract only; the doors adopt it separately. + +- **The request member.** `AIActionConfirmation` (`contracts/ai-service.ts`) declares the confirmation as a closed boolean member, and `AI_ACTION_CONFIRMATION_MEMBER` fixes its one spelling so every AI-facing action door and every retrying client read the same constant. It rides at the top level of the action request — deliberately not inside `params`, where it could collide with an author's own declared input, and deliberately not a transport header, which the in-process doors cannot carry and the tool schema an agent reads cannot show. +- **Which predicate gates the refusal.** A door refuses when, and only when, the action's author DECLARED `ai.requiresConfirmation: true` and the request does not carry the member as `true`. This is narrower than the predicate behind the `requiresConfirmation` field of a listing, which falls back to a destructiveness heuristic (`mode: 'delete'` / `variant: 'danger'`) when the author declared nothing: that field advises a client to ask, and an author who declared nothing has asked for nothing. Gating the refusal on the heuristic would start refusing calls that work today, on a guess the author never made. The member is accepted on every call and required only on the declared-gated ones, so a client that confirms whenever a listing says `requiresConfirmation: true` is always correct. +- **The refusal.** `ACTION_CONFIRMATION_REQUIRED`, registered in `ERROR_CODE_LEDGER` under `@objectstack/runtime`, answered 428 — the request is valid and merely incomplete, and the identical call with the member set succeeds. `error.details` is `ActionConfirmationRequiredDetails`: the action name, its object, and the exact member to set, so an agent builds the retry mechanically instead of re-parsing prose. It is not a re-spelling of the standard catalog's `PRECONDITION_REQUIRED`, which leaves a caller unable to tell a confirmation gate from a missing conditional header. +- **Two passages corrected.** The `tool.requiresConfirmation` retirement guidance and the ADR-0049 semantic migration entry now describe the enforced gate instead of a queue: nothing is parked, nothing is held for an operator to find later, and a call is either confirmed or it does not run. + +`list_actions` is unchanged and keeps reporting the flag exactly as it does today. diff --git a/packages/spec/src/ai/tool.zod.ts b/packages/spec/src/ai/tool.zod.ts index 7f8be1cc6b..c27e342da9 100644 --- a/packages/spec/src/ai/tool.zod.ts +++ b/packages/spec/src/ai/tool.zod.ts @@ -65,10 +65,13 @@ const TOOL_RETIRED_KEY_GUIDANCE: Record = { '`ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP bridge ' + '(which derives `destructiveHint` from a hardcoded name list). Delete the key. For a ' + 'REAL gate on a destructive operation, put it behind an action and set ' + - '`action.ai.requiresConfirmation` — that is the flag the HITL approval queue reads ' + - '(packages/runtime/src/action-execution.ts), and it is the only path that actually ' + - 'stops execution. For AI metadata mutations the ADR-0033 draft/publish workspace is ' + - 'the gate: nothing is live until a human publishes.', + '`action.ai.requiresConfirmation: true` — the platform\'s one enforced confirmation ' + + 'gate. An AI-facing call on an action DECLARING that flag must carry the confirmation ' + + 'member on the request, and is refused without it; the refusal names the action and ' + + 'the exact member to set, so the caller confirms and retries. It is a gate, not a ' + + 'queue: nothing is parked, nothing is held for an operator to find later, and a call ' + + 'is either confirmed or it does not run. For AI metadata mutations the ADR-0033 ' + + 'draft/publish workspace is the gate: nothing is live until a human publishes.', }; /** diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 579ecea1db..be9bd47f68 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -264,6 +264,36 @@ export const ERROR_CODE_LEDGER = { 'VALIDATION_FAILED', // record-level validation; carries `fields[]` (#3977) ], '@objectstack/runtime': [ + // The AI-facing action doors refuse a call against an action whose AUTHOR + // declared `ai.requiresConfirmation: true` when the request does not carry + // the confirmation member as `true` — 428, the standard catalog's "request + // is missing a required precondition" status, because the request is VALID + // and merely incomplete: the identical call with the member set succeeds, + // and nothing about server state changed. Never carries `status: 'failed'`: + // nothing dispatched, no action body ran, no record was read or written. + // Contract, including which request member and what `error.details` carries: + // `AIActionConfirmation` / `ActionConfirmationRequiredDetails` in + // `contracts/ai-service.ts`. + // + // ⛔ NOT a re-spelling of the standard catalog's `PRECONDITION_REQUIRED`, + // and the distinction is why it is registered: that member says SOME + // precondition is missing, which leaves a client unable to tell a + // confirmation gate from a missing conditional header, and unable to build + // the retry. This code says WHICH precondition — the human attestation on + // this named action — which is the thing the caller acts on, exactly the + // discrimination its `*_DISABLED` and `FLOW_*` neighbours make. Its + // nearest sibling is `UNIQUE_SCOPE_CONFIRMATION_REQUIRED` + // (`@objectstack/cloud-connection`), the platform's other confirmation + // gate, registered beside the same standard member for the same reason. + // + // Registered AHEAD of its producer by design — the `FLOW_INPUT_SCHEMA_INVALID` + // split shape. The emitting half is the runtime's pre-dispatch check in + // `action-execution.ts` (`invokeBusinessAction`), which the ruling split + // into its own card; this row is the contract half, and the door will + // assert this exact string by value. Registered HERE and not under a + // producing service for the same reason as its `FLOW_*` neighbours: the + // action door, not the predicate, is where the wire vocabulary is named. + 'ACTION_CONFIRMATION_REQUIRED', // [ADR-0126 §8 item 2] a packaged ACTION this installation switched off, // refused at dispatch — the activation-ledger consult in // `action-execution.ts`, answered 409 by BOTH action doors (the REST diff --git a/packages/spec/src/contracts/ai-service.ts b/packages/spec/src/contracts/ai-service.ts index e26bca8de4..e2fdbce1e8 100644 --- a/packages/spec/src/contracts/ai-service.ts +++ b/packages/spec/src/contracts/ai-service.ts @@ -192,6 +192,122 @@ export interface AIToolDefinition { requiresConfirmation?: boolean; } +// --------------------------------------------------------------------------- +// Action Confirmation Contract +// --------------------------------------------------------------------------- + +/** + * The member an AI-facing action call carries to state that the human in the + * loop has confirmed it. + * + * ONE exported constant, because the member's SPELLING is the whole contract: + * the door that refuses and the client that retries have to agree on it, and a + * door hand-spelling its own `'confirm'` is precisely how two doors drift into + * two dialects (Prime Directive #12). Every AI-facing action door reads this. + * + * It rides as a TOP-LEVEL member of the action request, beside `actionName` / + * `objectName` / `recordId`. Two placements were rejected, and the reasons are + * the contract: + * + * - NOT inside `params`. That object is the action author's own declared input + * vocabulary, so a platform member there is a name the author may already + * have used — a collision that silently reassigns one of the two meanings. + * - NOT a transport header. A header is invisible to the tool schema an agent + * reads, so the model cannot discover the retry it is being told to make; + * and the in-process doors (flow `call action` nodes, the action runner) + * have no header to carry it on at all. A free-form header would also be an + * OPEN channel, which is the one thing a safety member must not be. + */ +export const AI_ACTION_CONFIRMATION_MEMBER = 'confirm'; + +/** + * The confirmation half of an AI-facing action request — a CLOSED boolean + * member, declared once here and mixed into each door's own request shape + * rather than restated by it. + * + * ## What the member means + * + * `confirm: true` asserts that the human in the loop has approved THIS call. + * It is an attestation carried by the request, not a workflow: there is no + * queue, no parking, no server-side approval record, and a refused call is + * simply not executed. The caller fixes the request and retries. + * + * Absent, `false`, or any non-`true` value is NOT a confirmation. Executors + * MUST treat only the boolean `true` as an attestation — a truthy string is a + * transport artefact, not a decision. + * + * ## Which predicate gates the refusal — the DECLARED flag, never a heuristic + * + * An AI-facing action door MUST refuse a call when, and only when, + * `action.ai.requiresConfirmation === true` — the flag the action's AUTHOR + * declared — and the request does not carry the confirmation member as `true`. + * + * The predicate is deliberately narrower than the one behind the + * `requiresConfirmation` field of a tool/action LISTING (see + * {@link AIToolDefinition.requiresConfirmation}). That field answers "should a + * client ASK the human before calling?" and falls back to a destructiveness + * heuristic — `mode: 'delete'` or `variant: 'danger'` — when the author + * declared nothing. This one answers "will the server REFUSE without an + * attestation?", and an author who declared nothing has asked for nothing: + * gating the refusal on the heuristic would start refusing calls that work + * today, on a guess the author never made. + * + * So the two are not the same predicate and MUST NOT be collapsed: + * + * - listing `requiresConfirmation: true` + declared flag absent → the client + * is ADVISED to confirm; the door does not refuse. + * - declared `ai.requiresConfirmation: true` → the door REFUSES without the + * member, and the listing reports `true` as well. + * - declared `ai.requiresConfirmation: false` → no refusal, whatever the + * action's `mode` / `variant` look like. An explicit `false` is the author + * asserting the action is safe unattended, and it overrides the heuristic in + * that direction too. + * + * The member is ACCEPTED on every AI-facing action call and only REQUIRED on + * the declared-gated ones, so a client that always confirms whenever a listing + * says `requiresConfirmation: true` is always correct. Inferring the opposite + * — that a listing's `false` means no door will ever refuse — is sound only + * because the listing's predicate is the wider of the two. + * + * ## The refusal + * + * A door that refuses answers 428 with `error.code` `ACTION_CONFIRMATION_REQUIRED` + * (registered under `@objectstack/runtime` in `ERROR_CODE_LEDGER`) and + * `error.details` shaped as {@link ActionConfirmationRequiredDetails}, which + * names the action and the exact member to set. Nothing dispatches: the + * action body does not run, no record is read or written, and the refusal + * carries no `status`. + */ +export interface AIActionConfirmation { + /** + * `true` when the human in the loop has approved this call. Only the + * boolean `true` is an attestation; see the interface doc. + */ + confirm?: boolean; +} + +/** + * The `error.details` payload of an `ACTION_CONFIRMATION_REQUIRED` refusal. + * + * Machine-readable on purpose, and the reason the refusal is a contract rather + * than a message: an agent that has just been refused must be able to build + * the retry WITHOUT re-parsing the prose it was handed. It gets back the + * action it addressed and the member to set — so the fix is mechanical, and a + * client never has to hard-code the member's spelling from documentation. + */ +export interface ActionConfirmationRequiredDetails { + /** The declarative action name the refused call addressed. */ + actionName: string; + /** The object the action operates on; omitted for object-less actions. */ + objectName?: string; + /** + * The request member the caller must set to `true` and retry — always the + * value of {@link AI_ACTION_CONFIRMATION_MEMBER}, echoed so the caller + * reads it off the refusal instead of restating it. + */ + confirmationMember: typeof AI_ACTION_CONFIRMATION_MEMBER; +} + // --------------------------------------------------------------------------- // IAIService // --------------------------------------------------------------------------- diff --git a/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts b/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts index 9ff8b528ce..37f7189e22 100644 --- a/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts @@ -6,9 +6,11 @@ export const entry: SemanticMigration = { id: 'tool-requires-confirmation-retired', surface: 'ai.tool.requiresConfirmation', replacement: - 'put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — that ' - + 'is the flag the HITL approval queue actually reads, and the only path that stops ' - + 'execution', + 'put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — an ' + + 'AI-facing call on an action DECLARING that flag must carry an explicit confirmation ' + + 'member on the request and is refused without it, the refusal naming the action and the ' + + 'exact member to set so the caller confirms and retries. It is a gate, not a queue: ' + + 'nothing is parked and a call is either confirmed or it does not run', reason: '`ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: ' + 'not the LLM tool set (a tool reaches the model as name / description / parameters ' @@ -41,8 +43,9 @@ export const entry: SemanticMigration = { + 'load-bearing half is what happens NEXT, and no gate can check it for you: for every ' + 'tool that carried the flag, decide whether that operation genuinely needs a human in ' + 'the loop. If it does, move it behind an action carrying `ai.requiresConfirmation: ' - + 'true` and prove the pause exists — invoke it and observe the approval queue hold it, ' - + 'rather than assuming the declaration. If it does not, delete the key knowingly. ' + + 'true` and prove the refusal exists — invoke it WITHOUT the confirmation member and ' + + 'observe the call refused rather than dispatched, rather than assuming the ' + + 'declaration. If it does not, delete the key knowingly. ' + 'Deleting it without that decision leaves exactly the state the retirement exists to ' + 'end: a destructive tool nobody is approving, now without even the false flag to show ' + 'that somebody once meant to.', From 09248ca0695c6fc334d41dc41adfe6f306568c8c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 06:57:48 +0000 Subject: [PATCH 2/5] spec: regenerate the artifacts the contract change moved 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 --- content/docs/references/api/contract.mdx | 3 ++- content/docs/references/api/error-code-ledger.mdx | 1 + docs/protocol-upgrade-guide.md | 4 ++-- packages/spec/api-surface/contracts.json | 3 +++ packages/spec/export-origins/contracts.json | 3 +++ packages/spec/spec-changes.json | 4 ++-- packages/spec/src/migrations/registry.ts | 13 ++++++++----- 7 files changed, 21 insertions(+), 10 deletions(-) diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index b54b5ce9ca..d5db2646c4 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +297 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +298 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -89,6 +89,7 @@ const result = ApiErrorSchema.parse(data); * `INTEGRATION_ERROR` * `WEBHOOK_DELIVERY_FAILED` * `ACCOUNT_LOCKED` +* `ACTION_CONFIRMATION_REQUIRED` * `ACTION_DISABLED` * `ALREADY_REVERTED` * `AMBIGUOUS_MATCH` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index f3d7c8038b..2cd6bcc965 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -205,6 +205,7 @@ const result = ErrorCode.parse(data); * `INTEGRATION_ERROR` * `WEBHOOK_DELIVERY_FAILED` * `ACCOUNT_LOCKED` +* `ACTION_CONFIRMATION_REQUIRED` * `ACTION_DISABLED` * `ALREADY_REVERTED` * `AMBIGUOUS_MATCH` diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 6fdc8975e7..6a437f39a2 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -544,9 +544,9 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte - **`storage-service-list-retired`** — `contracts.IStorageService.list` → track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored in #6781 - Why not automatic: `list(prefix)` was an OPTIONAL contract method documented as "List files in a directory/prefix", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the "all files" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. #5172 was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 (analysis #5266). - Done when: No code calls `storage.list(...)` on the `file-storage` service or on any `IStorageService` value. Code that needed "which files are under this prefix" reads the records it wrote — `sys_file` / file-reference rows carry the storage key and page deterministically through ObjectQL — rather than asking the bucket, which is also the only form that stays correct past 1000 objects and across both adapters. An adapter that still IMPLEMENTS `list` keeps compiling (an extra method is not an error on a class) and is simply unreachable through the contract, so deleting it is cleanup that can follow. The break is on the CALLER side: `storage.list(...)` no longer type-checks, and a PROXY typed against `IStorageService` that forwards to `inner.list` is exactly such a caller — the one in `@objectstack/service-storage` goes with the adapters (#5541). ⚠️ AMENDED 2026-08-09 (#6781, maintainer ruling on cloud#1203, option B): the RESERVED route in the paragraph above was taken. `list` exists again on the contract, cursor-shaped — `list(prefix, { cursor, limit })` returning `{ items, nextCursor }` — because cloud had two first-party callers this repo could not see when the measurement said "nothing calls it" (tenant attachment reclamation, marketplace snapshot GC). This does NOT un-retire anything and the acceptance criterion above is unchanged for what it actually governs: the single-argument `list(prefix): StorageFileInfo[]` is gone for good, a call written against it still fails to compile, and the two dialects it had are now pinned against each other in `storage-adapter-list.conformance.test.ts` rather than left to diverge. What changed for an upgrader is only the destination: prefer the records you wrote, and reach for the restored member when there are none. -- **`tool-requires-confirmation-retired`** — `ai.tool.requiresConfirmation` → put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — that is the flag the HITL approval queue actually reads, and the only path that stops execution +- **`tool-requires-confirmation-retired`** — `ai.tool.requiresConfirmation` → put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — an AI-facing call on an action DECLARING that flag must carry an explicit confirmation member on the request and is refused without it, the refusal naming the action and the exact member to set so the caller confirms and retries. It is a gate, not a queue: nothing is parked and a call is either confirmed or it does not run - Why not automatic: `ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: not the LLM tool set (a tool reaches the model as name / description / parameters only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP bridge, which derives `destructiveHint` from a hardcoded name list. Setting it on a destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for a SAFETY property it is false compliance, the case ADR-0049 exists for — an author gates a destructive tool, sees the flag accepted, and ships believing a human is in the loop. It is made worse by the near-miss: `action.ai.requiresConfirmation` carries the same name and DOES work, so the mistake reads as correct in review. This is registered as a semantic entry rather than a mechanical conversion because the rewrite is not a rename at all — the replacement lives on a different metadata object at a different layer, and deciding which action should carry the gate (or whether the operation should be an action at all) is a judgement the chain cannot make. Deleting the key mechanically would be the worst possible transform here: it would leave the metadata parsing green while silently completing the removal of a safety gate the author believed was in place. `ToolSchema` was made `.strict()` in the same change, which is load-bearing rather than tidying — removing a key from a non-strict schema swaps one silent no-op for another, so the retired key now REJECTS and the parse error carries the prescription, that being the one channel every consumer bumping `@objectstack/spec` is guaranteed to hit. Registered by the #6350 stock reconciliation: the `retiredKey()` tombstone shipped with #3715 and still stands in `ai/tool.zod.ts`, but the ledger half never did. A retirement needs both — the tombstone is the proof the removal was declared, this entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0033 §2 / ADR-0049 / ADR-0087, #3715 (backfilled #6350). - - Done when: No tool definition carries `requiresConfirmation`; the key now raises a located parse error naming the replacement, so the sweep is "fix until nothing raises". ⚠️ The load-bearing half is what happens NEXT, and no gate can check it for you: for every tool that carried the flag, decide whether that operation genuinely needs a human in the loop. If it does, move it behind an action carrying `ai.requiresConfirmation: true` and prove the pause exists — invoke it and observe the approval queue hold it, rather than assuming the declaration. If it does not, delete the key knowingly. Deleting it without that decision leaves exactly the state the retirement exists to end: a destructive tool nobody is approving, now without even the false flag to show that somebody once meant to. + - Done when: No tool definition carries `requiresConfirmation`; the key now raises a located parse error naming the replacement, so the sweep is "fix until nothing raises". ⚠️ The load-bearing half is what happens NEXT, and no gate can check it for you: for every tool that carried the flag, decide whether that operation genuinely needs a human in the loop. If it does, move it behind an action carrying `ai.requiresConfirmation: true` and prove the refusal exists — invoke it WITHOUT the confirmation member and observe the call refused rather than dispatched, rather than assuming the declaration. If it does not, delete the key knowingly. Deleting it without that decision leaves exactly the state the retirement exists to end: a destructive tool nobody is approving, now without even the false flag to show that somebody once meant to. - **`ui-interaction-config-family-retired`** — `ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)` → (removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor, the #4910 way, not by un-retiring a declaration) - Why not automatic: Five `@objectstack/spec/ui` modules declared a full interaction-configuration vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol carried them. This is the ADR-0049 false-compliance shape in its most inviting form for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: `authorable-surface.json` listed 109 keys under these defs and `content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them as authoring tables, so the published documentation advertised a vocabulary with no carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a page component was rejected by `PageComponentSchema` for an unrecognized key — the docs and the schema disagreeing about the platform (Prime Directive #10). Three independent measurements, each with its controls passing in the same run: (1) no module under `packages/spec/src` imported any of the five except the `ui/index.ts` barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` (25 roots, 4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, `WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic carrier flipped all 21 — so unreachability was a fact about the graph, not a broken walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these modules' own unit tests. objectui holds TYPE re-exports and parity ratchets, never validators, and says so (#2561). The 2026-08-04 ruling weighed wiring a carrier key (option B) and rejected it: that is a feature with a renderer behind it, not ledger clean-up. It also weighed tightening the shapes to `strictObject` and rejected that explicitly — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave "a precisely validated dead slot, the more convincing lie" (#4583). Because there was no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, the same route 3 as #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ Not to be confused with #5021, which retired the THEME `animation` block — a different file, different defs, and that one did have a carrier key and therefore a tombstone. ADR-0049, #4988. - Done when: No code imports any of the 64 retired names from `@objectstack/spec` or `@objectstack/spec/ui` — `TouchTargetConfig(Schema)`, `GestureType(Schema)`, `SwipeDirection(Schema)`, `SwipeGestureConfig(Schema)`, `PinchGestureConfig(Schema)`, `LongPressGestureConfig(Schema)`, `GestureConfig(Schema)`, `TouchInteraction(Schema)`, `TransitionPreset(Schema)`, `EasingFunction(Schema)`, `TransitionConfig(Schema)`, `AnimationTrigger(Schema)`, `ComponentAnimation(Schema)`, `PageTransition(Schema)`, `MotionConfig(Schema)`, `DragHandle(Schema)`, `DropEffect(Schema)`, `DragConstraint(Schema)`, `DropZone(Schema)`, `DragItem(Schema)`, `DndConfig(Schema)`, `FocusTrapConfig(Schema)`, `KeyboardShortcut(Schema)`, `FocusManagement(Schema)`, `KeyboardNavigationConfig(Schema)`, `OfflineStrategy(Schema)`, `ConflictResolution(Schema)`, `SyncConfig(Schema)`, `PersistStorage(Schema)`, `EvictionPolicy(Schema)`, `OfflineCacheConfig(Schema)`, `OfflineConfig(Schema)` — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `ui/interaction-config-retirement.test.ts`). No metadata document needs editing, because none could ever carry one of these blocks: a stack that parsed before parses byte-for-byte the same after. If you consumed the bare `ConflictResolution` from `@objectstack/spec/ui` as a TYPE for your own offline code, declare that union locally — it is your client's policy, not the platform's. `@objectstack/spec/integration`'s `ConnectorConflictResolution` (connector sync) and `@objectstack/spec/api`'s `ConflictResolutionStrategy` (route merge policy) are different concepts and are untouched. diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index dc9f45ef18..431f40ea87 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -2,6 +2,7 @@ "description": "Every exported `name (kind)` of one published entry point of @objectstack/spec — the breadth half of the ADR-0059 backward-compatibility gate. Sharded by entry point (#5837) so two PRs touching different entry points never share a file. Reads the BUILT dist/*.d.ts: regenerate with `pnpm --filter @objectstack/spec gen:api-surface` after a real build.", "entry": "./contracts", "exports": [ + "AIActionConfirmation (interface)", "AIConversation (interface)", "AIMessage (type)", "AIMessageWithTools (type)", @@ -13,12 +14,14 @@ "AIToolCall (type)", "AIToolDefinition (interface)", "AIToolResult (type)", + "AI_ACTION_CONFIRMATION_MEMBER (const)", "APPROVAL_ACTION_KINDS (const)", "APPROVAL_ACTION_KIND_LABELS (const)", "APPROVAL_CANCEL_REASONS (const)", "APPROVAL_CANCEL_REASON_LABELS (const)", "APPROVAL_STATUSES (const)", "APPROVAL_STATUS_LABELS (const)", + "ActionConfirmationRequiredDetails (interface)", "AdapterContext (interface)", "AdapterSearchOptions (interface)", "AnalyticsDriverCapabilities (interface)", diff --git a/packages/spec/export-origins/contracts.json b/packages/spec/export-origins/contracts.json index a94038f6ef..87265e5bed 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -2,6 +2,7 @@ "description": "Which SOURCE DECLARATION each name exported by one public entry point of @objectstack/spec resolves to, after its alias chain is unwound: `# ()`. Two exports share an origin string iff they are the same declaration — so equal origins across two entries are a harmless re-export, and different origins under one name are the #4411 dual-source trap. Generated from src/ (no build needed) and read by the export-surface pin tests, which compare against it instead of each building their own ts.createProgram — that was ~55s of compilation per CI lap and a non-deterministic timeout that ejected unrelated PRs from the merge queue (#4796). Sharded by entry point (#5837) so two retirement PRs never share a file. Carries NO line numbers: the pins asserted the line as `\\d+`, and recording it would rewrite this artifact on every edit that shifts a line in any .zod.ts. Regenerate with `pnpm --filter @objectstack/spec gen:export-origins` and read the diff.", "entry": "./contracts", "exports": { + "AIActionConfirmation": "src/contracts/ai-service.ts#AIActionConfirmation (interface)", "AIConversation": "src/contracts/ai-service.ts#AIConversation (interface)", "AIMessage": "src/contracts/ai-service.ts#AIMessage (type)", "AIMessageWithTools": "src/contracts/ai-service.ts#AIMessageWithTools (type)", @@ -13,12 +14,14 @@ "AIToolCall": "src/contracts/ai-service.ts#AIToolCall (type)", "AIToolDefinition": "src/contracts/ai-service.ts#AIToolDefinition (interface)", "AIToolResult": "src/contracts/ai-service.ts#AIToolResult (type)", + "AI_ACTION_CONFIRMATION_MEMBER": "src/contracts/ai-service.ts#AI_ACTION_CONFIRMATION_MEMBER (const)", "APPROVAL_ACTION_KINDS": "src/contracts/approval-service.ts#APPROVAL_ACTION_KINDS (const)", "APPROVAL_ACTION_KIND_LABELS": "src/contracts/approval-service.ts#APPROVAL_ACTION_KIND_LABELS (const)", "APPROVAL_CANCEL_REASONS": "src/contracts/approval-service.ts#APPROVAL_CANCEL_REASONS (const)", "APPROVAL_CANCEL_REASON_LABELS": "src/contracts/approval-service.ts#APPROVAL_CANCEL_REASON_LABELS (const)", "APPROVAL_STATUSES": "src/contracts/approval-service.ts#APPROVAL_STATUSES (const)", "APPROVAL_STATUS_LABELS": "src/contracts/approval-service.ts#APPROVAL_STATUS_LABELS (const)", + "ActionConfirmationRequiredDetails": "src/contracts/ai-service.ts#ActionConfirmationRequiredDetails (interface)", "AdapterContext": "src/contracts/knowledge-adapter.ts#AdapterContext (interface)", "AdapterSearchOptions": "src/contracts/knowledge-adapter.ts#AdapterSearchOptions (interface)", "AnalyticsDriverCapabilities": "src/contracts/analytics-service.ts#AnalyticsDriverCapabilities (interface)", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index fb6dc458a0..7710814fe2 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -973,7 +973,7 @@ }, { "surface": "ai.tool.requiresConfirmation", - "replacement": "put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — that is the flag the HITL approval queue actually reads, and the only path that stops execution", + "replacement": "put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — an AI-facing call on an action DECLARING that flag must carry an explicit confirmation member on the request and is refused without it, the refusal naming the action and the exact member to set so the caller confirms and retries. It is a gate, not a queue: nothing is parked and a call is either confirmed or it does not run", "migrationId": "tool-requires-confirmation-retired", "toMajor": 17, "rationale": "`ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: not the LLM tool set (a tool reaches the model as name / description / parameters only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP bridge, which derives `destructiveHint` from a hardcoded name list. Setting it on a destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for a SAFETY property it is false compliance, the case ADR-0049 exists for — an author gates a destructive tool, sees the flag accepted, and ships believing a human is in the loop. It is made worse by the near-miss: `action.ai.requiresConfirmation` carries the same name and DOES work, so the mistake reads as correct in review. This is registered as a semantic entry rather than a mechanical conversion because the rewrite is not a rename at all — the replacement lives on a different metadata object at a different layer, and deciding which action should carry the gate (or whether the operation should be an action at all) is a judgement the chain cannot make. Deleting the key mechanically would be the worst possible transform here: it would leave the metadata parsing green while silently completing the removal of a safety gate the author believed was in place. `ToolSchema` was made `.strict()` in the same change, which is load-bearing rather than tidying — removing a key from a non-strict schema swaps one silent no-op for another, so the retired key now REJECTS and the parse error carries the prescription, that being the one channel every consumer bumping `@objectstack/spec` is guaranteed to hit. Registered by the #6350 stock reconciliation: the `retiredKey()` tombstone shipped with #3715 and still stands in `ai/tool.zod.ts`, but the ledger half never did. A retirement needs both — the tombstone is the proof the removal was declared, this entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0033 §2 / ADR-0049 / ADR-0087, #3715 (backfilled #6350)." @@ -2058,7 +2058,7 @@ }, { "surface": "ai.tool.requiresConfirmation", - "replacement": "put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — that is the flag the HITL approval queue actually reads, and the only path that stops execution", + "replacement": "put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — an AI-facing call on an action DECLARING that flag must carry an explicit confirmation member on the request and is refused without it, the refusal naming the action and the exact member to set so the caller confirms and retries. It is a gate, not a queue: nothing is parked and a call is either confirmed or it does not run", "migrationId": "tool-requires-confirmation-retired", "toMajor": 17, "rationale": "`ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: not the LLM tool set (a tool reaches the model as name / description / parameters only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP bridge, which derives `destructiveHint` from a hardcoded name list. Setting it on a destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for a SAFETY property it is false compliance, the case ADR-0049 exists for — an author gates a destructive tool, sees the flag accepted, and ships believing a human is in the loop. It is made worse by the near-miss: `action.ai.requiresConfirmation` carries the same name and DOES work, so the mistake reads as correct in review. This is registered as a semantic entry rather than a mechanical conversion because the rewrite is not a rename at all — the replacement lives on a different metadata object at a different layer, and deciding which action should carry the gate (or whether the operation should be an action at all) is a judgement the chain cannot make. Deleting the key mechanically would be the worst possible transform here: it would leave the metadata parsing green while silently completing the removal of a safety gate the author believed was in place. `ToolSchema` was made `.strict()` in the same change, which is load-bearing rather than tidying — removing a key from a non-strict schema swaps one silent no-op for another, so the retired key now REJECTS and the parse error carries the prescription, that being the one channel every consumer bumping `@objectstack/spec` is guaranteed to hit. Registered by the #6350 stock reconciliation: the `retiredKey()` tombstone shipped with #3715 and still stands in `ai/tool.zod.ts`, but the ledger half never did. A retirement needs both — the tombstone is the proof the removal was declared, this entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0033 §2 / ADR-0049 / ADR-0087, #3715 (backfilled #6350)." diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index d7fd7cc542..0470ca2e36 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -4569,9 +4569,11 @@ const step17: MigrationStep = { id: 'tool-requires-confirmation-retired', surface: 'ai.tool.requiresConfirmation', replacement: - 'put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — that ' - + 'is the flag the HITL approval queue actually reads, and the only path that stops ' - + 'execution', + 'put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — an ' + + 'AI-facing call on an action DECLARING that flag must carry an explicit confirmation ' + + 'member on the request and is refused without it, the refusal naming the action and the ' + + 'exact member to set so the caller confirms and retries. It is a gate, not a queue: ' + + 'nothing is parked and a call is either confirmed or it does not run', reason: '`ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: ' + 'not the LLM tool set (a tool reaches the model as name / description / parameters ' @@ -4604,8 +4606,9 @@ const step17: MigrationStep = { + 'load-bearing half is what happens NEXT, and no gate can check it for you: for every ' + 'tool that carried the flag, decide whether that operation genuinely needs a human in ' + 'the loop. If it does, move it behind an action carrying `ai.requiresConfirmation: ' - + 'true` and prove the pause exists — invoke it and observe the approval queue hold it, ' - + 'rather than assuming the declaration. If it does not, delete the key knowingly. ' + + 'true` and prove the refusal exists — invoke it WITHOUT the confirmation member and ' + + 'observe the call refused rather than dispatched, rather than assuming the ' + + 'declaration. If it does not, delete the key knowingly. ' + 'Deleting it without that decision leaves exactly the state the retirement exists to ' + 'end: a destructive tool nobody is approving, now without even the false flag to show ' + 'that somebody once meant to.', From 490d665375aa50ce6d682849a5a7fbeebd4e634d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 08:17:30 +0000 Subject: [PATCH 3/5] spec: repair pass on the confirmation contract review (A1/A3/A4/A5/A6/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 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- packages/spec/src/ai/tool.zod.ts | 30 +++- .../spec/src/api/error-code-ledger.zod.ts | 39 +++--- .../action-confirmation-contract.pin.test.ts | 131 ++++++++++++++++++ packages/spec/src/contracts/ai-service.ts | 44 ++++-- .../17.tool-requires-confirmation-retired.ts | 23 +-- 5 files changed, 223 insertions(+), 44 deletions(-) create mode 100644 packages/spec/src/contracts/action-confirmation-contract.pin.test.ts diff --git a/packages/spec/src/ai/tool.zod.ts b/packages/spec/src/ai/tool.zod.ts index c27e342da9..0a228c192e 100644 --- a/packages/spec/src/ai/tool.zod.ts +++ b/packages/spec/src/ai/tool.zod.ts @@ -33,6 +33,16 @@ import { strictObject } from '../shared/strict-object'; * `@objectstack/spec` is guaranteed to hit (pattern of `object.zod.ts`'s * `UNKNOWN_KEY_GUIDANCE`, ADR-0049 enforce-or-remove). */ +// The `requiresConfirmation` prescription below is deliberately +// CONTRACT-REFERENTIAL: `packages/spec/src/contracts/ai-service.ts` declares the +// confirmation member and `ACTION_CONFIRMATION_REQUIRED` names the refusal +// (#16293), but the runtime door that performs it lands in #15942. Until then a +// present-tense "the call is refused" here would send an author to test a +// destructive operation without the member and watch it EXECUTE — the exact +// declared-but-unenforced class ADR-0049 retired this key for. Tense follows +// enforcement: this text may promote to the present in the change that lands +// the door, and not before. (The ids stay in this comment: the prescription +// itself is customer-facing text, where `check:doc-authoring` reds on one.) const TOOL_RETIRED_KEY_GUIDANCE: Record = { permissions: '`tool.permissions` was removed in @objectstack/spec 17.0.0 (audit close-out) — it ' + @@ -65,13 +75,19 @@ const TOOL_RETIRED_KEY_GUIDANCE: Record = { '`ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP bridge ' + '(which derives `destructiveHint` from a hardcoded name list). Delete the key. For a ' + 'REAL gate on a destructive operation, put it behind an action and set ' + - '`action.ai.requiresConfirmation: true` — the platform\'s one enforced confirmation ' + - 'gate. An AI-facing call on an action DECLARING that flag must carry the confirmation ' + - 'member on the request, and is refused without it; the refusal names the action and ' + - 'the exact member to set, so the caller confirms and retries. It is a gate, not a ' + - 'queue: nothing is parked, nothing is held for an operator to find later, and a call ' + - 'is either confirmed or it does not run. For AI metadata mutations the ADR-0033 ' + - 'draft/publish workspace is the gate: nothing is live until a human publishes.', + '`action.ai.requiresConfirmation: true` — the flag the platform\'s confirmation ' + + 'CONTRACT is written against (`AIActionConfirmation`, `@objectstack/spec/contracts`). ' + + 'That contract DECLARES that an AI-facing call on an action declaring the flag must ' + + 'carry an explicit confirmation member on the request and is to be refused without ' + + 'it, the refusal naming the action and the exact member to set so the caller confirms ' + + 'and retries. It specifies a GATE, not a queue: nothing is parked and nothing is held ' + + 'for an operator to find later. Read this before you rely on it: the declaration is ' + + 'the contract, not yet the behaviour — the runtime door that performs the refusal ' + + 'ships separately, and until it does, setting the flag does NOT stop an unconfirmed ' + + 'call. Do not try to verify the gate by invoking the operation without the member: ' + + 'until that door lands, such a call simply RUNS. For AI metadata mutations the ' + + 'ADR-0033 draft/publish workspace is the gate: nothing is live until a human ' + + 'publishes.', }; /** diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index be9bd47f68..57350ea45e 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -264,13 +264,14 @@ export const ERROR_CODE_LEDGER = { 'VALIDATION_FAILED', // record-level validation; carries `fields[]` (#3977) ], '@objectstack/runtime': [ - // The AI-facing action doors refuse a call against an action whose AUTHOR - // declared `ai.requiresConfirmation: true` when the request does not carry - // the confirmation member as `true` — 428, the standard catalog's "request - // is missing a required precondition" status, because the request is VALID - // and merely incomplete: the identical call with the member set succeeds, - // and nothing about server state changed. Never carries `status: 'failed'`: - // nothing dispatched, no action body ran, no record was read or written. + // [#16293] The AI-facing action doors refuse a call against an action + // whose AUTHOR declared `ai.requiresConfirmation: true` when the request + // does not carry the confirmation member as `true` — 428, the standard + // catalog's "request is missing a required precondition" status, because + // the request is VALID and merely incomplete: the identical call with the + // member set succeeds, and nothing about server state changed. Never + // carries `status: 'failed'`: nothing dispatched, no action body ran, no + // record was read or written. // Contract, including which request member and what `error.details` carries: // `AIActionConfirmation` / `ActionConfirmationRequiredDetails` in // `contracts/ai-service.ts`. @@ -281,18 +282,24 @@ export const ERROR_CODE_LEDGER = { // confirmation gate from a missing conditional header, and unable to build // the retry. This code says WHICH precondition — the human attestation on // this named action — which is the thing the caller acts on, exactly the - // discrimination its `*_DISABLED` and `FLOW_*` neighbours make. Its - // nearest sibling is `UNIQUE_SCOPE_CONFIRMATION_REQUIRED` - // (`@objectstack/cloud-connection`), the platform's other confirmation - // gate, registered beside the same standard member for the same reason. + // discrimination its `*_DISABLED` and `FLOW_*` neighbours make. The + // platform's other confirmation gate, + // `UNIQUE_SCOPE_CONFIRMATION_REQUIRED` (`@objectstack/cloud-connection`), + // is a sibling in KIND only — it answers 409 and therefore sits beside + // `RESOURCE_CONFLICT`, not beside this row's standard member, so it is no + // precedent for choosing 428 over 409 here; that choice rests on the + // condition described above and on nothing it did. // // Registered AHEAD of its producer by design — the `FLOW_INPUT_SCHEMA_INVALID` // split shape. The emitting half is the runtime's pre-dispatch check in - // `action-execution.ts` (`invokeBusinessAction`), which the ruling split - // into its own card; this row is the contract half, and the door will - // assert this exact string by value. Registered HERE and not under a - // producing service for the same reason as its `FLOW_*` neighbours: the - // action door, not the predicate, is where the wire vocabulary is named. + // `action-execution.ts` (`invokeBusinessAction`), which decision batch #54 + // split into its own card, #15942; this row is the contract half landed by + // #16293, and the door will assert this exact string by value. Those two + // 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, + // not a residue. Registered HERE and not under a producing service for the + // same reason as its `FLOW_*` neighbours: the action door, not the + // predicate, is where the wire vocabulary is named. 'ACTION_CONFIRMATION_REQUIRED', // [ADR-0126 §8 item 2] a packaged ACTION this installation switched off, // refused at dispatch — the activation-ledger consult in diff --git a/packages/spec/src/contracts/action-confirmation-contract.pin.test.ts b/packages/spec/src/contracts/action-confirmation-contract.pin.test.ts new file mode 100644 index 0000000000..00f627f8f6 --- /dev/null +++ b/packages/spec/src/contracts/action-confirmation-contract.pin.test.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16293] The action-confirmation contract, pinned where a grep can find it. + * + * The contract landed with no test naming any of its symbols: `git grep -l` + * over test files returned ZERO for `AI_ACTION_CONFIRMATION_MEMBER`, + * `AIActionConfirmation`, `ActionConfirmationRequiredDetails` and + * `ACTION_CONFIRMATION_REQUIRED`, while the same grep over non-test files lit + * for each of them. A contract nothing asserts is a contract the next edit can + * change without telling anyone — and this one is a SAFETY contract, so the + * failure mode is silent. + * + * ## What is pinned, and what is deliberately NOT + * + * Pinned: that the refusal code is admitted by the closed `ErrorCode` union, + * that the ledger registers it under the package that owns the action doors, + * that it is not a synonym of a standard-catalog member (which is the whole + * argument for registering it rather than answering `PRECONDITION_REQUIRED`), + * and that `ActionConfirmationRequiredDetails.confirmationMember` is typed to + * the CONSTANT rather than to `string` — the property that lets a client read + * the member's spelling off a refusal instead of hard-coding it. + * + * NOT pinned: any refusal behaviour. The runtime door that performs it lands + * in #15942 and is not in this tree, so a behavioural assertion here would + * either be a stub asserting itself or a green test standing in for an absent + * gate. This file asserts the CONTRACT half, which is the half that exists. + * + * ## Why every leg carries a lit control + * + * Each assertion below is paired with one that must go the OTHER way on the + * same call. A zero from `standardSynonymOf` proves nothing unless the same + * function answers non-undefined for a code that really is a synonym; an + * `accepts` case proves nothing unless a near-miss of the same string is + * rejected by the same parser. The `@ts-expect-error` legs are the compile + * half of that discipline: an unused directive is itself a `tsc` error + * (TS2578) under `packages/spec`'s test-layer program, so a leg that stops + * detecting anything turns the type-check red rather than passing quietly. + */ + +import { describe, it, expect } from 'vitest'; + +import { + ERROR_CODE_LEDGER, + ErrorCode, + standardSynonymOf, +} from '../api/error-code-ledger.zod'; +import { + AI_ACTION_CONFIRMATION_MEMBER, + type AIActionConfirmation, + type ActionConfirmationRequiredDetails, +} from './ai-service'; + +/** The refusal code the contract names. */ +const CODE = 'ACTION_CONFIRMATION_REQUIRED'; +/** The package that owns both AI-facing action doors, and so owns the code. */ +const OWNER = '@objectstack/runtime'; + +/** Identity helpers, so a rejected shape errors on the argument's own line. */ +const asDetails = (d: ActionConfirmationRequiredDetails) => d; +const asConfirmation = (c: AIActionConfirmation) => c; + +describe('action-confirmation contract (#16293)', () => { + it('the refusal code is ADMITTED by the closed `ErrorCode` union', () => { + expect(ErrorCode.parse(CODE)).toBe(CODE); + // Control: the union is closed, so a one-character near-miss is refused. + // Without this, `parse` returning the input proves only that it is a + // string. + expect(ErrorCode.safeParse('ACTION_CONFIRMATION_REQUIRE').success).toBe(false); + }); + + it('the ledger registers it under the package that owns the action doors', () => { + expect(ERROR_CODE_LEDGER[OWNER]).toContain(CODE); + // Control: the row is not merely somewhere in the ledger. If the code + // moved owners, the assertion above would still pass under a `flat()`. + const otherOwners = Object.entries(ERROR_CODE_LEDGER) + .filter(([pkg]) => pkg !== OWNER) + .filter(([, codes]) => (codes as readonly string[]).includes(CODE)) + .map(([pkg]) => pkg); + expect(otherOwners).toEqual([]); + }); + + it('it is NOT a synonym of a standard-catalog member', () => { + // The argument for registering a code instead of answering the standard + // `PRECONDITION_REQUIRED`: this one says WHICH precondition. If the + // detector ever reads it as a synonym, the row needs a recorded waiver + // and the ledger's own admission test goes red — this states the intent. + expect(standardSynonymOf(CODE)).toBeUndefined(); + // Controls: the same detector, lit, on codes that ARE synonyms. + expect(standardSynonymOf('CONFLICT')).toBe('RESOURCE_CONFLICT'); + expect(standardSynonymOf('FORBIDDEN')).toBe('PERMISSION_DENIED'); + }); + + it('the member spelling is one exported constant, not a per-door string', () => { + expect(AI_ACTION_CONFIRMATION_MEMBER).toBe('confirm'); + }); + + it('`confirmationMember` is typed to the CONSTANT, not to `string`', () => { + // The refusal echoes the member so a client builds the retry off the + // response. Typed as `string` that echo is unverifiable; typed as + // `typeof AI_ACTION_CONFIRMATION_MEMBER` a refusal that names anything + // else does not compile. + const details = asDetails({ + actionName: 'archive_account', + objectName: 'account', + confirmationMember: AI_ACTION_CONFIRMATION_MEMBER, + }); + expect(details.confirmationMember).toBe(AI_ACTION_CONFIRMATION_MEMBER); + + const mismatched = asDetails({ + actionName: 'archive_account', + // @ts-expect-error — the member is pinned to the constant's literal type; a + // plausible near-miss is a compile error, which is the point of the type. + confirmationMember: 'confirmed', + }); + expect(String(mismatched.confirmationMember)).toBe('confirmed'); + }); + + it('the confirmation member is a BOOLEAN — a truthy string is not an attestation', () => { + expect(asConfirmation({ confirm: true }).confirm).toBe(true); + // Absent is legal: the member is required only on declared-gated actions. + expect(asConfirmation({}).confirm).toBeUndefined(); + + const stringy = asConfirmation({ + // @ts-expect-error — a transport artefact, not a decision; the contract + // admits only the boolean. + confirm: 'true', + }); + expect(String(stringy.confirm)).toBe('true'); + }); +}); diff --git a/packages/spec/src/contracts/ai-service.ts b/packages/spec/src/contracts/ai-service.ts index e2fdbce1e8..09a8849ad6 100644 --- a/packages/spec/src/contracts/ai-service.ts +++ b/packages/spec/src/contracts/ai-service.ts @@ -205,18 +205,34 @@ export interface AIToolDefinition { * door hand-spelling its own `'confirm'` is precisely how two doors drift into * two dialects (Prime Directive #12). Every AI-facing action door reads this. * - * It rides as a TOP-LEVEL member of the action request, beside `actionName` / - * `objectName` / `recordId`. Two placements were rejected, and the reasons are - * the contract: + * It rides as a TOP-LEVEL member of the action request. The two request + * shapes it is destined for are the MCP `run_action` tool input (`actionName` + * / `objectName` / `recordId` / `params`, `packages/mcp/src/mcp-http-tools.ts`) + * and the runtime action door's own request object — the `input` argument of + * `invokeBusinessAction` (`objectName` / `recordId` / `params`, + * `packages/runtime/src/action-execution.ts`), which the MCP bridge builds + * that object from. Each grows the member in the change that enforces it, so + * neither ever accepts-and-ignores it. Two placements were rejected, and the + * reasons are the contract: * - * - NOT inside `params`. That object is the action author's own declared input - * vocabulary, so a platform member there is a name the author may already - * have used — a collision that silently reassigns one of the two meanings. + * - NOT inside `params`. That bag is not a free surface: it is CLOSED against + * the action 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 one of the built-ins, so on an action that + * declares params at all a platform `confirm` riding there is REFUSED as an + * unknown action param — a 400 raised before the confirmation gate is ever + * reached, not merely a collision with a name the author might already have + * used. On an action declaring no params that check is a pass-through, so + * the same member would be silently accepted there instead: one placement, + * two opposite behaviours, which on its own disqualifies it for a safety + * member. * - NOT a transport header. A header is invisible to the tool schema an agent * reads, so the model cannot discover the retry it is being told to make; - * and the in-process doors (flow `call action` nodes, the action runner) - * have no header to carry it on at all. A free-form header would also be an - * OPEN channel, which is the one thing a safety member must not be. + * and a header exists only at an HTTP edge, while the action door itself is + * a plain function handed a request object rather than a transport envelope + * — there is no header there for it to ride on. A free-form header would + * also be an OPEN channel, which is the one thing a safety member must not + * be. */ export const AI_ACTION_CONFIRMATION_MEMBER = 'confirm'; @@ -245,9 +261,11 @@ export const AI_ACTION_CONFIRMATION_MEMBER = 'confirm'; * The predicate is deliberately narrower than the one behind the * `requiresConfirmation` field of a tool/action LISTING (see * {@link AIToolDefinition.requiresConfirmation}). That field answers "should a - * client ASK the human before calling?" and falls back to a destructiveness - * heuristic — `mode: 'delete'` or `variant: 'danger'` — when the author - * declared nothing. This one answers "will the server REFUSE without an + * client ASK the human before calling?" and falls back to the runtime's + * `actionLooksDestructive` heuristic (`packages/runtime/src/action-execution.ts`) + * when the author declared nothing — the signals that heuristic reads are + * named once, beside the authorable key in `ui/action.zod.ts`, and are not + * restated here. This one answers "will the server REFUSE without an * attestation?", and an author who declared nothing has asked for nothing: * gating the refusal on the heuristic would start refusing calls that work * today, on a guess the author never made. @@ -259,7 +277,7 @@ export const AI_ACTION_CONFIRMATION_MEMBER = 'confirm'; * - declared `ai.requiresConfirmation: true` → the door REFUSES without the * member, and the listing reports `true` as well. * - declared `ai.requiresConfirmation: false` → no refusal, whatever the - * action's `mode` / `variant` look like. An explicit `false` is the author + * action looks like to that heuristic. An explicit `false` is the author * asserting the action is safe unattended, and it overrides the heuristic in * that direction too. * diff --git a/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts b/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts index 37f7189e22..a43da70a15 100644 --- a/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts @@ -6,11 +6,14 @@ export const entry: SemanticMigration = { id: 'tool-requires-confirmation-retired', surface: 'ai.tool.requiresConfirmation', replacement: - 'put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — an ' - + 'AI-facing call on an action DECLARING that flag must carry an explicit confirmation ' - + 'member on the request and is refused without it, the refusal naming the action and the ' - + 'exact member to set so the caller confirms and retries. It is a gate, not a queue: ' - + 'nothing is parked and a call is either confirmed or it does not run', + 'put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — the ' + + 'flag the platform confirmation CONTRACT is written against. That contract DECLARES ' + + 'that an AI-facing call on an action declaring the flag must carry an explicit ' + + 'confirmation member on the request and is to be refused without it with ' + + '`ACTION_CONFIRMATION_REQUIRED`, the refusal naming the action and the exact member to ' + + 'set. A gate, not a queue: nothing is parked. ⚠ The refusal is DECLARED, not yet ' + + 'performed — the runtime door lands in #15942, so until then the flag stops nothing on ' + + 'its own and the human in the loop is still yours to arrange', reason: '`ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: ' + 'not the LLM tool set (a tool reaches the model as name / description / parameters ' @@ -43,9 +46,13 @@ export const entry: SemanticMigration = { + 'load-bearing half is what happens NEXT, and no gate can check it for you: for every ' + 'tool that carried the flag, decide whether that operation genuinely needs a human in ' + 'the loop. If it does, move it behind an action carrying `ai.requiresConfirmation: ' - + 'true` and prove the refusal exists — invoke it WITHOUT the confirmation member and ' - + 'observe the call refused rather than dispatched, rather than assuming the ' - + 'declaration. If it does not, delete the key knowingly. ' + + 'true`, which is what the confirmation contract (#16293) gates on. ⛔ Do NOT try to ' + + '"prove the gate" by invoking the operation without the confirmation member: the ' + + 'runtime door that refuses lands in #15942, so before that ships the call is not ' + + 'refused, it RUNS the destructive operation. Until then the declaration is a contract ' + + 'and the human in the loop is still yours to arrange — which is the decision this ' + + 'criterion is asking you to make, not a test to run. If the operation does not need ' + + 'a human, delete the key knowingly. ' + 'Deleting it without that decision leaves exactly the state the retirement exists to ' + 'end: a destructive tool nobody is approving, now without even the false flag to show ' + 'that somebody once meant to.', From 5a5e8d0649e8856bd668033bab6508308bc1d54d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 08:45:06 +0000 Subject: [PATCH 4/5] spec: regenerate the registry, spec-changes and upgrade guide for the 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 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- docs/protocol-upgrade-guide.md | 4 ++-- packages/spec/spec-changes.json | 4 ++-- packages/spec/src/ai/tool.zod.ts | 20 ++++++++++---------- packages/spec/src/migrations/registry.ts | 23 +++++++++++++++-------- 4 files changed, 29 insertions(+), 22 deletions(-) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 6a437f39a2..60f894af29 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -544,9 +544,9 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte - **`storage-service-list-retired`** — `contracts.IStorageService.list` → track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored in #6781 - Why not automatic: `list(prefix)` was an OPTIONAL contract method documented as "List files in a directory/prefix", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the "all files" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. #5172 was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 (analysis #5266). - Done when: No code calls `storage.list(...)` on the `file-storage` service or on any `IStorageService` value. Code that needed "which files are under this prefix" reads the records it wrote — `sys_file` / file-reference rows carry the storage key and page deterministically through ObjectQL — rather than asking the bucket, which is also the only form that stays correct past 1000 objects and across both adapters. An adapter that still IMPLEMENTS `list` keeps compiling (an extra method is not an error on a class) and is simply unreachable through the contract, so deleting it is cleanup that can follow. The break is on the CALLER side: `storage.list(...)` no longer type-checks, and a PROXY typed against `IStorageService` that forwards to `inner.list` is exactly such a caller — the one in `@objectstack/service-storage` goes with the adapters (#5541). ⚠️ AMENDED 2026-08-09 (#6781, maintainer ruling on cloud#1203, option B): the RESERVED route in the paragraph above was taken. `list` exists again on the contract, cursor-shaped — `list(prefix, { cursor, limit })` returning `{ items, nextCursor }` — because cloud had two first-party callers this repo could not see when the measurement said "nothing calls it" (tenant attachment reclamation, marketplace snapshot GC). This does NOT un-retire anything and the acceptance criterion above is unchanged for what it actually governs: the single-argument `list(prefix): StorageFileInfo[]` is gone for good, a call written against it still fails to compile, and the two dialects it had are now pinned against each other in `storage-adapter-list.conformance.test.ts` rather than left to diverge. What changed for an upgrader is only the destination: prefer the records you wrote, and reach for the restored member when there are none. -- **`tool-requires-confirmation-retired`** — `ai.tool.requiresConfirmation` → put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — an AI-facing call on an action DECLARING that flag must carry an explicit confirmation member on the request and is refused without it, the refusal naming the action and the exact member to set so the caller confirms and retries. It is a gate, not a queue: nothing is parked and a call is either confirmed or it does not run +- **`tool-requires-confirmation-retired`** — `ai.tool.requiresConfirmation` → put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — the flag the platform confirmation CONTRACT is written against. That contract DECLARES that an AI-facing call on an action declaring the flag must carry an explicit confirmation member on the request and is to be refused without it with `ACTION_CONFIRMATION_REQUIRED`, the refusal naming the action and the exact member to set. A gate, not a queue: nothing is parked. ⚠ The refusal is DECLARED, not yet performed — the runtime door lands in #15942, so until then the flag stops nothing on its own and the human in the loop is still yours to arrange - Why not automatic: `ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: not the LLM tool set (a tool reaches the model as name / description / parameters only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP bridge, which derives `destructiveHint` from a hardcoded name list. Setting it on a destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for a SAFETY property it is false compliance, the case ADR-0049 exists for — an author gates a destructive tool, sees the flag accepted, and ships believing a human is in the loop. It is made worse by the near-miss: `action.ai.requiresConfirmation` carries the same name and DOES work, so the mistake reads as correct in review. This is registered as a semantic entry rather than a mechanical conversion because the rewrite is not a rename at all — the replacement lives on a different metadata object at a different layer, and deciding which action should carry the gate (or whether the operation should be an action at all) is a judgement the chain cannot make. Deleting the key mechanically would be the worst possible transform here: it would leave the metadata parsing green while silently completing the removal of a safety gate the author believed was in place. `ToolSchema` was made `.strict()` in the same change, which is load-bearing rather than tidying — removing a key from a non-strict schema swaps one silent no-op for another, so the retired key now REJECTS and the parse error carries the prescription, that being the one channel every consumer bumping `@objectstack/spec` is guaranteed to hit. Registered by the #6350 stock reconciliation: the `retiredKey()` tombstone shipped with #3715 and still stands in `ai/tool.zod.ts`, but the ledger half never did. A retirement needs both — the tombstone is the proof the removal was declared, this entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0033 §2 / ADR-0049 / ADR-0087, #3715 (backfilled #6350). - - Done when: No tool definition carries `requiresConfirmation`; the key now raises a located parse error naming the replacement, so the sweep is "fix until nothing raises". ⚠️ The load-bearing half is what happens NEXT, and no gate can check it for you: for every tool that carried the flag, decide whether that operation genuinely needs a human in the loop. If it does, move it behind an action carrying `ai.requiresConfirmation: true` and prove the refusal exists — invoke it WITHOUT the confirmation member and observe the call refused rather than dispatched, rather than assuming the declaration. If it does not, delete the key knowingly. Deleting it without that decision leaves exactly the state the retirement exists to end: a destructive tool nobody is approving, now without even the false flag to show that somebody once meant to. + - Done when: No tool definition carries `requiresConfirmation`; the key now raises a located parse error naming the replacement, so the sweep is "fix until nothing raises". ⚠️ The load-bearing half is what happens NEXT, and no gate can check it for you: for every tool that carried the flag, decide whether that operation genuinely needs a human in the loop. If it does, move it behind an action carrying `ai.requiresConfirmation: true`, which is what the confirmation contract (#16293) gates on. ⛔ Do NOT try to "prove the gate" by invoking the operation without the confirmation member: the runtime door that refuses lands in #15942, so before that ships the call is not refused, it RUNS the destructive operation. Until then the declaration is a contract and the human in the loop is still yours to arrange — which is the decision this criterion is asking you to make, not a test to run. If the operation does not need a human, delete the key knowingly. Deleting it without that decision leaves exactly the state the retirement exists to end: a destructive tool nobody is approving, now without even the false flag to show that somebody once meant to. - **`ui-interaction-config-family-retired`** — `ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)` → (removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor, the #4910 way, not by un-retiring a declaration) - Why not automatic: Five `@objectstack/spec/ui` modules declared a full interaction-configuration vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol carried them. This is the ADR-0049 false-compliance shape in its most inviting form for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: `authorable-surface.json` listed 109 keys under these defs and `content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them as authoring tables, so the published documentation advertised a vocabulary with no carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a page component was rejected by `PageComponentSchema` for an unrecognized key — the docs and the schema disagreeing about the platform (Prime Directive #10). Three independent measurements, each with its controls passing in the same run: (1) no module under `packages/spec/src` imported any of the five except the `ui/index.ts` barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` (25 roots, 4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, `WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic carrier flipped all 21 — so unreachability was a fact about the graph, not a broken walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these modules' own unit tests. objectui holds TYPE re-exports and parity ratchets, never validators, and says so (#2561). The 2026-08-04 ruling weighed wiring a carrier key (option B) and rejected it: that is a feature with a renderer behind it, not ledger clean-up. It also weighed tightening the shapes to `strictObject` and rejected that explicitly — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave "a precisely validated dead slot, the more convincing lie" (#4583). Because there was no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, the same route 3 as #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ Not to be confused with #5021, which retired the THEME `animation` block — a different file, different defs, and that one did have a carrier key and therefore a tombstone. ADR-0049, #4988. - Done when: No code imports any of the 64 retired names from `@objectstack/spec` or `@objectstack/spec/ui` — `TouchTargetConfig(Schema)`, `GestureType(Schema)`, `SwipeDirection(Schema)`, `SwipeGestureConfig(Schema)`, `PinchGestureConfig(Schema)`, `LongPressGestureConfig(Schema)`, `GestureConfig(Schema)`, `TouchInteraction(Schema)`, `TransitionPreset(Schema)`, `EasingFunction(Schema)`, `TransitionConfig(Schema)`, `AnimationTrigger(Schema)`, `ComponentAnimation(Schema)`, `PageTransition(Schema)`, `MotionConfig(Schema)`, `DragHandle(Schema)`, `DropEffect(Schema)`, `DragConstraint(Schema)`, `DropZone(Schema)`, `DragItem(Schema)`, `DndConfig(Schema)`, `FocusTrapConfig(Schema)`, `KeyboardShortcut(Schema)`, `FocusManagement(Schema)`, `KeyboardNavigationConfig(Schema)`, `OfflineStrategy(Schema)`, `ConflictResolution(Schema)`, `SyncConfig(Schema)`, `PersistStorage(Schema)`, `EvictionPolicy(Schema)`, `OfflineCacheConfig(Schema)`, `OfflineConfig(Schema)` — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `ui/interaction-config-retirement.test.ts`). No metadata document needs editing, because none could ever carry one of these blocks: a stack that parsed before parses byte-for-byte the same after. If you consumed the bare `ConflictResolution` from `@objectstack/spec/ui` as a TYPE for your own offline code, declare that union locally — it is your client's policy, not the platform's. `@objectstack/spec/integration`'s `ConnectorConflictResolution` (connector sync) and `@objectstack/spec/api`'s `ConflictResolutionStrategy` (route merge policy) are different concepts and are untouched. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 7710814fe2..dc80ec20ce 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -973,7 +973,7 @@ }, { "surface": "ai.tool.requiresConfirmation", - "replacement": "put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — an AI-facing call on an action DECLARING that flag must carry an explicit confirmation member on the request and is refused without it, the refusal naming the action and the exact member to set so the caller confirms and retries. It is a gate, not a queue: nothing is parked and a call is either confirmed or it does not run", + "replacement": "put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — the flag the platform confirmation CONTRACT is written against. That contract DECLARES that an AI-facing call on an action declaring the flag must carry an explicit confirmation member on the request and is to be refused without it with `ACTION_CONFIRMATION_REQUIRED`, the refusal naming the action and the exact member to set. A gate, not a queue: nothing is parked. ⚠ The refusal is DECLARED, not yet performed — the runtime door lands in #15942, so until then the flag stops nothing on its own and the human in the loop is still yours to arrange", "migrationId": "tool-requires-confirmation-retired", "toMajor": 17, "rationale": "`ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: not the LLM tool set (a tool reaches the model as name / description / parameters only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP bridge, which derives `destructiveHint` from a hardcoded name list. Setting it on a destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for a SAFETY property it is false compliance, the case ADR-0049 exists for — an author gates a destructive tool, sees the flag accepted, and ships believing a human is in the loop. It is made worse by the near-miss: `action.ai.requiresConfirmation` carries the same name and DOES work, so the mistake reads as correct in review. This is registered as a semantic entry rather than a mechanical conversion because the rewrite is not a rename at all — the replacement lives on a different metadata object at a different layer, and deciding which action should carry the gate (or whether the operation should be an action at all) is a judgement the chain cannot make. Deleting the key mechanically would be the worst possible transform here: it would leave the metadata parsing green while silently completing the removal of a safety gate the author believed was in place. `ToolSchema` was made `.strict()` in the same change, which is load-bearing rather than tidying — removing a key from a non-strict schema swaps one silent no-op for another, so the retired key now REJECTS and the parse error carries the prescription, that being the one channel every consumer bumping `@objectstack/spec` is guaranteed to hit. Registered by the #6350 stock reconciliation: the `retiredKey()` tombstone shipped with #3715 and still stands in `ai/tool.zod.ts`, but the ledger half never did. A retirement needs both — the tombstone is the proof the removal was declared, this entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0033 §2 / ADR-0049 / ADR-0087, #3715 (backfilled #6350)." @@ -2058,7 +2058,7 @@ }, { "surface": "ai.tool.requiresConfirmation", - "replacement": "put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — an AI-facing call on an action DECLARING that flag must carry an explicit confirmation member on the request and is refused without it, the refusal naming the action and the exact member to set so the caller confirms and retries. It is a gate, not a queue: nothing is parked and a call is either confirmed or it does not run", + "replacement": "put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — the flag the platform confirmation CONTRACT is written against. That contract DECLARES that an AI-facing call on an action declaring the flag must carry an explicit confirmation member on the request and is to be refused without it with `ACTION_CONFIRMATION_REQUIRED`, the refusal naming the action and the exact member to set. A gate, not a queue: nothing is parked. ⚠ The refusal is DECLARED, not yet performed — the runtime door lands in #15942, so until then the flag stops nothing on its own and the human in the loop is still yours to arrange", "migrationId": "tool-requires-confirmation-retired", "toMajor": 17, "rationale": "`ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: not the LLM tool set (a tool reaches the model as name / description / parameters only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP bridge, which derives `destructiveHint` from a hardcoded name list. Setting it on a destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for a SAFETY property it is false compliance, the case ADR-0049 exists for — an author gates a destructive tool, sees the flag accepted, and ships believing a human is in the loop. It is made worse by the near-miss: `action.ai.requiresConfirmation` carries the same name and DOES work, so the mistake reads as correct in review. This is registered as a semantic entry rather than a mechanical conversion because the rewrite is not a rename at all — the replacement lives on a different metadata object at a different layer, and deciding which action should carry the gate (or whether the operation should be an action at all) is a judgement the chain cannot make. Deleting the key mechanically would be the worst possible transform here: it would leave the metadata parsing green while silently completing the removal of a safety gate the author believed was in place. `ToolSchema` was made `.strict()` in the same change, which is load-bearing rather than tidying — removing a key from a non-strict schema swaps one silent no-op for another, so the retired key now REJECTS and the parse error carries the prescription, that being the one channel every consumer bumping `@objectstack/spec` is guaranteed to hit. Registered by the #6350 stock reconciliation: the `retiredKey()` tombstone shipped with #3715 and still stands in `ai/tool.zod.ts`, but the ledger half never did. A retirement needs both — the tombstone is the proof the removal was declared, this entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0033 §2 / ADR-0049 / ADR-0087, #3715 (backfilled #6350)." diff --git a/packages/spec/src/ai/tool.zod.ts b/packages/spec/src/ai/tool.zod.ts index 0a228c192e..fc88f6e0c7 100644 --- a/packages/spec/src/ai/tool.zod.ts +++ b/packages/spec/src/ai/tool.zod.ts @@ -33,16 +33,6 @@ import { strictObject } from '../shared/strict-object'; * `@objectstack/spec` is guaranteed to hit (pattern of `object.zod.ts`'s * `UNKNOWN_KEY_GUIDANCE`, ADR-0049 enforce-or-remove). */ -// The `requiresConfirmation` prescription below is deliberately -// CONTRACT-REFERENTIAL: `packages/spec/src/contracts/ai-service.ts` declares the -// confirmation member and `ACTION_CONFIRMATION_REQUIRED` names the refusal -// (#16293), but the runtime door that performs it lands in #15942. Until then a -// present-tense "the call is refused" here would send an author to test a -// destructive operation without the member and watch it EXECUTE — the exact -// declared-but-unenforced class ADR-0049 retired this key for. Tense follows -// enforcement: this text may promote to the present in the change that lands -// the door, and not before. (The ids stay in this comment: the prescription -// itself is customer-facing text, where `check:doc-authoring` reds on one.) const TOOL_RETIRED_KEY_GUIDANCE: Record = { permissions: '`tool.permissions` was removed in @objectstack/spec 17.0.0 (audit close-out) — it ' + @@ -66,6 +56,16 @@ const TOOL_RETIRED_KEY_GUIDANCE: Record = { '`tool.builtIn` was removed in @objectstack/spec 17.0.0 (audit close-out) — no ' + 'runtime branches on it; it never affected registration, selection or execution. Delete ' + 'the key.', + // This prescription is deliberately CONTRACT-REFERENTIAL. + // `contracts/ai-service.ts` declares the confirmation member and + // `ACTION_CONFIRMATION_REQUIRED` names the refusal (#16293), but the runtime + // door that performs it lands in #15942. Until then a present-tense "the call + // is refused" here would send an author to test a destructive operation + // without the member and watch it EXECUTE — the exact declared-but-unenforced + // class ADR-0049 retired this key for. Tense follows enforcement: promote it + // in the change that lands the door, not before. The ids stay in this comment + // and out of the string: `check:doc-authoring` reds on an internal tracker id + // inside a customer-facing prescription. requiresConfirmation: '`tool.requiresConfirmation` was removed from @objectstack/spec in the 16.x line ' + '(ADR-0033 §2) — it never had a consumer, and a SAFETY flag that is merely ' + diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 0470ca2e36..9a9bfa0a71 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -4569,11 +4569,14 @@ const step17: MigrationStep = { id: 'tool-requires-confirmation-retired', surface: 'ai.tool.requiresConfirmation', replacement: - 'put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — an ' - + 'AI-facing call on an action DECLARING that flag must carry an explicit confirmation ' - + 'member on the request and is refused without it, the refusal naming the action and the ' - + 'exact member to set so the caller confirms and retries. It is a gate, not a queue: ' - + 'nothing is parked and a call is either confirmed or it does not run', + 'put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — the ' + + 'flag the platform confirmation CONTRACT is written against. That contract DECLARES ' + + 'that an AI-facing call on an action declaring the flag must carry an explicit ' + + 'confirmation member on the request and is to be refused without it with ' + + '`ACTION_CONFIRMATION_REQUIRED`, the refusal naming the action and the exact member to ' + + 'set. A gate, not a queue: nothing is parked. ⚠ The refusal is DECLARED, not yet ' + + 'performed — the runtime door lands in #15942, so until then the flag stops nothing on ' + + 'its own and the human in the loop is still yours to arrange', reason: '`ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: ' + 'not the LLM tool set (a tool reaches the model as name / description / parameters ' @@ -4606,9 +4609,13 @@ const step17: MigrationStep = { + 'load-bearing half is what happens NEXT, and no gate can check it for you: for every ' + 'tool that carried the flag, decide whether that operation genuinely needs a human in ' + 'the loop. If it does, move it behind an action carrying `ai.requiresConfirmation: ' - + 'true` and prove the refusal exists — invoke it WITHOUT the confirmation member and ' - + 'observe the call refused rather than dispatched, rather than assuming the ' - + 'declaration. If it does not, delete the key knowingly. ' + + 'true`, which is what the confirmation contract (#16293) gates on. ⛔ Do NOT try to ' + + '"prove the gate" by invoking the operation without the confirmation member: the ' + + 'runtime door that refuses lands in #15942, so before that ships the call is not ' + + 'refused, it RUNS the destructive operation. Until then the declaration is a contract ' + + 'and the human in the loop is still yours to arrange — which is the decision this ' + + 'criterion is asking you to make, not a test to run. If the operation does not need ' + + 'a human, delete the key knowingly. ' + 'Deleting it without that decision leaves exactly the state the retirement exists to ' + 'end: a destructive tool nobody is approving, now without even the false flag to show ' + 'that somebody once meant to.', From 10abca6c2825c3b91e113be6f56ceb3183568adc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 09:32:02 +0000 Subject: [PATCH 5/5] changeset: carry the A4 tense fix into the text that ships to consumers 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 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- .changeset/action-confirmation-contract.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/action-confirmation-contract.md b/.changeset/action-confirmation-contract.md index 05792c9354..faabfd5d3c 100644 --- a/.changeset/action-confirmation-contract.md +++ b/.changeset/action-confirmation-contract.md @@ -2,13 +2,13 @@ "@objectstack/spec": minor --- -`action.ai.requiresConfirmation` gets a real contract: an AI-facing call on an action that declares it must carry an explicit confirmation, and the refusal tells the caller how to retry. +`action.ai.requiresConfirmation` gets a real contract — a DECLARATION, not yet a live gate: the contract states that an AI-facing call on an action declaring the flag must carry an explicit confirmation and is refused without it, with a refusal that tells the caller how to retry. No door performs that refusal yet; setting the flag still stops nothing until the runtime half lands. The flag has always read as a safety gate and has only ever filled one field of the MCP `list_actions` summary. Two of the spec's own passages went further and told authors it "actually stops execution" through an HITL approval queue — a queue the open framework path does not have (the server-side queue is an ObjectOS layer over these same actions). This change defines the gate the flag was always claimed to be. It is additive and defines the contract only; the doors adopt it separately. -- **The request member.** `AIActionConfirmation` (`contracts/ai-service.ts`) declares the confirmation as a closed boolean member, and `AI_ACTION_CONFIRMATION_MEMBER` fixes its one spelling so every AI-facing action door and every retrying client read the same constant. It rides at the top level of the action request — deliberately not inside `params`, where it could collide with an author's own declared input, and deliberately not a transport header, which the in-process doors cannot carry and the tool schema an agent reads cannot show. +- **The request member.** `AIActionConfirmation` (`contracts/ai-service.ts`) declares the confirmation as a closed boolean member, and `AI_ACTION_CONFIRMATION_MEMBER` fixes its one spelling so every AI-facing action door and every retrying client read the same constant. It rides at the top level of the action request — deliberately not inside `params`, which is strict by default (`enforceActionParams`, ADR-0104 D2) and would REJECT an undeclared `confirm` outright on any action that declares params, and deliberately not a transport header, which the action door — a plain function handed a request object — cannot carry and the tool schema an agent reads cannot show. - **Which predicate gates the refusal.** A door refuses when, and only when, the action's author DECLARED `ai.requiresConfirmation: true` and the request does not carry the member as `true`. This is narrower than the predicate behind the `requiresConfirmation` field of a listing, which falls back to a destructiveness heuristic (`mode: 'delete'` / `variant: 'danger'`) when the author declared nothing: that field advises a client to ask, and an author who declared nothing has asked for nothing. Gating the refusal on the heuristic would start refusing calls that work today, on a guess the author never made. The member is accepted on every call and required only on the declared-gated ones, so a client that confirms whenever a listing says `requiresConfirmation: true` is always correct. - **The refusal.** `ACTION_CONFIRMATION_REQUIRED`, registered in `ERROR_CODE_LEDGER` under `@objectstack/runtime`, answered 428 — the request is valid and merely incomplete, and the identical call with the member set succeeds. `error.details` is `ActionConfirmationRequiredDetails`: the action name, its object, and the exact member to set, so an agent builds the retry mechanically instead of re-parsing prose. It is not a re-spelling of the standard catalog's `PRECONDITION_REQUIRED`, which leaves a caller unable to tell a confirmation gate from a missing conditional header. -- **Two passages corrected.** The `tool.requiresConfirmation` retirement guidance and the ADR-0049 semantic migration entry now describe the enforced gate instead of a queue: nothing is parked, nothing is held for an operator to find later, and a call is either confirmed or it does not run. +- **Two passages corrected.** The `tool.requiresConfirmation` retirement guidance and the ADR-0049 semantic migration entry no longer describe an approval queue. They state what the contract DECLARES — a gate, nothing parked, nothing held for an operator to find later — and say plainly that the door which performs the refusal has not landed, so the flag does not stop an unconfirmed call today. They also no longer tell an author to prove the gate by invoking the operation without the confirmation member: until that door ships, such a call is not refused, it RUNS. `list_actions` is unchanged and keeps reporting the flag exactly as it does today.