Repository navigation
Commit 9059a94
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
File tree
- .changeset
- docs
- packages/spec
- src
- ai
- migrations
- entries/semantic
Lines changed: 72 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
967 | 967 | | |
968 | 968 | | |
969 | 969 | | |
970 | | - | |
| 970 | + | |
971 | 971 | | |
972 | 972 | | |
973 | 973 | | |
| |||
2046 | 2046 | | |
2047 | 2047 | | |
2048 | 2048 | | |
2049 | | - | |
| 2049 | + | |
2050 | 2050 | | |
2051 | 2051 | | |
2052 | 2052 | | |
| |||
0 commit comments