Skip to content

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

Draft
wenzowski wants to merge 4 commits into
mainfrom
claude/prose-dispatch-gate-verify-u0fnx3
Draft

feat(receipt): owe a code review over the branch's change, and price it at the ready transition#880
wenzowski wants to merge 4 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.

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 seven 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. Six rounds found real defects; the seventh came back clean. Four 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.

And the last round found the one that mattered most: 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.

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

Seven 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.

Two 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.

Other gaps, named rather than papered over

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:cargo4881/4881 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 — 59 bundles, 758 passed, 0 failed.
  • The channel was confirmed by a pair, never one arm, per CLOUD-1049's measured lesson.
  • batten ready lint --issue CLOUD-1547 — green.
  • The column census caught key_base unclassified and the schema test caught the generated artifacts going stale; both are gates doing their job, and neither was hand-edited around.

🤖 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 24 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: 13d3aefb-8814-4754-b563-90881c841a44

📥 Commits

Reviewing files that changed from the base of the PR and between fe01a13 and 3e55a8d.

📒 Files selected for processing (13)
  • .claude/rules/policy-modules.md
  • batten.toml
  • crates/batten/src/facts.rs
  • crates/batten/src/git.rs
  • crates/batten/src/lib.rs
  • crates/batten/src/mint.rs
  • crates/batten/src/trust.rs
  • crates/batten/tests/it/code_review_dispatched.rs
  • crates/batten/tests/it/main.rs
  • mise.toml
  • policy/code-review-dispatched.rego
  • 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.

…n 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015H3zMrSGxu4K3fMZu4KFX1
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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015H3zMrSGxu4K3fMZu4KFX1
`/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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015H3zMrSGxu4K3fMZu4KFX1
…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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015H3zMrSGxu4K3fMZu4KFX1
@wenzowski
wenzowski force-pushed the claude/prose-dispatch-gate-verify-u0fnx3 branch from 002cf49 to 3e55a8d Compare September 5, 2026 23:14
@sonarqubecloud

sonarqubecloud Bot commented Sep 5, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@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
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