Skip to content

feat(hook): make a host's event set a declared capability, not an assumption - #266

Merged
wenzowski merged 1 commit into
mainfrom
claude/phase-3-sequential-landing-h26kx0
Aug 11, 2026
Merged

wenzowski merged 1 commit into
mainfrom
claude/phase-3-sequential-landing-h26kx0

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

Implements CLOUD-45, in the shape its own Re-scope section specifies rather
than the shape its title does.

The title encodes a false premise, and the issue says so

"Claude-only events" reads as Claude has more, others degrade. The M1 survey
(CLOUD-209) measured the asymmetry running both ways: Gemini's
BeforeModel/AfterModel/BeforeToolSelection rewrite model traffic and
constrain tool selection; Cursor's beforeReadFile sees full file contents
before a read. Claude Code can do none of those.

So the table is host × capability, as the Re-scope requires — not a list of
things other hosts lack.

What the table encodes

Capabilities is a row per host, every field a survey finding with a citation in
the code:

  • events — which lifecycle events the host emits. Event gains
    TaskCompleted and ConfigChange, the two genuinely Claude-only ones.
  • ask — absent on Gemini (allow/deny only) and not in effect on Codex (its
    schema lists it, its docs mark it "parsed but not supported yet" — advertised
    is not available). A policy wanting human confirmation must hard-deny
    there; degrading ask to allow would turn "check with a human" into "go
    ahead", which is the one direction that must never be a fallback.
  • stop_vetoes_completion — false on every surveyed host, Claude included.
    The field exists to make a uniform fact checkable rather than remembered:
    behaviour keyed on "Stop blocks" is wrong everywhere, not degraded somewhere,
    and a table omitting the column would let someone assume the opposite.
  • The fail-open edges — Copilot's timeouts always fail open (even for
    admin-deployed policy hooks), Cursor needs failClosed per hook, Gemini reads
    unparseable stdout on exit 0 as Allow. These are host behaviours Batten cannot
    change and must not forget, so they are capabilities too.

Degradation

Capabilities::degrade maps TaskCompleted to the Stop family on hosts that
lack it — the one substitution the survey endorses, since every host has a
Stop-family event. ConfigChange degrades to nothing: no honest stand-in
exists for "a settings edit is happening", and substituting an unrelated event
would be worse than admitting the gap.

Degrading is explicitly not equivalence — the stand-in cannot veto either — so
a caller still has to read stop_vetoes_completion before assuming it can block.
What the substitution buys is observing the moment, not refusing it.

The dispatcher consults it before keying on anything

An event the host does not declare allows and fires nothing — never an error,
never a deny. An absent capability is a fact about the host, and refusing would
make Batten the reason a session cannot proceed on a host that simply offers
less. The note rides the ladder above normal, because where it is reachable it
is the ordinary state rather than news.

Two clippy lints allowed, with reasons

struct_excessive_bools wants the row folded into an enum or bitflags. Its
premise — several bools are usually one state machine in disguise — is false
here: these are mutually independent measured facts about third parties, found on
different pages of different vendors' docs. An enum would enumerate 2^n
meaningless combinations; bitflags would trade a named field for a bit position.

match_same_arms would collapse the rows that agree today. Each arm carries the
citation for why its values are what they are, and collapsing would delete those
reasons and make a future divergence a structural edit rather than a one-value
one.

Tests

Unit: table totality across every host × every event; no host vetoes completion
from Stop; TaskCompleted is Claude-only and degrades to Stop everywhere else;
ConfigChange degrades to None; ask is absent exactly where the survey found
it absent; the fail-open edges are set where measured and not set elsewhere.

E2E: the same TaskCompleted payload under a host that declares it and four that
do not — all allow, none emit a decision document, and a default run stays quiet;
asking for detail produces the note naming the fallback. The payload carries a
command the fixture policy denies, so an accidental adjudication of an undeclared
event would surface as a deny rather than passing silently.

The event census gains rows for both new variants — its own totality gate is what
caught them missing.

Refs: CLOUD-45

@linear-code

linear-code Bot commented Aug 11, 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 (host events are declared capabilities, never baseline assumptions)

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • Source of truth (§1). One capability table per host, declared beside that host's shim (CLOUD-44): which events the host emits (worktree, stop, session, config events) as data the dispatcher consults. No behavior keys on an event without consulting the table; a host's event 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 an event absent from a host's table degrades cleanly (correct decision, no panic, nothing fired) and an exhaustiveness check pinning a row for every event the dispatcher keys on, per supported host. 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 → minor.
  • Test obligation (§7). E2E (crates/batten/tests/cli.rs): the same fixture event fed under a harness whose table lacks it → allow, nothing fired; under a harness that declares it → dispatched. A unit test pins table totality against the dispatcher's event set.
  • 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.

Review in Linear

@wenzowski
wenzowski marked this pull request as ready for review August 11, 2026 10:03
@sonarqubecloud

Copy link
Copy Markdown

@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

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.

2 participants