Skip to content

feat(hook)!: make the host capability axis data, and declare per-host attribution rows - #450

Merged
wenzowski merged 2 commits into
mainfrom
claude/capabilities-attribution-matrix-lreyf5
Aug 14, 2026
Merged

feat(hook)!: make the host capability axis data, and declare per-host attribution rows#450
wenzowski merged 2 commits into
mainfrom
claude/capabilities-attribution-matrix-lreyf5

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

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-45
Closes CLOUD-276

The blockedBy edge 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/main at a3b4a23 before 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:

§7 clause Verdict What happened
(a) undeclared event → allow, nothing fired; declared → dispatched already satisfied an_event_a_host_does_not_declare_degrades_cleanly covers both arms; the [[hook.action]] pair covers the dispatched side. Nothing added.
(c) TaskCompleted degrades to the Stop family already satisfied task_completed_is_claude_only_and_degrades_to_the_stop_family plus the E2E whose verbose stderr names stop. Nothing added.
(e) no test asserts a Stop event blocks completion already satisfied, no defect to remove no_surveyed_host_can_veto_completion_from_a_stop_event asserts the negation for all six variants. Re-grepped crates/batten/{src,tests} for veto / 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 distinction stop.rs's module docs already draw.
(d) totality over the full (host, capability) set partial → closed The landed test ranged over (host × event) only; the capability axis was five ad-hoc per-field assertions a new row joined only if someone remembered.
(b) a policy wanting ask hard-denies where it is absent gap → mechanism landed, vocabulary deferred to CLOUD-340 Capabilities::ask was a column every host declared and nothing consulted.

(d) the capability axis becomes data

A Capability vocabulary enum with const ALL, plus Capabilities::declares projecting every row — bool or otherwise — onto one comparable Declaration. Totality is now a test over Harness::ALL × Capability::ALL.

This is a test rather than a compiler guarantee for a reason the compiler cannot cover: the exhaustive match in capabilities() 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 no Capability. That is 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 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: None from encode_ask means refuse, never proceed.

A confirm = true column on the shape row was written, tested green, and then removed. CLOUD-340 has already refined this vocabulary as an ask severity accepted only for mediated_call scope, with a minor bump. Shipping confirm would 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::Ask arm 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: true and escalation is reachable on one. Cursor's verdict is event-dependent and "parses but is not enforced" on the generic preToolUse — the event wiring() 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, one Declaration type, 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-idanswered for all five (session_id / sessionId / conversation_id, all three already read by decode). yes.
  • exposes-model-id — answered in the sense that matters: no host's payload carries one. Each host plainly runs a model, so this is unknown (not on the surface read at record time), not no.
  • sets-git-identity, the two trailer rows, attribution-config-surfaceno M1 row exists.

The four unanswered groups are unknown by declaration, except where this repository measured its own history (2026-08-09, in attribution.rs's docs) — which is Claude Code only, and gives injects-*-trailer: yes plus attribution-config-surface: partial (a setting exists and demonstrably does not govern every path). sets-git-identity stays unknown even 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 no from 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 Declaration is four-valued. no is measured absence, unknown is evidence that does not answer, and CLOUD-276's stated assumption turns on their staying distinct. The neutral exit-code contract is the one column that can honestly say no — 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.rs already holds that a vendor name is configuration and never a literal in the crate, so the engine declares that a coauthorship trailer is expected and batten.toml's trailer_deny names it. Asserted: nothing a trailer is spelled reaches the capture.

The capture degradation: the declaration governs, not the payload

attribution::capture yields Provenance::Unknown for any row that is not Yeseven 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: Yes is 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_message and set_identity take 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) unknown capture 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.rs records that surfacing it is CLOUD-275's, still Todo — so the caller is projected through attribution check -J rather 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 check gains an optional --harness, stays Effect::Read and 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 --json now 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 -J census asserts "one JSON document" and byte-stability rather than a shape, so it and tests/pointer_only.rs hold 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 verify green, rebased on current origin/main. Every new test was seen to fail before it passed — in particular the capture case fails if capture trusts the payload instead of the declaration, and the partition case fails if a capability joins neither subset.

… 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
@linear-code

linear-code Bot commented Aug 14, 2026

Copy link
Copy Markdown
CLOUD-45 Treat Claude-only events as optional capabilities

Why
Some hook events exist only on Claude-side integrations. They should be modeled as optional capabilities rather than baseline assumptions.

Acceptance

  • A capability table exists per host
  • Behavior keyed on Claude-only events does not fail on hosts that do not expose them

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.

  • Source of truth (§1). One host × capability table, declared beside the host shims (CLOUD-44), carrying a row per host per capability — not a list of Claude-only events. The asymmetry runs both ways, which is what makes it a matrix rather than a list: Gemini CLI's BeforeModel/AfterModel/BeforeToolSelection and Cursor's beforeReadFile exist on no Claude surface, while TaskCompleted and ConfigChange exist only on Claude Code. Capability classes the table must encode: emitted events; whether ask/escalate-to-human is reachable; the fail-open edge (Copilot always fail-open, Cursor's per-hook failClosed, Gemini reading non-JSON stdout on exit 0 as Allow). No behaviour keys on a capability without consulting the table, and a host's capability set is stated nowhere else.
  • Computable predicate (§2). Not a batten.toml rule — capability declaration is engine structure, not repo policy. The gate: E2E tests that a capability absent from a host's row degrades cleanly (correct decision, no panic, nothing fired), plus an exhaustiveness check pinning a row for every (host, capability) pair the dispatcher keys on — a host variant with no row fails to compile rather than defaulting to some other host's behaviour. Run by mise run test:cargo inside the shared hk gate and in CI.
  • Effect (§3). No new command; a table consulted inside hook. No effect-table change.
  • Output & exit (§5). A missing capability is never an error exit and never a deny: the keyed behavior simply does not fire, and the hook still answers its contract for the events the host does emit.
  • Commit / bump (§6). feat — patch until 0.1.0.
  • Test obligation (§7). E2E (crates/batten/tests/cli.rs): (a) the same fixture event fed under a harness whose row lacks it → allow, nothing fired; under a harness that declares it → dispatched; (b) a policy wanting ask degrades to hard-deny on a host declaring no escalate-to-human row (Gemini CLI, Codex CLI), rather than silently allowing; (c) a policy keyed on TaskCompleted degrades to the Stop family on every host that lacks it. A unit test pins table totality against the dispatcher's full (host, capability) set. No test may assert that a Stop-family event blocks completion — no surveyed host can veto completion, all only force continuation, so that behaviour is wrong everywhere rather than degraded somewhere.
  • Blockers (§8). blockedBy CLOUD-43 (the event dispatch this table gates). Refinable now; implement after CLOUD-43 lands. relatedTo CLOUD-44 — each shim carries its host's table; relatedTo CLOUD-209 — the matrix research fills rows for hosts beyond the wired ones and does not gate the structure.

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 BeforeModel/AfterModel/BeforeToolSelection rewrite model traffic and constrain tool selection; Cursor's beforeReadFile sees full file contents before a read — none of these exist on Claude Code.

Constraints the table must encode:

  • Every surveyed host has a Stop-family event and none can veto completion — all only force continuation (with per-host loop caps: Cursor loop_limit default 5, Copilot 8 consecutive blocks). Behavior keyed on "Stop blocks" is wrong everywhere, not degraded somewhere.
  • ask/escalate-to-human is absent on Gemini CLI and Codex CLI hooks; a policy wanting confirmation must degrade to hard-deny there.
  • The load-bearing genuinely Claude-only event is TaskCompleted (exit 2 prevents completion); policies keyed on it degrade to the Stop family elsewhere. ConfigChange (blocking mid-session settings edits) is likewise Claude-only.
  • Fail-open edges are per-host capabilities too: Copilot timeouts are always fail-open (even policy hooks); Cursor needs failClosed:true per hook; Gemini treats non-JSON stdout on exit 0 as Allow.

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

  • Attribution rows in the per-host capability table: sets-git-identity, injects-which-trailers, exposes-model-id, exposes-session-id, attribution-config surface.
  • The commit-metadata normalization gate consults the rows for what to expect from a host; provenance capture consults them for what can be recorded versus unknown.
  • Enforcement seams stay git-native (hook + CI) and therefore host-independent; only capture fidelity varies by host. An absent capability degrades — correct decision, no panic, nothing fired — never an error.

Acceptance

  • E2E per supported host: the same fixture session yields normalized commit metadata, and a provenance record whose caller fields reflect that host's declared capabilities.
  • A host lacking a capability yields unknown capture and unchanged enforcement, pinned by test.

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.

  • Source of truth (§1). The attribution rows beside the existing per-host capability table in crates/batten/src/hook.rs — sets-git-identity, injects-which-trailers, exposes-model-id, exposes-session-id, attribution-config surface. One table, extended; not a second per-host registry living beside attribution.rs. Host facts cite the M1 harness capability matrix, which is evidence rather than a second authority.
  • Computable predicate (§2). Not expressible as a batten.toml rule — it grows the engine's capability table, the same way the event rows did. The gate is the per-host E2E in §7 under mise run test (hk gate + CI), plus the exhaustive-match totality the table already forces: a host variant with no attribution row fails to compile rather than defaulting to some host's behaviour.
  • Effect (§3). No new command and no effect-table row. Reading a host's declared capabilities is inspection; the normalization gate and the provenance record are existing surfaces consuming a new input.
  • Output & exit (§5). An absent capability degrades to unknown in the captured provenance field and leaves enforcement unchanged — a correct decision, nothing fired, no error and no panic. Enforcement seams stay git-native (hook plus CI) and therefore host-independent: only capture fidelity varies by host, which is the invariant the §7 fixtures assert rather than a nice-to-have.
  • Commit / bump (§6). featpatch until 0.1.0.
  • Test obligation (§7). E2E per supported host over the compiled binary: (a) one fixture session yields normalized commit metadata identical across hosts; (b) its provenance record's caller fields reflect exactly that host's declared capabilities; (c) a host lacking a capability yields unknown capture with enforcement unchanged, pinned rather than assumed; (d) byte-identical output across two runs on one fixture.
  • Blockers (§8). blockedBy CLOUD-268 (the agent-neutral attribution decision these rows encode — the rows cannot declare what a host must inject until the decision says what is expected) and CLOUD-45 (the optional-capability table the attribution rows are added to). Neither had landed at refinement time, so this is refinable now and implementable after they land; the block stands unchanged in the meantime. The board owns their columns — graph-check resolves both edges and reports this issue's frontier exclusion, so no column is restated here.

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 unknown for that host by declaration rather than by omission — an absent capability and an undeclared one must not be the same value, and the §7 (c) fixture is what keeps them distinct.

Review in Linear

@wenzowski
wenzowski marked this pull request as ready for review August 14, 2026 08:01
@wenzowski
wenzowski marked this pull request as draft August 14, 2026 08:12
…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
@wenzowski
wenzowski marked this pull request as ready for review August 14, 2026 14:35
@sonarqubecloud

Copy link
Copy Markdown

@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 302dab1 into main Aug 14, 2026
9 checks passed
@wenzowski
wenzowski deleted the claude/capabilities-attribution-matrix-lreyf5 branch August 14, 2026 14:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant