CLOUD-312 row 8 retires: mcp-allow-check --session goes through the door, and row 10 cannot - #707
Conversation
CLOUD-312 The engine is the pre-tool entry point; the shell guards retire behind it
WhyThe pre-commit layer and CI are already adjudicated by the engine reading the committed authority. The agent tool-call layer is not: Two implementations of one policy is two authorities for one fact, and the divergence is silent. A rule added to It also makes the README's three-layer claim true. Today one third of it describes the design rather than the state. The counts in this section are the pre-wiring state and are kept as the historical baseline, not as current fact. Re-counted 2026-08-20: Mechanism
Ready
The gap is measured, not assertedCounted against One clarification for whoever picks this up, because the neighbouring language invites the wrong move: the table Done
The remaining inventory, re-counted 2026-08-22 against
|
| # | Event / matcher | Command (lines) | Owner | Destination | Blocker & ordering |
|---|---|---|---|---|---|
| 1 | PreTool .*save_issue |
mise-tasks/issue-search-guard.sh (93) |
312 | config — a receipt row over the search receipt |
none; first in the board family |
| 2 | PreTool .*save_issue |
mise-tasks/issue-read-guard.sh (117) |
312 | config — a receipt row with the recency bound facts::Sourced borrowed from it |
none; after 1 (shares the matcher and the receipt store) |
| 3 | PreTool .*save_issue |
mise-tasks/board-move-guard.sh (158) |
312 | config — a receipt row keyed on the issue key |
none; after 2 |
| 4 | PreTool .*(subscribe_pr_activity|send_later|create_trigger) |
mise-tasks/connector-verb-guard.sh (174) |
312 | config — but the predicate is a tool-name suffix, and no rule kind selects on one today; [[verb]] names a shell program |
blocked on CLOUD-924 — no rule kind keys on the tool a call names, and this guard matches by SUFFIX deliberately |
| 5 | PreTool ^mcp__ |
mise-tasks/connector-allow-guard.sh (88) |
312 | config — needs a connector-grant table in batten.toml; the grants live in .claude/settings.json today |
blocked on CLOUD-924 (the selector), plus that grant table |
| 6 | PreTool Task |
mise-tasks/fanout-guard.sh (158) |
312 | config — Field::Prompt exists, but [budget.<name>] is a file-set budget over globs, not a per-call ceiling |
blocked on CLOUD-925 — [budget] counts a file set, so a per-call ceiling is inexpressible |
| 7 | PostTool .*save_issue|.*save_comment |
mise-tasks/board-write-record.sh (329) |
312 | core — it derives a record from a tool response, which is exactly the capture bundle's first consumer | ordered after CLOUD-919; porting it first would build a second reader of the response |
| 8 | UserPromptSubmit | mise-tasks/mcp-allow-check.sh --session (415) |
312 | handler — reads settings files and MCP client logs, not the envelope; its sibling mcp-attach-check already went this way |
none; the door is landed |
| 9 | Stop | mise-tasks/stop-guard.sh (318) + five gates (1,412) |
892 | config / core | CLOUD-892 owns it end to end |
| 10 | SessionStart | .claude/hooks/session-start.sh (295) |
312 | handler — it provisions a toolchain and preflights the container. There is no decision table in it to move; it is deliberately synchronous and deliberately loud on failure | none, but see the bound below |
| 11 | PreTool Bash |
mise-tasks/run-shape-guard.sh (647) |
821 | config, partially — Field::RunInBackground landed, so the exemption predicate is expressible |
CLOUD-613 for the heredoc-binding family; CLOUD-821 owns the row |
| 12 | Stop, merged $HOME |
stop-hook-git-check.sh |
605 / 893 | out of repo — not ours to port | CLOUD-893 owns visibility, CLOUD-605 the identity conflict |
| 13 | SessionStart, merged $HOME |
session-start-git-identity.sh |
605 / 893 | out of repo — same | as 12 |
Row 10 carries a bound the door does not give for free
[[hook.handler]] imposes a timeout_ms, and this script's whole reason for existing is that a cold mise install inside the MCP client's startup window took 24s. A bound tighter than the cold path turns a fail-open handler into the absence the hook was built to close. So its handler row declares a measured bound, and the migration records the cold measurement beside it — the same standard mcp-attach-check's timeout_ms = 2000 was held to.
Per row, the two obligations this issue has always carried
Unchanged in substance from Mechanism above, restated because the table needs them per row:
- Differential test. Every refusal the retiring script renders is reproduced from the committed authority before the script is deleted, proved by replaying that script's own
.batsfixtures through the engine and asserting the same decision and the same reason text. A handler destination has the same obligation with the door in the path: the fixture goes throughbatten hook, and the reply is byte-compared. - Exact deletion condition. The script, its
DECLAREDrow, and its bats suite go in one change, and only once its fixtures pass through the engine — so coverage never drops below what the retiring guard had. ADECLAREDrow naming a deleted command already fails aswiring-declaration-stale, and a command with no row already fails aswiring-sibling-command, so both directions of the deletion are gated rather than reviewed.
Blockers, re-verified 2026-08-22 — this supersedes §8 above
- CLOUD-446 — cleared, Done. The claimed-key lookup it called unreachable from the mediated path is reachable: CLOUD-776 landed the agent-sourced fact channel, and
claim-not-racedis its worked instance. - CLOUD-461 — cleared, landed (In Review). The advisory channel is on
main, andcontract-driftretired with it. Its own release is not this row's precondition. - New, per row rather than campaign-wide, and filed rather than deferred: rows 4 and 5 are blocked on CLOUD-924 (no rule kind keys on the tool a mediated call names); row 5 additionally needs a connector-grant table in
batten.toml; row 6 is blocked on CLOUD-925 ([budget]counts a file set, so a per-call ceiling is inexpressible); row 7 is ordered after CLOUD-919. Nothing blocks rows 1, 2, 3, 8, 10. - Two rows first named here as blockers are Done, and naming them would have been the defect this table gates against. CLOUD-684 (MCP allow rules naming labels host servers never register under) and CLOUD-734 (re-projecting the grants at SessionStart) are both closed. What row 5 actually lacks is a config surface, which is why CLOUD-924 exists and those two do not appear above.
Stating them per row is the correction: a single campaign-wide blockedBy is what let this row sit blocked on a capability that only one of its thirteen entries needed.
The end-state test
Three predicates, all decidable by machinery that exists:
- Exactly one Batten registration per supported event, per harness —
doctor hooksalready failshook-wiring-event-registered-n-timesandhook-wiring-event-unregistered, andhook-wiring-matcher-narrowson any matcher at all. - No unmanaged sibling command —
doctor hooksreportssiblings == 0andmerged == 0, or every remainder is aDECLAREDrow naming a key that is still open. A row naming a closed key already fails, which is what keeps this from becoming a permanent waiver list. - Every remaining dispatched behaviour is declared in committed configuration and validated from it — each surviving program is a
[[hook.handler]]row inbatten.tomlwith a declared bound, and its behaviour is pinned by a differential case run through the door. Nothing reaches a hook surface that the committed authority does not name.
Done is the three above holding together, with main green: not "the scripts are gone", because a deleted script whose refusals nothing reproduces is a coverage loss wearing a retirement's clothes.
CLOUD-898 `[[hook.handler]]`: one door to the hooking surface, so the contract is enforced whatever is dispatched behind it
Why
batten hook registers on every surface of every harness, fast, even for a no-op — and then thirteen bash hooks are registered beside it in .claude/settings.json, each carrying its own contract, its own timeout handling, its own fail-open posture and its own output shape. None of them measured. The engine is one voice among fourteen at the boundary it is supposed to own.
The fix is not to rewrite each script. It is to make batten hook decide what to run and how, including bash, so the only path to the hooking surface is through Batten and hooking is as consistent across harnesses as it can be.
Why [[hook.action]] cannot serve
CLOUD-91's surface already dispatches a declared command on an event, and its module doc states three properties as deliberate — each exactly wrong for a handler:
firereturns(), and "an action can never change the answer" is called the load-bearing property, structural rather than promised;- the child's streams are discarded as a rule-4 secret risk;
pre-toolis refused outright.
Those are right for a side effect and wrong for a participant. So action keeps its guarantee unchanged and this is a second noun. A reader choosing between them has one question — may this change the answer? — and the kind is the answer.
pre-tool is admitted, and not by symmetry. action's first objection does not transfer: a side effect there runs before a possible deny, which inverts what a mediated gate is for, but a handler is part of that decision. Its second objection transfers intact and is honoured: run_hook's hot path touches no config when a pre-tool payload carries neither a command nor a write, so handler resolution takes CLOUD-460's narrowing — the shape reads_prospective uses — and a call no handler selects for still does less work than --help.
The four things the door enforces, each previously re-derived differently in every script:
- A bound. Declared per handler, imposed by the parent.
stop-guardhand-rollstimeout 1s catfor exactly this and says so; the rest have none. - Fail-open, centrally. Spawn failure, timeout, an undefined exit code — all could not look, which allows. Each script spelled this itself, and a missed path was a hook that could refuse because it was broken.
- A stated output shape. stdout on a passing exit is advisory text; a refusal's reason is on stderr. Anything else is a contract violation, reported and never forwarded. This is also what retires the "hooks announce success" defect (CLOUD-891) as a property rather than a habit.
- One reply per call. Handler results merge; the host reads one document.
stdout is interpreted, never forwarded — the rule-4 answer and the portability answer at once. action must discard its child's streams; a handler's output is the channel, so it is read into Batten's types and Batten re-renders per harness. A handler speaks to Batten, in Batten's vocabulary, never to the host — so it cannot emit a host decision document, and a repository's hooks behave the same on every harness rather than on the one whose JSON its author happened to write. A handler that tries anyway is reported rather than passed along.
Refinement — Ready
Refinement gate: Definition of Ready & Done. This body carries only specializations.
- Source of truth (§1).
batten.toml's[hook]table, which CLOUD-91 already established as the place a repository attaches to hook events. One authority;.claude/settings.jsonkeeps only thebatten hookregistration. - Computable predicate (§2). The kind, its validator (empty id, unknown event,
unrecognized, empty argv, zero timeout, duplicate id — all refused at load), and the dispatch.selectsis the narrowing and is asserted, not intended. - Effect (§3).
hookis already classified; a handler is ambient by construction — it spawns with the calling user's authority, exactly as acommandrow does, and that is why the contract rather than the code is what constrains it. - Generated artifacts (§4).
schema/batten.schema.jsonis derived and drift-gated. - Output & exit (§5). §7's table with no per-kind exception:
0pass,1the handler reported,2refusal, anything else outside the contract. Pointer-only — a violation names the handler id and what it did, never a byte the handler wrote, since its streams are the widest secret surface on this path. - Commit / bump (§6).
feat→ patch until0.1.0. - Test obligation (§7). A payload no handler selects for does less work than
--help; a handler that hangs is killed at its bound and the turn still ends; an undefined exit allows and reports; a host decision document on stdout is a violation, not an advisory; two refusals yield the first rather than a merged reason; every violation variant's pointer carries no handler output. Mutation coverage per CLOUD-418. - Blockers (§8). None — the advisory channel it merges into is already on this branch.
Done
.claude/settings.json carries one entry per event, batten.toml carries the handlers, and the contract holds identically whether what runs behind the door is Rego, bash, or something nobody has written yet.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
💤 Files with no reviewable changes (2)
Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review. 📝 WalkthroughWalkthroughThe direct 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Full details: Docstring CoverageExplanation No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0 files. (1 skipped: 1 unsupported.) ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
`mcp-allow-check --session` moves from a direct `UserPromptSubmit` registration to a `[[hook.handler]]` row, beside `mcp-attach-check`, which went through the same door first as the cheapest proof of it. The script and its bats suite both stay — a handler destination keeps its program by intent — so no cases retire and no `conserves` arms are owed. The argument is the point of the row. This gate's scope split lives on `--session`: three predicates are pure functions of the committed settings and belong to the commit, while the fourth reads which servers THIS session attached, which is a property of the world. Only this registration passes the flag, so moving it here puts that split in the one authority instead of spelling it in a settings file the gate cannot read. No matcher, deliberately. Row 5 needed one because `pre-tool` fires per tool call and an unnarrowed handler doubled the mediated cost of every call it was silent on. `user-prompt-submit` carries no tool, so there is nothing to narrow and a matcher would read as a restriction while expressing none. Its `exit 2` is safe through the door, and that is a property of the door rather than luck: the script exits 2 for could-not-look — unparseable settings, or no `jq` on the PATH a by-path hook inherits — and 2 is the deny code, but a refusal at `user-prompt-submit` is demoted to advice, so an unreadable settings file reports and never costs the author their prompt. Registered directly, that exit was whatever the host chose to make of it. `timeout_ms` is measured rather than defaulted, like its sibling's: warm, the predicates are `jq` over one file plus a read of the generated MCP config and the client's logs, returning in tens of milliseconds. 2000 is two orders of headroom. Both deletion directions stay gated: `wiring-declaration-stale` refuses a `DECLARED` row naming a deleted command and `wiring-sibling-command` refuses a command with no row, so neither half of the move can be forgotten. Green at 24 registrations across 5 harnesses. Row 10 is NOT in this commit and is not portable: `session-start.sh` is what runs `install:local`, so a handler dispatched by `batten hook` could never run on a cold container that has no `batten` yet. The bootstrap deadlock and what it means for this campaign's Done predicate are recorded on CLOUD-312. Refs: CLOUD-312 Refs: CLOUD-898
9d1371f to
1e22bf0
Compare
|
❌ The last analysis has failed. |
|
/fast-forward |
Refs CLOUD-312. Every key this commit serves is already Done, so each is declined explicitly rather than left to the automation:
DO-NOT-CLOSE CLOUD-312
DO-NOT-CLOSE CLOUD-898
Row 8 of CLOUD-312's ten-guard wave retires, taking the tally to 7 of 10. It is a small change on purpose — 37 lines added, 9 removed — and most of the added lines are the comment recording why two columns are absent.
The move
mise-tasks/mcp-allow-check.sh --sessiongoes from a directUserPromptSubmitregistration in.claude/settings.jsonto a[[hook.handler]]row inbatten.toml, besidemcp-attach-check, which went through the same door first as the cheapest honest proof of it.The script and its bats suite both stay — a handler destination keeps its program by intent — so no cases retire and no
conservesarms are owed. What moves is the dispatch, and with it the four things the door enforces that no dispatched program can enforce for itself: the bound, central fail-open, a stated output shape, and one reply per call.The argument is the point of the row. This gate's scope split lives on
--session: three predicates are pure functions of the committed settings and belong to the commit, while the fourth reads which servers this session attached, which is a property of the world. Only this registration passes the flag, so moving it here puts that split in the one committed authority instead of spelling it in a settings file the gate itself cannot read.Two things decided rather than defaulted
No matcher, deliberately. Row 5 needed one because
pre-toolfires per tool call, and an unnarrowed handler there doubled the mediated cost of every call it was silent on — measured 41.5 ms against a 21 ms path.user-prompt-submitfires once per prompt and carries no tool, so there is nothing to narrow, and a matcher would read as a restriction while expressing none. The absence is the statement.Its
exit 2is safe through the door, and that is a property of the door rather than luck. The script exits 2 for could-not-look — unparseable settings, or nojqon the PATH a by-path hook inherits — and 2 is the deny code. A refusal atuser-prompt-submitis demoted to advice (lib.rs: "a refusal is demoted to advice on a moment that cannot carry one"), so an unreadable settings file reports and never costs the author their prompt. Registered directly, that same exit was whatever the host chose to make of it.timeout_msis measured rather than defaulted, like its sibling's: warm, the predicates arejqover one file plus a read of the generated MCP config and the client's logs, returning in tens of milliseconds. 2000 is two orders of headroom.Row 10 is NOT here, and it is not portable
CLOUD-312's table lists row 10 (
.claude/hooks/session-start.sh) as a handler destination with blockers "none, but see the bound below". The bound —timeout_msagainst the 24s coldmise install— is real but is not the blocker.session-start.sh:160isstep batten-build mise run install:local: this hook is what puts thebattenbinary on PATH. A[[hook.handler]]is dispatched bybatten hook, so on a cold container the sequence is no binary → nothing dispatches the handler →install:localnever runs → still no binary. A fixed point, not a slow path; every fresh clone would start unprovisioned. A bound above the cold path solves duration and leaves existence untouched.So row 10's destination is "stays a direct registration" — the same shape as row 5's correction, in the opposite direction. Recorded on CLOUD-312, along with the consequence for the campaign's own end-state test: row 10 becomes a permanent remainder rather than a pending one, so predicate 2 needs a key for it to name. Filing that row is a scoping decision about this campaign's Done and belongs to the board owner, not to this PR.
Verification
mise run testgreen on this tree;mise run verifygreen before readyinghooks-wiring-checkgreen at 24 registrations across 5 harnesses, with the direct registration and theDECLAREDrow both gone —wiring-declaration-staleandwiring-sibling-commandgate both directions, so neither half of the move can be forgottenmcp-allow-checkitself green over this repo's own settings, through the pre-commit gatehook-matcher-checkgreen: all 16 declared verbs still reach the engine through the onePreToolUseentryTally: 6 of 10 retired before this PR, 7 of 10 after it. Row 7 (
board-write-recordinto core — unblocked now that CLOUD-919 is Done) and row 9 (CLOUD-892's Stop surface, 1,412 lines, the one closing key still open) remain. Row 10 is reclassified rather than pending. CLOUD-312 stays open regardless: rows 11–13 outlive the wave.Generated by Claude Code