Skip to content

Commit 9059a94

Browse files
os-steveclaude
andauthored
fix(spec): state the confirmation gate in the present tense — three shipped prescriptions were denying a door that exists (#19234)
Fixes #17487 Clause-②: no ## The defect, and its direction Three shipped, customer-facing prescriptions in `@objectstack/spec` stated in the present tense that the runtime confirmation door had not shipped. It has: `actionConfirmationRefusal` is called pre-dispatch by `invokeBusinessAction` in `@objectstack/runtime`, and the MCP `run_action` tool grew the `confirm` member in the same change (the card behind it, #15942, is done — `state_reason=completed`; its changeset `action-confirmation-gate-enforced` is still pending, so the door is on `main` and not yet released). So the published text denied a door that exists, and it failed 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 at the moment it starts working. That is the ADR-0049 false-compliance class with the sign flipped. ## Re-derivation — all three sites read on today's `origin/main` Triage's unblock comment verified site 1 only and said the other two were unmeasured. All three were re-read at merge base `805811e0d`. | # | Path | Current text | Verdict | |---|---|---|---| | 1 | `packages/spec/src/ai/tool.zod.ts` — `TOOL_RETIRED_KEY_GUIDANCE.requiresConfirmation` | "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." | **FALSE today** | | 2 | `packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts` — `replacement` | "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" | **FALSE today** | | 3 | the same file — `acceptanceCriteria` | "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." | **FALSE today** | **Correction to the card's count of the carriers.** The card names the `spec-changes` entry, the upgrade guide and the `os migrate meta` projection as if they were separate sites. They are not: all three are projections of the **one** ADR-0087 D3 entry file above. The measurement is therefore **three false prescriptions living in two source files**, plus three generated artefacts that carry them (`src/migrations/registry.ts`, `spec-changes.json`, `docs/protocol-upgrade-guide.md`), all regenerated here by `check:generated --fix`. Sweep radius for "is that all of them": eleven denial phrasings grepped repo-wide (`not yet the behaviour`, `ships separately`, `not yet performed`, `stops nothing`, `simply RUNS`, `until it does`, `until that door`, `door lands`, `yours to arrange`, `nothing server-side`, `no pause`), with `requiresConfirmation` lighting 10 files under `packages/spec/src` as the positive control. Two adjacent texts were read and left alone as **NOT A DEFECT**: `packages/spec/src/contracts/ai-service.ts` already states the gate in normative present tense, and `content/docs/ai/tools.mdx` says the retired **tool**-level key "returns only together with its enforcement", which is still true — the tool key has not returned. Two further readings are recorded under *Acceptance notes*. ## What the prose says now, and what holds it there Each prescription now states the refusal in the present tense **with the door's bounds**, because an unbounded "the platform refuses unconfirmed calls" is this same defect in the other direction. Read off the door's own docblock and its shipped changeset, never inferred: - the refusal is `ACTION_CONFIRMATION_REQUIRED`, 428, naming the action and the member `confirm: true`; - a GATE, not a queue — nothing is parked, and a refused call did not run: the gate sits before `loadActionSubjectRecord`, so no record is 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 MCP `run_action`. REST `/actions` is **not** `ai.exposed`-gated and sits outside the gate, so an API-key agent on that route still needs its own human; - only the author's declared `ai.requiresConfirmation: true` refuses, and only the boolean `true` confirms; the wider `list_actions` heuristic advises and never refuses; - `confirm: true` is an unverifiable caller claim: the gate makes forgetting loud, it does not prove a human. `packages/spec/src/ai/tool-confirmation-prescription-tense.pin.test.ts` is the tie that was missing the first time — the prose was never bound to the function it describes, which is how it rotted. It reads the three shipped strings **and** the runtime door, and fails in both directions. **No pin was moved.** `ui/action-requires-confirmation-docblock.pin.test.ts` was read: it anchors on the `ai.requiresConfirmation` JSDoc in `ui/action.zod.ts` and on `actionLooksDestructive`, neither of which this diff touches, so it covers none of the three sites and stays as it is. ## Clause-②: no — the accept set did not move `check:authorable-surface` and `check:api-surface` are green with **zero** diff under `packages/spec/authorable-surface/` and `packages/spec/api-surface/`. The pin's last case feeds the same authored metadata in before and after: `tool.requiresConfirmation` still refused, a minimal tool still accepted, `action.ai.requiresConfirmation` still accepted for both `true` and `false`. What moved is string content inside `dist` and `spec-changes.json`, which is why a `patch` changeset is owed and present. ## Tests, and the reverse verification `pnpm --filter @objectstack/spec test` — 499 files / 14614 tests passed. `test:repo` — 34 files / 580 tests passed. `typecheck` — clean. New pin: 8/8. Three ablation legs, each mutated on disk through `scripts/ablation-replace.mjs` (anchor hit declared, blob hash proven to move), direction predicted before the run, restored and proven by blob hash against `HEAD` with `git diff HEAD` empty: | leg | mutation | predicted | observed | |---|---|---|---| | 1 | re-insert `The refusal is DECLARED, not yet performed` into the D3 entry's `replacement` | RED on "no shipped prescription denies the refusal" | RED, naming the replacement carrier | | 2 | rename the gate call inside `invokeBusinessAction` | RED on "the AI-facing door still calls the gate pre-dispatch" | RED | | 3 | make the REST `/actions` door name the gate | RED on the over-claim guard | RED | Leg 3's **first attempt was a no-op** and is reported as such: the replacement text still contained the anchor, so `ablation-replace` refused (anchor drop 0, not the declared 1) and nothing ran. It was re-anchored and re-run; the reading above is the re-run. ## Gates All 85 commands derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` for this diff were run locally and exit 0, exit codes captured before any pipe. Eight first returned a stale-`dist` or `PREREQUISITE NOT MET` result (exit 1 / exit 3 — not measured, not findings); they were re-run green after `pnpm --filter @objectstack/spec build` and a full `turbo run build` closure. `pnpm lint` (`eslint . --no-inline-config`, whole repo, no narrowing) exits 0 at `HEAD`. CI still owns its own farm: the five path-scheduled CI jobs, the 11 wide-population families and the artifact rosters are outside that 85 and are NOT MEASURED here. ## Acceptance notes Two readings taken while re-deriving, both **out of scope for this card** and neither edited here: 1. `packages/spec/docs/MCP_GUIDE.md` (around the "Side Effects" section) tells an author to gate side effects with "`ai.requiresConfirmation` on the underlying **action** (+ the HITL approval queue)" and then warns, in the adjacent block, that "nothing server-side pauses on it". The warning is correctly scoped to the MCP capability descriptor in that page's examples and is true of it; but the approval-queue requirement now overstates what the action-level flag needs, and the two paragraphs read together in the card's own dangerous direction. Not in the declared file surface. Reported for filing with dedupe words: `MCP_GUIDE`, `requiresConfirmation`, `HITL approval queue`, `nothing server-side pauses`, `confirmation gate`. 2. `content/docs/ai/actions-as-tools.mdx` — the "Human-in-the-loop approval" section still says that on the open MCP path "the approval step lives at the protocol boundary" (client-side prompting), and the numbered open-MCP action-gate list enumerates five gates without the confirmation gate that now sits between the param contract and the subject-record load. An omission against a contract that `@objectstack/spec/contracts` declares. Reported for filing with dedupe words: `actions-as-tools`, `human-in-the-loop`, `protocol boundary`, `run_action`, `confirmation gate`. Noted, not filed: `packages/spec/src/api/error-code-ledger.zod.ts` says of the `ACTION_CONFIRMATION_REQUIRED` row that "the door will assert this exact string by value" — a forward tense about something that is now true. It misleads nobody about the gate and it is provenance prose about the row's split registration, not a prescription. Successor: the next change that touches that ledger row. ## Occupancy Re-scanned at 2026-09-20T01:52Z over all 21 open PRs, with PR #17076 (639 files) fully paged so no path is under-read. `packages/spec/src/ai/tool.zod.ts`, the D3 entry, `spec-changes.json`, `docs/protocol-upgrade-guide.md`, `vitest.repo-tests.json` and `src/ai/tool.test.ts` all read FREE. Firing controls in the same scan: `packages/spec/src/ui/component.zod.ts` HELD by #19219, `packages/spec/src/ui/view.test.ts` HELD by #19226; dark control (a nonexistent path) reads FREE. One reading to flag: `packages/spec/src/migrations/registry.ts` reads HELD by #19223, #19090 and #18319 — it is a generated, `merge=os-regen` artefact and none of those three touches the D3 entry this diff edits, so the contention is the one the regen driver exists for rather than two hands on the same prose. --- _Generated by [Claude Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 847e577 commit 9059a94

8 files changed

Lines changed: 429 additions & 54 deletions
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): the three shipped confirmation-gate prescriptions state the gate in the present tense — they were denying a door that exists (#17487)
6+
7+
Clause-②: no
8+
9+
No accept-set change and no export moves. `ToolSchema` still refuses
10+
`requiresConfirmation` with a located parse error, `ActionSchema` still accepts
11+
`ai.requiresConfirmation` in both directions, and `check:authorable-surface` /
12+
`check:api-surface` are byte-identical across this diff. What moves is text.
13+
14+
Three customer-facing prescriptions were written while the runtime confirmation
15+
door was a separate, unlanded change, and each said so in the present tense. The
16+
door has since landed on `main` — `actionConfirmationRefusal`, called pre-dispatch
17+
by `invokeBusinessAction` in `@objectstack/runtime`, with the `confirm` member
18+
grown on the MCP `run_action` tool in the same change. From that moment the
19+
published prose DENIED a door that exists, and it denied it in the dangerous direction: an author who
20+
reads it concludes the safety flag stops nothing and either arranges a human in
21+
the loop some other way or stops setting the flag — losing the gate exactly when
22+
it starts working. That is the ADR-0049 false-compliance defect with the sign
23+
flipped.
24+
25+
**The three carriers**, all of them shipped text rather than comments:
26+
27+
1. the `requiresConfirmation` entry of `TOOL_RETIRED_KEY_GUIDANCE`
28+
(`ai/tool.zod.ts`), which reaches consumers as the parse error on the
29+
`.strict()` `ToolSchema` — the one channel every consumer bumping
30+
`@objectstack/spec` is guaranteed to hit;
31+
2. the ADR-0087 D3 entry's `replacement`, and
32+
3. its `acceptanceCriteria` — what `spec-changes.json`,
33+
`docs/protocol-upgrade-guide.md` and `os migrate meta` project to consumers.
34+
35+
FROM → TO, on the sharpest of the three (the acceptance criterion):
36+
37+
```
38+
was: Do NOT try to "prove the gate" by invoking the operation without the
39+
confirmation member: ... before that ships the call is not refused, it
40+
RUNS the destructive operation.
41+
now: ... that gate is PERFORMED: invoking the operation over an AI-exposed
42+
door without the confirmation member is REFUSED with
43+
ACTION_CONFIRMATION_REQUIRED (428) and nothing runs, so that call is a
44+
real check you can make rather than a destructive experiment.
45+
```
46+
47+
**The corrections carry the door's BOUNDS, because over-promising here is the
48+
same defect in the other direction.** Each prescription now states, as the door
49+
itself declares them: the refusal is `ACTION_CONFIRMATION_REQUIRED` / 428 naming
50+
the action and the member `confirm: true`; it is a GATE, not a queue — nothing
51+
is parked and a refused call did not run, no record read and none written; the
52+
enforced set is the doors that enforce the author's `ai.exposed` opt-in, today
53+
the action door reached from the MCP `run_action` tool, while REST `/actions` is
54+
not `ai.exposed`-gated and sits outside the gate; only the author's declared
55+
`ai.requiresConfirmation: true` refuses, while the wider listing heuristic
56+
advises and never refuses; and `confirm: true` is an unverifiable caller claim,
57+
so the gate makes FORGETTING loud without proving a human.
58+
59+
`ai/tool-confirmation-prescription-tense.pin.test.ts` is the tie that was
60+
missing the first time: it reads the three shipped strings AND the runtime door,
61+
so a prescription that re-acquires a not-yet-shipped denial fails, and a door
62+
that is removed, narrowed off the DECLARED flag, unhooked from
63+
`invokeBusinessAction`, or widened onto REST `/actions` fails naming both files.
64+
The denial predicate is fed the three retired sentences verbatim, so it cannot
65+
pass by the prose merely falling silent.
66+
67+
**On release ordering.** The door ships in the same release this correction
68+
does: the runtime changeset that carries it (`action-confirmation-gate-enforced`)
69+
is still pending alongside this one, and one `changeset version` run consumes
70+
both. A release cut before this lands is the failure this card exists to end —
71+
the runtime refusing calls while the published spec text tells authors the flag
72+
stops nothing.

‎docs/protocol-upgrade-guide.md‎

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

‎packages/spec/spec-changes.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -967,7 +967,7 @@
967967
},
968968
{
969969
"surface": "ai.tool.requiresConfirmation",
970-
"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",
970+
"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",
971971
"migrationId": "tool-requires-confirmation-retired",
972972
"toMajor": 17,
973973
"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 @@
20462046
},
20472047
{
20482048
"surface": "ai.tool.requiresConfirmation",
2049-
"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",
2049+
"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",
20502050
"migrationId": "tool-requires-confirmation-retired",
20512051
"toMajor": 17,
20522052
"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)."

0 commit comments

Comments
 (0)