Skip to content

feat(receipt): owe a code review over the branch's change, and price it at the ready transition - #880

Merged
wenzowski merged 21 commits into
mainfrom
claude/prose-dispatch-gate-verify-u0fnx3
Sep 7, 2026
Merged

feat(receipt): owe a code review over the branch's change, and price it at the ready transition#880
wenzowski merged 21 commits into
mainfrom
claude/prose-dispatch-gate-verify-u0fnx3

Conversation

@wenzowski

@wenzowski wenzowski commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Closes CLOUD-1484.
Closes CLOUD-1547.
Closes CLOUD-1553.

Why

review-dispatched (CLOUD-472, #826) already answers did a named prompt run over these bytes, keyed by two digests and refusing absence only. Both its declared subjects are proseAGENTS.md and a tracker body. Nothing has ever asked whether a review ran over the branch's own diff, so a pull request may change every line of crates/batten unread.

Three measurements on 38fa830, stated rather than inferred:

  • the two declared review subjects are AGENTS.md and the refinement body; neither is the diff;
  • review::record_path writes under $GIT_DIR, which is empty on every fresh clone and every CI runner;
  • the gate is inert here todaybatten-review-runner is on no PATH and has no implementation in the tree, so review::resolve returns NoRunner, the whole fact collapses to could-not-look, and required contains "ready-pressure-test" has never refused anything.

What changed

The dispatch inverts rather than repeating. CLOUD-472's engine spawns a reviewer, which is what forced a runner, a readiness probe and a prompt channel into a repo-agnostic core. Here Batten refuses, the refusal names what the agent owes, the agent dispatches with its own harness, and [[mint]] writes the receipt from the call the boundary was already mediating. Nothing about that harness reaches crates/batten: tool, selects_at and selects are strings batten.toml supplies and the engine matches.

This is not CLOUD-1265's refuted producer-writes-outside shape. tool-verdict's store went measured dead because somebody had to remember to run the producer; nobody has to remember here, because the hook sees the call.

  • MintKey::Delta + key_base key a receipt by git::branch_patch_id — a wrapper over the existing cumulative_patch_id, not a second computation, so the same change means here exactly what landing already means by it. A rebase does not re-owe the review; any commit moving code does.
  • selects_at/selects narrow a row to one call. Every skill this harness dispatches arrives under one tool name, so tool alone discriminates nothing. The selector reads the tool input, requires reads the result.
  • base-delta gains patch-id, so a module can name the change without re-deriving an identity from the path lists.
  • ReceiptKey::Delta is the read side, so a receipt rule can be keyed the same way the mint files. ready-needs-review is that row.

The gate was run over itself fourteen times, and that is the finding

/code-review was dispatched over this branch — the obligation the gate imposes — repeatedly, because each fix moved the patch identity and re-owed one. Four defects from round one would have shipped a broken gate:

  1. The could-not-look arm was backwards. looked guarded is_object(input.tree.minted), but minted::fields leaves a declared id ABSENT when it cannot list the store — so an unlistable store is an empty map, is_object({}) holds, and every fresh clone and every CI runner would have been refused, precisely the arm the METADATA promises is silent.
  2. The clean-tree conjunct at the mint was unsatisfiable here. git::uncommitted does not skip a GITLINK, so the uninitialised tests/bats submodule counts as changed forever: Ok(0) could never hold and the receipt could never be written. Withdrawn rather than repaired in place — it was also a second authority over a question tree-clean owns. The primitive's own defect is CLOUD-1500.
  3. delta_sources does not narrow what a module sees. rules.rs builds ONE base_delta from the union of every row's globs, and six rows here declare ["**"].
  4. A declared mutation was non-discriminatingno-identity-priced named a conjunct another already excluded, so it would have survived the sweep.

Later rounds found the two that mattered most, both invisible to every test then present:

  • delta := input.tree["base-delta"] was dereferenced with no is_object guard while its sibling looked_at carried one. rules.rs projects null when the base does not resolve, indexing null is a hard Rego fault, and a fault takes the whole bundle down — so on any fresh clone with no origin/main the gate would have been off, silently, along with every other predicate in it.
  • delta_subject resolved the patch identity from "." while the mint resolves it from the repo root. batten hook runs with whatever cwd the harness had, so from any subdirectory the read side opens a different repository, gets no identity, and — because an absent identity is Missing rather than could-not-look here — refuses every call whatever receipt is on disk. Every existing case ran at the root and so could not see it; the_identity_is_resolved_from_the_repo_root_and_not_the_cwd is the one that does.

And then the loop itself was the defect (CLOUD-1547)

Fourteen dispatches in one session is not a gate working, it is a gate priced at the wrong point. code-review-dispatched was a tree rule, so batten check decided it under verify — which is what an agent runs repeatedly while iterating. Every fix commit moved the identity and owed another review, over bytes the deterministic gates had not judged yet.

The keying was right; the enforcement point was wrong. So the module, its tier and its MUTANT_GATES entry are deleted, patch read never is withdrawn naming the successor, and the question is asked once at the event that spends CI minutes:

[[rule]]
id = "ready-needs-review"
kind = "receipt"
scope = "mediated_call"
pattern = "gh pr ready"
checks = ["code-review"]
key = "delta"
key_base = "origin/main"

A rebase-only lap resolves the same identity and buys nothing. A content change files the next review under a name the row does not look up.

And the waiver it needed exposed an inverted gate (CLOUD-1553)

Retiring the tree rule left filed-over-own-diff refusing rows this PR closes, with no walkable route on this host: closes needs a pr-closes record minted from a gh pr view call and there is no gh here (CLOUD-1126, CLOUD-1481), and the class's override route binds a call: anchor nothing queries for a tree finding (CLOUD-1551).

So the branch needs a [[waiver]], and writing one surfaced a contradiction between the two halves of the waiver mechanism:

spelling waiver::apply config lint
rule = "filed-over-own-diff" suppresses refuseswaiver-names-no-rule
rule = "filed-here" suppresses nothing clean

A policy rule publishes PREDICATE ids and a finding carries the predicate id, so for any such rule no value satisfied both gates — and the spelling the lint blessed was the dead one, which is the "exemption someone is relying on" CLOUD-208 opened that smell to catch, produced by the catcher.

config lint already receives the compiled bundles for its set analysis and simply never consulted them. Bundle::declared is the module's own published set, so accepting a predicate id there acquires nothing and adds no second authority. Fixed here rather than filed, because a wrongly refusing gate is a defect to repair. The fix deliberately does not go the other way: making waiver::apply match the rule id would let one waiver silently suppress every predicate a module publishes.

The waiver therefore takes the narrow spelling, suppressing filed-over-own-diff alone while filed-unrefined stays live — which immediately earned its keep by catching two rows filed without Ready blocks.

Three things this does NOT do, in batten.toml rather than discovered later

The freeze invariant does not ship. A push-needs-review row is what would keep the readied head from changing under the ready gate — pricing to a transition is only sound if the head is frozen. It was written and measured, and it is too blunt to commit: a receipt row selects on a command pattern, so it fires on every push. Measured over the suite, it denied forced_push.rs's benign fixture push, and 15 test files drive a push unrelated to review. What it wants to refuse is a push to a branch whose PR is already ready, and forge.rs carries no draft field at all, so that narrowing is unsayable today. That is CLOUD-1446's gap and it closes in CLOUD-1548, where a declared loop can name the ready state. The tier keeps a push case over its own config, so the keying is shown not to be pattern-specific — but this repository is not frozen against a mid-ready push, and the header says so.

Retiring the module widens the gate. It gated on code-changed intersected with the reviewable prefixes, so a prose-only branch owed nothing. A receipt row has no such column, so a documentation-only branch now buys one review before readying. Deliberate and cheap at one dispatch per branch, but a widening rather than an oversight.

The receipt attests the DISPATCH, not the completion. Measured on the live host: a skill arrives as tool_name: "Skill" with tool_input: {"skill": "<name>"}, and its result is the bare string Launching skill: <name>. There is no field to require and nothing that says the agent then did what the skill told it.

CI is honestly silent, not falsely green. The receipt store dies with the container, so until CLOUD-877 gives it a portable form this gate speaks where land runs and abstains on a fresh checkout.

Verification

Measured end to end on this branch, not asserted:

  • mise run test:cargo4958/4958 passed, 0 failed.
  • crates/batten/tests/it/review_receipt_delta.rs over the compiled binary, driving real adjudicate calls. The rebase and content-change cases are an anti-vacuity pair in the strict sense: a head keying fails the first and a branch keying fails the second, so a suite carrying only one is silently satisfied by the wrong column.
  • mise run policy-test — 58 bundles, 746 passed, 0 failed.
  • batten config lint --config-from origin/main — 0, with both weakenings admitted (groomed).
  • The channel was confirmed by a pair, never one arm, per CLOUD-1049's measured lesson.
  • The column census caught key_base unclassified, the schema test caught the generated artifacts going stale, clippy's line ceiling caught smells growing past 100, and commit-lint caught ten commits with no Refs: trailer. Every one is a gate doing its job, and none was hand-edited around.

Filed rather than fixed here

Each was found by a review dispatched over this branch that reached past its own diff, and each is pre-existing: CLOUD-1551 (a tree finding's admission binds call: and suppresses nothing), CLOUD-1552 (taskset drops non-literal argv words), CLOUD-1554 (the mediation bypass discards waiver records), CLOUD-1555 (singleton_task_started anchors on the first argument).

🤖 Generated with Claude Code

https://claude.ai/code/session_015H3zMrSGxu4K3fMZu4KFX1

@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 3 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: Team

Run ID: 56b9dcb4-a5e1-4f53-9f41-b09499fda667

📥 Commits

Reviewing files that changed from the base of the PR and between cdda2d8 and 6eb08e1.

📒 Files selected for processing (22)
  • .claude/rules/policy-modules.md
  • batten.toml
  • completions/batten.bash
  • completions/batten.fish
  • completions/batten.zsh
  • crates/batten/src/config.rs
  • crates/batten/src/facts.rs
  • crates/batten/src/git.rs
  • crates/batten/src/hook.rs
  • crates/batten/src/lib.rs
  • crates/batten/src/lint.rs
  • crates/batten/src/mint.rs
  • crates/batten/src/perf.rs
  • crates/batten/src/receipt.rs
  • crates/batten/src/rules.rs
  • crates/batten/src/trust.rs
  • crates/batten/tests/it/main.rs
  • crates/batten/tests/it/review_receipt_delta.rs
  • man/batten-receipt-status.1
  • schema/batten.local.schema.json
  • schema/batten.schema.json
  • schema/policy-input.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/prose-dispatch-gate-verify-u0fnx3 branch from 002cf49 to 3e55a8d Compare September 5, 2026 23:14
@wenzowski wenzowski changed the title feat(mint): key a receipt to the branch's change, so a code review can be owed feat(receipt): owe a code review over the branch's change, and price it at the ready transition Sep 6, 2026
…an be owed

CLOUD-1484. `review-dispatched` (CLOUD-472) answers *did a named prompt run over
these bytes*, and both its declared subjects are prose — `AGENTS.md` and a
tracker body. Nothing has ever asked whether a review ran over the branch's own
diff, so a pull request may change every line of `crates/batten` unread.

THE DISPATCH INVERTS RATHER THAN REPEATING. CLOUD-472's engine spawns a
reviewer, which is what forced a runner, a readiness probe and a prompt channel
into a repo-agnostic core. Here Batten refuses, the refusal names what the agent
owes, the agent dispatches with its own harness, and `[[mint]]` writes the
receipt from the call the boundary was already mediating. That is not
CLOUD-1265's refuted producer-writes-outside shape: nobody has to remember to
run a producer, because the hook sees the call.

`MintKey::Delta` keys a receipt by `git::branch_patch_id` — a wrapper over
`cumulative_patch_id` rather than a second computation, so *the same change*
means here exactly what `landing` already means by it. A rebase therefore does
not re-owe the review and any commit moving code does. The boundary refuses to
mint at all while the tree is dirty: a receipt keyed to committed bytes must not
be taken in a session looking at something else.

`selects_at`/`selects` are the load-bearing pair. Every skill this harness
dispatches arrives under one tool name, so `tool` alone discriminates nothing
and the row would attest that *something* ran. `requires` cannot reach it — the
field is present whichever skill ran, it just holds another value. The selector
reads the tool INPUT and `requires` reads the RESULT: the input says which call
this is, the result says whether it answered.

Two measurements changed the design while building it. A skill's result on this
host is the bare string `Launching skill: <name>`, which is not JSON, so
`record_mints` gave up before reaching the row — this is the first row whose
whole reading is of the input and the repository, so the result is now `null`
rather than an early return. And the clean-tree condition is at the WRITE, not
in the module: as a conjunct it would let a dirty tree silence the gate, and as
a refusal it would be a second opinion where `tree-clean` already owns the
question.

The receipt attests the DISPATCH, not the completion, and that gap is named in
`batten.toml` rather than papered over: the harness returns nothing that would
say the agent then did what the skill told it.

BREAKING CHANGE: `mint::Declared` gains `key_base`, `selects_at` and `selects`,
and `mint::MintKey` gains `Delta`. Both are library-API breaks — the struct is
constructible so a downstream literal no longer compiles, and an exhaustive match
on the enum no longer covers it. The TOML surface is unaffected: all three
columns are optional and a row that omits them is judged exactly as before, which
is what an earlier draft of this branch wrongly generalised into `no consumer
break`. `semver check` is what caught the difference.

Weakens: mint-added mint[code-review]
Refs: CLOUD-1484
Admits: 2b23578df77a1290c5cfa5f47512c93cc502705647553d90a63c5c47d4c0e5e4
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-anchor: call:7e2207d701d6c58684d79582e310b37ef9e54367
Admits-epoch: f3ee6646fee52db519d6e2493d1e490f8eb22a3172c03b6bc50fc435199006b4
Admits-author: alec@wenzowski.com
Admits-prev: -
Admits-answer-lost: The gate would not exist. code-review-dispatched is only reachable because a [[rule]] row registers its module and a [[mint]] row keys its receipt; without them the engine change is dead code, and nothing would refuse a branch that changed code with no code review dispatched over it — the defect CLOUD-1484 was filed for.
Admits-answer-precondition: A gate is declared as data in batten.toml: the [[mint]] row, the [[rule]] + [[rule.minted]] rows and the [[verdict]] class ARE the change CLOUD-1484 asks for. No [[redirect]] surface can express adding a rule, and non-negotiable rule 1 forbids the consumer facts they carry from living in crates/batten. The write lands in PR #880 where a reviewer reads it beside the module and the engine change it configures.
Admits-answer-rejected-route: config read first — I did read batten.toml, and reading is not the change; the row has to be added. patch run first (git restore) — that discards the rows, which is the change itself, not a repair to it. Both routes assume the write was a mistake to undo; here it is the deliverable.
Generated, never hand-edited: `mise run schema` over the four surfaces
together, so none can be refreshed alone and drift while the others look
current.

Refs: CLOUD-1484
`/code-review` was dispatched over this branch, which is what the gate exists to
demand, and it found six things. Four are repaired here; two are bounds and are
now written down where a reader meets them.

THE COULD-NOT-LOOK ARM WAS BACKWARDS, which is the one that would have shipped a
gate refusing exactly what it documents itself as abstaining on. `looked` guarded
`is_object(input.tree.minted)`, but `minted::fields` leaves a declared id ABSENT
when it cannot list the store — so an unlistable store is an EMPTY map, not
`null`, `is_object({})` holds, and every fresh clone and every CI runner would
have been refused. The guard is per-id now.

THE CLEAN-TREE CONJUNCT AT THE MINT WAS UNSATISFIABLE IN THIS REPOSITORY.
`git::uncommitted` reimplements status and does not skip a GITLINK, where
`walk_blob_ids` explicitly does, so an uninitialised submodule counts as changed
forever: `git status` reports this tree clean while `changed_paths` returns
`{"tests/bats"}`, a mode-160000 entry. `Ok(0)` could never hold and the receipt
could never be minted. Withdrawn rather than repaired in place — it was also a
second authority over a question `tree-clean` owns, and a reviewer reads the
working tree anyway, so refusing to record a dispatch taken over uncommitted work
attests less than happened. The primitive's defect is CLOUD-1500.

`delta_sources` DOES NOT NARROW WHAT A MODULE SEES. `rules.rs` builds ONE
`base_delta` from the union of every row's globs, and six rows here declare
`["**"]`, so a workflow-only or schema-only branch owed a code review. The module
carries the reviewable prefixes itself.

A DECLARED MUTATION WAS NON-DISCRIMINATING. `no-identity-priced` named a case
whose `code-changed` was empty, so `owed` already excluded the mutated conjunct
and the mutation would have survived the sweep. The fixture now leaves the edit
UNCOMMITTED, which is the one state where the working-tree delta says code moved
and the committed range says nothing did.

Two bounds stated rather than fixed: `owed` is a tip diff and `subject` is a
merge-base one, so a stale branch can owe a review until it rebases; and the
identity covers the whole change, so a prose commit on a branch that also carries
code re-owes it. Both err toward owing a review that is not strictly due, and
neither can produce the other direction, which is a branch landing unread.

Refs: CLOUD-1484
…usal

The per-id guard made the old case's expectation wrong, and it was wrong for a
reason worth keeping: `minted` is keyed by DECLARED ROW, and `minted::fields`
inserts an entry for every row whose store it could list — so an id missing from
the map means the engine never looked for it, never that it looked and found
nothing. Refusing there is a verdict about the engine's reach wearing a verdict
about the branch.

The claim the case meant to make is already `test_a_receipt_over_another_change_
does_not_answer`, where the id IS present and the subject belongs to another
change.

Refs: CLOUD-1484
`lint:fmt` refused the `let (Some(path), Some(expected)) = ...` line in
`mint::selects`. No behaviour changes; prettier reflowed
`.claude/rules/policy-modules.md` in the same pass.

Refs: CLOUD-1484
`/code-review` was dispatched again after the enrolment commit moved the patch
identity — which is the gate demanding its own re-attestation — and this time
every finding was inside this branch's diff.

THE WRITER WAS NEVER DRIVEN, and that is the one that would have shipped a gate
refusing forever with a green suite. Every case hand-wrote `code-review.<id>` and
called `branch_patch_id` directly, so `mint_receipts` was never invoked and the
two halves were never shown to agree: a different key base, separator or
authority root would have left the reader looking somewhere the writer never
wrote. That is the `with input as` class one layer over, in the suite of the
module whose own header refuses it. `mint_receipts_for_test` drives the real
boundary, and the wrong-skill arm is there too, so the selector is proven over
the engine rather than over `selects` alone.

`batten.toml` ASSERTED A GUARANTEE THIS CHANGE HAD WITHDRAWN. The block still
said the boundary refuses to mint while the tree is dirty; that conjunct was
removed earlier in this same branch because `git::uncommitted` counts an
uninitialised gitlink as changed forever (CLOUD-1500), so it could never hold.
A false guarantee in the policy authority is worse than a missing one.

`branch_patch_id` PROMISED could-not-look AND RAISED. `head_commit(dir)?`
propagates where the doc says `None` — latent while both callers swallow it, and
a trap for the next one that reads the doc instead of the body.

`base_delta` PAID FOR THE IDENTITY UNCONDITIONALLY while every sibling git fact
is declaration-gated. It is a second repository open, a merge-base walk and a
tree diff, on every `check` declaring any `delta_sources` — which is most of
them — for a field one module reads. Gated on a row declaring `minted`.

`mint::selects` AND THE FOUR NEW LOAD REFUSALS HAD NO TESTS. Loosening the
equality to a prefix, or returning true on an unresolvable path, reddened
nothing — and either makes the row mint on every skill dispatch, which is the
false green the column exists to remove.

Two findings are bounds rather than fixes, stated where a reader meets them:
`requires = []` has no success predicate because `Declared` carries no `is_error`
column, so an errored dispatch still mints; and `reviewable` restates the row's
`delta_sources` with nothing holding the two in agreement.

Refs: CLOUD-1484
Admits: 0a91c088ab4d93b4a6e1bd01f340b1d94d756cb57f3e003a56baf2f07593684c
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-anchor: call:58efe536b4d39ef6aac43e52def6da3ea67e74b4
Admits-epoch: 976d2841a1864bc485ab2531d686611f0708820fc8f307ba0f2b965502c679cf
Admits-author: alec@wenzowski.com
Admits-prev: 2b23578df77a1290c5cfa5f47512c93cc502705647553d90a63c5c47d4c0e5e4
Admits-answer-lost: A false guarantee stays in the file every gate reads. A reader checking whether a dirty tree is refused would find batten.toml saying yes and the engine doing nothing, which is worse than no statement at all — it is the shape this repository records as a false assurance, and the code review that found it is the only thing that would have caught it.
Admits-answer-precondition: The write is a CORRECTION to prose in batten.toml that asserted a guarantee this branch had already withdrawn: the block said the boundary refuses to mint while the tree is dirty, and that conjunct was removed earlier in this same branch because git::uncommitted counts an uninitialised gitlink as changed forever (CLOUD-1500). No [[redirect]] surface can edit a comment, and the claim is in the policy authority where a reader meets it. It lands in PR #880 beside the code that withdrew the conjunct.
Admits-answer-rejected-route: config read first — reading is how the defect was found; the false sentence still has to be replaced. patch run first (git restore) — that discards the correction and restores the false guarantee, which is the opposite of the repair.
`clippy::expect_used` is denied crate-wide, and the new `mod tests` cases build
their fixtures with `serde_json::from_value(...).expect(...)`. `render.rs`,
`contract.rs` and `admission.rs` each carry the same allow on the same
attribute — the lint is about reachable library paths, and a test fixture that
cannot be constructed should fail the test loudly.

Refs: CLOUD-1484
The gate demanded a third review after the floor move shifted the patch
identity, and every finding was again inside this branch's diff.

THE NULL FALLBACK WIDENED `satisfied` VACUOUSLY, and it is mine. `record_mints`
passes `Value::Null` where it used to return early, so a row reading none of the
result can mint from a call whose answer carried no JSON. For a `requires = []`
row whose BODY reads the result, the success test is then vacuously true, every
path piece records the could-not-look token, and under `MintMode::Replace` that
all-`-` record overwrites a good receipt. No landed row is shaped that way, which
is exactly why nothing would have caught it. `render` now withholds a record when
the result is null and any piece reads it; `Piece::reads_the_result` is
exhaustive with no wildcard, so a variant added later fails to compile rather
than classifying itself as reading nothing.

THE `patch-id` GATING WAS UNSOUND AND IS WITHDRAWN. A previous review asked for
it to be declaration-gated like every other git fact, and the gating shipped
keyed on `!rule.minted.is_empty()` — which is not the declaration that reads the
fact. A module reading `patch-id` with no sibling `[[rule.minted]]` row would see
`null`, decide nothing, and exit 0; for `code-review-dispatched` itself, dropping
the minted block would have silenced the gate through two channels at once. The
merge-base walk is paid again and the cost is stated rather than hidden. Gating
it honestly needs a column naming which facts a row reads, which is a bigger
change than this one.

`selects` NAMED THE WRONG SURFACE. Its parameter and doc said RESULT while every
caller passes the INPUT, contradicting the call site — the wrong-surface trap
that produces a row matching nothing and a gate that denies forever. The unit
cases could not catch it, because they supply the object themselves.

A BLANK `selects` LOADED CLEAN where a blank `selects_at` was refused. It matches
no value any host sends, so the row mints nothing and its gate denies forever
with a remedy that cannot clear it. Both halves are refused now.

Two false claims are corrected where a reader meets them. The module METADATA
said it could not under-owe: it can, because the identity is over COMMITTED
bytes, so an uncommitted code edit leaves the receipt valid and `batten check`
reports clean over unreviewed work — `tree-clean` is what covers that, and only
on the landing path. And the verdict route names `/code-review` without saying
that a TYPED slash command may mint nothing on a host that expands it with no
tool call, which is a remedy the reader cannot walk.

One finding is left as a bound: `key = "delta"` is the first unbounded keying —
one receipt per patch identity, never pruned, and `minted::fields` reads every
matching file on each `check`.

Refs: CLOUD-1484
Admits: 486f8518288e82b9b457c9df8dd28a0d34b8f705466d0b2952eb2b6f7e1953b6
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-anchor: call:d874abf892e4207f698df9ad33c4d3dd6114e20c
Admits-epoch: 5b699dcd1dcec80408ccacd8807b5e803d6068c2530bc97924477d4bfd87c93b
Admits-author: alec@wenzowski.com
Admits-prev: 958bfb7cb712ab26af8d9194bc7fd64b9fa29ad2822ad226bedbd96b1e87cd14
Admits-answer-lost: A route that cannot be walked stays in the file as though it can. The reader who hits it is a person at a keyboard following the refusal's own instruction, and the loop has no exit visible from the message — which is strictly worse than a class with no route at all, because it reads as a way out.
Admits-answer-precondition: Two corrections to prose in batten.toml, both found by the code review this branch's own gate demanded. The [[verdict.route]] block names /code-review as the remedy without stating that a TYPED slash command may mint nothing on a host that expands it with no tool call, so the gate re-denies with a remedy the reader cannot walk. No [[redirect]] surface can edit a route's commentary, and the claim lives in the policy authority where a reader meets it. It lands in PR #880 beside the module it describes.
Admits-answer-rejected-route: config read first: reading is how the defect was found, and the sentence still has to be written; a route stated wrongly is not repaired by re-reading it. patch run first (git restore): that discards the correction and restores the unqualified route, which is the defect rather than the repair.
…ier could not see it

The fourth review found the defect that would have shipped this gate broken in
production while every case here stayed green.

`record_mints` OPENED WITH `if envelope.result.is_null() { return; }` — three
lines above the comment this branch added saying a null result is NOT an early
return. Both cannot be true, and the code won. A `Skill` post-tool whose host
sends no `tool_response`, or sends `null`, took that return: no receipt written,
`code-review-dispatched` denies, and the refusal names `/code-review` as the
remedy for something the agent has already done. A gate whose stated remedy
cannot clear it is worse than an absent one.

THE TIER COULD NOT CATCH IT, WHICH IS THE PART WORTH RECORDING.
`mint_receipts_for_test` enters BELOW this guard, so the compiled cases prove the
writer and reader agree about a key and say nothing about whether the boundary is
reached at all. That is the seam a test which drives the function directly always
leaves: the cases were right and the entry point was not, and only a reader
looking at the call site would have known.

The guard is gated on `envelope.event != Event::PostTool` now, which is the
question the line was actually asking. Nullness of the result was never that
question, and reading it as such is what made the two statements disagree.

`mint_receipts`' DOC COMMENT WAS ORPHANED by this branch's own test shim, which
landed between the comment and the signature — so the boundary is undocumented
and the helper carried four paragraphs describing it. Restored.

`validate` REFUSED HALF A SYMMETRY. A `key_base` on a row not keyed `delta` is
refused; a `key_from` on a row not keyed `named` was ignored, though it is read
by nothing in exactly the same way. Both refused now, with a case pinning each
direction.

One finding stays a bound rather than a fix: `key = "delta"` accumulates one
receipt per patch identity with no prune, and `minted::fields` reads every
matching file on each `check`.

Refs: CLOUD-1484
…the filter

The fifth review found that the previous round's repair was itself wrong, in the
direction the previous round's repair was written to fix.

`envelope.event != Event::PostTool` IS THE RIGHT QUESTION ASKED IN THE WRONG
PLACE. `record_post_tool` is `record_mints`' only caller and `lib.rs:8319` already
gates on exactly that, so the guard was dead code — and adding it DELETED the only
cheap filter without replacing it, while the comment above claimed one was there.
That is the same defect as the round before it, one layer over: a comment
asserting what the code does not do, caught by a reader rather than a test.

Both guards are gone. The event is decided at the call site, and the config load
is paid on every post-tool event — the cost of admitting a dispatch whose result
carried nothing, stated rather than bought back with a predicate that cannot
express it. A cheaper filter would have to know which tools any row names before
the rows are loaded, which is the thing being loaded.

THE EMPTY-SELECTOR REFUSAL ONLY TRIMMED. `selects = "code-review "` loaded clean,
compared against a value no host sends, and left the row minting nothing — the
same silent dead gate the check was added to close, one space away. Refused
rather than trimmed: trimming decides on the author's behalf which of two
readings they meant.

`mint_receipts_for_test` HARD-CODED `None` FOR THE GRAMMAR where the boundary
passes one built from the resolved policy, so the helper whose entire
justification is fidelity diverged from the boundary for `{authority:…}` pieces.
It takes the parameter now, and the tier passes `None` explicitly with a note, so
a case that adds such a piece has to decide rather than inherit.

Refs: CLOUD-1484
…esh clone

`delta := input.tree["base-delta"]` was dereferenced in `owed` and `subject`
with no `is_object` guard, while its sibling `looked_at` guarded
`input.tree.minted` for exactly that reason. `rules.rs` projects `base-delta` as
`null` when the base ref does not resolve, indexing `null` is a hard evaluation
fault in Rego rather than a silent miss, and a fault takes the whole bundle down
— every predicate in it, not only this one. A fresh clone with no `origin/main`
is the reachable instance, and the module's own metadata already says so about
`minted`.

Guarded, so an unresolvable base leaves `delta`, `owed` and `subject` undefined
and every arm goes quiet — the could-not-look reading this module already takes
for an absent identity. `test_a_delta_the_engine_could_not_build_does_not_fault`
is the case; the load-time tier goes 757 → 758.

Found by the code review this gate exists to demand, over its own diff.

Admits: 4cdb9099ad184ad0242ff2b6f369c089d334cf1aee433a4c42b131f0b39ec09d
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: policy/code-review-dispatched.rego
Admits-anchor: call:8bbc08550d75be92205e1037158331621a9352e0
Admits-epoch: af7bc401f010623937d6e9319c981c2b4d33dcf318281035ed45e57da25037a9
Admits-author: alec@wenzowski.com
Admits-prev: -
Admits-answer-lost: Without it, `delta := input.tree["base-delta"]` is dereferenced unguarded while its sibling `looked_at` guards `input.tree.minted` for exactly that reason. On a checkout whose base ref does not resolve the engine projects `base-delta` as null, indexing null is a hard evaluation fault in Rego, and the fault takes the whole bundle down — every predicate in it, not just this one. The gate this branch exists to add would be off on any fresh clone with no origin/main.
Admits-answer-precondition: The change is a Rego predicate guard inside a policy module, and no declared surface can express it: batten.toml holds config rows, not module bodies, and git restore reverts rather than edits. Writing the module directly is the only route, and the guard lands in this branch's diff where a reviewer reads it.
Admits-answer-rejected-route: `config read first` (batten.toml) does not apply: a `[[pattern]]` or path-set row cannot carry a Rego body, and the missing guard is a clause in the module, not a value the config supplies. `patch run first` (git restore) does not apply either: it restores committed bytes, and the committed bytes are the ones carrying the defect.

Refs: CLOUD-1484
…be owed at a transition

CLOUD-1547. The read side of `MintKey::Delta`: a `receipt` rule may now declare
`key = "delta"` with a `key_base`, and its subject is the identity of this
branch's whole change against that ref.

The three existing keyings price a review at the wrong rate. `head` expires on
every commit AND every rebase, so a loop that rebases per lap re-buys the receipt
each lap for a change that did not move — the false-positive rate `branch`'s own
doc says gets a guard bypassed. `branch` expires on nothing but a restart, so a
branch reviewed once may then push anything. Neither is "these bytes, however
they were replayed".

`receipt::delta_subject` resolves through `git::branch_patch_id` — the same
function the mint files under. That shared call is the agreement: a second
derivation of "the same change" would be free to disagree with the mint's about
exactly the rebase this keying exists to survive.

Refused at load in both directions: `delta` with no `key_base` has no second side
to diff, so it would read no file for any call and deny everything, which is the
direction that gets a guard switched off; a `key_base` on any other keying is a
column that reads as configured and is never consulted.

An unresolvable base or an EMPTY diff answers `Missing` rather than
could-not-look, which is the opposite of what the `branch` and `named` arms do
and is deliberate: could-not-look allows, so a branch whose base does not resolve
could push anything. A branch with nothing to review is refused loudly and
cleared cheaply.

`Policy::receipt_key_base_for` carries the prefix because `key_base_for` already
means `requires_key`'s base — one ref for the whole call, answering since which
commit evidence is looked for. Two refs, two questions; a reader reaching the
wrong one gets a plausible value and the wrong receipt.

`--key delta` is refused from `receipt status` as `--key named` is, one reason
further on: the base is the declaring row's, and defaulting one would answer
about a base no row named.

Refs: CLOUD-1547
…commit

CLOUD-1547. `code-review-dispatched` was a tree rule, so `batten check` decided it
under `verify` — which is what an agent runs repeatedly WHILE iterating. Every fix
commit moved the patch identity and owed another review, and the review that found
something produced the fix that owed the next one. Measured on the branch that
built it: six dispatches in one session, each provoked by the fix for the last,
each charged over bytes the deterministic gates had not judged yet.

The keying was right; the enforcement POINT was wrong. `ready-needs-review` is a
`delta`-keyed `receipt` row over `gh pr ready` — the event that spends CI minutes.
A rebase-only lap resolves the same identity and buys nothing; a content change
files the next review under a name the row does not look up.

The module, its compiled tier and its `MUTANT_GATES` entry are deleted, and
`patch read never` is withdrawn naming the successor rather than left orphaned.
Its route is kept verbatim: what satisfies the demand is unchanged, only where
the demand is made.

`crates/batten/tests/it/review_receipt_delta.rs` is the tier, over real
`adjudicate` calls. The rebase and content-change cases are an anti-vacuity pair
in the strict sense — a `head` keying fails the first and a `branch` keying fails
the second — so a suite carrying only one is satisfied by the wrong column.

Two things this does NOT do, written into `batten.toml` rather than discovered:

`push-needs-review` is not committed. It is what would freeze the readied head,
and pricing to a transition is only sound if the head cannot change under it. A
`receipt` row selects on a command pattern, so it fires on every push in the
repository, while what it wants to refuse is a push to a branch whose PR is
already ready — and `forge.rs` carries no draft field, so that narrowing is
unsayable. Measured: it denied `forced_push.rs`'s benign fixture push, and 15 test
files drive a push unrelated to review. The gap is CLOUD-1446's and closes in
CLOUD-1548, where a declared loop can name the ready state. The tier keeps a push
case over its own config, so the keying is shown not to be pattern-specific.

And retiring the module widens the gate. It gated on `code-changed` intersected
with the reviewable prefixes, so a prose-only branch owed nothing; a `receipt` row
has no such column, so a documentation-only branch now buys one review before
readying. Deliberate and cheap at one dispatch per branch, but a widening.

`key_base` is classified in the column census as not fact-bearing — an
unclassified column is declarable on a mediated row and read by nothing, which is
what that census exists to refuse.

Admits: eb43570d9ba0d9204b29ca2eb2d34cd74707723ab246201cb0e06a0db187cc7f
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-anchor: call:5ffde3395ad0846c5a344da6047ff073406c4505
Admits-epoch: af7bc401f010623937d6e9319c981c2b4d33dcf318281035ed45e57da25037a9
Admits-author: alec@wenzowski.com
Admits-prev: 486f8518288e82b9b457c9df8dd28a0d34b8f705466d0b2952eb2b6f7e1953b6
Admits-answer-lost: Without it CLOUD-1547 has an engine keying nothing declares. `ReceiptKey::Delta` would be a column no row uses — the dead-gate class this repository records, shipped deliberately rather than by accident — and `code-review-dispatched` would go on pricing a review per commit on the tree surface, which is the defect the keying was built to remove and which cost six model calls in one session on this branch alone.
Admits-answer-precondition: The change is two `[[rule]]` rows and a `[[verdict]]` withdrawal in the policy authority itself, plus dropping the retired `[[rule]]` and its module registration. No declared surface can express a rule row: batten.toml IS the surface, `batten init` refuses an existing file, and `git restore` reverts rather than edits. Writing it directly is the only route, and the rows land in this branch's diff where a reviewer reads them.
Admits-answer-rejected-route: `config read first` (batten.toml) is the path being written, so it names itself and resolves to nothing. `patch run first` (git restore) does not apply: it restores committed bytes, and the committed bytes are the ones carrying the per-commit pricing.

Refs: CLOUD-1547
…t a git init chain

`fixture-forks` refused `review_receipt_delta.rs:104` and was right: the fixture
hand-rolled `git init` + `add` + `commit` + `update-ref`, which is the habit
CLOUD-1419 measured at 1,819 init processes and 4.49s over one run.

`common::Fixture` copies a published template instead of forking `git init`, and
its `base_commit` already pins `refs/remotes/origin/main` — the very ref these
rows name — so the chain was not only expensive but redundant.

4881/4881 unchanged.

Refs: CLOUD-1547
… here

CLOUD-1547 is implemented and closed by this PR, so its §1 names these eight
paths because they are the files this change edits. That is precisely what
`filed-here.rego`'s `closes` arm exempts, and the exemption cannot fire: the
`pr-closes` record is minted from a `gh pr view --jq .body` call and there is no
`gh` on this host, so `closes` is empty. Empty is not `closes_unreadable` either
— nothing attempted the call, so the blocked store has nothing to record.
CLOUD-1126 named that shape; CLOUD-1481 carries the residual.

The class's own override route is spent without suppressing. Nine admissions
were issued and spent against these subjects and the findings did not move. The
consumption is NOT missing — `admission::Anchor::Finding` exists for exactly this
and `apply_admissions` suppresses stored findings by fingerprint. What fails is
the anchor resolver: `lib.rs:5428` matches stored findings on `(rule, path)` and
falls back to `Anchor::Call` on zero matches, so the spend produced
`Admits-anchor: call:2dee1726…` for a tree finding, and nothing queries a call
anchor for one. The ambiguity arm refuses precisely to avoid "an override that
appears to work"; the zero arm walks into it. CLOUD-1551 carries that, with why
the match returned zero as its open question.

So all four declared routes are unavailable here: the fix is committed, the body
says `Closes CLOUD-1547` and cannot be read, filing from a clean tree cannot
apply to a row already filed and already implemented, and the override binds an
anchor nothing reads. A `[[waiver]]` is the blunt instrument that route existed
to avoid, which is why it names both rows, carries a reason, and expires
2026-10-11 rather than switching the rule off.

It names `filed-here` rather than `filed-over-own-diff` because a waiver keys on
a rule id and that is one of the two predicates the rule publishes, so it is
wider than intended and also silences `filed-unrefined`. Stated on the row.

Admits: 09521541a8bd8da59b93ddfb949fd6af188f81dfae43ca4b5775397dd437e9a2
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-anchor: call:2dee17263fa63c4951cc4b4b3e802d08f6e20195
Admits-epoch: 1ab5aef43a9bb3a8da9b8c2adf82a92d26c626ded0af816a694267fc8337d576
Admits-author: alec@wenzowski.com
Admits-prev: 36aa4e297aebc8c5fa0490acac99a36be208cd6938f3ff623419c2ff6c939433
Admits-answer-lost: A committed waiver whose stated cause is false. It says filed-here.rego has no admission conjunct and nothing reads the store; measured, admission::Anchor::Finding exists for exactly this and apply_admissions suppresses stored findings by fingerprint. The real cause is the anchor resolver falling back to a call anchor on zero matches. A waiver is the one artefact whose job is to say why a gate is suppressed and when to stop, so a wrong cause in it is the false-pointer failure scanning.md records, committed.
Admits-answer-precondition: The change corrects the reason string and comment on a [[waiver]] row in the policy authority. batten.toml IS the surface a waiver lives on; batten init refuses an existing file and git restore reverts rather than edits, so writing it directly is the only route. It lands in this branch diff where a reviewer reads it.
Admits-answer-rejected-route: config read first names batten.toml, the path being written, so it resolves to nothing. patch run first (git restore) would restore committed bytes that do not carry the waiver at all.

Weakens: waiver-added waiver[filed-over-own-diff]
…at suppresses

CLOUD-1553. `waiver-names-no-rule` resolved a waiver's `rule` against
`config.rules` alone. A policy rule publishes PREDICATE ids from its module and a
policy finding carries the predicate id, so `waiver::apply` and `config lint`
disagreed about which name a `[[waiver]]` holds — and each was self-consistent:

  rule = "filed-over-own-diff"  suppresses (8 audit lines)  refused by the lint
  rule = "filed-here"           suppresses nothing          clean to the lint

Measured on this tree minutes apart by changing only that value. For any policy
rule whose module publishes an id of its own, no value satisfied both halves, and
the spelling the lint blessed was the dead one — which is the "exemption somebody
is relying on" CLOUD-208 opened this smell to catch, produced by the catcher.

The bundles were already in hand for the set analysis below and simply never
consulted. `Bundle::declared` is the module's own published set — the same
authority `attribute` resolves a violation's id against — so this acquires
nothing and adds no second list that can drift.

The fix deliberately does NOT go the other way. Making `waiver::apply` match the
rule id instead would let a waiver over `filed-here` suppress every predicate the
module publishes, so waiving one finding class would silently waive the rest.
That is a weakening of the raise-only invariant, and it is the widening this
branch had to accept and write a comment about before the repair.

Second arm: bundles are EMPTY when a module will not load, which a config judged
away from its own tree always is — `repo_with_committed_config` is exactly that,
the committed `batten.toml` with no `policy/*.rego` beside it. With no bundle to
ask, a predicate id is indistinguishable from a typo, so the smell abstains
rather than resuming the inversion. Narrow on purpose: it abstains only where the
config declares a policy rule and nothing resolved, so a config with no policy
rules keeps the smell's full reach.

The `filed-over-own-diff` waiver added last commit takes the narrow spelling now.
It suppresses that predicate alone and leaves `filed-unrefined` deciding, so the
widening its comment apologised for is gone.

A wrongly refusing gate is a defect to repair rather than a ticket to file, so
this lands here rather than being left for CLOUD-1553 to pick up.

Admits: 0a3b4f556f824a4a46783cdac0ad57aae4141253de32752d0a30ac7e5173175c
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-anchor: call:3b21e71b75f10a06de5851c15536534d709d2f64
Admits-epoch: ff75259a4c4c2868d11c45ce05bb1768588122d48b8579c106ae1495d71c21a0
Admits-author: alec@wenzowski.com
Admits-prev: 09521541a8bd8da59b93ddfb949fd6af188f81dfae43ca4b5775397dd437e9a2
Admits-answer-lost: A committed waiver whose stated cause is false. It says filed-here.rego has no admission conjunct and nothing reads the store; measured, admission::Anchor::Finding exists for exactly this and apply_admissions suppresses stored findings by fingerprint. The real cause is the anchor resolver falling back to a call anchor on zero matches. A waiver is the one artefact whose job is to say why a gate is suppressed and when to stop, so a wrong cause in it is the false-pointer failure scanning.md records, committed.
Admits-answer-precondition: The change corrects the reason string and comment on a [[waiver]] row in the policy authority. batten.toml IS the surface a waiver lives on; batten init refuses an existing file and git restore reverts rather than edits, so writing it directly is the only route. It lands in this branch diff where a reviewer reads it.
Admits-answer-rejected-route: config read first names batten.toml, the path being written, so it resolves to nothing. patch run first (git restore) would restore committed bytes that do not carry the waiver at all.

Refs: CLOUD-1553
The ninth code review over this branch found it, and it would have shipped a gate
that refuses everything.

`delta_subject` opened `Path::new(".")` where the mint opens the repo root.
`batten hook` runs with whatever cwd the harness had — routinely a subdirectory,
sometimes outside the repository — so the read side reached a different
repository or none, `branch_patch_id` answered `None`, and because an absent
identity is `Missing` rather than could-not-look at that call site, a
`delta`-keyed row refused EVERY call whatever receipt was on disk.

The two halves disagreeing about *where the repository is* is the same class as
the two halves disagreeing about *what the same change is*, which the function's
own doc already warns against one paragraph up: the mint and the reader must
resolve one identity. They call the same function; they now open the same tree.

Invisible to all eight existing cases because they run at the fixture root, so
the new case runs `adjudicate` from `crates/batten/src` and asserts the same
receipt still answers. It checks the root arm first, so a refusal from the nested
arm is attributable rather than ambiguous.

Refs: CLOUD-1547
…imes

The tenth code review over this branch found it. `delta_subject` was recomputed
at each of the three sites that want it — the validity arm, the `max_age` branch
and the `requires_field` branch — and each call is a repository open plus a
merge-base walk and a tree diff. A `delta`-keyed row declaring both `max_age` and
`requires_field` therefore paid three of them on every mediated call, against the
~100 ms budget `perf-assert` holds this path to.

Hoisted to one binding per CHECK, which is the narrowing the arm's own comment
already argued for: the base is a property of the ROW, so two rows keying on
different bases must not answer from whichever was read first — but within one
check there is exactly one base and therefore exactly one identity, so
recomputing it bought nothing.

Only a `delta`-keyed check pays anything: every other keying leaves the binding
`None` without opening a repository, which is the same cheap-when-irrelevant
posture `max_age` takes one arm below.

No verdict moves — the `Option` is the same three-valued answer each site already
read, so this is the identical decision made once.

Refs: CLOUD-1547
…hoist

1adec082 moved the identity into a per-check binding and left the arm saying it
is "NOT resolved once above", which is now the opposite of what the code does —
and it attributed the two-rows-two-bases hazard to the arm, where it is actually
handled by the per-check map plus the key guard. The eleventh review over this
branch caught it.

A comment that tells the next reader the opposite of the code is worse than none
here in particular: the whole arm exists because its answer is deliberately the
inverse of its two neighbours, so a reader arriving to check that reasoning is
exactly the reader this would mislead.

Refs: CLOUD-1547
… the line ceiling

CLOUD-1553's two arms pushed `smells` to 105 lines against clippy's 100, and the
waiver loop is the self-contained half to lift out: it reads the waiver table,
the rules those waivers name, the predicate ids the bundles publish, and the
date — nothing else in `smells` and nothing after it.

No verdict and no ordering move. The caller extends at the position the loop
occupied, and the whole list is sorted by (at, id) afterwards regardless, which
is what keeps the report byte-stable under §6.

Refs: CLOUD-1553
…tespace

The literal was written as one long string with the source indentation folded
into it, so the emitted census text read "the classifier rides              the
advisory channel". Every neighbouring entry uses backslash-continued strings;
this one now does too.

Cosmetic in the source and not in the output: the census is a document a reader
is meant to read, so multi-space gaps in it are a defect in what the engine says
rather than in how it is typed.

Refs: CLOUD-1547
@wenzowski
wenzowski marked this pull request as ready for review September 7, 2026 02:23
@wenzowski
wenzowski force-pushed the claude/prose-dispatch-gate-verify-u0fnx3 branch from 3e55a8d to 6eb08e1 Compare September 7, 2026 02:24
@wenzowski
wenzowski marked this pull request as draft September 7, 2026 02:24
@sonarqubecloud

sonarqubecloud Bot commented Sep 7, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@wenzowski
wenzowski marked this pull request as ready for review September 7, 2026 02:49

Copy link
Copy Markdown
Contributor Author

/fast-forward


Generated by Claude Code

@wenzowski
wenzowski merged commit 6eb08e1 into main Sep 7, 2026
21 of 22 checks passed
@wenzowski
wenzowski deleted the claude/prose-dispatch-gate-verify-u0fnx3 branch September 7, 2026 03:02
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