Skip to content

fix(hook): deliver the class definition on a first sighting - #906

Draft
wenzowski wants to merge 1 commit into
mainfrom
claude/cloud-1637-first-sighting-renders-the-definition
Draft

fix(hook): deliver the class definition on a first sighting#906
wenzowski wants to merge 1 commit into
mainfrom
claude/cloud-1637-first-sighting-renders-the-definition

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

Closes CLOUD-1637

A three-word class token is a pointer to a definition, and the definition never arrived. deny_text filled its route list from command_routes, so a class whose routes are all document or issue resolved to an empty list, took the empty-routes early return, and emitted the same bare line on the first sighting as on a repeat. The gloss rendered on no arm at all.

Counted over batten.toml and verdict.rs: 112 of 162 consumer classes and 32 of 39 vendored ones declare no command route — 144 of 201 classes bare on every firing.

The two arms

arm line
first sighting {token} {pointers} {rule-id} — {gloss}; {routes}
repeat {token} {pointers} {rule-id}

Measured in a fixture whose sightings store started empty:

tool run loose batten.toml head no-tool-substitution — a shell text utility stood in for the structured file surface; read rules/scanning.md
tool run loose batten.toml head no-tool-substitution

$GIT_DIR/batten-sightings/<digest> is present between the two. That is the row's acceptance clause, with the amendment's two corrections applied: the program is head (not grep), and the route target is rules/scanning.md (not the .claude/ stub, which five of six harnesses cannot read).

Routes are every non-override route rendered by kind — run/read/see — through an exhaustive match over RouteKind with no wildcard arm, so CLOUD-1639's new variants will be a compile error here rather than a route silently omitted.

Two decisions that depart from the Ready block

1. The sighting key is the pair, not the rule id. The second amendment prescribes keying on the rule id, having found that a class key let the first of fourteen shape rows consume the sighting for all of them. The rule id alone is wrong the other way, and it was measured: verdict-not-discarded is one row raising verdict read dropped, verdict carry other and turn watch dropped, and firing the three in order left the second and third compact on their own first firing — the same silent withholding, one axis over. The key is now (rule, class), which is what the line actually delivers. Worth an amendment on the row so the single key is not reinstated as a simplification.

2. batten policy rule <id> did not exist, and this builds it. CLOUD-1286 moved a row's reason off the hot path saying that verb was where it went; it had never been written. So for every row whose class gloss is generic the specific remedy was unreachable from the refusal naming it — call name refused says only that the call matches a shape the config refuses, while "read the row through batten mcp call Linear get_issue" is no-raw-issue-read's own reason. Non-negotiable rule 2: a rule ships with its mechanism. It is a thin wrapper over explain's existing rule fallback, not a second renderer.

This is why the diff touches surface.rs — one additive CommandDecl and one FlagDecl. Flagging it because a sibling session owns that file.

Also in scope

  • Fix::Run is a dedup key only, never rendered. A consumer reason is prose written for a reader with no budget pressure; an-update-owes-a-recent-read's is ~700 characters and ends by naming a different rule, which is what CLOUD-1286 removed the prefix to prevent. The doc prose at hook.rs:4403-4423 that argued the opposite is rewritten rather than left contradicting the code.
  • The undeclared arm is bounded. It returned before any ceiling was consulted and repeated its long form identically forever. It now sheds the hatch sentence — the constant part, per CLOUD-437 — and never sheds reason-or-fix. Bounding one arm and leaving its sibling unbounded is half a change.
  • [refusal] first_sighting_max_tokens = 64, read off a measurement (widest line the tree emits today is 36 estimated tokens) plus the declared maxima (a 120-char gloss is ~30 on its own). refusal::validate refuses one at or below max_tokens, since the first sighting is the repeat plus a gloss and a route. The trust.rs ratchet gained the second comparison so an override cannot raise it unreported.
  • The irreducible line is emitted, not truncated. Routes drop from the end and the gloss never does; when only the first route is left it goes out over budget. No truncator exists in the tree and half a command is not a way out. Pinned by a unit case.
  • GLOSS_MAX's comment now records that 120 is chosen, not measured, the way [hook_output] records the same about its own figure. CLOUD-1117 is the trial that would settle it.
  • [epoch]: floor not raised, and why. min_batten_version is compared against the running build, which here is built from this tree, so naming the next release makes the repo refuse its own config for the life of the PR. deny_unknown_fields already delivers what [epoch] wants.
  • An orphaned doc block in lib.rs (a second /// started beneath it, so it documented explain_redirects two functions away) is reattached to explain_rule.

Verification

refusal_ceiling.rs — 9 passed, both arms each against its own ceiling, plus the completeness arm (no declared class would render a first sighting with no route), the shared-class two-row case, and the store-written case. //MUTANT document-route-dropped is anchored on verdict::render_route, where the kind mapping lives, with //MUTANT-SUITE refusal_ceiling.rs.

Full mise run verify is the gate on this branch.

Note on attribution

This repo's [attribution] trailer_deny refuses ^Co-Authored-By:.*(Claude|Anthropic), ^Claude-Session: and claude\.ai/code, and body_deny refuses 🤖 Generated with. AGENTS.md non-negotiable rule 8 makes that config outrank the harness's identity request, so neither the commit nor this body carries them.

The batten.toml write went through the declared override route; the admission and its three answers are in the commit's Admits: block.


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 32 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 3fa3f84f-cb20-4891-b505-f8e2d5607128

📥 Commits

Reviewing files that changed from the base of the PR and between a4b1548 and f82caf5.

⛔ Files ignored due to path filters (1)
  • crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap is excluded by !**/*.snap
📒 Files selected for processing (23)
  • AGENTS.md
  • batten.toml
  • completions/batten.bash
  • completions/batten.fish
  • completions/batten.zsh
  • crates/batten/src/cli.rs
  • crates/batten/src/hook.rs
  • crates/batten/src/lib.rs
  • crates/batten/src/refusal.rs
  • crates/batten/src/spec.rs
  • crates/batten/src/surface.rs
  • crates/batten/src/trust.rs
  • crates/batten/src/verdict.rs
  • crates/batten/tests/it/board_receipts.rs
  • crates/batten/tests/it/cli.rs
  • crates/batten/tests/it/connector_verbs.rs
  • crates/batten/tests/it/pointer_only.rs
  • crates/batten/tests/it/refusal_ceiling.rs
  • crates/batten/tests/it/review_answered.rs
  • man/batten-policy-rule.1
  • man/batten-policy.1
  • mise.toml
  • schema/batten.schema.json

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@wenzowski
wenzowski force-pushed the claude/cloud-1637-first-sighting-renders-the-definition branch 6 times, most recently from f44b976 to 9b6f605 Compare September 8, 2026 05:55
A three-word class token is a pointer to a definition, and the definition
never arrived. `deny_text` filled its route list from `command_routes`, so a
class whose routes are all `document` or `issue` resolved to an empty list,
took the empty-routes early return, and emitted the same bare line on the
FIRST sighting as on a repeat. The gloss rendered on no arm at all.

Counted over batten.toml and verdict.rs: 112 of 162 consumer classes and 32
of 39 vendored ones declare no `command` route, so 144 of 201 classes were
bare on every firing.

The first-sighting arm now renders

    {token} {pointers} {rule-id} — {gloss}; {routes}

and the repeat arm renders `{token} {pointers} {rule-id}`, so the repeat is a
byte prefix of the first sighting. Routes are every non-override route
rendered by kind — run/read/see — through an exhaustive match over RouteKind
with no wildcard arm, so a kind added later is a compile error rather than a
route silently dropped.

The rule id is on both arms. 66 of this config's 128 rows declare no class of
their own and raise their kind's native one, so it is the only discriminator
for half the population, not a tie-break for a rare collision.

A consumer row's `reason` no longer reaches the line as `Fix::Run`. It is
prose written for a reader with no budget pressure — one is ~700 characters
and ends by naming a different rule — and it has a destination, which this
change builds: `batten policy rule <id>`. CLOUD-1286 said that verb was where
the remedy went; it had never been written, so for every row whose class
gloss is generic the specific remedy was unreachable from the refusal naming
it. The projection it prints is `explain`'s existing rule fallback, reused
rather than re-rendered.

Sightings are keyed on the rule AND the class. Each name alone is wrong in
one direction and both were measured: under a class key the first of
fourteen `shape` rows consumed the sighting for all of them, and under a rule
key firing `verdict-not-discarded`'s three classes in order left the second
and third compact on their own first firing.

The undeclared arm is bounded too. It returned before any ceiling was
consulted and repeated its long form identically forever; it now sheds the
hatch sentence, which is the constant part, and never sheds reason-or-fix.

`[refusal] first_sighting_max_tokens` bounds the long arm, read off a
measurement rather than chosen; `refusal::validate` refuses one at or below
its sibling, since the first sighting is the repeat plus a gloss and a route.
Over budget, routes drop from the end and the gloss never does; when the line
is already irreducible it is emitted, because no truncator exists and half a
command is not a way out.

GLOSS_MAX's comment now records that 120 is chosen rather than measured, the
way [hook_output] records the same about its own figure.

Closes CLOUD-1637

BREAKING CHANGE: `refusal::Ceiling` gains a public `first_sighting_max_tokens`
field, so a downstream constructing it with a struct literal no longer compiles
(`constructible_struct_adds_field`). The field is `Option`, defaults to absent,
and absent means unbounded — so no CONFIG breaks and no behaviour changes for a
consumer who does not declare it. Only the Rust struct literal does. Declared
rather than worked around: the field has to be public for `[refusal]` to
deserialize into it and for the `trust.rs` ratchet to read it, and
`#[non_exhaustive]` would be the larger break.

Admits: b532fd94f6d591d25b82340517d05975a437c57df7591a4da40b3b8b96d16773
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-anchor: call:7fe35efd0b28cf086de2086028764a0a8eb7cf9b
Admits-epoch: f94fe13bc60f874e757d2545b7b1e8f283572bc344979bbacf9a278d9e12c3d4
Admits-author: alec@wenzowski.com
Admits-prev: -
Admits-answer-lost: CLOUD-1637 cannot land. The row requires a consumer-declared `[refusal] first_sighting_max_tokens` bounding the first-sighting arm; without it the arm that carries the class definition is bounded by nothing, which is half the change (non-negotiable rule 2) — the engine would ship a ceiling nothing declares and 144 of 201 classes stay bare.
Admits-answer-precondition: The change is two new keys in the [refusal] table itself — `first_sighting_max_tokens` and its provenance comment. No surface this class names can express an edit to the policy authority: `config read first` reads batten.toml and `patch run first` restores it, and neither writes. The write lands as a reviewed diff in the PR closing CLOUD-1637, which is the remedy the [[redirect]] row for batten.toml names verbatim ("change it in a pull request ... `mise run config-lint` is what checks the edit before it lands"), and config-lint runs in verify before it lands.
Admits-answer-rejected-route: `config read first` (document, batten.toml). Rejected because it is a READ of the authority and the work is a write to it — following it tells me what the table currently declares, which I have already read, and leaves the new key undeclared. `patch run first` (command, git restore) is rejected for the stronger reason that it undoes the edit rather than making it.
@wenzowski
wenzowski force-pushed the claude/cloud-1637-first-sighting-renders-the-definition branch from 9b6f605 to f82caf5 Compare September 8, 2026 06:35
@sonarqubecloud

sonarqubecloud Bot commented Sep 8, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

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