feat(hook)!: make the host capability axis data, and declare per-host attribution rows - #450
Conversation
… attribution rows CLOUD-45 was audited against the landed tree rather than rebuilt: three of its five §7 clauses were already satisfied by #266 and got no code. Two did not. Table totality ranged over (host × event) only, leaving the capability axis pinned by five ad-hoc per-field assertions. A `Capability` vocabulary enum plus a `Capabilities::declares` projection make that axis rangeable, so totality is a test over `Harness::ALL × Capability::ALL`. The exhaustive match already forces every host to fill every field; what it cannot notice is a field reachable through no `Capability` — a row nothing can range over, which is how a capability comes to be declared and consulted by nothing. `Capability::DISPATCH` and `Capability::ATTRIBUTION` partition `ALL`, asserted, so the derived attribution document cannot silently omit a declared row. `Capabilities::ask` was exactly that defect: a column every host declared and nothing consulted, because no policy could express wanting an escalation. `Decision::Ask`, `encode_ask` and the boundary arm close the degradation, with the table consulted before the wire shape and the fallback pinned in the safe direction — an unanswerable escalation refuses, never proceeds. The `batten.toml` vocabulary is deliberately absent: CLOUD-340 already refined it as an `ask` severity, and a second column here would give one question two config surfaces. CLOUD-276 adds the attribution rows to the same table. The M1 survey answers one of its five row groups — the session id — and has no row at all for git identity, injected trailers, or an attribution config surface, so those are `unknown` by declaration rather than by omission. `Declaration` is four-valued for that reason: `no` is measured absence, `unknown` is evidence that does not answer, and CLOUD-276's stated assumption turns on their staying distinct. The one host with measured rows is the one this repository's own 2026-08-09 commit survey covers; `sets-git-identity` stays `unknown` even there, because that measurement traces the injection to container git config plus harness prompt and cannot separate the host from the container. `attribution::capture` degrades on the declaration and not the payload: a row that is not `Yes` yields `unknown` even when the host offered a value, since a value from a surface the table does not vouch for is one nobody can call a model identity. Enforcement is untouched and cannot vary — the seams are git-native and a produced commit carries no record of which host made it, so capture fidelity varies by host and enforcement never does. `attribution check` gains an optional `--harness` that changes no verdict, and its `--json` document becomes an object carrying `caller`, `expects` and `findings` under stable keys — every key present on every run, including a clean one and one naming no host. BREAKING CHANGE: two library types grow, both reported by `cargo-semver-checks` and declared here rather than worked around. `hook::Decision` gains an `Ask` variant, so a caller matching it exhaustively must handle the third verdict; `cli::AttributionCommand::Check` gains a `harness` field, so a caller constructing or destructuring that variant must name it. Neither changes the exit-code contract, which has no third code and needs none. The §6 arrow on both issues is unchanged — `patch` until 0.1.0 describes the bump, and the `!` marker declares the API break, the way `feat(attribution)!` did for CLOUD-274. Refs: CLOUD-600 Refs: CLOUD-601
CLOUD-45 Treat Claude-only events as optional capabilities
Why Acceptance
Refinement — Ready (a host × capability table: every host declares what it has and lacks, and no behaviour keys on a capability without consulting it) Refinement gate: Definition of Ready & Done. This body carries only specializations.
Re-scope (evidence: Harness capability matrix (M1)): The capability table must be host × capability, not a list of Claude-only events — the asymmetry runs both ways. Gemini CLI's Constraints the table must encode:
CLOUD-276 Per-host attribution adapters: identity capture and injection expectations as declared capabilities
Why Hosts differ in what they inject into commits (trailers, footers, git identity) and what they expose about the caller (model id, session id, attribution config surfaces). Attribution behavior keyed on one host's surface hard-codes that host; the per-host capability table (CLOUD-45) is the substrate that keeps it neutral. Mechanism
Acceptance
Refinement — Ready (attribution expectations become rows in the per-host capability table; capture fidelity varies by host, enforcement never does) Refinement gate: Definition of Ready & Done. This body carries only specializations.
Stated assumption (flagged rather than blocking) The row set above is the one the M1 matrix can actually answer for every surveyed host. If a host exposes a model id only through a surface Batten does not read at record time, that row is |
…h `-c` CI red where `verify` was green, and the discriminator was the ambient git config, so the two runs were one observation twice rather than two. `attribution check --message` reads the identity git is about to stamp through `git var GIT_AUTHOR_IDENT`. `common::git_command` supplies the fixture's identity transiently with `-c`, which covers every git call the HARNESS makes and reaches the `batten` child process not at all — so the binary resolved whatever the ambient global config carried. A dev container has one and a CI runner does not, which is exit 2 here and exit 1 (`could not look`) there. Two changes, following `tests/attribution.rs::fixture`'s existing pattern: the identity is written into the repo config where the binary's own `git var` finds it, and the run fences `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` so an ambient identity cannot satisfy the case for a reason CI does not share. The second half is what makes it assert its own premise rather than inherit one (CLOUD-249). Reproduced both directions under `GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null`: all five cases fail before and pass after, and dropping the repo-local write brings the failure back. The class is recorded on CLOUD-513, whose title scopes it to `test:bats` — this instance is a cargo suite, so the mechanism is any suite whose subject process resolves git config for itself. The gate belongs to that issue; this commit carries only the fixture repair. Refs: CLOUD-513
|
|
/fast-forward |



Two issues, one PR, because CLOUD-276's rows are columns of CLOUD-45's matrix and both edit the same table in
crates/batten/src/hook.rs.Closes CLOUD-45Closes CLOUD-276The
blockedByedge 276 → 45 is real and stays in place: it is satisfied inside this diff, since there is no intermediate landed state between the matrix and its new columns.CLOUD-45 — audited against the landed tree, not rebuilt
Measured on
origin/mainata3b4a23before writing anything. Most of the re-scoped Ready block was already landed by #266. Three of the five §7 clauses needed no code at all, and saying so is part of the deliverable:an_event_a_host_does_not_declare_degrades_cleanlycovers both arms; the[[hook.action]]pair covers the dispatched side. Nothing added.TaskCompleteddegrades to the Stop familytask_completed_is_claude_only_and_degrades_to_the_stop_familyplus the E2E whose verbose stderr namesstop. Nothing added.no_surveyed_host_can_veto_completion_from_a_stop_eventasserts the negation for all six variants. Re-greppedcrates/batten/{src,tests}forveto/blocks completion/prevent…completion: nothing asserts the forbidden claim. Stop-gate cases expecting exit 2 assert forced continuation through the deny channel, which every surveyed host offers — the distinctionstop.rs's module docs already draw.askhard-denies where it is absentCapabilities::askwas a column every host declared and nothing consulted.(d) the capability axis becomes data
A
Capabilityvocabulary enum withconst ALL, plusCapabilities::declaresprojecting every row —boolor otherwise — onto one comparableDeclaration. Totality is now a test overHarness::ALL × Capability::ALL.This is a test rather than a compiler guarantee for a reason the compiler cannot cover: the exhaustive
matchincapabilities()forces every host to fill every field, and#[non_exhaustive]plus struct-literal construction forces a new field into all six arms — but neither notices a field that exists, is filled, and is reachable through noCapability. That is a row nothing can range over, which is how a capability comes to be declared and consulted by nothing.Capability::DISPATCHandCapability::ATTRIBUTIONpartitionALL, asserted, so a new capability cannot land belonging to neither — which is what keeps the attribution document honest as a derivation rather than a second list.(b) the escalation degradation, minus the config surface
Decision::Ask(Refusal),hook::encode_ask, and the boundary arm resolving an unanswerable escalation to a hard deny. The table is consulted before the wire shape, so nothing keys on escalation without asking whether the host has it, and the invariant is pinned in the safe direction:Nonefromencode_askmeans refuse, never proceed.A
confirm = truecolumn on the shape row was written, tested green, and then removed. CLOUD-340 has already refined this vocabulary as anaskseverity accepted only formediated_callscope, with a minor bump. Shippingconfirmwould have given one question two config surfaces and overridden a recorded decision (non-negotiable rule 6; "where they disagree the spec wins"). So §7(b)'s over-the-binary E2E is deferred to CLOUD-340, which now adds a config token to a landed mechanism rather than designing one — recorded on both issues.Stated cost: the boundary's
Decision::Askarm has no producer until then, so it ships uncovered. That is the smaller debt than a column built to be deleted.Filed, not fixed (CLOUD-601): three hosts declare
ask: trueand escalation is reachable on one. Cursor's verdict is event-dependent and "parses but is not enforced" on the genericpreToolUse— the eventwiring()registers — so an ask there would proceed; Copilot's output fields are documented but the object they sit in is not, and a guessed envelope that fails to parse reads as no decision. Both hard-deny today, which is correct and narrower than the row implies.CLOUD-276 — attribution rows, and what the evidence actually answers
Six flat rows in
AttributionCapabilities, oneDeclarationtype, filled per host from the cited M1 matrix.M1 answers exactly one of the five row groups. It surveys hook surfaces and never asked what a host does to a commit:
exposes-session-id— answered for all five (session_id/sessionId/conversation_id, all three already read bydecode).yes.exposes-model-id— answered in the sense that matters: no host's payload carries one. Each host plainly runs a model, so this isunknown(not on the surface read at record time), notno.sets-git-identity, the two trailer rows,attribution-config-surface— no M1 row exists.The four unanswered groups are
unknownby declaration, except where this repository measured its own history (2026-08-09, inattribution.rs's docs) — which is Claude Code only, and givesinjects-*-trailer: yesplusattribution-config-surface: partial(a setting exists and demonstrably does not govern every path).sets-git-identitystaysunknowneven there: the same measurement traces the injection to container git config plus harness prompt, which cannot separate host from container, so crediting the host would be a claim the evidence does not support.Declaring
nofrom memory was refused deliberately — M1's own methodology note measured model recall of this space as "badly stale", four remembered URLs 404ing. Filed as CLOUD-600 (an attribution-shaped survey pass); each row it resolves is a one-value edit, since the vocabulary and the consumers are in place.This is why
Declarationis four-valued.nois measured absence,unknownis evidence that does not answer, and CLOUD-276's stated assumption turns on their staying distinct. The neutralexit-codecontract is the one column that can honestly sayno— it is the envelope Batten itself defines, not a third party.Non-negotiable rule 1, extended to vendors
The trailer rows declare the shape, never the spelling.
attribution.rsalready holds that a vendor name is configuration and never a literal in the crate, so the engine declares that a coauthorship trailer is expected andbatten.toml'strailer_denynames it. Asserted: nothing a trailer is spelled reaches the capture.The capture degradation: the declaration governs, not the payload
attribution::captureyieldsProvenance::Unknownfor any row that is notYes— even when the host offered a value. A value arriving from a surface the table does not vouch for is a value nobody can say is a model identity; trusting it would make the table decorative. The reverse degrades too:Yesis permission to record a value, never a promise one exists.Reuses the landed
decision::{Caller, Provenance, UNKNOWN}(CLOUD-133/275) rather than minting a second provenance type, so the fields degrade in their value and never by disappearing.Enforcement is untouched, and that asymmetry is the point.
judge,read_range,read_messageandset_identitytake no harness and cannot: the seams are git-native, and a produced commit carries no record of which host made it. Capture fidelity varies by host; enforcement never does.§7 coverage, and the clause that is narrower than written
(a) findings and exit code identical across all six hosts and the no-host run · (b) the declared rows observable per host in
expects· (c)unknowncapture with enforcement unchanged over the binary, plus the declaration-refuses-an-offered-value arms at the library surface · (d) byte-identical across two runs, per host.Narrower than written: §7(b) says "its provenance record's caller fields". There is no CLI-observable stored provenance record —
tests/decision_record.rsrecords that surfacing it is CLOUD-275's, still Todo — so the caller is projected throughattribution check -Jrather than read back from the decisions log. That verb also has no session or model to offer, so over the binary all three caller fields degrade; the offered-value arms are pinned as unit tests instead of left unpinned. Reading the caller back out of the append-only record remains CLOUD-275's join.Surface changes
attribution checkgains an optional--harness, staysEffect::Readand pointer-only, and changes no verdict — asserted across all six hosts plus the unnamed run. No new command and no effect-table row, per CLOUD-276 §3. The flag has no default: "no host was named" is its own answer, not a stand-in for one.attribution check --jsonnow emits an object —{caller, expects, findings}— where it emitted a bare array. Every key is present on every run, including a clean one and one naming no host: a shape that varied with the flags would be the same unparseable problem the unconditional empty list already exists to avoid. The-Jcensus asserts "one JSON document" and byte-stability rather than a shape, so it andtests/pointer_only.rshold unchanged;tests/attribution.rs's shape case is updated.Derived artifacts regenerated for the new flag and the schema: completions,
man/batten-attribution-check.1, both JSON Schemas.Verification
mise run verifygreen, rebased on currentorigin/main. Every new test was seen to fail before it passed — in particular the capture case fails ifcapturetrusts the payload instead of the declaration, and the partition case fails if a capability joins neither subset.