Enforcement by host
Governance in this package is applied two ways, and which one a host gets depends on what that host exposes. We say so plainly rather than imply “deterministic everywhere”.
- Compile-time (every host). Rules and Iron Laws are compiled into each
host’s native instruction format at projection time (
.cursor/rules/*.mdc,.windsurfrules,copilot-instructions.md,GEMINI.md, Claude/Augment native rule dirs). This is the universal layer — it works on all projection targets. It is model-cooperative: the agent is instructed, strongly, but the host does not hard-block the call. - Runtime hooks (hook-capable hosts only). On hosts that expose a tool
lifecycle (
PreToolUse/PostToolUse/Stop), a small set of guards can deterministically block a call (e.g.block_no_verify). This is a superset on top of the compile-time layer, not a replacement.
| Host | Compile-time rules | Lifecycle slots bound |
|---|---|---|
| Claude Code (plugin) | ✅ | 11 |
| Cowork | ✅ | 8 |
| Augment | ✅ native rules | 5 |
| Cursor | ✅ .cursor/rules/*.mdc |
5 |
| Cline | ✅ .clinerules |
5 |
| Gemini | ✅ GEMINI.md |
5 |
| Windsurf | ✅ .windsurfrules |
3 |
| Copilot | ✅ copilot-instructions.md |
0 |
| Codex | ✅ skill bundle to ~/.codex/ |
0 |
The two columns carry different guarantees and the difference is the point.
Every path named in the compile-time column is checked, per PR, against paths
generated from the emitters — check_host_format_column runs the two
install-time rule emitters into a throwaway tree and reads what they wrote, and
compares that against the tool-root registries the projection generators are
already bound to. A cell naming a path no emitter produces fails the gate. The
slot-count column beside it carries no such check: it is a hand-read of
src/scripts/hook_manifest.yaml’s platforms: bindings, measured elsewhere and
the subject of other work, so a reader must not carry the format column’s
guarantee across to it.
This table used to carry a fourth column, and it was removed on 2026-09-12
rather than corrected. The column read Deny honoured and answered per HOST,
with one binary cell each — ✅ the only host that refuses on a deny against
Claude Code, ❌ against the rest. It was measured against
src/scripts/hooks/host_lowering.yaml, the file the runtime resolver actually
reads, and the measurement is
enforcement-table-slot-drift-2026-09-12.md:
1 mismatch of 8 comparable hosts, and the mismatched row was the one carrying
the enforcement claim. claude configures a refusal on 3 of its 9 lowerable
slots, not on 9.
No binary value would have fixed that cell. ✅ overclaims six slots and ❌
denies three, because a host-level cell has to summarise a column of slot
values and no summary is faithful when the column disagrees with itself. So the
question moved to where its data lives — one row per host and bound slot, in
the generated region below — and the two columns that remain here stay
hand-written because neither is in the lowering file: compile-time projection
is a fact about condense.ts’s output, and the slot count is a fact about
declared bindings in src/scripts/hook_manifest.yaml.
This table was wrong until 2026-09-07 and the correction is worth naming,
because the old shape is the one a reader reconstructs from memory. It said
Cursor, Windsurf and Gemini were “— static only” while
src/scripts/hook_manifest.yaml binds 5, 3 and 5 lifecycle slots on them
respectively (:1266-1271, :1300-1305, :1317-1322); it attributed Cline’s
hooks to MCP where the manifest documents native .clinerules/hooks/<HookName>
files (:1273-1280); and it omitted cowork entirely, which is a first-class
platform in the manifest (:1231-1256), in the architecture contract, and in
lint_hook_manifest.ts’s KNOWN_PLATFORMS.
Static-only was never the right axis. A host can bind many slots and honour
no refusal — which is exactly what cowork and augment do — so “does it have
hooks” and “can it stop me” are two questions, and the old single column
answered neither reliably. The enforcement claim is the generated region below;
the slot count above is only how many slots exist to ask the question of.
Codex, stated from the manifest rather than by analogy. The installer
detects Codex (src/install/toolDetection.ts) and deploys the same
Anthropic-shaped rule/skill/command bundle to ~/.codex/
(src/install/wizard-plan.ts), so the compile-time layer reaches it exactly as
it reaches the rows above. Its slot count is a different fact, read
off src/scripts/hook_manifest.yaml: its platforms: block declares eight
keys — augment, claude, cowork, cursor, cline, windsurf, gemini,
copilot — and Codex is not among them. No slot is bound, so no guard runs,
and nothing here should be inferred from Claude Code sharing a bundle format
with it. Codex was previously absent from this table altogether, which read as
unsupported rather than as unlisted; the row says which of the two it is.
Why we lead with compile-time, not hooks. Runtime hooks reach only a minority of supported hosts. Building the governance story on hooks would make it a two-tier experience — a refusal is configured on three slots of one host and nowhere else, and Copilot binds nothing at all. So the universal lever is the compile-time layer; runtime hooks are an opt-in bonus on the hosts that can run them, never the floor.
The ADR-124 code-graph nudge is a case in point: on hook-capable hosts the
default-off code-graph PreToolUse hook surfaces “query the graph first” once
per session; on instruction-file hosts the same intent rides the always-loaded
external-code-graph-interop
rule and the code-intelligence
skill — the capability degrades gracefully, it does not disappear.
Where MCP fits. MCP is a transport surface, not a third enforcement layer.
On a host that runs MCP servers, MCP is one path by which a tool lifecycle (and
therefore the runtime-hook column above) can become available — but MCP presence
does not by itself add enforcement. Which hosts consume which surface is tracked
authoritatively, per artifact type, in
capability-matrix.md (derived from the projection
dispatcher, drift-checked in CI) — we do not restate per-host surface facts here,
to avoid drift between two hand-maintained tables.
Configured enforcement, per host and slot
Section titled “Configured enforcement, per host and slot”Everything from here to the end marker below is a projection of
src/scripts/hooks/host_lowering.yaml — the file the runtime resolver reads.
The prose in this section is authored; the region after it is build output.
What a generated table here can and cannot prove
Section titled “What a generated table here can and cannot prove”It proves agreement with the configuration. It proves nothing about a host.
A drift check compares two artifacts in this repository. If a lowering rule is
wrong — if claude does not in fact act on exit 2 at pre_tool_use — the table
and the YAML stay in perfect agreement and are both false, and the check stays
green over it. That is why the region is titled configured behavior and
every cell is worded as a configuration fact, in a file whose own name says
enforcement. Reading a generated table as stronger evidence than the
hand-written one it replaced is the specific mistake available here, and the
wording is what is guarding against it.
What would establish the other half is a runtime conformance test: drive the
dispatcher on a real host, return the block exit, record whether the call was
refused. This tree has no such test for any host. The one adjacent measurement
it does have covers continuation rather than refusal and is n=1 — see the
Loop primitive section’s cited observation.
The closed vocabulary
Section titled “The closed vocabulary”A cell carries exactly one of four values, and no cell may carry a value the configuration cannot produce.
| Value | Means | Read off |
|---|---|---|
refusal |
This package has established an exit code the host honours as a refusal on this slot. A bound guard’s deny is expected to stop the call. | a non-null block_exit, with the row’s verified block unexpired |
halt-by-state |
The host halts because the dispatcher changed a state the host then reads — not because of an exit code, and not because of output. | nothing. Currently unused — see below. |
warning |
The dispatcher’s verdict reaches the host and the run continues. A guard bound here can report; it cannot stop anything. | block_exit: null under fail_policy: propagate |
unenforced |
The trampoline exits 0 regardless, so the verdict reaches nothing. A guard bound here runs and is discarded. | block_exit: null under fail_policy: discard |
halt-by-state is defined and unused, deliberately. No field in the
lowering configuration expresses it, so nothing in this tree can produce it and
no cell below carries it. It is defined anyway, so that a reader who meets the
value later — when some host’s lowering is written in those terms — meets a
category with a definition rather than an unexplained word. The risk that comes
with defining a value nothing emits is that a generator bug prints it into a
table that looks authoritative, so the gate rejects any cell carrying an
outcome no line in the configuration produces, before the write and again on
the committed file. Its self-test carries that case by name
(./scripts-run src/scripts/check_enforcement_matrix --self-test).
The guard’s reach is narrow and worth stating: it catches a value the derivation can never return. It cannot catch a value that is reachable but wrong for its row — that is what the drift comparison covers, and behind that, the configured-not-enforced caveat above.
Why one row per pair, and not a matrix
Section titled “Why one row per pair, and not a matrix”Two shapes were built from the same data before this one was chosen.
A host-by-slot matrix — nine slot rows across six host columns, plus the label column — was built first, and it is the more compact of the two: eleven lines against forty-one. It was rejected on three counts. A one-cell change rewrites a row of six values whose host must be recovered by counting columns, which is the review surface where a wrong cell hides. There is no room for the backing column, so no cell is checkable against the YAML without trusting the derivation. And it needs a fifth glyph for “this host does not bind this slot”, sitting in the same visual column as the four real values — a layout that has to invent a value outside the closed set in order to render is fighting the vocabulary rather than carrying it.
The long form below is four times as long and its rows are no narrower in characters, so the honest claim for a phone is not that it fits where the matrix does not: it is that four columns degrade better than seven when a viewport forces a wrap, because each row stays one readable fact. Its diff is the stronger argument, and the decisive one: one fact per line, each line naming its own host and slot, so a configuration change moves exactly the lines it changed and a reviewer reads the change rather than reconstructing it.
Codex now has a row, and the third kind of absence is gone. This paragraph
used to read “No row for Codex … it has no entry in the lowering file at
all”. That stopped being true when the lowering file gained a codex row, and
the sentence then contradicted the region’s own trailer two paragraphs down,
which lists codex among the hosts modelled with an empty slots: map. All
three of cowork, copilot and codex are now modelled the same way: a row
that exists, carrying a dated verified: block, with slots: {}.
What has NOT changed is the fact the old paragraph was reaching for: Codex
still has no platforms: key in src/scripts/hook_manifest.yaml, whose
platforms: block declares eight hosts and not Codex. So the slot count stays
0 — but it is now a 0 with a date and a citation on it rather than a silence,
which is the whole point of the row existing.
And the 0 is now backed by a probe, not only by a reading. The codex row
records a live deny probe on 2026-10-01 against codex-cli 0.148.0: a
PreToolUse hook bound to the documented exit 2 refusal path did not block
the tool call, with the hooks feature reported stable true on that build.
That does not say the host cannot refuse — host_lowering.yaml’s header
forbids reading a row that way, and the probe left two explanations unexcluded
(an installed build 24 stable releases behind the documented one, and a matcher that
may never have matched). It says this package has demonstrated no refusal path
there, which is what a 0 in this table has always meant.
Projected from src/scripts/hooks/host_lowering.yaml — configured behavior, not observed behavior.
A cell says what this package has written down about a host, never what the host does.
Configured: 3 of 32 host-slot pairs configure a refusal — claude on 3 of its 9 (stop, user_prompt_submit, pre_tool_use). 19 are warning, 10 are unenforced, 0 are halt-by-state. 1 of 9 modelled hosts configures a refusal on any slot; 0 configure one on every slot it binds.
| Host | Slot | Configured outcome | Backing | Answered |
|---|---|---|---|---|
claude |
session_start |
warning |
block_exit: null · fail_policy: propagate |
2026-09-29 |
claude |
session_end |
warning |
block_exit: null · fail_policy: propagate |
2026-09-29 |
claude |
stop |
refusal |
block_exit: 2 · fail_policy: propagate |
2026-09-29 |
claude |
user_prompt_submit |
refusal |
block_exit: 2 · fail_policy: propagate |
2026-09-29 |
claude |
pre_tool_use |
refusal |
block_exit: 2 · fail_policy: propagate |
2026-09-29 |
claude |
post_tool_use |
warning |
block_exit: null · fail_policy: propagate |
2026-09-29 |
claude |
pre_compact |
warning |
block_exit: null · fail_policy: propagate |
2026-09-29 |
claude |
subagent_start |
warning |
block_exit: null · fail_policy: propagate |
2026-09-29 |
claude |
subagent_stop |
warning |
block_exit: null · fail_policy: propagate |
2026-09-29 |
augment |
session_start |
unenforced |
block_exit: null · fail_policy: discard |
2026-09-29 |
augment |
session_end |
unenforced |
block_exit: null · fail_policy: discard |
2026-09-29 |
augment |
stop |
unenforced |
block_exit: null · fail_policy: discard |
2026-09-29 |
augment |
pre_tool_use |
unenforced |
block_exit: null · fail_policy: discard |
2026-09-29 |
augment |
post_tool_use |
unenforced |
block_exit: null · fail_policy: discard |
2026-09-29 |
cursor |
session_start |
warning |
block_exit: null · fail_policy: propagate |
2026-09-29 |
cursor |
session_end |
warning |
block_exit: null · fail_policy: propagate |
2026-09-29 |
cursor |
stop |
warning |
block_exit: null · fail_policy: propagate |
2026-09-29 |
cursor |
user_prompt_submit |
warning |
block_exit: null · fail_policy: propagate |
2026-09-29 |
cursor |
post_tool_use |
warning |
block_exit: null · fail_policy: propagate |
2026-09-29 |
cline |
session_start |
unenforced |
block_exit: null · fail_policy: discard |
2026-09-29 |
cline |
session_end |
unenforced |
block_exit: null · fail_policy: discard |
2026-09-29 |
cline |
stop |
unenforced |
block_exit: null · fail_policy: discard |
2026-09-29 |
cline |
user_prompt_submit |
unenforced |
block_exit: null · fail_policy: discard |
2026-09-29 |
cline |
post_tool_use |
unenforced |
block_exit: null · fail_policy: discard |
2026-09-29 |
windsurf |
session_start |
warning |
block_exit: null · fail_policy: propagate |
2026-09-29 |
windsurf |
user_prompt_submit |
warning |
block_exit: null · fail_policy: propagate |
2026-09-29 |
windsurf |
stop |
warning |
block_exit: null · fail_policy: propagate |
2026-09-29 |
gemini |
session_start |
warning |
block_exit: null · fail_policy: propagate |
2026-09-29 |
gemini |
session_end |
warning |
block_exit: null · fail_policy: propagate |
2026-09-29 |
gemini |
stop |
warning |
block_exit: null · fail_policy: propagate |
2026-09-29 |
gemini |
user_prompt_submit |
warning |
block_exit: null · fail_policy: propagate |
2026-09-29 |
gemini |
post_tool_use |
warning |
block_exit: null · fail_policy: propagate |
2026-09-29 |
No row above for cowork, copilot, codex — modelled in the configuration with an empty slots: map, so there is no host-slot pair to carry an outcome. That is an absence of bindings, not an outcome of unenforced.
The slot rows are not a statement about any concern
Section titled “The slot rows are not a statement about any concern”A row above answers can a refusal leave this slot on this host. It does not
answer is the concern bound here able to refuse, and the two come apart in
both directions. check_enforcement_matrix prints that second reading on every
read-only run — per host, how many lowerable slots deny, and every concern bound
to a slot whose block_exit is null. It is a printed count and never an exit
code, for the same reason the estate and continuity ratchets report distance
instead of gating on it: these bindings predate the check, and reddening the
tree on them would punish whichever change added the reading.
What a blocking severity means where a refusal cannot land
Section titled “What a blocking severity means where a refusal cannot land”A concern’s severity is a property of the concern; whether a refusal can
leave a slot is a property of the host slot. The table below publishes both,
side by side, and never folds them into one number.
That separation is the decision, not an artifact of the layout. An AI council took the question on 2026-10-01 and both seats converged on publishing the effective picture rather than editing the manifest or dropping the binding — and one seat refused the phrase effective severity outright: severity has not changed, enforcement strength has, and a generated page that silently redefines the manifest’s own vocabulary is worse than one that stays quiet. Hence two columns with two different names.
Two values look similar and are not. warning-only says this package has a
lowering row for the slot and that row’s block_exit is null — the host is
known not to refuse there. unverified says there is no row at all, which
is never-looked rather than cannot: host_lowering.yaml states in its own
header that an absence “does NOT mean the host cannot enforce”. Publishing the
first where only the second is established would turn a gap in our measurement
into a claim about somebody else’s product, and check_enforcement_matrix
fails on a row that does, in either direction.
A blocking concern on a slot that cannot refuse is not silent. It runs, and
its verdict reaches the agent as a warning. What it does not do is stop the
call, and that is the whole content of the right-hand column.
Projected from src/scripts/hook_manifest.yaml (declared severity) and src/scripts/hooks/host_lowering.yaml (verified enforcement). Two independent facts, never folded into one.
The manifest declares what a concern is meant to do; the lowering table records what the host slot it is bound on can do. A blocking concern on a slot that cannot refuse still runs and still warns — it is not silent, and it is not a refusal either. These columns say which.
23 binding(s) declare blocking: 9 refuse, 7 run and warn on a slot verified unable to refuse, 7 sit on a slot with no lowering row, 0 sit on a slot whose proof has lapsed.
| Host | Slot | Concern | Declared severity | Verified enforcement | What that means |
|---|---|---|---|---|---|
augment |
pre_tool_use |
block-config-weakening |
blocking |
warning-only |
the slot is bound and block_exit is null — it runs and warns, it cannot refuse |
augment |
pre_tool_use |
block-credential-reads |
blocking |
warning-only |
the slot is bound and block_exit is null — it runs and warns, it cannot refuse |
augment |
pre_tool_use |
block-kernel-rule-writes |
blocking |
warning-only |
the slot is bound and block_exit is null — it runs and warns, it cannot refuse |
augment |
pre_tool_use |
block-no-verify |
blocking |
warning-only |
the slot is bound and block_exit is null — it runs and warns, it cannot refuse |
augment |
pre_tool_use |
block-plumbing-writes |
blocking |
warning-only |
the slot is bound and block_exit is null — it runs and warns, it cannot refuse |
augment |
pre_tool_use |
block-speaking-inbox-dir |
blocking |
warning-only |
the slot is bound and block_exit is null — it runs and warns, it cannot refuse |
augment |
pre_tool_use |
evidence-independence |
blocking |
warning-only |
the slot is bound and block_exit is null — it runs and warns, it cannot refuse |
claude |
pre_tool_use |
block-config-weakening |
blocking |
refusal |
the slot denies — the concern does what it declares |
claude |
pre_tool_use |
block-credential-reads |
blocking |
refusal |
the slot denies — the concern does what it declares |
claude |
pre_tool_use |
block-kernel-rule-writes |
blocking |
refusal |
the slot denies — the concern does what it declares |
claude |
pre_tool_use |
block-no-verify |
blocking |
refusal |
the slot denies — the concern does what it declares |
claude |
pre_tool_use |
block-plumbing-writes |
blocking |
refusal |
the slot denies — the concern does what it declares |
claude |
pre_tool_use |
block-speaking-inbox-dir |
blocking |
refusal |
the slot denies — the concern does what it declares |
claude |
pre_tool_use |
evidence-independence |
blocking |
refusal |
the slot denies — the concern does what it declares |
claude |
pre_tool_use |
one-question-per-ask |
blocking |
refusal |
the slot denies — the concern does what it declares |
claude |
stop |
turn-end-gate |
blocking |
refusal |
the slot denies — the concern does what it declares |
cowork |
pre_tool_use |
block-config-weakening |
blocking |
unverified |
no lowering row for this slot — nothing is bound natively, and nothing is established |
cowork |
pre_tool_use |
block-credential-reads |
blocking |
unverified |
no lowering row for this slot — nothing is bound natively, and nothing is established |
cowork |
pre_tool_use |
block-kernel-rule-writes |
blocking |
unverified |
no lowering row for this slot — nothing is bound natively, and nothing is established |
cowork |
pre_tool_use |
block-no-verify |
blocking |
unverified |
no lowering row for this slot — nothing is bound natively, and nothing is established |
cowork |
pre_tool_use |
block-plumbing-writes |
blocking |
unverified |
no lowering row for this slot — nothing is bound natively, and nothing is established |
cowork |
pre_tool_use |
block-speaking-inbox-dir |
blocking |
unverified |
no lowering row for this slot — nothing is bound natively, and nothing is established |
cowork |
pre_tool_use |
evidence-independence |
blocking |
unverified |
no lowering row for this slot — nothing is bound natively, and nothing is established |
Detector C’s record path — is a failed exit visible, per host
Section titled “Detector C’s record path — is a failed exit visible, per host”Detector C refuses a turn that edited a file and then verified nothing. It has
two inputs: the RECORD written on post_tool_use, which carries an exit code,
and a transcript scan that sees only command names. Where the record is absent
or carries no exit code, the detector degrades to the name match — so this table
is the difference between a mechanism and its appearance.
No row is inferred. An unprobed host reads unknown, and one probed host
does not license a claim about its neighbours.
| Host | Output on post_tool_use |
Failed exit distinguishable | Observed field | Probed |
|---|---|---|---|---|
claude |
yes | undetermined — see below | exit_code · exit_source · stdout_tail |
2026-09-30 |
augment |
unknown | unknown | — | never |
cursor |
unknown | unknown | — | never |
cline |
unknown | unknown | — | never |
gemini |
unknown | unknown | — | never |
windsurf |
unknown | unknown | — | never |
copilot |
unknown | unknown | — | never |
cowork |
unknown | unknown | — | never |
The claude row is a live reading of a real session’s witness file, not a
fixture. Output is surfaced and the exit code arrives with its provenance
recorded. The middle column nevertheless reads undetermined, because across 24
records in that session none carried a non-zero exit code — including one
command that genuinely failed and does not appear in the record at all. The
per-turn cap does not explain it: _cap_runs preserves the earliest failing
record by design. Cause unresolved; the measurement and why it was not rounded
up to yes are in
verification-classifier-before-after-2026-09-30.
An advisory concern on a null-block slot is consistent — most concerns are advisory and belong on a reporting slot. Two readings are worth naming because a reader looking only at the rows above would get them wrong:
injection-scandetects; it does not block, and nothing here claims it does. It is bound topost_tool_useon all six hosts that carry it, andpost_tool_useisblock_exit: nulleverywhere. That is not a downgrade: the concern declaresseverity: advisory, its own header states it warns and never blocks, and it must read the tool result —needs_payload_bodies: [input, result]— which does not exist on a pre-call slot. So it cannot be moved to a slot that denies without losing the input it exists to inspect. The published claims elsewhere in this repository already readwarn-only/detected, not blocked; this paragraph is where the slot reason is written down rather than re-derived by hand.- The same five
blockingguards cannot refuse onaugmentor oncowork, for two different reasons.block-no-verify,block-kernel-rule-writes,block-config-weakening,block-speaking-inbox-dirandevidence-independenceare bound topre_tool_useon both. Onaugmenta lowering row exists and readsblock_exit: null·fail_policy: discard, so they run and are ignored. Oncoworkthere is no row at all —host_lowering.yamlgives itslots: {}against eight declared binding slots — so nothing is lowered natively in the first place. The audit reports those as distinct reasons (null-blockversusunlowerable) and prints a bound-slot count beside the lowerable one, which is what keepscoworkfrom reading likecopilot, the host that genuinely binds nothing.
A third class exists and is worded deliberately. When a row’s literal
block_exit can deny but its verified block has expired, the audit says the
proof has lapsed and never that the host cannot deny — host_lowering.yaml
states in its own header that an absent verified “does NOT mean the host
cannot enforce”, and the deny count is therefore taken from the literal.
destructive: — which layer guards a typed op, per host
Section titled “destructive: — which layer guards a typed op, per host”road-to-adversarial-verification-and-long-runs 7.2. The eleven typed ops need a
layer that can refuse one. Three values, and the column says which layer a host
actually has rather than asserting a floor it does not:
hook— apre_tool_usebinding whose configured outcome is a refusal. A guard can deny the call.daemon— no such binding, but a guardrail daemon watches the host. No host isdaemontoday: 7.1’s daemon ships observation-only and its enforcing mode is blocked ondaemon-host-kill-switch, so the value exists in the vocabulary and describes nothing yet. Stated rather than omitted, because a column with no unreachable value reads as a complete taxonomy.manual-only— neither. The typed op is guarded by the model and by the human, and the package says so instead of implying a mechanism.
Measured, not asserted, from src/scripts/hooks/host_lowering.yaml — the file
the runtime resolver reads — on 2026-09-13:
| Host | destructive: |
Measured from |
|---|---|---|
claude |
hook |
pre_tool_use bound with block_exit: 2 — the slot can refuse, and no bound concern recognises a typed op yet (corrected 2026-10-18, see below) |
augment |
manual-only |
pre_tool_use bound, block_exit: null — it runs and is ignored |
cursor |
manual-only |
no pre_tool_use slot (5 other slots bound) |
cline |
manual-only |
no pre_tool_use slot (5 other slots bound) |
gemini |
manual-only |
no pre_tool_use slot (5 other slots bound) |
windsurf |
manual-only |
no pre_tool_use slot (3 other slots bound) |
cowork |
manual-only |
empty slots: map — no bindings at all |
copilot |
manual-only |
empty slots: map — no bindings at all |
Seven of eight are manual-only, and the distinction inside that seven is worth
keeping. augment binds the slot and discards the result; cursor, cline and
gemini do not bind it although native_event_aliases already maps their native
pre-tool events onto it — unbound, not unbindable; windsurf and copilot
have no alias row at all. Four states, one value, because what the column answers
is can a typed op be refused here, and for all four the answer is no.
hook on claude measures the binding, not a guard. Corrected 2026-10-18
(road-to-a-hard-floor-the-shell-cannot-talk-past 7.2): the slot is bound and
can refuse, and no bound concern recognises a typed op yet — none of the
pre_tool_use concerns on claude classifies a destructive command, so the cell
says what the slot could carry, not what it carries. When that roadmap’s
destructive-consequence concern enforces, this cell names it.
What this column is NOT. It is not a measurement of whether a host’s pre-tool event can deny — nothing in this tree records that for an unbound host. It reads this package’s own configuration, which is the same honesty boundary the generated region above states for itself: a cell says what has been written down about a host, never what the host does.
manual-only is a measurement here, and the policy it implies is UNDECIDED
Section titled “manual-only is a measurement here, and the policy it implies is UNDECIDED”THIS COLUMN MEASURES CAPABILITY AND AUTHORISES NOTHING.NEVER READ A `manual-only` CELL AS A RECORDED DECISION THAT AUTONOMYMAY PROCEED THERE. THE FALLBACK POLICY FOR THE SEVEN IS AN OPENOWNER DECISION — `daemon-host-kill-switch`.The seven rows above say no layer here can refuse a typed op. They do not say what should follow from that, and the two candidate answers are not interchangeable. Stated here rather than only in the roadmap, because a reader arriving at this table sees seven settled-looking cells and nothing telling them the policy behind the cells was never taken.
The decision brief, so it takes one reading:
- The seven hosts are
augment,cursor,cline,gemini,windsurf,cowork,copilot. The missing capability is apre_tool_usebinding whose configured outcome is a refusal. - The two options. (a)
destructive: manual-only— autonomy continues on those hosts with model-carried confirmation as the only guard. (b) No autonomous mode on those hosts at all. - The threat scenario the choice turns on. Under (a) a typed destructive op proceeds whenever the model fails to classify it as destructive or skips the confirmation, with nothing downstream able to refuse the call.
- An unresolved prior question. Whether model-carried destructive confirmation on unenforceable hosts is already the approved baseline, or whether this choice establishes it. The tree does not record which.
manual-only’s operational semantics are undefined and need stating with the choice: who classifies an op as destructive, whether confirmation is per-action or standing, and what prevents the action mutating after approval.- The dissent is recorded rather than resolved. The blocker’s own
recommendation is
manual-only; the opposing reading, held by one council seat, is no autonomous mode without an enforceable stop. - Option (b) may conflict with ADR-268 § 0’s declared autonomy outcome, which is itself owner-protected. That tension is why this cannot be settled one layer down.
Why no agent writes the answer into this section. A 2/2 convergent council
pass of 2026-10-01 found the choice owner-reserved on two independent
grounds: the options are not authority-equivalent — prohibiting autonomy
preserves the restrictive state while authorising manual-only establishes a
safety floor no host can enforce — and fixing whether the Hard Floor applies on
one host or eight is governance self-amendment under
decision-revisit-gate’s own
owner-reserved table. The same pass named the specific error available here:
writing a preferred fallback into this column would convert an inventory into a
permission.
revisit-if: a host gains deny-capable pre-execution enforcement · an
external enforcement mechanism is demonstrated and tested · the owner explicitly
approves model-carried destructive confirmation as the safety floor for that host.
Where the answer goes when it is taken: here, as a recorded decision beside
each manual-only row, and in the Resolved when of daemon-host-kill-switch
in road-to-adversarial-verification-and-long-runs.
non-destructive-by-default’s enforced_by: should name the live layer on the
current host. It still reads none and this change did not move it: that rule
is one of the nine kernel rules, and block_kernel_rule_writes refuses every agent
write to it — the denial was reproduced, not assumed. Lifting it is a human action
outside an agent session, so 7.2’s second clause is recorded here as owed rather
than delivered.
Lifecycle slots — three different truths, kept apart
Section titled “Lifecycle slots — three different truths, kept apart”A host×slot cell can be true in three independent senses, and collapsing them is how a declaration comes to read as evidence of runtime behavior:
- Declared — the manifest binds concerns there
(
src/scripts/hook_manifest.yaml,platforms:at:1179). A declaration is a statement about this package’s configuration, never about the host. - Lowerable — the installer can write a native binding for it
(
src/scripts/hooks/host_lowering.yaml, per-hostslots:). This is what an install actually emits, and it is checkable from the tree. - Observed — the host really invokes it. This is the only sense that is evidence, and it requires access to the host.
D = declared · L = lowerable · — = neither. Sourced from the two files
above at 60e84da85; nothing in this table is an observation claim.
| slot | claude | cowork | augment | cursor | cline | gemini | windsurf | copilot |
|---|---|---|---|---|---|---|---|---|
session_start |
D+L | D | D+L | D+L | D+L | D+L | D+L | — |
session_end |
D+L | D | D+L | D+L | D+L | D+L | — | — |
stop |
D+L | D | D+L | D+L | D+L | D+L | D+L | — |
user_prompt_submit |
D+L | D | — | D+L | D+L | D+L | D+L | — |
pre_tool_use |
D+L | D | D+L | — | — | — | — | — |
post_tool_use |
D+L | D | D+L | D+L | D+L | D+L | — | — |
pre_compact |
D+L | — | — | — | — | — | — | — |
subagent_start / subagent_stop |
D+L | D | — | — | — | — | — | — |
cowork is D and never L. host_lowering.yaml:172 gives it slots: {}
against eight declared bindings. That is an internal disagreement between two
files in this repository, not a fact about Cowork, and it passes CI only because
tests/scripts/install_snapshot.test.ts:175 loops over five hosts and omits
claude and cowork.
What happens when a bound hook on this slot runs too long
Section titled “What happens when a bound hook on this slot runs too long”The tables above record which concerns are bound per slot. Binding is not
delivery, and the gap between them has a name: what the host does when a bound
hook runs too long. On claude’s user_prompt_submit that gap is the widest in
this tree, because src/scripts/hook_manifest.yaml’s
platforms.claude.user_prompt_submit binds 13 concerns on that one slot —
chat-history, verify-before-complete, minimal-safe-diff,
language-mirror, delegation-nudge, skill-route, git-authorization,
session-canary, self-repair, session-register, rule-inject,
suggestion-capture, journal-record — and they share one process.
Every row below is read from the host’s documentation, not measured here.
The provenance is the same for all three and is stated once rather than per
cell: host Claude Code 2.1.286 (claude --version, 2026-09-30), page
https://code.claude.com/docs/en/hooks, retrieved 2026-09-30, sections
named per row. The older path docs.claude.com/en/docs/claude-code/hooks
301-redirects to it, which is worth recording because the stale URL is the
one a reader reconstructs from memory.
| # | What the host documents | Section | Provenance |
|---|---|---|---|
| 1 | The default timeout for a command hook is 600 s, but Claude Code lowers it to 30 s on UserPromptSubmit. |
§ Common fields | read-from-host-documentation |
| 2 | A command hook that reaches its timeout is cancelled and its output discarded, “so on most events a timed-out hook renders no decision”. The page scopes this to hooks not run with async: true. |
§ Timeouts | read-from-host-documentation |
| 3 | UserPromptSubmit is one of four events where plain stdout is added as context Claude can see and act on, rather than written to the debug log as on most events. |
§ Exit code 0 | read-from-host-documentation |
Read together, rows 1–3 say the loss is total for this slot and not silent to
Claude in the ordinary case. Because hook_manifest.yaml sets no timeout
key anywhere — grepped 2026-09-30, zero hits — all 13 concerns run inside the
one 30 s budget row 1 names, and row 2 discards the whole process output when it
is reached, so the 13 fail together or not at all. Row 3 is the half that
matters for the non-timeout path: this slot’s stdout is a context channel, not a
log, so what a timeout destroys here is context Claude would otherwise have
acted on.
Two things the page does not say, recorded as gaps rather than guessed. It
does not state whether other hooks continue after one times out; and it does not
address transcript visibility in general terms — it distinguishes the debug log
from “context Claude can see”, and says nothing about what a reader sees in
transcript mode. An earlier draft of this section asserted that neither the
plain-stdout nor the additionalContext channel produces a visible transcript
entry. That claim does not reproduce against the page and is withdrawn: for
this slot the stdout half is contradicted by row 3, and the transcript half is
simply unaddressed.
Why the rows exist now and did not on 2026-09-29. An independent
two-provider review refused an earlier draft on a ground this document could not
argue with: the citation was Claude Code's own hooks reference, § hook execution / timeout with no URL, no host version and no retrieval date, in a
document whose every other column is read off a file in this repository. One
seat further held that the current primary source contradicts part of what the
rows asserted — it does, and the withdrawal two paragraphs up is that
finding. The refusal named its own return condition: the host and host version
observed, the exact page and section with its URL, and the date it was read. All
three are supplied above. A provenance marker on an unanchored claim marks it as
unanchored; the marker on these rows points at a page a reader can open.
What is still not here, and it is the runtime half. The same return condition also asked, for the part that is a runtime claim rather than a documentation claim, for a session in which the timeout was actually reached and its effect on the 13 concerns recorded. No such session exists in this tree, so no cell above is marked as measured here and none may be cited as one.
That absence was searched for on 2026-10-05, so it is now a measured zero
rather than an unexamined one. All 1257 .jsonl transcripts under the host’s
store were parsed per line and every timeout-shaped match classified by channel:
71 sat in toolUseResult (Bash tool caps at 2m/3m/7m/10m, and greps of this
repository’s own prose, this file’s included), 3 in agent or user prose, and
0 in hookError. Two
controls make the zero a finding: the same classifier did surface the real Bash
timeouts, so it can see a timeout; and hookError is populated in 278 of those
files, so the channel is instrumented and simply carries no timeout. The
condition stays unmet — by measurement now, not by nobody having looked. The
distinction is
host-capability-manifest.md
§ Observation protocol’s, and it is the reason every row carries the same
read-from-host-documentation marker instead of a stronger one.
One measurement this tree does hold, and no conclusion is drawn from it.
docs/hook-latency.json records user_prompt_submit at p95 81 ms over 50 CI
invocations on 2026-07-27, against the 30 s budget in row 1. The pair is stated
and left alone. A low p95 beside any timeout invites the reading that the
timeout is unreachable and slot-failure behavior therefore does not matter, and
that reading is refused here: a p95 is the 95th percentile of a synthetic bench
on an idle runner, silent about the tail, and the tail is the only part of the
distribution a timeout ever meets. Thirteen concerns sharing one process is a
failure mode to be designed against on its shape, not dismissed on a
median-adjacent statistic.
Open internal inconsistency — pre_compact on cursor and cline
Section titled “Open internal inconsistency — pre_compact on cursor and cline”native_event_aliases carries preCompact: pre_compact for cursor
(hook_manifest.yaml:1380) and PreCompact: pre_compact for cline (:1389),
and cowork inherits Claude’s PreCompact alias (:1365). None of the three
has a pre_compact entry in host_lowering.yaml (:102-106, :120-124,
:172), and none binds the slot in platforms:.
Recorded as unverified, dated 2026-09-07, and deliberately not resolved. An alias row asserts that the host emits a native event by that name; the lowering table asserts this package can bind it. Which of the two is wrong is a question about Cursor and Cline, and neither host was reachable from the session that found this. Writing a resolution either way — deleting the aliases, or adding the slots — would record an unavailable observation as a verified one.
Resolve by: running the dispatcher on a Cursor and a Cline build and recording whether a compaction event arrives, with host version and date. Until then the honest reading of the pair is “declared in one file, absent from the other”.
What writes what at the two context-ending slots
Section titled “What writes what at the two context-ending slots”Step 1.2 of road-to-one-continuity-record asks this question because two
external proposals assumed the slots were free. They are not, and the accurate
correction is that none of the concerns bound there writes a continuity
record:
| slot | bound on | concerns | what each writes |
|---|---|---|---|
pre_compact |
claude only (:1210) |
language-mirror |
a pin-lost marker re-emitted once by post_tool_use |
rule-inject |
injected rule text; writes no state | ||
journal-record |
the runtime journal — default-OFF (src/config/agent-settings.template.yml:1289), so it writes nothing on a default install |
||
session_end |
claude, cowork, augment, cursor, cline, gemini (:1182,1189,1233,1268,1283,1319) |
chat-history |
appends to agents/runtime/.agent-chat-history |
memory-learn |
memory intake | ||
session-register |
deregisters this session’s record | ||
roadmap-progress |
roadmap dashboard state | ||
telemetry-flush |
telemetry | ||
journal-record (claude only) |
as above — default-OFF |
Neither slot carries a continuity writer any more. hot-context used to be the
closest thing to one — keyed by workspace rather than by session, overwritten on
every stop, and declared lossy — and step 3.1 of
road-to-continuity-writer-activation retired that half on 2026-09-09. The concern
survives on session_start only, where it restores the memory session index and
writes no state. session-eol is not in either row — it binds stop, on claude
only (:1190).
Loop primitive — can this host be told to keep going, and by what
Section titled “Loop primitive — can this host be told to keep going, and by what”The enforcement columns above answer can this host stop me. This one answers the opposite question — can this host be told to keep going — and until this section existed the tree had nowhere to write the answer. It matters because exactly one re-engagement authority exists today, and adopting a host’s own loop verb would silently create a second one. A row per host is where that becomes visible before it happens.
The closed value set, and why it has five members rather than four
Section titled “The closed value set, and why it has five members rather than four”none · in-session · subagent · durable · unknown.
The roadmap step that commissioned this table (road-to-loop-governance-truth
2.1) names four values and omits unknown; its next step (2.2) then requires
that an unverified host read unknown and never none. Both are right and the
reconciliation is stated here rather than left for a reader to discover as a
contradiction: the four are the answers, unknown is the absence of one,
and the effective closed set is therefore five.
| value | meaning |
|---|---|
none |
The host exposes no loop primitive. Requires a cited observation, exactly like a false in the capability registry — see the note below the table. |
in-session |
The host can be told to continue the turn or session it is already in. The loop has no wall between iterations. |
subagent |
The loop is driven by spawning child agents; each iteration is a fresh child, the parent is the driver. |
durable |
The loop survives the session — a scheduled, cron-shaped, or otherwise out-of-session wake that starts work nobody is sitting in front of. |
unknown |
Not researched. Never “absent”. |
unknown is the honest default and this table is expected to be mostly
unknown. It is the same discipline
src/scripts/_lib/host_capability.ts already applies to its registry: seven of
the eight platform keys have no row at all, so every field resolves to the
safe default and describeHostCapabilities reports its source as default —
“never looked”, recorded as a silence rather than as a table of negatives. A
row here written none on no evidence would convert an unasked question into a
measured absence, which is the single failure this section exists to prevent.
The value names the strongest kind this tree has an observation for, not the
result of an exhaustive audit. A host reading in-session has not been
researched for durable; the row says so by not claiming it.
The table
Section titled “The table”| Host | Loop primitive | Host’s own verb | Verified | Cited observation |
|---|---|---|---|---|
| Claude Code (plugin) | in-session |
the Stop hook’s exit-2 continuation channel, marked on the next payload by stop_hook_active — no user-facing verb name is recorded in this tree |
2026-09-12 | agents/evidence/analysis/stop-slot-warn-continuation-2026-09-12.md — one measured case: a warn-only stop fire returning exit 2 is followed by three dispatcher invocations with no user_prompt_submit among them. The turn continued. Tier 1 in hook-architecture-v1.md § Stop-event capability tiers. n=1, one host version, one date — the artifact states four things it does not establish. |
| Cowork | unknown |
none recorded | 2026-09-12 | — |
| Augment | unknown |
none recorded | 2026-09-12 | — |
| Cursor | unknown |
none recorded | 2026-09-12 | — |
| Cline | unknown |
none recorded | 2026-09-12 | — |
| Gemini | unknown |
none recorded | 2026-09-12 | — |
| Windsurf | unknown |
none recorded | 2026-09-12 | — |
| Copilot | unknown |
none recorded | 2026-09-12 | — |
| Codex | unknown |
none recorded | 2026-09-12 | — |
The nine hosts are the nine rows of the enforcement table at the top of this
file. The 2026-09-12 dates on the eight unknown rows are the date the tree
was searched and no observation was found — they are verification dates for the
search, not for a capability.
Eight unknown rows, and one thing they are not
Section titled “Eight unknown rows, and one thing they are not”No host reads none, and that is the finding rather than a gap. none
would assert that a host has no loop primitive, and no such observation exists
for any host in this tree. Producing one requires reaching the host — the same
limit src/scripts/_lib/host_capability.ts records for the seven platforms it
calls not reachable from the session that wrote the registry.
What is NOT evidence for a none, stated because it looks like it is. The
stop-slot capability tiers in
hook-architecture-v1.md place augment
and cowork on tier 2 — their trampolines discard the dispatcher’s verdict and
exit 0, so the turn ends. That is a fact about this package’s reach on
those hosts, derived from code in this repository. It says nothing about whether
the host has a loop verb of its own, and reading it as a none would be exactly
the inference this section forbids.
The one /loop reference in the tree names no host.
src/domains/engineering-base/fix/pr-comments-loop/command.md:60-63 instructs
the agent to drive iterations via “the host’s /loop mechanism” in self-paced
mode “when available”, and to iterate inline “on hosts without /loop”. It is
written host-agnostically on purpose, so it cannot fill a cell here. A verb
column stays empty until some host’s verb is observed, not until one is
plausible.
Drift is a documentation defect, not a breakage. No code reads this table. The value set is closed and every row carries its date, so a host that renames or re-scopes a loop verb makes a row stale and nothing else. Re-derive a row from its cited observation rather than trusting the cell.
Host switches ahead of the hooks — Claude Code
Section titled “Host switches ahead of the hooks — Claude Code”Every blocking row above holds only while the host runs the hook. Three host
inputs can silence or lift it, per the host’s documentation and not measured
here: disableAllHooks, a managed allowManagedHooksOnly, and a user-installed
mod that returns a verdict before the package’s PreToolUse hook. Detail and
what the package does about it:
harness-expectations § Class D.
Vocabulary — the enforcement ladder (glossary, not a migration)
Section titled “Vocabulary — the enforcement ladder (glossary, not a migration)”An external “enforcement-first” architecture proposal (reviewed by AI
council 2026-07-26, road-to-self-critical; not adopted — disposition in
agents/settings/contexts/enforcement-first-disposition.md) contributed a
useful vocabulary for talking about how strongly a rule can be held. It
is recorded here as a glossary alongside the resolver taxonomy this repo
already measures with (enforced_by: → validator / validator-local /
observer / none, per
ADR-127). No
migration toward it is scheduled.
| Ladder level | Meaning | Nearest resolver tier today |
|---|---|---|
L1 impossible |
The violating action cannot be expressed (capability removed, API absent) | — (no per-rule tier; this is tool-grant design, see tool-safety) |
L2 blocked |
A deterministic gate rejects the action at call time | validator (CI-reachable), or a fail_closed: true hook |
L3 verified |
The action runs; a check detects the violation after the fact and fails a build | validator / validator-local |
L4 just-in-time |
The constraint is injected into context at the moment of relevance, not always-loaded | per host — see the L4 table below; no host is measured as receiving one yet |
L5 prose |
The constraint is instructed, model-cooperatively | observer / none (honest prose, per the compile-time-first stance above) |
L4 per host — what each one actually has (2026-09-08)
Section titled “L4 per host — what each one actually has (2026-09-08)”Replaces the L4 row’s former “nothing here has one” cell, which was true when the ladder was written and stopped being true when the delivery concern shipped. Every cell cites the emitter or the binding that produces it.
| Host | L4 mechanism | Cited at | Measured to reach the model? |
|---|---|---|---|
| Claude Code | hook delivery — rule-inject on user_prompt_submit, re-armed on pre_compact |
src/scripts/hooks/rule_inject_hook.ts, hook_manifest.yaml:1221 |
unobserved |
| Cursor | description-gated native form — alwaysApply: false plus globs, with the rule’s own trigger terms lowered into the description |
src/scripts/condense.ts::_emit_cursor_mdc, src/install/claudePathsPlan.ts::applies_when |
n/a — no hook delivery bound |
| Windsurf | description-gated native form — trigger: model_decision, same lowering |
src/scripts/condense.ts::_emit_windsurf_rule |
n/a — no hook delivery bound |
| Cowork | none | — | observed-false: the trampoline discards dispatcher output and exits 0 |
| Cline · Gemini · Augment · Copilot · Codex | none — the full corpus is projected instead | src/scripts/condense.ts TOOL_DIRS loop |
unobserved |
No host is measured as receiving a just-in-time constraint yet, Claude Code
included. The mechanism exists and its byte-equivalence is measured; what is
not measured is a model visibly acting on a delivered body, which is the bar
owner ruling E3 sets and which no transcript in this tree meets. The per-host
record and its citation requirement are
src/config/host-injection-effect.json and
agents/evidence/analysis/host-injection-effect-2026-09.md.
A host without a measured injection path receives the full corpus, and that
is the cost of the host rather than a defect. Nothing is withheld from it: the
projection writes every rule body, and check_host_tree_parity asserts per PR
that such a host’s tree is byte-identical to an eager-all run.
Expiry: 2026-12-08. This table is a snapshot of an observation state that is expected to change; re-derive it from the record above rather than trusting the cells after that date.
The ladder is descriptive vocabulary. The measured stance stands: lead with compile-time prose everywhere, bind deterministic checks where a host supports them, and never delete the prose from static-host projections — that is where the measured discipline lift lives.
one-question-per-ask — reach, stated on the manifest’s terms
Section titled “one-question-per-ask — reach, stated on the manifest’s terms”The one-question-per-ask PreToolUse guard (road-to-asked-not-parked 5.1)
denies a structured-ask tool call carrying more than one question. Its reach is
the narrowest sentence the manifest supports and no wider:
- Bound on
claudeonly.hook_manifest.yamllists it in that platform’spre_tool_usearray and nowhere else.agent-config hooks:statusprints the binding for the host actually running, and it is the answer to prefer over this paragraph. - A deny only where the host honours one.
claudeis the platform this repository has verified both bindspre_tool_useand acts on the dispatcher’s verdict. Where a trampoline discards dispatcher output — augment and cowork bothexit 0unconditionally — a bound guard runs and is ignored, which is why it is not bound there: dead weight wearing a guard’s name is worse than an honest absence. - On every other host the constraint is prose, carried by
ask-when-uncertain’s one-question Iron Law anduser-interaction’s one-decision-point clause. That is L5 on the ladder above, and it is the floor everywhere. - One host’s delivered surface HAS been observed carrying a picker, and it is
claude._lib/host_capability.tsrecordsstructured_ask: truefor it, andSTRUCTURED_ASK_SHAPES(src/scripts/_lib/structured_ask.ts) carries the matching shape: 48AskUserQuestioncalls across host versions 2.1.252 to 2.1.284, 2026-09-01 to 2026-10-02, artifactstructured-ask-host-observation-2026-10.md. This bullet asserted the opposite until 2026-10-05, on a measurement base of onefalserow read from a subagent leg — a leg that carries no picker because a subagent has no user to ask. The reading was sound; the step from it to a claim about the host was not, and which leg a surface was read from is now part of every row. The other eight hosts in the table at the top of this file still have no row at all, which is never-looked rather than measured, so nothing here is a claim about them. The guard’s deny threshold is unchanged — one question per call is this repo’s own Iron Law, and every observed call already carried one (47 of 48 — the 48th payload was unparsable, so its question count is unknown rather than one).
See also the artifact-projection view: capability-matrix.md.
Its hooks row records which host consumes the hooks/ artifact — that is a
projection fact, not a runtime-capability claim, and reading it as the latter is
how the corrected table above went wrong in the first place.