Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .changeset/17487-confirmation-gate-prescriptions-present-tense.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions docs/protocol-upgrade-guide.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/spec/spec-changes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
Expand Down Expand Up @@ -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)."
Expand Down
Loading
Loading