diff --git a/.changeset/17487-confirmation-gate-prescriptions-present-tense.md b/.changeset/17487-confirmation-gate-prescriptions-present-tense.md new file mode 100644 index 0000000000..d5c75e5948 --- /dev/null +++ b/.changeset/17487-confirmation-gate-prescriptions-present-tense.md @@ -0,0 +1,72 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): the three shipped confirmation-gate prescriptions state the gate in the present tense — they were denying a door that exists (#17487) + +Clause-②: no + +No accept-set change and no export moves. `ToolSchema` still refuses +`requiresConfirmation` with a located parse error, `ActionSchema` still accepts +`ai.requiresConfirmation` in both directions, and `check:authorable-surface` / +`check:api-surface` are byte-identical across this diff. What moves is text. + +Three customer-facing prescriptions were written while the runtime confirmation +door was a separate, unlanded change, and each said so in the present tense. The +door has since landed on `main` — `actionConfirmationRefusal`, called pre-dispatch +by `invokeBusinessAction` in `@objectstack/runtime`, with the `confirm` member +grown on the MCP `run_action` tool in the same change. From that moment the +published prose DENIED a door that exists, and it denied it in the dangerous direction: an author who +reads it concludes the safety flag stops nothing and either arranges a human in +the loop some other way or stops setting the flag — losing the gate exactly when +it starts working. That is the ADR-0049 false-compliance defect with the sign +flipped. + +**The three carriers**, all of them shipped text rather than comments: + +1. the `requiresConfirmation` entry of `TOOL_RETIRED_KEY_GUIDANCE` + (`ai/tool.zod.ts`), which reaches consumers as the parse error on the + `.strict()` `ToolSchema` — the one channel every consumer bumping + `@objectstack/spec` is guaranteed to hit; +2. the ADR-0087 D3 entry's `replacement`, and +3. its `acceptanceCriteria` — what `spec-changes.json`, + `docs/protocol-upgrade-guide.md` and `os migrate meta` project to consumers. + +FROM → TO, on the sharpest of the three (the acceptance criterion): + +``` +was: Do NOT try to "prove the gate" by invoking the operation without the + confirmation member: ... before that ships the call is not refused, it + RUNS the destructive operation. +now: ... that gate is PERFORMED: invoking the operation over an AI-exposed + door without the confirmation member is REFUSED with + ACTION_CONFIRMATION_REQUIRED (428) and nothing runs, so that call is a + real check you can make rather than a destructive experiment. +``` + +**The corrections carry the door's BOUNDS, because over-promising here is the +same defect in the other direction.** Each prescription now states, as the door +itself declares them: the refusal is `ACTION_CONFIRMATION_REQUIRED` / 428 naming +the action and the member `confirm: true`; it is a GATE, not a queue — nothing +is parked and a refused call did not run, no record read and none written; the +enforced set is the doors that enforce the author's `ai.exposed` opt-in, today +the action door reached from the MCP `run_action` tool, while REST `/actions` is +not `ai.exposed`-gated and sits outside the gate; only the author's declared +`ai.requiresConfirmation: true` refuses, while the wider listing heuristic +advises and never refuses; and `confirm: true` is an unverifiable caller claim, +so the gate makes FORGETTING loud without proving a human. + +`ai/tool-confirmation-prescription-tense.pin.test.ts` is the tie that was +missing the first time: it reads the three shipped strings AND the runtime door, +so a prescription that re-acquires a not-yet-shipped denial fails, and a door +that is removed, narrowed off the DECLARED flag, unhooked from +`invokeBusinessAction`, or widened onto REST `/actions` fails naming both files. +The denial predicate is fed the three retired sentences verbatim, so it cannot +pass by the prose merely falling silent. + +**On release ordering.** The door ships in the same release this correction +does: the runtime changeset that carries it (`action-confirmation-gate-enforced`) +is still pending alongside this one, and one `changeset version` run consumes +both. A release cut before this lands is the failure this card exists to end — +the runtime refusing calls while the published spec text tells authors the flag +stops nothing. diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index ee10bfeb4f..972a010e80 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -543,9 +543,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 — 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 +- **`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, and that contract is ENFORCED. An AI-facing call on an action declaring the flag must carry the confirmation member `confirm: true` on the request and is REFUSED without it with `ACTION_CONFIRMATION_REQUIRED` (428), the refusal naming the action and the exact member to set. A gate, not a queue: nothing is parked, and a refused call did not run — no record was read and none was written. ⚠ Two bounds: the enforced set is the doors that enforce the author's `ai.exposed` opt-in, today the action door reached from the MCP `run_action` tool, while REST `/actions` is not `ai.exposed`-gated and sits outside the gate; and `confirm: true` is an unverifiable caller claim, so the gate makes forgetting loud without proving a human approved - 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`, 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. + - 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 — and that gate is PERFORMED: invoking the operation over an AI-exposed door without the confirmation member is REFUSED with `ACTION_CONFIRMATION_REQUIRED` (428) and nothing runs, so that call is a real check you can make rather than a destructive experiment. ⚠ Two bounds on what it proves: the enforced set is the doors that enforce the author's `ai.exposed` opt-in — today the action door reached from the MCP `run_action` tool — while REST `/actions` is not `ai.exposed`-gated and sits outside the gate, so an agent holding an API key on that route is still yours to put a human in front of; and `confirm: true` is an unverifiable caller claim, so the gate makes forgetting loud without proving a human approved. The decision above is still the one this criterion asks you to make. 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 43601bf31f..fb929df3b5 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -967,7 +967,7 @@ }, { "surface": "ai.tool.requiresConfirmation", - "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", + "replacement": "put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — the flag the platform confirmation CONTRACT is written against, and that contract is ENFORCED. An AI-facing call on an action declaring the flag must carry the confirmation member `confirm: true` on the request and is REFUSED without it with `ACTION_CONFIRMATION_REQUIRED` (428), the refusal naming the action and the exact member to set. A gate, not a queue: nothing is parked, and a refused call did not run — no record was read and none was written. ⚠ Two bounds: the enforced set is the doors that enforce the author's `ai.exposed` opt-in, today the action door reached from the MCP `run_action` tool, while REST `/actions` is not `ai.exposed`-gated and sits outside the gate; and `confirm: true` is an unverifiable caller claim, so the gate makes forgetting loud without proving a human approved", "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)." @@ -2046,7 +2046,7 @@ }, { "surface": "ai.tool.requiresConfirmation", - "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", + "replacement": "put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — the flag the platform confirmation CONTRACT is written against, and that contract is ENFORCED. An AI-facing call on an action declaring the flag must carry the confirmation member `confirm: true` on the request and is REFUSED without it with `ACTION_CONFIRMATION_REQUIRED` (428), the refusal naming the action and the exact member to set. A gate, not a queue: nothing is parked, and a refused call did not run — no record was read and none was written. ⚠ Two bounds: the enforced set is the doors that enforce the author's `ai.exposed` opt-in, today the action door reached from the MCP `run_action` tool, while REST `/actions` is not `ai.exposed`-gated and sits outside the gate; and `confirm: true` is an unverifiable caller claim, so the gate makes forgetting loud without proving a human approved", "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-confirmation-prescription-tense.pin.test.ts b/packages/spec/src/ai/tool-confirmation-prescription-tense.pin.test.ts new file mode 100644 index 0000000000..224366b540 --- /dev/null +++ b/packages/spec/src/ai/tool-confirmation-prescription-tense.pin.test.ts @@ -0,0 +1,272 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#17487] The shipped confirmation-gate prescriptions are held to the door + * that actually runs — in BOTH directions. + * + * Three customer-facing prescriptions tell an author what + * `action.ai.requiresConfirmation: true` does: the `requiresConfirmation` entry + * of `TOOL_RETIRED_KEY_GUIDANCE` (reached as a parse error on the `.strict()` + * `ToolSchema`, the one channel every consumer bumping `@objectstack/spec` is + * guaranteed to hit) and the ADR-0087 D3 entry's `replacement` and + * `acceptanceCriteria`, which are what `spec-changes.json`, the upgrade guide + * and `os migrate meta` project to consumers. + * + * ## Why this pin exists, and why it reads the RUNTIME + * + * All three used to carry a hedge — "the declaration is the contract, not yet + * the behaviour … until that door lands, such a call simply RUNS" — written + * while the runtime door was a separate, unlanded card. The door landed. The + * hedge then DENIED a door that exists, and denied it in the dangerous + * direction: an author who reads it concludes the safety flag is inert and + * stops setting it, losing the gate at the moment it starts working. That is + * the mirror of the ADR-0049 defect the tool-level key was retired for — + * "a SAFETY flag that is merely accepted is false compliance" — with the sign + * flipped. + * + * Nothing tied the prose to the function it describes, which is exactly how the + * sentence rotted the first time (the same diagnosis + * `ui/action-requires-confirmation-docblock.pin.test.ts` records for its own + * surface). This pin is the tie. It fails in BOTH directions: + * + * - if the prose re-acquires a not-yet-shipped DENIAL, the self-tested + * predicate below flags it — and the predicate is fed the three historical + * sentences verbatim, so it cannot pass merely by the prose falling silent; + * - if the runtime door is removed or narrowed — `actionConfirmationRefusal` + * gone, no longer reading the DECLARED flag, or no longer called + * pre-dispatch by `invokeBusinessAction` — this goes red naming both files, + * and whoever makes that change is told the prescriptions are now the thing + * that has to move. + * + * ## The over-claim guard is the other half + * + * An unbounded "the platform refuses unconfirmed calls" would be this same + * defect in the first direction. The door's enforced set is bounded by + * `ai.exposed`, so the prose must carry that bound, and the REST `/actions` + * door must still be outside it — asserted here against + * `runtime/src/domains/actions.ts` rather than trusted. If REST ever joins the + * gate, the sentence saying it sits outside becomes false and this pin says so. + * + * ⛔ Scope: the RELATION and the two load-bearing facts, never the wording. + * Rewording these prescriptions freely is fine. What they may not do is deny + * the refusal, drop the code/status that names it, or drop the `ai.exposed` + * bound that keeps the claim honest. + * + * ⛔ `entry.reason` is deliberately NOT scanned. It is the retirement's + * historical rationale for the TOOL-level key ("Setting it on a destructive + * tool produced NO PAUSE"), which is past tense about a key that really was + * inert — true then, true now, and not a prescription an author acts on. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, it, expect } from 'vitest'; + +import { ToolSchema } from './tool.zod'; +import { ActionSchema } from '../ui/action.zod'; +import { entry as toolConfirmationEntry } from '../migrations/entries/semantic/17.tool-requires-confirmation-retired'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +/** …/packages/spec/src/ai → repo root */ +const REPO_ROOT = resolve(HERE, '../../../..'); +const GATE_SOURCE = join(REPO_ROOT, 'packages', 'runtime', 'src', 'action-execution.ts'); +const REST_ACTIONS_SOURCE = join(REPO_ROOT, 'packages', 'runtime', 'src', 'domains', 'actions.ts'); + +/** The shipped prescription, read off the rejection an author actually meets. */ +function toolRejectionMessage(): string { + const result = ToolSchema.safeParse({ + name: 'delete_everything', + label: 'Delete Everything', + description: 'Destructive', + parameters: {}, + requiresConfirmation: true, + }); + expect(result.success, '`requiresConfirmation` stopped being rejected — re-anchor this pin').toBe(false); + if (result.success) return ''; + return result.error.issues.map((i) => i.message).join(' '); +} + +/** + * The three carriers, each named. A prescription reaching consumers through a + * different projection is the same text, so one set of assertions covers all. + */ +function shippedPrescriptions(): Array<[string, string]> { + return [ + ['ToolSchema rejection (TOOL_RETIRED_KEY_GUIDANCE.requiresConfirmation)', toolRejectionMessage()], + ['ADR-0087 D3 entry — replacement', toolConfirmationEntry.replacement], + ['ADR-0087 D3 entry — acceptanceCriteria', toolConfirmationEntry.acceptanceCriteria], + ]; +} + +/** + * Claims that the refusal is not performed. A closed list, every member taken + * verbatim from a sentence one of these three carriers really shipped — so the + * predicate answers "did the retired shape come back", never "does this read + * hedged to me". + */ +const DENIALS: Array<[string, RegExp]> = [ + ['not yet the behaviour', /not yet the behaviour/i], + ['the door ships separately', /ships separately/i], + ['DECLARED, not yet performed', /not yet performed/i], + ['setting the flag does NOT stop an unconfirmed call', /does NOT stop an unconfirmed/i], + ['the flag stops nothing', /stops nothing/i], + ['such a call simply RUNS', /simply RUNS/i], + ['the call is not refused', /is not refused/i], + ['until it does / until then / until that door lands', /until (?:it does|then|that door lands)\b/i], +]; + +/** Which denials a text carries, by name. */ +function deniesTheDoor(text: string): string[] { + return DENIALS.filter(([, probe]) => probe.test(text)).map(([name]) => name); +} + +/** The body of `actionConfirmationRefusal` — the function this prose describes. */ +function gateBody(): string { + const source = readFileSync(GATE_SOURCE, 'utf8'); + const start = source.indexOf('export function actionConfirmationRefusal'); + expect(start, '`actionConfirmationRefusal` moved — re-anchor this pin').toBeGreaterThan(-1); + const open = source.indexOf('{', start); + const end = source.indexOf('\n}', open); + expect(end, 'unterminated `actionConfirmationRefusal` body').toBeGreaterThan(open); + return source.slice(open, end); +} + +/** The body of `invokeBusinessAction` — the AI-facing door that calls the gate. */ +function aiDoorBody(): string { + const source = readFileSync(GATE_SOURCE, 'utf8'); + const start = source.indexOf('export async function invokeBusinessAction'); + expect(start, '`invokeBusinessAction` moved — re-anchor this pin').toBeGreaterThan(-1); + const end = source.indexOf('\nexport ', start + 1); + return source.slice(start, end === -1 ? source.length : end); +} + +describe('[#17487] the confirmation-gate prescriptions match the door that runs', () => { + it('anchors on real text in all three carriers', () => { + // Anti-vacuity for every assertion below: an empty carrier would pass + // "carries no denial" by reading nothing. + for (const [name, text] of shippedPrescriptions()) { + expect(text.length, `${name} read empty`).toBeGreaterThan(200); + expect(text, `${name} no longer names the replacement key`).toMatch(/ai\.requiresConfirmation/); + } + }); + + it('the runtime door is still there, and still reads the DECLARED flag', () => { + const body = gateBody(); + const source = readFileSync(GATE_SOURCE, 'utf8'); + + // The narrow predicate — the author's own declaration, never the listing + // heuristic. If this ever widens, the prose's second bound is stale. + expect(body, 'the gate stopped reading the DECLARED flag').toMatch( + /action\?\.ai\?\.requiresConfirmation !== true/, + ); + // Only the boolean `true` attests. + expect(body, 'the gate stopped requiring the confirmation member').toMatch( + /AI_ACTION_CONFIRMATION_MEMBER\] === true/, + ); + // The envelope the prose names, by value. + expect(source).toMatch(/ACTION_CONFIRMATION_REQUIRED_CODE = 'ACTION_CONFIRMATION_REQUIRED'/); + expect(source).toMatch(/ACTION_CONFIRMATION_REQUIRED_STATUS = 428/); + }); + + it('the AI-facing door still calls the gate pre-dispatch', () => { + expect( + aiDoorBody(), + '`invokeBusinessAction` no longer calls `actionConfirmationRefusal` — the prescriptions ' + + 'in `ai/tool.zod.ts` and the ADR-0087 D3 entry now describe a door that does not run', + ).toMatch(/actionConfirmationRefusal\(/); + }); + + it('REST `/actions` is still OUTSIDE the gate, which is what the bound claims', () => { + // The over-claim guard. The prose tells an author to arrange their own + // human in front of REST; if that door joins the gate, the sentence is + // false and this is where it is caught. + expect( + readFileSync(REST_ACTIONS_SOURCE, 'utf8'), + 'the REST actions door now enforces the confirmation gate — the `ai.exposed` bound in ' + + 'the shipped prescriptions is stale', + ).not.toMatch(/actionConfirmationRefusal|ACTION_CONFIRMATION_REQUIRED/); + }); + + it('would flag each retired sentence as a denial (self-test)', () => { + // Verbatim, the three sentences this card retired. Without these the + // assertion below could pass simply because the prose went quiet. + expect( + deniesTheDoor( + '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.', + ).length, + ).toBeGreaterThan(0); + expect( + deniesTheDoor( + 'The refusal is DECLARED, not yet performed — the runtime door lands separately, so ' + + 'until then the flag stops nothing on its own.', + ).length, + ).toBeGreaterThan(0); + expect( + deniesTheDoor( + 'Do NOT try to "prove the gate" by invoking the operation without the confirmation ' + + 'member: before that ships the call is not refused, it RUNS the destructive operation.', + ).length, + ).toBeGreaterThan(0); + + // And the predicate is not "never say `until`" — the ADR-0033 sentence the + // corrected prescription still carries must pass. + expect( + deniesTheDoor( + 'For AI metadata mutations the ADR-0033 draft/publish workspace is the gate: nothing ' + + 'is live until a human publishes.', + ), + ).toEqual([]); + }); + + it('no shipped prescription denies the refusal', () => { + for (const [name, text] of shippedPrescriptions()) { + expect(deniesTheDoor(text), `${name} denies a door that exists`).toEqual([]); + } + }); + + it('every shipped prescription states the refusal, and states its bound', () => { + for (const [name, text] of shippedPrescriptions()) { + // The refusal, by the code and status the door really answers — what a + // caller builds the retry from. + expect(text, `${name} no longer names the refusal code`).toMatch(/ACTION_CONFIRMATION_REQUIRED/); + expect(text, `${name} no longer names the 428 status`).toMatch(/\b428\b/); + expect(text, `${name} no longer names the confirmation member`).toMatch(/`confirm: true`/); + // The bound. Without it the sentence over-promises, which is this same + // defect in the other direction. + expect(text, `${name} dropped the \`ai.exposed\` bound`).toMatch(/`ai\.exposed`/); + expect(text, `${name} dropped the REST \`/actions\` carve-out`).toMatch(/REST `\/actions`/); + expect(text, `${name} dropped the unverifiable-claim caveat`).toMatch(/unverifiable caller claim/); + } + }); + + it('moves NO accept set — the same metadata is accepted and refused as before', () => { + // This card is a text correction (`Clause-②: no`). If a future + // "clarification" moves what parses, that is a contract change wearing a + // text-change costume, and it fails here. + expect( + ToolSchema.safeParse({ + name: 'delete_everything', label: 'D', description: 'd', parameters: {}, + requiresConfirmation: true, + }).success, + '`tool.requiresConfirmation` must still be REFUSED', + ).toBe(false); + expect( + ToolSchema.safeParse({ name: 'ok_tool', label: 'OK', description: 'd', parameters: {} }).success, + 'a minimal tool must still be ACCEPTED', + ).toBe(true); + + for (const declared of [true, false]) { + expect( + ActionSchema.safeParse({ + name: 'archive_lead', label: 'Archive Lead', target: 'noop', + ai: { requiresConfirmation: declared }, + }).success, + `\`action.ai.requiresConfirmation: ${declared}\` must still be ACCEPTED`, + ).toBe(true); + } + }); +}); diff --git a/packages/spec/src/ai/tool.zod.ts b/packages/spec/src/ai/tool.zod.ts index fc88f6e0c7..66110127be 100644 --- a/packages/spec/src/ai/tool.zod.ts +++ b/packages/spec/src/ai/tool.zod.ts @@ -56,16 +56,24 @@ 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. + // TENSE FOLLOWS ENFORCEMENT — and it is promoted here (#17487), because the + // trigger the old note named has arrived. The door that performs the refusal + // landed with #15942: `actionConfirmationRefusal`, called pre-dispatch by + // `invokeBusinessAction` in `packages/runtime/src/action-execution.ts`. The + // hedge this entry used to carry ("the declaration is the contract, not yet + // the behaviour … setting the flag does NOT stop an unconfirmed call") now + // DENIES a door that exists, and denies it in the dangerous direction: an + // author who reads it concludes the safety flag is inert and stops setting it, + // losing the gate at the moment it starts working. + // + // The prescription below therefore states the refusal in the present tense AND + // states the door's two BOUNDS with it. Over-claiming here is the same ADR-0049 + // defect with the sign flipped, so the bounds are the ones that door's own + // docblock declares and nothing wider. `ai/tool-confirmation-prescription-tense.pin.test.ts` + // ties this text to that function, so neither half can move alone again. + // + // 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 ' + @@ -76,18 +84,24 @@ const TOOL_RETIRED_KEY_GUIDANCE: Record = { '(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 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.', + 'CONTRACT is written against (`AIActionConfirmation`, `@objectstack/spec/contracts`), ' + + 'and that contract is ENFORCED. An AI-facing call on an action declaring the flag ' + + 'must carry the confirmation member `confirm: true` on the request; without it the ' + + 'call is REFUSED with `ACTION_CONFIRMATION_REQUIRED` (428), the refusal naming the ' + + 'action and the exact member to set so the caller confirms and retries. A GATE, not ' + + 'a queue: nothing is parked, nothing is held for an operator to find later, and a ' + + 'refused call did not run — no record was read and none was written. Two BOUNDS to ' + + 'read before you rely on it. (1) The enforced set is the doors that enforce the ' + + 'author\'s `ai.exposed` opt-in — today the action door reached from the MCP ' + + '`run_action` tool. REST `/actions` is NOT `ai.exposed`-gated and sits OUTSIDE this ' + + 'gate, so a human in front of that route is still yours to arrange. (2) Only the ' + + 'author\'s declared `ai.requiresConfirmation: true` refuses, and only the boolean ' + + '`true` confirms: the wider listing heuristic that reports `requiresConfirmation` ' + + 'for a destructive-looking action ADVISES a client to ask and never refuses, and an ' + + 'explicit `false` never refuses. `confirm: true` is an unverifiable caller claim — ' + + 'the gate makes FORGETTING loud, it does not prove a human. 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/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts b/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts index a43da70a15..76b6f3cf13 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 @@ -7,13 +7,16 @@ export const entry: SemanticMigration = { surface: 'ai.tool.requiresConfirmation', 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', + + 'flag the platform confirmation CONTRACT is written against, and that contract is ' + + 'ENFORCED. An AI-facing call on an action declaring the flag must carry the ' + + 'confirmation member `confirm: true` on the request and is REFUSED without it with ' + + '`ACTION_CONFIRMATION_REQUIRED` (428), the refusal naming the action and the exact ' + + 'member to set. A gate, not a queue: nothing is parked, and a refused call did not ' + + 'run — no record was read and none was written. ⚠ Two bounds: the enforced set is the ' + + 'doors that enforce the author\'s `ai.exposed` opt-in, today the action door reached ' + + 'from the MCP `run_action` tool, while REST `/actions` is not `ai.exposed`-gated and ' + + 'sits outside the gate; and `confirm: true` is an unverifiable caller claim, so the ' + + 'gate makes forgetting loud without proving a human approved', 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 ' @@ -46,13 +49,18 @@ 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`, 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. ' + + 'true`, which is what the confirmation contract (#16293) gates on — and that gate is ' + + 'PERFORMED: invoking the operation over an AI-exposed door without the confirmation ' + + 'member is REFUSED with `ACTION_CONFIRMATION_REQUIRED` (428) and nothing runs, so ' + + 'that call is a real check you can make rather than a destructive experiment. ⚠ Two ' + + 'bounds on what it proves: the enforced set is the doors that enforce the author\'s ' + + '`ai.exposed` opt-in — today the action door reached from the MCP `run_action` tool ' + + '— while REST `/actions` is not `ai.exposed`-gated and sits outside the gate, so an ' + + 'agent holding an API key on that route is still yours to put a human in front of; ' + + 'and `confirm: true` is an unverifiable caller claim, so the gate makes forgetting ' + + 'loud without proving a human approved. The decision above is still the one this ' + + 'criterion asks you to make. 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.', diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index c0ee8e31d0..625baecac6 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -4587,13 +4587,16 @@ const step17: MigrationStep = { surface: 'ai.tool.requiresConfirmation', 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', + + 'flag the platform confirmation CONTRACT is written against, and that contract is ' + + 'ENFORCED. An AI-facing call on an action declaring the flag must carry the ' + + 'confirmation member `confirm: true` on the request and is REFUSED without it with ' + + '`ACTION_CONFIRMATION_REQUIRED` (428), the refusal naming the action and the exact ' + + 'member to set. A gate, not a queue: nothing is parked, and a refused call did not ' + + 'run — no record was read and none was written. ⚠ Two bounds: the enforced set is the ' + + 'doors that enforce the author\'s `ai.exposed` opt-in, today the action door reached ' + + 'from the MCP `run_action` tool, while REST `/actions` is not `ai.exposed`-gated and ' + + 'sits outside the gate; and `confirm: true` is an unverifiable caller claim, so the ' + + 'gate makes forgetting loud without proving a human approved', 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 ' @@ -4626,13 +4629,18 @@ 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`, 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. ' + + 'true`, which is what the confirmation contract (#16293) gates on — and that gate is ' + + 'PERFORMED: invoking the operation over an AI-exposed door without the confirmation ' + + 'member is REFUSED with `ACTION_CONFIRMATION_REQUIRED` (428) and nothing runs, so ' + + 'that call is a real check you can make rather than a destructive experiment. ⚠ Two ' + + 'bounds on what it proves: the enforced set is the doors that enforce the author\'s ' + + '`ai.exposed` opt-in — today the action door reached from the MCP `run_action` tool ' + + '— while REST `/actions` is not `ai.exposed`-gated and sits outside the gate, so an ' + + 'agent holding an API key on that route is still yours to put a human in front of; ' + + 'and `confirm: true` is an unverifiable caller claim, so the gate makes forgetting ' + + 'loud without proving a human approved. The decision above is still the one this ' + + 'criterion asks you to make. 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.', diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index ff7bf00674..98e1689f9e 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -24,6 +24,7 @@ "scripts/solution-blueprint-header-row.test.ts", "scripts/strictness-ledger-doc.test.ts", "scripts/strictness-ledger.test.ts", + "src/ai/tool-confirmation-prescription-tense.pin.test.ts", "src/api/error-catalog-docs.test.ts", "src/data/api-methods-batch-conformance.test.ts", "src/identity/position-delegatable-enforcer.pin.test.ts",