Skip to content

The refusal ABI's five unreachable consumers, connected - #821

Merged
wenzowski merged 12 commits into
mainfrom
claude/refusal-surface-bundle-5l3ovp
Sep 2, 2026
Merged

wenzowski merged 12 commits into
mainfrom
claude/refusal-surface-bundle-5l3ovp

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

What this is

Phase 2 of the refusal ABI. PR #807 made a mediated refusal one line — a declared
three-word class and its pointers, with the reasoning dereferenced through
batten policy explain. That established the ABI; this connects it. Five
consumers could not read the registry, and each was a separate hole in the same
wall.

consumer what it could not see
a Finding from a Rego module its verdict's remediation — so it was dropped before findings::record
a Finding's owner inferred from a side map keyed on a fingerprint that can collide
the findings store no verb minted a Disposition, so an event-anchored finding could never be settled
the config loader UsageError(String) — no class at all, across ~172 sites
a preset three parallel hardcoded tables, no manifest, no declared vocabulary

Plus the published guide, stale on exactly this surface.

Closes CLOUD-1220
Closes CLOUD-1087
Closes CLOUD-587
Closes CLOUD-1313
Closes CLOUD-1317
Closes CLOUD-969
Closes CLOUD-1181
Closes CLOUD-936

The two acceptance arms that span the bundle

  1. batten enforce on this repository reports zero findings carrying no
    remediation.
    It reported two when this started.
  2. batten policy explain <token> resolves every class any surface can raise
    — module, composer, loader and preset alike — and for a config-fault class it
    does so without loading the config, which is the state that class describes.

Four things measured that changed the work

Each of these was found by running something rather than by reading, and each
corrected a claim that was already written down:

  • explain went dark exactly when a config broke. It loaded the config to
    answer, so a config-fault class could not be looked up while the config was
    malformed — including for vendored classes needing no config at all. That is
    CLOUD-1313's load-bearing constraint and the row was filed without it.
  • A preset's scope mismatch was ALREADY refused. CLOUD-1181 predicted a
    silent dead gate; with the new check disabled and the binary rebuilt, the
    module input-key check already caught it. The manifest's scope refuses
    earlier and names the preset a consumer enabled, and it is a declaration
    which is the row's real gap, and one no refusal can close. Shipping this as
    "closes the silent dead gate" would have been a claim about a channel nobody
    measured.
  • CLOUD-1284's deferral said "five names where there is now one". It is two.
    The module raises from one violation body; a class per conjunct is
    unbuildable, because a job failing any conjunct is not a finding at all. The
    two arms of selects_outside_head are the real split, and they have different
    remedies — a lane told to test head_repository.full_name on an
    issue_comment event is sent to a field its payload does not carry.
  • Two new gates passed on their first run and could not discriminate. The
    README census matched the whole section, so deleting the policy table row —
    the exact defect — still passed. The preset manifest's raised-set read every
    "verdict": it could see, so test fixtures' landing records read as raised
    classes. Both were rewritten after the probe, not before.

Shown able to fail

Every gate here was probed, and three probes changed the gate:

gate probe first result
every_native_class_is_raised_by_production_code inject a declared, unraised Native two earlier probes reddened the wrong test; only the full four-edit injection reaches this one
every_load_time_validator_refuses_under_a_declared_class unwrap markers::validate fails naming markers and the class its reader can no longer reach
the lane guard/lane resolve split collapse arm two onto one class refused at load: a declared row nothing raises
every_declared_path_has_an_id / no_id_is_declared_twice empty one id; duplicate one id each fails naming the row
the README census delete the policy table row passed — rewritten to assert the table row, then failed correctly

Notes for review

  • Three commits carry BREAKING CHANGE footers (UsageError's shape, Finding's
    owner field, SpecDocument/CommandSpec/FlagSpec's new fields). All are
    pre-0.1.0 and mise run semver decided each.
  • batten.toml and .serena/memories/core.md writes each carry a spent
    Admits: block in the commit that makes them.
  • Every derived artifact was regenerated with mise run fix, never hand-edited.
  • The branch is deliberately keyless: branch-name precedence beats the PR body,
    so a branch named for one ticket would move that one and strand the rest.

Deliberately out of scope, said rather than left implicit

  • Twelve parse_ungated validators outside the VALIDATED_AT_LOAD census still
    refuse classless. The census is CLOUD-1313's stated predicate; widening it here
    would be scope the row did not buy.
  • The [[pattern]] inline-regex exemption stays open and is CLOUD-934's. The
    manifest carries patterns as the site its declaration would live in, which
    makes the exemption countable rather than invisible.
  • CLOUD-129's no-network verdict is unchanged and restated in two places, so a
    reader does not take "manifest" as permission to fetch one.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KG7BZ3zkHufrDsC4vrJgR5


Generated by Claude Code

@linear-code

linear-code Bot commented Sep 2, 2026

Copy link
Copy Markdown
CLOUD-1220 A policy-module finding carries no remediation, so every Rego rule's finding is unrecordable — `persisted:false` on a path whose own comment says it can never fire

Why

Measured 2026-08-30 on this tree, ./target/debug/batten enforce -J:

{"rule":"filed-over-own-diff","remediation":null,"path":null,"line":null}
{"rule":"prose-only","remediation":null,"path":null,"line":null}

and on stderr:

batten: 2 finding(s) carry no remediation: persisted:false

Both are kind = "policy" rules backed by a policy/*.rego module, and both have [[verdict]] rows carrying declared routes. V-PROSE-ONLY-DIFF declares two — R-BATCH-IT (kind = "command") and R-OVERRIDE-PROSE-ONLY (kind = "override", precondition "the prose IS the deliverable and cannot wait for the next change to these files"). The registry has the remediation. The Finding does not.

The path that reports it says it cannot happen

crates/batten/src/lib.rs:6730-6746:

// `record` refuses a finding with no remediation as a usage error, which is
// the right answer for a recording verb and the wrong one here: it would let
// one unfixable rule row turn a policy verdict into exit 1. `Rule::validate`
// already refuses such a row, so this partition should never fire — which is
// exactly why it reports a count instead of being an `expect`.
let (recordable, unrecordable): (Vec<_>, Vec<_>) = scan
    .findings.iter().cloned()
    .partition(|finding| finding.remediation.is_some());

The comment is load-bearing and it is wrong in practice: the partition fires on every policy-module finding this tree produces. Whatever Rule::validate refuses, it is not reaching the remediation a module's verdict token supplies — most likely because a typed rule carries its remedy on the row while a policy row's lives in the [[verdict]] registry, and the two are joined somewhere the Finding never sees. Confirm that before fixing; this row states what was measured, not the mechanism.

Why it matters more than one warning line

unrecordable findings are dropped before findings::record. So a policy-module finding never enters the store, and everything keyed off the store is blind to it:

  • the baseline (baseline.rs) — the persisted set of identities that already existed, so a Rego finding can never be baselined
  • dedup and the three-valued disposition model (CLOUD-78, CLOUD-396)
  • the unlanded/sink accounting that reads recorded findings

That is the whole findings subsystem, and it is invisible for exactly the rule kind CLOUD-843's campaign is porting ~132 governed programs onto. Every gate that becomes a policy/*.rego module inherits this.

The verdict still refuses correctlyenforce exits 2 and the class and routes print — so this is not a silently-off gate. What is lost is persistence, and with it the ability to baseline, settle or dedupe anything a module decides.

Not the neighbouring rows

CLOUD-1087 is provenance on Finding inferred from a side map; this is the remediation field being absent altogether for one rule kind. CLOUD-242 is the same shape one table over — a validation whose refusal never fires — and is the precedent for distrusting a "this cannot happen" comment.


Refinement — Ready (join the verdict registry's remedy to the module's finding)

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

  • **Authority boundary (§1). **crates/batten/src/ — wherever a policy rule's finding is constructed, plus Rule::validate and the lib.rs:6735 partition, and crates/batten/tests/. **No **mise-tasks/ **program and no **tests/**/*.bats is edited or added. No [[verdict]] row's content changes: the registry already carries the routes, and this row makes them reach the finding.
  • Computable predicate (§2). A finding produced by a kind = "policy" rule carries the remediation its [[verdict]] token declares, and reaches findings::record like any typed rule's.
  • Determine the mechanism before changing it (§2). Establish whether Rule::validate skips policy rows, or validates them against a remedy field the module path never populates. The comment at lib.rs:6730 asserts a guarantee that does not hold; the fix is to make it hold or to correct the comment, and which one depends on that answer.
  • Deliberately not in scope (§2). Changing what prose-only or filed-over-own-diff decide — both refused correctly here. Changing any [[verdict]] row. The transcript-decode failure observed in the same run, which is CLOUD-1188's.
  • **Effect (§3). **read — the change is in how a finding is constructed and validated; no new IO.
  • Output and exit (§5). Unchanged: enforce still exits 2 on these findings and still prints class and routes. What changes is that the findings persist. Pointer-only as today.
  • **Commit / bump (§6). **fix(policy) — patch until 0.1.0.
  • Test obligation (§7). Over the compiled binary. Shown able to fail per CLOUD-418, and the discriminating case is the one this row was found by: **a fixture whose only violation comes from a **policy/*.rego module is recorded in the store, with its verdict's routes attached — red today. Plus: a typed rule's finding still records (no regression); and **the **unrecordable partition reports zero on this repository's own tree, which is the end-to-end assertion that the guarantee lib.rs:6730 claims is real.
  • Blockers (§8). None. relatedTo CLOUD-843 (the campaign whose target kind this is), CLOUD-242 (a validation that never fires, same shape), CLOUD-1087 (the adjacent Finding field defect), CLOUD-1050 (the refusal contract these routes come from).

Acceptance

  • A policy-module finding carries its verdict's remediation and is recorded.
  • enforce on this repository reports zero findings carrying no remediation.
  • lib.rs:6730's comment is either true or corrected — a guarantee that does not hold is worse than none, which is CLOUD-242's lesson.
  • A module-only fixture's finding is baselineable, asserted rather than assumed.

Found while reading why batten-check refused PR #751: the refusal was correct (prose-only on a genuinely prose-only branch), and the persisted:false line beside it was not.

CLOUD-1087 A finding's owner is inferred from a side map keyed on its fingerprint, so sink attribution rests on an identity assumption instead of stating it: put provenance on `Finding` and delete `Scan::attributed`

Why

CLOUD-1083 gave Scan an attributed map so requested_sinks can answer "which findings are this row's" for a policy row, whose findings report the PREDICATE's id rather than the row's (CLOUD-832). That fixed a real vacuous pass — the row was recording count = 0 and the sha256 of the empty string — and it landed in #721.

But the map is a side channel keyed on a value that is not unique to policy findings, and review of #721 drew the same objection twice in two different shapes:

  1. Keyed on the predicate id: a module may declare a predicate whose id is also a Rule::id. The two namespaces are separate and nothing checks one against the other, so the map could hand an ordinary row's findings to the policy row. Fixed in CLOUD-1083 — a policy row's sink counts the findings its own module reported #721 by re-keying.
  2. Keyed on the scope fingerprint: crates/batten/src/rules.rs:7634 builds a command finding as scope_fingerprint(&rule.id, glob) and policy_rule builds scope_fingerprint(id, &fingerprint_of(violation)). A command rule whose id equals the predicate id and whose glob equals the verdict token produces the same fingerprint. Not fixed in CLOUD-1083 — a policy row's sink counts the findings its own module reported #721 — see below.

Two rounds converging on the same shape is the finding. Re-keying a third time answers the instance, not the class. What both rounds are really saying is that the owner is being inferred from a value that means something else, when it could simply be recorded.

Why #721 did not carry the second one

Stated on the thread (#721, discussion_r3878102339) rather than silently dropped, and the reasoning is worth keeping:

dedup_scoped drops one of two findings sharing a FindingKind::Scope fingerprint **before **requested_sinks runs. So in the colliding construction the engine has already lost a finding — the count is wrong whichever row it lands on, and every baseline entry, waiver match and store identity keyed on that fingerprint is wrong with it. attributed is keyed on exactly the value dedup_scoped, baseline.rs and waiver::apply already treat as a finding's identity, so it is no weaker than what the engine assumes everywhere else. Hardening sink attribution alone would fix one symptom of a collision that already corrupts dedup, and leave the cause.

That makes it a real row rather than a real blocker — and a row about identity, not about sinks.

Shape of the fix

Finding carries its own owner: owner: Option<String>, None meaning "this finding's rule id is its owner", which is true for every kind but policy. policy_rule sets it beside the predicate id it already writes. requested_sinks reads f.owner.as_deref().unwrap_or(&f.rule).

Scan::attributed is then deleted, not re-keyed: nothing is inferred, no key can collide, and the question "which row does this finding belong to" is answered by the finding rather than by a lookup that has to be right about identity. Finding::rule is untouched — it keeps the predicate id, because reporting and waiver::apply are asking a different question and that answer is already correct.

Cost, measured rather than guessed: 15 Finding { .. } construction sites across 8 files in crates/batten/src (rules.rs 7, baseline.rs 2, and one each in defects.rs, budget.rs, secrets.rs, findings.rs, waiver.rs, design.rs), plus every test that constructs one. Mechanical, but wide — which is precisely why it did not belong in #721, whose diff is four files.


Refinement — Ready

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

  • Source of truth (§1). The finding itself. A finding's owner becomes a field it carries, never a fact reconstructed from a second register keyed on something that means something else.
  • Computable predicate (§2). With Scan::attributed deleted, a policy row carrying produces still records the count and digest of its module's violations, and no construction of rule ids, globs, predicate ids or verdict tokens can move a finding to a row that did not produce it. Today the second half holds only because fingerprints do not collide.
  • **Effect (§3). **read. No new acquisition and no spawn — the value is already in hand at every construction site.
  • **Generated artifacts (§4). **schema/batten.schema.json and schema/batten.local.schema.json regenerate with mise run fix if the shape reaches Config; never merge a generated diff.
  • Output / exit (§5). Unchanged. owner is engine-internal and reaches no pointer, so rule 4 holds without a new decision; the record stays a digest and a count.
  • **Commit / bump (§6). **refactor(rules)!Finding is public and gains a field, so a struct literal naming every field stops compiling. mise run semver will refuse it as constructible_struct_adds_field until the commit says so, which is how CLOUD-1083 — a policy row's sink counts the findings its own module reported #721's own break was caught.
  • Test obligation (§7). Shown able to fail (CLOUD-418), observed not asserted: (a) the colliding construction review named — a command rule whose id equals a predicate id and whose glob equals the verdict token — puts each finding on its own row, red before this change; (b) a_policy_rows_sink_counts_the_violations_its_module_reported and a_predicate_named_after_a_row_leaves_that_rows_sink_alone stay green, so the refactor is shown to conserve what CLOUD-1083 — a policy row's sink counts the findings its own module reported #721 established; (c) a forbid row's sink is unchanged.
  • **Blockers (§8). **CLOUD-1083, landed in CLOUD-1083 — a policy row's sink counts the findings its own module reported #721 — this replaces the mechanism that row added rather than competing with it.

Acceptance

  • Scan::attributed no longer exists.
  • No pair of rule ids, globs, predicate ids or verdict tokens can put a finding on a row that did not produce it — asserted over the construction review named, not argued.
  • Finding::rule still carries the predicate id; waiver matching and every reported pointer are byte-identical.
  • CLOUD-1083's two arms still pass unchanged.
  • Every arm above observed red or green as stated.

CLOUD-587 An event-anchored finding can never be settled: the store models a disposition but no verb mints one

Why

CLOUD-78 gives every finding a three-valued disposition (acted /
rejected-by-design / rejected-wrong), journal::merge folds a disposition
entry from a shard, and FindingRecord::merge_disposition joins two by
precedence. stop.rs reads it: deny-stop ⇔ at-risk work ∨ an undischarged denial, where undischarged means disposition == None.

Nothing mints one. Grepping the surface, state offers adopt / record /
migrate / list, and no other verb writes a Disposition. The only producers
of a disposition entry anywhere in the tree are unit tests in journal.rs. So
the field is read by a gate, joined by a merge rule, and persisted by a journal
— and no caller can ever set it.

What made this bite. CLOUD-98 landed bypass.rs, whose finding anchors to an
immutable transcript event: a bypass that happened, happened, so re-evaluation
keeps finding it and the observation never resolves to zero. That issue's stated
assumption 1 says such a finding "clears by disposition in the store, not by the
condition vanishing" — which is correct as a design and today unreachable as a
mechanism. The finding is raised, it is emittable, and there is no way to answer
it.

CLOUD-97's finding is unaffected: it is state-anchored, so landing the work
clears it with no acknowledgement. The gap is specific to the event-anchored
class, which is now populated.

Rejected alternative

Making the bypass finding self-clear instead — for example by dropping it once
the transcript changes. Rejected: that reads a new session's silence as evidence
about an old session's bypass, which is the fail-open reading
Observation::NotObserved exists to prevent. The finding is right to persist;
what is missing is the answer channel.

Definition of done

  • A surface that records a Disposition against a stored finding identity,
    through journal's existing append (so the concurrent path stays lock-free and
    the merge rule stays the one join).
  • stop.rs's undischarged-denial predicate and CLOUD-79's drain both observe it
    without either re-typing what "settled" means.
  • Pointer-only: the identity and the disposition token, never the finding's
    content.

Acceptance

  • A raised event-anchored finding can be answered, and after answering it is no
    longer undischarged.
  • Two worktrees answering the same finding differently converge by
    Disposition::merge, in either order.
  • No new blocking exit: recording a disposition is bookkeeping, never a verdict.

Notes for whoever picks this up

The verb needs a surface.rs row and a cli.rs arm — both were owned by an
open PR when CLOUD-98 landed, which is why that ticket filed this rather than
absorbing it. Check whether a raise/settle pair belongs under state or under a
new noun before adding the row; house style §2 owns that call.

Probe plan

  • Unresolved decision. Whether the answer surface is a state raise/settle pair or a new noun.
  • Probe. Inspect the existing state surface (adopt / record / migrate / list), surface.rs, cli.rs, and the disposition producers in journal.rs; exercise the existing journal append and FindingRecord::merge_disposition test path with the bypass finding identity from CLOUD-98.
  • Record. Which surface can name the stored identity without retyping settlement, and whether opposite dispositions converge in either merge order.
  • Ready / next step. Ready when one surface is selected and the existing append/merge path demonstrably settles the event-anchored finding for both stop.rs and the drain; then add its surface.rs row and cli.rs arm.

Refinement — Ready (the answer channel is a verb under the existing noun, not a second entry point)

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

The surface question is answered: a settle verb under state, not a new noun. The findings store already has exactly one noun, and record is already a per-observation write into the journal — so a disposition is the same kind of act against the same object. A new noun would give one store two entry points, and the cost of that is paid forever by every reader trying to work out which one owns settlement. The cost of the chosen option is stated too: state's verb list grows by one, which is the narrower widening of the two.

  • **Authority boundary (§1). **journal's existing append is the one writer, and Disposition::merge stays the one join — the new verb adds a caller, never a second convergence rule. stop.rs's undischarged-denial predicate and CLOUD-79's drain both observe the field and neither re-types what "settled" means; that re-typing is the failure this row exists to avoid, since two definitions of settled is worse than none.
  • Computable predicate (§2). A disposition is recorded against a stored finding identity, and disposition == None stops holding for that finding. Decidable from the store alone. The convergence property is equally decidable and is the one that matters under concurrency: two worktrees answering the same finding differently reach the same result under Disposition::merge in either order, which is what keeps the lock-free append path honest.
  • **Effect (§3). **write, declared on the command surface rather than smuggled into a read verb — the verb appends to the journal. It is the append that already exists, so no new write path and no new lock is introduced.
  • Output & exit (§5). Pointer-only: the finding identity and the disposition token, never the finding's content — which matters more here than usual, since the event-anchored findings this settles are drawn from a transcript. No new blocking exit: recording a disposition is bookkeeping, never a verdict, so the verb exits 0 on success and follows the one table for the ordinary failures.
  • **Commit / bump (§6). **feat(state)patch until 0.1.0, since below that release-plz bumps the patch whatever the type says; declaring minor states something the tool will not produce. Not ! for the consumer surface: a new verb is additive, no existing verb's behaviour, exit code or output shape moves, and a consumer who never calls it sees byte-identical runs. mise run semver decides the library half — the disposition-writing path becoming reachable is a pub API change and that must be asked, not assumed.
  • Test obligation (§7). Over the compiled binary, shown able to fail per CLOUD-418. Red today by construction, and it is the whole point: a raised event-anchored finding — CLOUD-98's bypass finding is the live instance — must be answerable, and after answering must no longer be undischarged for stop.rs. The discriminating case is order-independence: two worktrees settling the same identity differently must converge to the same record whichever order the shards merge in, since an implementation that simply last-writer-wins passes the single-worktree case and silently loses one answer. And the direction a careless fix breaks: settling must not make a state-anchored finding clear, because those clear by the condition vanishing and answering one would be a bypass of the work itself.
  • Blockers (§8). None. This row blocks CLOUD-98, whose finding is raised and unanswerable until it lands. relatedTo CLOUD-78 (the three-valued store this makes reachable), CLOUD-79 (the drain that must observe the same field), CLOUD-97 (the state-anchored finding deliberately unaffected, and the contrast that scopes this) and CLOUD-418.

CLOUD-1313 Twelve load-time config validators refuse with a bare String, so no config fault carries a declared class — CLOUD-1050's defect, one surface over

Why

config::parse_ungated wires 12 table validators — verbs, patterns, verdicts, redirects, markers, rules, exec_patterns, provisions, waivers, facts, mints, and the remedy resolver CLOUD-1189 added. Every one refuses by returning UsageError::raise(String).

A String is exactly what CLOUD-1050 removed from violation, in those words: with msg as a String, "a refusal naming no remedy, naming a task that does not exist, or offering an override with no precondition were all expressible and none checkable". [[verdict]] fixed that for the policy surface. The config-load surface still has it, across twelve refusal sites, and nothing gates any of them.

So a config fault is the one refusal class in this engine that batten policy explain cannot resolve, that carries no declared route, and that no gate holds to the [[verdict]] registry.

Found by hitting the wall it puts up

CLOUD-1189's §5 required its gate to ship a [[verdict]] row — "a gate about unresolvable remedies that itself raised an unregistered token would be the joke writing itself". It shipped without one, and the reason is structural rather than an omission: a load-time refusal is a UsageError, not a raised class, and **a **[[verdict]] row nothing raises fails the load. Declaring the row would have broken every consumer's config on the next parse.

That is not CLOUD-1189's problem to solve. It is this one's, and it is why filing it separately is the right call rather than a punt: the fix is a shared ABI for twelve sites, not a thirteenth special case.

The tension to resolve, stated because it is the whole design question

The registry's two directions are what make it honest, and they are what block the naive fix:

  • check_verdicts_are_declared — a raised token no row declares fails the load.
  • check_registry_is_exhausted — a declared row nothing raises fails the load.

A config-fault class is raised by the loader, before and outside the policy-module evaluation those two checks run over. So either the exhaustion check learns about a second raiser, or config-fault classes live in a namespace it does not walk. Picking between those is the work.

Refinement — Ready (a config fault names a declared class)

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

  • **Source of truth (§1). **crates/batten/src/config.rs's parse_ungated and its VALIDATED_AT_LOAD census; crates/batten/src/error.rs's UsageError; crates/batten/src/verdict.rs's Native/VENDORED and policy.rs's two registry checks. Tier in crates/batten/tests/it/.
  • Computable predicate (§2). Every refusal a VALIDATED_AT_LOAD validator can return names a class the [[verdict]] registry declares. VALIDATED_AT_LOAD already exists and already fails when a call is deleted, so the census the gate needs is landed — this adds a column to it rather than a second list.
  • The registry direction that must be decided first (§2). Whether config-fault classes are Native (compiled in, exhaustiveness-checked by the wildcard-free match verdict.rs already carries) or a declared namespace the exhaustion check skips. Native is the candidate — it is the existing home for a class the engine raises rather than a module, it is already compile-checked, and it needs no new escape in either registry check. Confirm or reject with the two checks in hand, not from this paragraph.
  • Deliberately not in scope (§2). Rewording any existing refusal string. Changing any exit code — a config fault is exit 1 and stays exit 1 (house style §6–§7, non-negotiable rule 5). Moving any predicate into config.
  • **Effect (§3). **read.
  • Output and exit (§5). The emitted line becomes <class> <pointer…> like every other refusal (CLOUD-1286's shape), with the prose dereferenced through batten policy explain. Pointer-only: the table key and the offending id, never the row's content.
  • **Commit / bump (§6). **fix(config) — patch.
  • Test obligation (§7). Over the compiled binary, never with input as. Shown able to fail per CLOUD-418: (a) a validator returning an undeclared class fails the gate; (b) the anti-vacuity mirror — the committed config still loads, and every one of the 12 validators' refusals resolves through batten policy explain; (c) each validator's refusal is reachable at all, since a class no site raises fails the load and a site no test reaches would be found only by a consumer.
  • Three constraints this row did not name, found by surveying the surface before building it (§2). Each changes the shape of the fix rather than its scope:
    1. A class per TABLE, not per site. There are ~172 UsageError::raise sites across the twelve validator regions and only ~30 sit at a top-level validate; the rest are in per-entry helpers (validate_shape, Rule::validate_*). A class per site is unbuildable and a class per table is twelve Native variants, with the pointer carrying which row and key failed.
    2. explain LOADS THE CONFIG TO ANSWER, through resolve::resolve at lib.rs:3221. So a malformed config makes batten policy explain unusable for exactly the class that malformed config raises — the remedy is unreachable at the only moment it is needed. A config-fault class must therefore resolve from verdict::vendored() alone, with no config load. This is the load-bearing constraint and the row was written without it.
    3. Nothing proves a Native **is ever raised. **check_registry_is_exhausted *exempts *native_tokens() rather than proving them, so a declared-and-never-raised loader class passes silently — the dead-gate shape this repository exists to refuse. The gate that closes it ships with the variants, or the twelve new classes are twelve new ways to be vacuous.
  • Blockers (§8). None.

Acceptance

  • No VALIDATED_AT_LOAD validator returns a refusal that names no declared class.
  • batten policy explain resolves every class a config fault can raise.
  • Exit codes and the committed config's behaviour are unchanged; the diff is the class and its plumbing.

Provenance. Filed after PR #807 merged and from a clean tree — filed-over-own-diff's third declared route — because during that PR crates/batten/src/config.rs was open in the diff, and filing it then would have been the punt that gate exists to price.

CLOUD-1317 `lane guard missing` does not say HOW a job became a subject, so its two remedies name different fields and the refusal names neither

Why

CLOUD-1284 renamed V-PRIVILEGED-LANE-UNTESTED-ORIGIN to lane guard missing and recorded the cost in its own words: "the name no longer distinguishes which of the five conditions fired… Splitting stays available and is strictly a follow-up: it would be five names where there is now one." This is that follow-up, and the count is wrong — it is two, not five.

The corrected measurement, which is why this row exists rather than the one CLOUD-1284 described

policy/privileged-lane.rego raises lane guard missing from exactly ONE violation body. There are not five conditions to name. What the module actually carries is is_subject's three conjuncts, and a job failing any one of them is not a finding at all — so "a class per conjunct" is unbuildable by construction, and a mutation over a conjunct another conjunct already excludes survives. The module's own header records that trap, measured twice: perf.yml was named as what the third conjunct spares and is not, because the first conjunct already excludes it.

The one distinction a reader can ACT on is selects_outside_head's two arms, and it is load-bearing because the two have different remedies:

how the job became a subject what the reader must test
a trigger carries the head (pull_request, pull_request_target, workflow_run) github.event.*.head_repository.full_name
the job resolves one through the pulls API (/pulls), on any trigger .head.repo.full_name on the resolved PR

tests_origin already encodes exactly that pair — two field names, one clause — so the split names a distinction the module has held all along and the class has never carried. A cron-driven or issue_comment lane gets told to test a field its event does not have.

Nothing is broken today. Both remedies live in the class prose and batten policy explain lane guard missing is the one hop to them. This is vocabulary work under CLOUD-122's dereference, not a defect fix, and the row must not be read as one.

Refinement — Ready (a refusal names which head it could not see tested)

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

  • **Source of truth (§1). **policy/privileged-lane.rego's violation, is_subject and selects_outside_head; the [[verdict]] rows in batten.toml; the compiled tier crates/batten/tests/it/privileged_lane.rs.
  • Computable predicate (§2). A subject job with no origin test raises lane guard missing where a trigger carries the head, and a second declared class where the head is resolved through the pulls API. Both are three words on the landed vocabulary; arity stays fixed at 3.
  • Deliberately not in scope (§2). Judging WHICH comparison a lane makes — the module's header settles that under non-negotiable rule 3 and this row does not reopen it. Adding a conjunct to is_subject. Arity 4.
  • **The count is a finding, not a specialization (§2). **CLOUD-1284's clause says five. Five is not buildable and this row says so with the module in hand; a reader comparing the two must find the correction here rather than infer it.
  • **Effect (§3). **read.
  • Output and exit (§5). Unchanged — a deny row, exit 2, <class> <pointer…>.
  • **Commit / bump (§6). **fix(verdict) — patch.
  • Test obligation (§7). Both tiers per .claude/rules/policy-modules.md. Shown able to fail per CLOUD-418: the existing test_a_scheduled_resolver_of_pulls_is_a_subject must move to the new class and the trigger-carried cases must stay on lane guard missing, so a single-class implementation reddens. The anti-vacuity arm is the registry's own: a declared row nothing raises fails the load, so both classes must be reachable or the config does not load.
  • **The mutation must discriminate (§7). **CLOUD-931's lesson applies directly: choose one that is not already excluded by another conjunct, and expect the first choice to survive.
  • Blockers (§8). None.

Acceptance

  • Two declared classes, each raised by exactly one arm of selects_outside_head, each resolving through batten policy explain to the field its reader must test.
  • No predicate changes meaning: the same jobs are subjects, and the same jobs are findings.
  • CLOUD-1284's "five" is corrected in this body with the measurement behind it.

Provenance. The deferral CLOUD-1284 recorded and did not file. Filed while batten.toml is open in the same branch, so filed-over-own-diff's second route applies: the PR body closes this row.

CLOUD-969 The emitted spec carries no version and no stable ids, so a third party reading it has nothing to pin against — add `spec_version` and a stable id per command path

Why

batten spec is this repository's answer to jdx's usageCLOUD-114 was literally titled "emit the usage spec at runtime (batten usage)" before being folded into CLOUD-27/CLOUD-20, and CLOUD-244 dropped the KDL encoding while keeping the concept. It is the one machine contract a third party reads.

**Measured against **main on 2026-08-30, it carries no version and no identity. spec.rs:131-141:

pub struct SpecDocument {
    #[serde(flatten)]
    pub command: CommandSpec,
    pub read_only_allowlist: Vec<String>,
}

Two keys. CommandSpec (spec.rs:36-48) carries path, about, effect, flags, subcommands. There is **no **spec_version, and the only identity a row has is its path — the human-facing spelling, which is exactly the thing the surface-repair work (CLOUD-1179, CLOUD-1180) is about to change.

So a consumer that reads the spec today has:

  • nothing to pin against. Every change is indistinguishable from every other change; there is no way to say "I understand spec shape N" and no way for the binary to say "I emit shape N".
  • no stable handle. read_only_allowlist is a list of paths, and the whole point of §5's derivation is that a consumer honours it. Rename payload field and every consumer's pinned allowlist silently stops matching — in the direction where a command it thought was read-only is now unrecognised, or worse, a path it still trusts no longer means what it did.

This is the ordinary versioning obligation that CLOUD-1179's window makes cheap: §2 says "no back-compatibility surface and no deprecation aliases" pre-0.1.0. **After **0.1.0 an unversioned contract has to be versioned with consumers already on it.

Why this is now its own row

This row was previously scoped as "Preset package and capability discovery" — one issue covering both the spec contract and the canonical preset-package manifest. Those are different sizes and different blockers: the spec half is a struct with two new fields and its pinning test, unblocked and landable today; the manifest half is a design against policy.rs's three parallel hardcoded tables and blocks CLOUD-970. Split so the small half is not held behind the large one. The manifest half is CLOUD-1181.

What "stable id" has to mean here, or it buys nothing

**2026-08-30 — two more fields belong in this row, found while pressure-testing the surface redesign. **CommandSpec is emitted with five fields and the surface declares more than it publishes. effect already ships — that is correct as the Why states, and is not the gap. The gaps are:

  • data_channel **never leaves the binary. **CommandDecl declares data_channel: bool (surface.rs:408), and CommandSpec has no such field; walk() and describe() never read it. It is a build-time-only column feeding every_data_emitting_verb_declares_the_json_flag (surface.rs:3045) and is_noun() (surface.rs:2723). A consumer of the emitted spec can only infer the data channel by scanning each row's flags for one named json.
  • positional **is not expressible at all. **FlagSpec (spec.rs:18-30) has no positional field, so a positional argument emits as long: null, takes_value: trueindistinguishable from a flag that lost its long form. A consumer reconstructing an invocation from the spec emits a broken command line and gets no signal. This becomes acute under CLOUD-1184's grammar, where check's rule selector moves from --rule into the object position: the change would be invisible to every spec consumer.

Both are the same defect as this row's own: the published contract omits what the surface declares, so a versioned spec would be versioning an incomplete document. They belong here rather than in a separate row precisely because spec_version should not ship first and then immediately need to move.

This also sharpens the third acceptance bullet. Under CLOUD-1184 read_only_allowlist becomes derived from the verb rather than filtered from the effect table — which is a stronger answer than either option that bullet offers, and removes the "keyed on a spelling that is about to change" hazard by construction.

An id that is derived from the path is the path with extra steps and re-breaks on the same rename. The id has to be **declared on the **CommandDecl **row in **surface.rs — the one command authority (CLOUD-19's directive; surface.rs:387-411) — so a rename changes path and leaves id alone, and SURFACE remains the only place a command is described.

That is also what makes the id checkable: spec::tests::the_emitted_surface_is_exactly_the_committed_row_set already pins the row set literally, so an id added to a row and an id changed on a row are both visible in that same diff.


Refinement — Ready (version and identify the emitted spec)

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

  • Authority boundary (§1). crates/batten/src/spec.rs and crates/batten/src/surface.rs, plus the derived artifacts mise run fix regenerates. One authority: the id is a field on CommandDecl, never a second table keyed by path. No mise-tasks/ program and no tests/**/*.bats is added or edited.
  • Computable predicate (§2). batten spec --format json emits a spec_version at the document root, and every command row carries a stable id declared on its SURFACE row. Two invariants hold in cargo test: every declared path has an id, and no id is declared twice — the same shape as every_path_is_declared_once, over the new field.
  • Deliberately not in scope (§2). Capability ids for anything other than a command path — no preset ids, no policy-module ids, no provider capability vocabulary; that is CLOUD-1181's manifest and CLOUD-970's resolver. Any back-compatibility machinery: pre-0.1.0 there is nothing to be compatible with, and adding a shim now is the deprecation surface §2 refuses. Deciding the version's bump policy for 0.1.0 and after — state the rule, do not build enforcement for it.
  • Effect (§3). read. spec is already a read leaf and stays one; this adds fields to what it emits and changes no effect annotation.
  • Output and exit (§5–§6). Byte-stable JSON across two runs of the same binary; spec_version is a literal in the source, never derived from a clock, an environment variable or the crate version at runtime — a version that moves with CARGO_PKG_VERSION says "the binary changed", which is what the tag already says, and tells a consumer nothing about the shape. Exit follows the 0/1/2/3 table unchanged.
  • Commit / bump (§6). feat(spec)patch until 0.1.0 on the consumer surface. The library half is mise run semver's: SpecDocument and CommandSpec are pub, so adding public fields is a semver event that task decides, and this row records its verdict rather than asserting one.
  • Test obligation (§7). Over the compiled binary in crates/batten/tests/, plus the two spec.rs unit invariants. Shown able to fail per CLOUD-418, three observed: (a) a SURFACE row with no id fails the completeness assertion; (b) two rows sharing an id fails the uniqueness assertion; (c) spec --format json run twice is byte-identical, and the emitted document contains spec_version. Add each id to the_emitted_surface_is_exactly_the_committed_row_set's literal list with a comment, per that test's own convention.
  • Blockers (§8). None — this is the unblocked half of the split and needs no manifest. relatedTo CLOUD-1181 (the preset manifest, formerly this row's second half), CLOUD-970 (which consumes both), CLOUD-1180 (the agent subtree, whose discovery leaf reads this document), CLOUD-1179 (the §2/binary drift, which is why path is not a durable id), CLOUD-973 (extending the emitted surface), CLOUD-244 (which dropped the KDL encoding and set the "SURFACE is authoritative" rule), CLOUD-418.

Acceptance

  • batten spec --format json emits spec_version, and its value is a source literal with a stated rule for when it moves.
  • Every SURFACE row declares an id; the completeness and uniqueness assertions both exist and were each shown to fail.
  • read_only_allowlist is reconciled with the id: either it emits ids alongside paths, or the row records why paths alone are sufficient for the allowlist specifically. Leaving §5's safety-critical derivation keyed on a spelling that is about to change is the failure this row exists to prevent.
  • mise run fix then mise run verify regenerates every derived artifact to a zero byte diff.

Split out of the former "Preset package and capability discovery" while recovering the batten agent subtree (CLOUD-1180): the manifest half blocks CLOUD-970 and needs a design, and the spec half is a struct field that was holding behind it.

CLOUD-1181 A preset is three parallel hardcoded tables and no declaration, so a third party cannot ship one at all — give a preset one manifest that carries its identity, scope, modules and verdict vocabulary

Why

The goal is an ecosystem: publish Button's presets, and make it easy for anyone else to publish theirs. Measured against main on 2026-08-30, a preset is not a thing that can be published — it is three unrelated const tables in the binary, and nothing declares a preset.

where what it holds shape
policy.rs:254 PRESETS four names → six (pointer, include_str!) pairs &[(&str, &[(&str, &str)])]
verdict.rs:686 VENDORED the presets' verdict tokens, in the same table as the native ones, under a // ── vendored presets ── comment &[VendoredVerdict]
policy::check_no_inline_regex the [[pattern]] refusal, exempted for presets (CLOUD-934) a branch

Three consequences, each measurable:

  • A preset carries no identity beyond its name. No version, no scope, no declared input keys, no compatibility range, no owner. preset_names() returns ["commit-hygiene", "trunk-based", "shell-hygiene", "pinned-toolchain"] and that is the whole of what a consumer can know.
  • **Scope is invisible where it is load-bearing. **shell-hygiene is tree-scoped and trunk-based is mediated-call-scoped, and .claude/rules/policy-modules.md records the exact defect this invites: "a key from the wrong one is a silent dead gate… a dead gate and a clean tree are byte-identical on the decision surface." A consumer enabling a preset today cannot see which surface it decides.
  • **A third-party preset cannot raise a verdict. **VENDORED is a const compiled into this binary. A module raising a token no row declares fails to load — correctly — so an out-of-tree preset has no route to declare its own vocabulary. The exemptions in the other two tables are the same shape: .claude/rules/policy-modules.md already calls the pattern exemption "a hole rather than a design — a preset ships to every consumer while a consumer module reaches one, so the exemption is inverted."

That inversion is the through-line. Every place a preset is exempted from a rule a consumer module obeys, it is exempted because there is nowhere to write the declaration, not because the rule does not apply.

Why the manifest, and not more tables

The manifest is the thing that turns four exemptions into four declarations. The prior art is on the pinned toolchain already: mise's registry is a curated short-name → package mapping, and its backends resolve without any provider being a binary dependency. That is the shape, and adopting it is non-negotiable scope rule's own instruction rather than an expansion of the core.

Concretely, one declaration per preset carrying: identity (name, version), the scope its modules decide, the input.* keys they read, the modules themselves, the verdict tokens they raise with their glosses and remedies, and the [[pattern]] rows they depend on. Derived from it: preset_names(), the verdict rows for presets, the pattern set, the published schema, and the discovery projection CLOUD-969's versioned spec carries.

Scope boundary — this row does not open the network

CLOUD-129 rejected remote policy fetch, and policy.rs:228-236 states that verdict "is intact": include_str! at build time, *"no network here, no registry and no trust-on-first-use." *This row does not touch that. The manifest is a declaration format; whether a preset can arrive from outside the binary is CLOUD-970's question and needs CLOUD-129 amended or explicitly bounded there. A manifest is what makes that question askable — today a third-party preset has no shape to arrive in, so the trust question cannot even be posed.


Refinement — Ready (declare a preset, once, instead of exempting it three times)

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

  • **Authority boundary (§1). **crates/batten/src/policy.rs, crates/batten/src/verdict.rs, and the preset sources under crates/batten/src/policy/presets/**, plus the artifacts mise run fix regenerates. One authority per fact: after this row, preset_names() and the presets' half of VENDORED are both derived from the manifest, not maintained beside it. No mise-tasks/ program and no tests/**/*.bats is added or edited.
  • Computable predicate (§2). Every vendored preset has exactly one manifest declaring name, version, scope, modules, the verdict tokens it raises, and the patterns it uses; and preset_names() plus the presets' verdict rows are derived from those manifests rather than declared a second time. A preset raising a token its own manifest does not declare fails to load — the same refusal a consumer module already gets, now reachable because there is somewhere to declare it.
  • Non-negotiable rule 1 binds the manifest exactly as it binds the preset sources. A manifest field may describe a practice and may never name a path, a task, a tracker key or an entity. presets_are_inside_the_rule_one_glob already covers crates/**; assert the manifests are inside that glob rather than assuming it.
  • Deliberately not in scope (§2). Loading a preset from outside the binary — no network, no registry, no filesystem discovery, no trust-on-first-use; CLOUD-129 stands and CLOUD-970 owns any amendment. Signatures, digests, lockfiles and resolver output — CLOUD-970. Closing the [[pattern]] exemption itself — that is CLOUD-934's row, and this one supplies the place its declaration would live rather than doing its work. Adding or removing any preset.
  • Effect (§3). Nothing new is dispatched. The manifest is read at load; the surface is unchanged.
  • Output and exit (§5–§6). Pointer-only: a refusal names the preset, the token or pattern id, and which declaration is missing — never a module's contents. Exit follows the 0/1/2/3 table; a malformed manifest is a load failure, and the row states which of 1/3 it is and why, rather than leaving it to the implementation.
  • **Commit / bump (§6). **feat(preset)patch until 0.1.0 on the consumer surface. preset_names() is pub, so the library half is mise run semver's verdict and the row records it.
  • Test obligation (§7). Over the compiled binary in crates/batten/tests/, plus the module-level test_ tier for anything a .rego change touches — both tiers, per .claude/rules/policy-modules.md, and the second is the one that proves the engine builds what the manifest claims. Shown able to fail per CLOUD-418, four observed: (a) a preset whose manifest omits a token its module raises fails to load, naming both; (b) a manifest declaring a token no module raises fails to load — the anti-vacuity mirror the [[verdict]] registry already enforces in-tree; (c) a manifest declaring the wrong scope is reported rather than producing an empty violation set, which is the silent-dead-gate class stated above; (d) preset_names() and the presets' verdict rows both change when a manifest changes, and neither can be changed alone.
  • Blockers (§8). Blocked by CLOUD-969 — the manifest's identity and version fields are the same versioning decision, and settling it twice is how the two disagree. Blocks CLOUD-970, which resolves manifests it cannot consume until they exist. relatedTo CLOUD-934 (the inverted pattern exemption this gives a home to), CLOUD-836 (which shipped the vendored preset mechanism), CLOUD-129 (the remote-fetch rejection this row deliberately does not touch), CLOUD-1180 (the agent subtree whose discovery reads this), CLOUD-1050 (the {rule, verdict, subjects} shape and why msg is not a String), CLOUD-418.

Acceptance

  • One manifest per vendored preset, and preset_names() plus the presets' verdict rows are derived from them — a grep for a second hardcoded preset list returns the manifests and nothing else.
  • Every preset declares its scope, and a scope mismatch is a load-time refusal rather than an empty result set.
  • The four exemptions are enumerated with a disposition each: closed by this row, given a declaration site by this row, or explicitly still open with the row that owns it named. An exemption left unlisted is the failure this row exists to end.
  • CLOUD-129's verdict is restated as unchanged in policy.rs's doc comment, with the manifest's boundary stated beside it — so the next reader does not read a manifest as permission to fetch one.
  • mise run fix then mise run verify regenerates every derived artifact to a zero byte diff.

Split out of CLOUD-969 while recovering the batten agent subtree (CLOUD-1180): that row covered the spec contract and the preset manifest together, and the manifest half is the one that blocks CLOUD-970.

CLOUD-936 The published extension guide is stale by a whole rule kind: README names three surfaces and omits `policy`, and neither starter config mentions a preset

Why

README.md:236-250 is titled "Extending Batten: three surfaces, and which to reach for." Measured at 170c7c4, the three it names are command rules, exec output predicates, and fail_on_warning. It does not mention the policy rule kind, rego modules, bundles, or presets — a whole rule kind and a whole distribution mechanism, both shipped.

grep over README for rego|preset|policy module|bundle: no matches. README.md:535-544's Roadmap is six phase names and references neither.

Same gap in the config a new repository actually starts from. crates/batten/src/starter.toml and batten.example.toml carry no kind = "policy" row, no preset =, no module =starter.toml mentions "policy" only in prose about raise-only overrides. README.md:118-122 describes batten init as "the first command a new repository runs", so the introduction to Batten omits the four preset modules the binary ships.

Why this is a defect rather than a docs backlog item

CLOUD-836 vendored the presets on an explicit argument: "A consumer adopting Batten got an empty batten.toml and had to author every predicate from scratch, which is the anomaly rather than the discipline — Conftest ships OCI bundles, Semgrep p/default, ESLint's recommended, Clippy its lint groups."

That argument is unrealised while the front door does not mention them. A consumer following the README authors command rules from scratch, which is the state CLOUD-836 was filed to end. The feature exists and is unreachable by the documented path — which is a stronger version of the "shipped surface nobody uses is a surface nobody tested" reasoning batten.toml's own [[hook.handler]] comment gives for shipping a worked example.

Distinct from the adjacent rows, checked

  • CLOUD-715 retires batten.example.toml in favour of the starter. Different subject — it is about which file, not what either contains. If it lands first this row's starter half narrows to one file, which is a simplification rather than a conflict.
  • CLOUD-314 is the README's novelty claim over-claiming. Different section, opposite direction: that row says a claim is too strong, this says a guide is incomplete.
  • CLOUD-206 built batten init. Done; this is about what it emits.

The mechanism question, which is what makes this filable

reference-check already holds the rendered CLI reference and the command spec to each other in both directions (CLOUD-171), and derived-check holds completions and man pages to the binary. There is no equivalent for "the extension guide names every rule kind the engine has" — and unlike prose currency generally, that one is computable: RuleKind is an enum, and its variants are enumerable, exactly as Fact::ALL and Surface::ALL are censused in crates/batten/tests/facts.rs.

So the row's deliverable is the gate as much as the text: a rule kind that lands without reaching the documented extension surface should fail, the way a new Fact variant fails to compile until somebody states its pairing.


Refinement — Ready

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

  • Source of truth (§1). RuleKind in crates/batten/src/rules.rs is the authority on which kinds exist; policy.rs's preset_names() on which presets ship — the same derivation schema/batten.schema.json's preset enum already uses. The README and the starter consume those rather than restating them, so neither can drift silently.
  • Computable predicate (§2). A census, in the shape facts.rs uses: every RuleKind variant is named in the README's extension section, and every shipped preset is named where a consumer would enable one. A new kind or preset that reaches neither fails the test.
  • Effect (§3). read.
  • Generated artifacts (§4). None necessarily — but if the extension section is better derived than asserted, that lands under derived-check like the CLI reference, and the decision between asserting and deriving belongs to this row.
  • Output & exit (§5). Pointer-only: the missing kind or preset name and the file that should carry it, never the prose.
  • Commit / bump (§6). docs — no bump. The census test is test-shaped and rides the same commit rather than claiming a second type.
  • Test obligation (§7). Shown able to fail per CLOUD-418, and the discriminating case is a census over the enum rather than a list — a list is exactly what went stale here, and facts.rs:81-85 records the same lesson: "a list is what was already wrong here: an eighth variant would join the enum and go unasserted in silence." So the test must fail to compile or fail outright when a variant is added, not merely when a name is deleted. Assert the starter half too: a preset named in preset_names() and absent from the starter's commentary reds.
  • Blockers (§8). None. relatedTo CLOUD-715 (which file the starter is), CLOUD-314 (the other README defect, opposite direction), CLOUD-836 (whose adoption argument this unblocks), CLOUD-206 (which built batten init).

Acceptance

  • The README's extension section names every RuleKind, including policy, and points at the preset mechanism.
  • The starter config a new repository gets mentions the shipped presets and how to enable one.
  • A census test reds when a rule kind or preset is added without reaching either surface — shown able to fail by adding one.
  • The test is a census over the enum, not a hand-kept list.

Review in Linear

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 12 minutes.

Check out review usage here.

View limit details

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

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: 0c33502f-a4c3-4581-a690-02f665457066

📥 Commits

Reviewing files that changed from the base of the PR and between 9ece058 and 27228e2.

⛔ 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 (45)
  • .serena/memories/core.md
  • README.md
  • batten.example.toml
  • batten.toml
  • completions/batten.bash
  • completions/batten.fish
  • completions/batten.zsh
  • crates/batten/src/baseline.rs
  • crates/batten/src/budget.rs
  • crates/batten/src/cli.rs
  • crates/batten/src/config.rs
  • crates/batten/src/defects.rs
  • crates/batten/src/design.rs
  • crates/batten/src/emission.rs
  • crates/batten/src/error.rs
  • crates/batten/src/findings.rs
  • crates/batten/src/hookcost.rs
  • crates/batten/src/journal.rs
  • crates/batten/src/lib.rs
  • crates/batten/src/policy.rs
  • crates/batten/src/preset.rs
  • crates/batten/src/rules.rs
  • crates/batten/src/secrets.rs
  • crates/batten/src/spec.rs
  • crates/batten/src/starter.toml
  • crates/batten/src/surface.rs
  • crates/batten/src/verdict.rs
  • crates/batten/src/waiver.rs
  • crates/batten/tests/it/cli.rs
  • crates/batten/tests/it/config_fault_class.rs
  • crates/batten/tests/it/enforce_journal.rs
  • crates/batten/tests/it/extension_surfaces.rs
  • crates/batten/tests/it/main.rs
  • crates/batten/tests/it/pointer_only.rs
  • crates/batten/tests/it/preset_manifest.rs
  • crates/batten/tests/it/privileged_lane.rs
  • crates/batten/tests/it/sinks.rs
  • crates/batten/tests/it/verdict_registry.rs
  • crates/batten/tests/it/verdict_vocabulary.rs
  • man/batten-state-settle.1
  • man/batten-state.1
  • policy/module-layering.rego
  • policy/privileged-lane.rego
  • schema/batten.local.schema.json
  • schema/batten.schema.json

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

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

…ises

CLOUD-1220. Measured on this repository during #807: `enforce` printed
`2 finding(s) carry no remediation: persisted:false`, and both were
`kind = "policy"` rows whose `[[verdict]]` tokens declare routes. The registry
had the remedy; the `Finding` did not, so both were dropped before
`findings::record` and never entered the store.

THE MECHANISM, established before changing anything, because the row required
it and because `lib.rs`'s comment asserted the opposite:

  RuleKind::Policy => &["severity"]
  RuleKind::Judge  => &["glob", "criteria", "no_fix_reason"]

A policy row requires only `severity`. Judge requires `no_fix_reason` outright,
and its own comment says why — "a judge finding reaches the store and CLOUD-81's
ingest refuses one nothing can close ... Requiring it here is what keeps that
refusal unreachable from a config that parses." Policy rows never got that
treatment, so `rule.remediation()` returned `None` for every one of them.
`lib.rs:6730`'s "this partition should never fire" generalised Judge's guarantee
to a kind that never had it — CLOUD-242's lesson, one table over.

THE FIX JOINS THE REGISTRY RATHER THAN ADDING A SECOND REMEDY COLUMN. Requiring
`no_fix_reason` on a policy row would have been the smaller diff and the wrong
one: a module's remedy is per PREDICATE and one row can carry many (CLOUD-832),
so a single column could not say which violation it answered — and it would be a
second spelling of what `[[verdict]]` already declares. `policy_remediation`
resolves the raised class instead: a `command` route becomes `Remediation::Fix`,
and every other route — document, issue, override — becomes a pointer-only
`NoFix` naming route ids and kinds, which is a real answer rather than an
absence, since `verdict::validate` already refuses a class with no route and one
whose only route is an override.

`RunInputs` gained the union registry, taken from `Vocabulary.verdicts` which
`run` already receives. The union rather than the consumer table, via
`registry_for`: a module may raise a preset's class as readily as a consumer's.

WHY THE TESTS ASSERT THE STORE AND NOT THE EXIT CODE. `enforce` exited 2 and
printed the class correctly throughout — what was lost was persistence, so only
a store read can see it. Running `enforce` on this repository is NOT evidence
either: the tree is clean, so zero findings fire and zero unrecordable findings
is vacuously true. The discriminating case is a fixture whose only violation
comes from a `policy/*.rego` module, read back out of the store.

Both arms, because a fix handling only `command` routes would leave every class
whose remedy is a read or an override exactly as broken — which is most of this
registry: a command route records the runnable argv, and a document route
records the pointer without copying the route's target into it (rule 4).

Refs: CLOUD-1220
CLOUD-1087. `Scan::attributed` mapped a finding's scope fingerprint to the row
that produced it, so `requested_sinks` could answer "which findings are this
row's" for a `policy` row, whose findings report the PREDICATE's id (CLOUD-832).
Review of #721 raised the same objection twice: keyed on the predicate id, then
keyed on the fingerprint. The first was fixed by re-keying; the second was not.

TWO ROUNDS CONVERGING ON ONE SHAPE IS THE FINDING. A third re-key answers the
instance. What both rounds were saying is that the owner was INFERRED from a
value meaning something else — an identity, not a row — when it can be RECORDED.
`rules.rs` builds a `command` finding as `scope_fingerprint(&rule.id, glob)` and
a policy finding as `scope_fingerprint(id, &fingerprint_of(violation))`, so a
command row whose id equals a predicate id and whose glob equals the verdict
token mints the identical key and the map hands one row's findings to the other.

`Finding::owner: Option<String>` — `None` meaning "the rule id is the owner",
true for every kind but `policy`. Set at the one construction site that holds
both ids at once. `requested_sinks` and `decidability_of` read
`owner.as_deref().unwrap_or(&rule)`. `Scan::attributed` is deleted rather than
re-keyed, with its threading through `run_rule` and `policy_rule`: nothing is
looked up, so nothing can collide.

`Finding::rule` is untouched and still carries the predicate id — reporting and
`waiver::apply` ask a different question and their answer was already right, so
a waiver still names the gate rather than the bundle holding it.

THE SITE COUNT IN THE ROW WAS STALE AND SO WAS MY FIRST RE-COUNT. The row said
15 across 8 files, measured 2026-08-28. A `git grep "Finding {"` says 77, which
is wrong — it counts `FindingKind` and `FindingRecord` too. rustc's own
missing-field spans say 17 across 9 files, and those spans are what drove the
edit rather than either count: a hand-applied sweep over a number nobody
verified is how one site gets the wrong owner silently.

TWO API BREAKS, AND THE ROW PREDICTED ONE. `constructible_struct_adds_field` is
the one it named. `function_parameter_count_changed` is the direct consequence
of the deletion it asks for — `any_blocking` and `decidability_of` no longer
take `&BTreeMap`. Declaring both rather than the expected one.

The discriminating case is the collision review named and #721 could not carry:
a `command` row and a policy predicate constructed to mint the same fingerprint,
each still counting its own finding and only its own. CLOUD-1083's two arms pass
unchanged, which is what shows this replaces that mechanism rather than
competing with it.

BREAKING CHANGE: `rules::Finding` gains an `owner` field, so a struct literal
naming every field no longer compiles. `None` is the correct value for every
kind but `policy`.
BREAKING CHANGE: `rules::any_blocking` and `rules::decidability_of` lose their
`attributed: &BTreeMap<String, String>` parameter, which `Scan::attributed`'s
deletion leaves nothing to pass.

Refs: CLOUD-1087
CLOUD-587. CLOUD-78 gave every finding a three-valued `disposition`,
`journal::merge` folds it, `FindingRecord::merge_disposition` joins two by
precedence, and `stop.rs` reads it — `deny-stop` means at-risk work or an
undischarged denial, where undischarged is `disposition == None`.

NOTHING MINTED ONE. `state` offered adopt/record/migrate/list and no verb wrote
a `Disposition`; the only producers anywhere in the tree were unit tests. The
field was read by a gate, joined by a merge rule, persisted by a journal, and
unreachable from every caller.

That bit once CLOUD-98 landed `bypass.rs`, whose finding anchors to an immutable
transcript event: a bypass that happened, happened, so re-evaluation keeps
finding it and the observation never resolves to zero. CLOUD-98's own assumption
says such a finding "clears by disposition in the store, not by the condition
vanishing" — correct as a design, unreachable as a mechanism.

A VERB UNDER `state`, NOT A NEW NOUN. The store has one noun and `record` is
already a per-observation write into the journal, so a disposition is the same
act against the same object. A new noun would give one store two entry points
and every later reader would have to work out which owns settlement. The cost is
stated: `state`'s verb list grows by one, the narrower of the two widenings.

THE FOLD NEEDED NO CHANGE, which is the evidence this adds a caller rather than
a second convergence rule. `journal::merge` already applies `disposition` from
any origin and `presentation` from `Origin::Drain` alone, so a settle entry
carrying only an identity and a token folds correctly through the append that
already exists — no new write path and no new lock.

`Origin::Settle` is a third variant rather than reusing `Scan`. They fold
identically, so it costs the merge nothing and buys the record its provenance: a
reader auditing why a finding is settled should not have to infer whether a scan
or an agent said so. The mixed-fleet cost is real and is the one `Scan` itself
paid — a binary predating the variant skips that shard line — and it fails safe,
leaving the finding unsettled rather than making the record lie about who decided.

TWO CENSUSES REFUSED THE VERB UNTIL IT WAS WRITTEN DOWN, and both were right.
`spec.rs`'s committed row set fails on any verb added, renamed or re-parented, so
the surface cannot move silently and §2 gets reconciled in the same change.
`pointer_only`'s census demands a stated disposition per leaf rather than a
default. Here pointer-only is load-bearing rather than routine: the findings this
answers are drawn from a session transcript, so the content is exactly what must
not travel — it emits the identity and the token and nothing else.

Both positionals are required. An omitted identity would have to mean "every
finding"; an omitted disposition would have to guess what an agent decided, and a
guessed disposition is the un-auditable settlement this verb exists to prevent.
An unknown identity is refused rather than appended, because `journal::merge`
deliberately KEEPS an entry whose record it cannot find — so a silent append
would settle nothing and be invisible forever.

The discriminating case is order-independence: two worktrees answering one
finding differently converge to the same record whichever order the shards merge
in, and to the STRONGER answer rather than the last written. A last-writer-wins
implementation passes the single-answer case and silently loses one answer.

Refs: CLOUD-587
…rows done

Six deferrals, all from this branch's own rows, all now in this PR. Each was
claimed as met and none had been.

CLOUD-1220's comment, and this is the ironic one. Its acceptance says
"`lib.rs:6730`'s comment is either true or corrected — a guarantee that does not
hold is worse than none". The row is ABOUT distrusting a "this cannot happen"
comment, and the false comment was left in place: it said `Rule::validate`
already refuses a row with no remediation, which is untrue for the one kind it
mattered for. Corrected, and narrowed to what is actually true now — a policy
finding takes its remedy from its class, but the partition stays a count rather
than an `expect` because a consumer's registry could still fail to resolve a
token this binary did not vendor.

CLOUD-1220's end-to-end arm. §7 asks that the `unrecordable` partition report
zero on this repository's tree. Running `enforce` here by hand is NOT that
assertion: the committed tree is clean, so zero findings fire and zero
unrecordable is vacuously true. The arm drives a tree that DOES produce a
module finding.

CLOUD-1220's baseline arm — "asserted rather than assumed", and it had been
assumed. Reaching the store is necessary and not sufficient: `baseline.rs` is
the persisted identity set, and a finding the baseline cannot take is invisible
to every ratchet built on one. It asserts the baseline TOOK it, since a
`baseline` exiting 0 having recorded nothing is the same vacuous pass.

CLOUD-587's state-anchored arm, which that row names as the direction a careless
fix breaks. A finding whose condition still holds must keep firing after a
settle — settling one would be a bypass of the work rather than an answer — and
the converse is asserted too: removing the condition clears it with no
acknowledgement.

CLOUD-1087's waiver arm. `waiver::covers` keys on `self.rule != finding.rule`,
so a waiver names the GATE a reader saw rather than the bundle holding it. Had
`owner` been folded into `rule` instead of sitting beside it, every waiver
written against a module predicate would have silently stopped matching. It
ships with its anti-vacuity mirror — a second fixture waiving the ROW id and
exiting 2 — because without it the first fixture's exit 0 is equally explained
by the module never firing.

CLOUD-587's stop-reader arm. Asserting the stored record shows the store
changed, not that the reader changed its answer. This drives `stop::facts`
directly and watches the pending list empty.

FOUR CORRECTIONS ON THE BASELINE ARM, all fixture mechanics rather than the
claim, and recorded because guessing is what cost them: uncommitted paths, a
missing `refs/remotes/origin/main`, a missing `must_land_on`, and then that key
landing inside a `[[verdict.route]]` table because it was appended after a
header rather than hoisted above one. `baseline` needs all three facts to call a
tree landed; reading `worktree.rs` is what settled it, after two guesses that
did not.

Refs: CLOUD-1220
Refs: CLOUD-1087
Refs: CLOUD-587
CLOUD-1313, first half. `run_policy_explain` opened with
`resolve::resolve(..)?`, so a config that would not load killed the verb before
it consulted any registry. Measured on a repository whose `batten.toml` carries
one malformed table:

    $ batten policy explain "path write refused"
    batten: invalid config ./batten.toml: TOML parse error at line 3

`path write refused` is VENDORED. It needs no consumer config, it is what the
mediated boundary raises dozens of times a session, and its remedy was
unreachable in exactly the repository state where a reader is most likely to be
stuck. The remedy channel went dark precisely when the config broke — and one of
that class's own routes is `config read first  document  batten.toml`, which is
the advice such a reader needs.

A LOAD FAILURE NOW DEGRADES RATHER THAN REFUSING. The union where a config loads,
which is what stops `explain` resolving a token differently from the gate that
raised it; this binary's vendored classes where it does not. What genuinely needs
the config still says so rather than guessing: a `[[rule]]` id and the
`[[redirect]]` table are the consumer's, and the refusal names "this config could
not be read" instead of reporting zero rows — an empty "what to do instead" reads
as "nothing to do", which is a worse answer than the refusal.

WHY THIS IS A PRECONDITION OF CLOUD-1313 RATHER THAN A NICETY BESIDE IT. That row
gives twelve config-fault classes to the twelve load-time validators. Shipping
them onto a surface that goes dark the moment a config breaks would be twelve
remedies nobody can read in the only state they describe — the dead-gate shape
the row exists to close, built in deliberately. The row as filed did not name
this; it was found by running the verb against a broken config rather than by
reading the code, and the row now carries it as its load-bearing constraint.

The measured transcript above is a `text` fence rather than a `console` one:
`no-doctests` caught it as a runnable doctest, and `test:cargo` runs nextest,
which executes no doctests — so it would have shipped as an example nothing runs
(CLOUD-813).

Refs: CLOUD-1313
`check_registry_is_exhausted` refuses a declared-and-unraised consumer
token, but it EXEMPTS `native_tokens()` — so a native class could be
declared, carry a gloss and routes, resolve through `policy explain`, and
be raised by nothing. That is the dead-gate class this repository exists
to refuse, and CLOUD-1313 is about to add twelve native classes at once.

The gate scans production sources under `crates/batten/src` (truncated at
`#[cfg(test)]`, `verdict.rs` itself skipped, since declaring a variant is
not raising it) and fails naming any variant no production site mentions.

Shown able to fail. Two earlier probes did NOT discriminate and both are
worth recording: a variant with no `VENDORED` row reddens
`the_vendored_table_validates` first, and one missing from
`every_native_class_is_listed`'s wildcard-free match reddens at compile
time. Only the full four-edit injection reaches this gate — and it then
fails alone, naming `DeadProbe`. All twenty pre-existing classes pass, so
this is a ratchet rather than a repair.

Refs: CLOUD-1313
The twelve `VALIDATED_AT_LOAD` validators refused through
`UsageError::raise(String)` across ~172 sites, so a config fault was the ONE
refusal class in this engine that `batten policy explain` could not resolve,
that carried no declared route, and that no gate held to the `[[verdict]]`
registry. CLOUD-1050's defect, one surface over — and stated in its own words:
with the refusal a free `String`, "a refusal naming no remedy, naming a task
that does not exist, or offering an override with no precondition were all
expressible and none checkable".

THIRTEEN CLASSES, ONE PER TABLE, ATTACHED AT THE ONE CALL SITE. Only ~30 of
those raises sit at a top-level `validate`; the rest are in per-entry helpers
several tables share, so a class per site is unbuildable and also the wrong
grain — what a reader needs first is which TABLE would not load, and the message
the validator already composed says which row and key. `under(Native::X, ..)` in
`validate_tables` is the whole plumbing; not one of the ~172 messages is
reworded.

The thirteenth is `remedy resolve missing`, and it is the row's own origin
rather than a bonus. CLOUD-1189 owed its gate a `[[verdict]]` row and could not
declare one, because a load-time refusal was not a raised class and a declared
row nothing raises fails the load. Closing CLOUD-1313 without it would have
closed the row and left the case that produced it open.

`UsageError` gains the class as a TYPED FIELD rather than a formatted prefix,
which is the point rather than an ornament: formatting a token into the message
would have reproduced CLOUD-1050's defect exactly, since nothing would check the
token is declared. `Native` is an enum, so a raise site can only name a class
that exists. `raise` keeps its signature and sets `verdict: None`, so every
existing site compiles untouched.

`None` stays a decision, not a gap: a file that will not parse as TOML failed
before any validator ran, so naming a table would be inventing an attribution
the loader does not have. `an_unparseable_config_refuses_without_inventing_a_table`
is that clause.

WHAT IS DELIBERATELY OUT, said here rather than left as a silent gap.
`validate_ungated` makes ~24 validator calls; the census names twelve and this
change classes those plus the remedy resolver. `mcp`, `action`, `handler`,
`budget`, `refusal`, `advisory`, `hookcost`, `ci`, `defects`, `prune`,
`attribution`, `commit`, `transcript` and `facts::validate_keying` still refuse
classless. The census is the row's stated predicate and widening it here would
have been scope this row did not buy.

THE SPLIT INTO `validate_tables`/`validate_sections` PRESERVES REFUSAL ORDER.
Which fault a multi-fault config reports first is observable output under house
style §6, so the cut is the smallest one that clears the line lint without
reordering anything — not the tidiest one. The census reads both bodies,
because the loader is two functions and the predicate is about the loader.

Gates, and each shown able to fail:

* `every_load_time_validator_refuses_under_a_declared_class` reads the wrapping
  rather than trusting it. Probed by unwrapping `markers::validate`: it fails
  naming `markers` and the class the reader can no longer reach. A `?` that has
  lost its `under(..)` compiles and passes everything else.
* `Native::CONFIG_FAULTS` is one authority with three readers, held equal to the
  census in both directions and to the fixture set in the compiled-binary tier —
  so a fourteenth table cannot arrive wrapped-but-untested.
* `crates/batten/tests/it/config_fault_class.rs` fires all thirteen over the
  compiled binary. Two of its cases were wrong before they were read: the remedy
  resolver decides `batten` invocations written as CODE SPANS, so a fixture
  naming a mise task, or naming the verb in bare prose, exits 0 and ships as
  coverage. Both were measured, not reasoned.
* The mirror runs both ways: a clean config raises no class, and this
  repository's own committed authority still loads.

The eleven subject words the classes spend are declared with glosses, because a
class whose subject the dictionary does not define is the unreadable name the
grammar exists to prevent. All eleven measure one token under the pinned
tokenizer.

BREAKING CHANGE: `UsageError` is a named struct carrying `message` and an
optional `verdict`, not a tuple struct. `UsageError::raise` is unchanged.

Refs: CLOUD-1313

Admits: f9f5fc1db6427d0337addcf869ab86e7bdeb3c7da995ffd4f398590e1118242d
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-head: 8cffdb8
Admits-epoch: c07c8db721c71e3c3f89648345f400a0a4dcbd989d79dcdf96db73d4513cb4a4
Admits-author: alec@wenzowski.com
Admits-prev: 1a1d32bd7185ccb54a5c1bd9373e0a2a1b7defc22c1d5046ebcac201fbcb2508
Admits-answer-lost: Thirteen new config-fault classes whose subjects the dictionary does not define. The grammar's stated purpose is that a name needs no lookup because every word is glossed once; a class spending an undeclared subject is exactly the unreadable name the vocabulary exists to prevent.
Admits-answer-precondition: The class this write adds vocabulary for is a `Native`, raised by the config LOADER before any config exists to declare it, so no `[[verdict]]` row can carry it and no surface verb can add a `[vocabulary]` word. Editing batten.toml is the only route, and the eleven added subject rows land in this diff where a reviewer reads them.
Admits-answer-rejected-route: `config read first` names batten.toml, which is the file being refused — the remedy is the thing denied. `patch run first` (`git restore`) reverts the write rather than performing it, so it answers a different question: it is the route for an unwanted change, not for one the diff exists to make.
CLOUD-1284 renamed `V-PRIVILEGED-LANE-UNTESTED-ORIGIN` to `lane guard missing`
and recorded the cost in its own words: "the name no longer distinguishes which
of the five conditions fired... Splitting stays available and is strictly a
follow-up: it would be five names where there is now one." This is that
follow-up.

THE COUNT WAS WRONG AND THE ROW SAYS SO. It is two, not five.
`policy/privileged-lane.rego` raises `lane guard missing` from exactly ONE
`violation` body; there were never five conditions to name. What the module
carries is `is_subject`'s three conjuncts, and a job failing any one of them is
not a finding at all — so "a class per conjunct" is unbuildable by construction,
and a mutation over a conjunct another conjunct already excludes survives, which
this module's own header records having been measured twice.

The distinction a reader can ACT on is `selects_outside_head`'s two arms, and it
is load-bearing because the remedies name DIFFERENT FIELDS:

* a trigger carries the head — test `head_repository.full_name` on the event;
* the job resolved one through the pulls API — test `.head.repo.full_name` on
  what the lookup returned, because the event payload has no head at all.

`tests_origin` has encoded exactly that pair all along, two field names in one
clause. An `issue_comment` lane told to test `head_repository.full_name` is sent
to a field its payload does not have.

THE ARMS ARE DISJOINT ON PURPOSE. `not trigger_carries_head(doc)` in the second
arm is what stops a `workflow_run` job that also calls `/pulls` raising both
classes for one job — two remedies for one fix, and a finding count that doubles
over a tree nothing changed. Arm one wins that overlap because its remedy is the
earlier of the two, applied before the lookup happens.

`is_subject` is gone rather than re-keyed: it collapsed a two-remedy disjunction
into one predicate, which is precisely what made the refusal unable to say which
field to test.

THE COMPILED TIER'S OBVIOUS ASSERTION DOES NOT WORK, and finding that out is why
this row has one. `check`'s line is `<path> <rule-id>` and its `--json` finding
carries `rule`, `path`, `severity`, `report` and `identity` — the verdict class
is on neither. Two cases asserting the class appears in `check` output were
written first and both went red against a correctly split module: a test
asserting its own premise rather than its conclusion. What the engine does carry
is the registry, and its two directions ARE a statement about how many classes
the module raises, so the split is asserted where the engine decides it.

Shown able to fail (CLOUD-418), and by the mechanism rather than by a case:
collapsing arm two back onto `lane guard missing` is refused at LOAD —

    batten: `[[verdict]]` declares `lane resolve missing`, which nothing raises

— so a collapse cannot ship green, and neither can a rename. The compiled tier's
mirror is the other direction: dropping the row while the module raises it
refuses the load naming the undeclared token, at exit 1, with the anti-vacuity
arm beside it so the pair is not satisfied by a fixture that refuses everything.

The `#MUTANT` row moved with the conjunct it corrupts — `resolves_head(body)`,
now inside the arm that asks that question. Its named case is unchanged, because
the input that discriminates it is unchanged.

No predicate changed meaning: the same jobs are subjects and the same jobs are
findings.

Refs: CLOUD-1317

Admits: bfe4071d4e5e287d41ddb6e4aaf4ddf0204429d4dbf38e9bf082f307c7aa2b84
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-head: 4072675
Admits-epoch: 0fe0905937edc9927e590d1b990039c4b21ffa35e29a360f57cc719c4fdabf77
Admits-author: alec@wenzowski.com
Admits-prev: f9f5fc1db6427d0337addcf869ab86e7bdeb3c7da995ffd4f398590e1118242d
Admits-answer-lost: A refusal that names neither of its two remedies. `lane guard missing` and `lane resolve missing` send a reader to different fields — `head_repository.full_name` on the event, `.head.repo.full_name` on a resolved pull request — and a lane told to test the wrong one is sent to a field its payload does not carry.
Admits-answer-precondition: The second class is raised by a Rego module and must be declared in `[[verdict]]` or the config does not load — the registry refuses a raised token no row declares. `batten.toml` is the only file that can carry the row, and it lands in this diff beside the module arm that raises it.
Admits-answer-rejected-route: `config read first` names batten.toml, which is the file being refused. `patch run first` (`git restore`) undoes the write rather than performing it, so it is the route for an unwanted change, not for the one this diff exists to make.
`batten spec` is the one machine contract a third party reads, and it carried
neither a version nor an identity. A consumer had nothing to pin against — every
change was indistinguishable from every other — and the only handle a row had
was its `path`, the human spelling, which is exactly what the surface-repair work
is about to move. `read_only_allowlist` is §5's safety-critical derivation and it
was keyed on that spelling, so a rename silently stops a consumer's pinned
allowlist matching, in the direction where a path it still trusts no longer means
what it did.

FOUR PUBLISHED FIELDS, AND THREE OF THEM CLOSE THE SAME GAP: the document omitted
what the surface already declares, so versioning it first would have versioned an
incomplete shape and immediately had to move.

* `spec_version`, a SOURCE LITERAL. Never `CARGO_PKG_VERSION`: a version moving
  with the crate says "the binary changed", which is what the tag already says.
  It moves on a change to the emitted SHAPE and not when a command row is added,
  removed or renamed — that is the surface changing, and tracking it is what the
  per-row id is for.
* `id` on every command row, DECLARED on `CommandDecl` rather than derived. An id
  computed from `path` is the path with extra steps and re-breaks on the same
  rename. The whole of its contract is that it is not edited when `path` changes.
  The seeds resemble today's paths because a seed has to come from somewhere and
  an arbitrary one would be unreadable in the committed row set; that is history,
  not a rule, and `surface.rs` says so at the field.
* `data_channel`, which never left the binary. It was a build-time-only column,
  so a consumer could only infer the channel by scanning a row's flags for one
  named `json` — a second derivation of a declared fact, and one that reads
  `spec` (whose switch is `--format`) wrong.
* `positional` on a flag. Without it a positional emitted as
  `long: null, takes_value: true`, byte-identical to a flag that lost its long
  form — so a consumer reconstructing an invocation writes `--<name> <value>`
  for something that takes neither and gets no signal that it did.

`read_only_allowlist` is reconciled rather than left keyed on the spelling: an
entry is now `{id, path}`. One struct rather than two parallel lists, because two
lists can disagree about their own ordering and a consumer would have to zip them
to find out. It sorts by ID — the stable half — so the document's byte order does
not move under a rename that changed nothing about which commands are read-only,
and `spec.rs`'s literal list is sorted to meet it with that stated.

The root node takes no id. It is the binary, which the release tag already
identifies, and a second name for it would be a second authority.

Shown able to fail (CLOUD-418), both observed rather than argued:

* emptying one row's id fails `every_declared_path_has_an_id`, naming
  `capture show`;
* pointing one row at another's id fails `no_id_is_declared_twice`.

That second assertion NAMES THE PAIR rather than comparing two lengths, and the
reason is measured: the length form reported `86 != 85` and nothing else, which
sent two rounds of source parsing after a duplicate that a stale build had
invented. An assertion that says which two rows collide costs one loop and would
have ended it immediately.

`the_lookup_returns_the_declared_literal_rather_than_a_computed_one` is the third
arm and it is deliberately narrow: there is no honest exit code over "did the
author re-derive this id during a rename", so what is held is the reachable half
— `id_for` answers with the row's own literal.

Every derived artifact was regenerated with `mise run fix`, never by hand; the
golden JSON snapshot carries the four new keys and nothing else.

BREAKING CHANGE: `SpecDocument` gains `spec_version` and its `read_only_allowlist`
is `Vec<ReadOnlyEntry>` rather than `Vec<String>`; `CommandSpec` gains `id` and
`data_channel`; `FlagSpec` gains `positional`.

Refs: CLOUD-969
…ur exemptions

A preset was three unrelated `const`s that nothing tied together: the
name-to-modules table in `policy.rs`, its verdict rows sitting inside
`verdict.rs`'s `VENDORED` under a comment, and a branch exempting it from the
`[[pattern]]` refusal. Nothing declared a preset, so a preset carried no identity
beyond its name, no version, and no SCOPE — and a third-party preset had no
route to declare its own vocabulary at all, because `VENDORED` is a `const` in
this binary.

The through-line the row names: every place a preset was exempted from a rule a
consumer module obeys, it was exempted BECAUSE THERE WAS NOWHERE TO WRITE THE
DECLARATION, not because the rule did not apply. `crate::preset` is the place.

WHAT IS NOW DERIVED RATHER THAN DECLARED TWICE: `preset_names()` and the
presets' half of the vendored registry both read the manifests. 21 verdict rows
moved out of `VENDORED` into the six manifests that raise them, and the partition
is exact — measured 21 blocks against 21 raised tokens, no leftovers in either
direction. `preset_modules` is deleted rather than kept: a second lookup
returning only the modules was one more way to answer about a preset without
seeing its scope, which is the fact the manifest exists to stop being separable.

THE SCOPE CHECK DOES NOT DO WHAT THE ROW PREDICTED, AND SAYING SO IS THE POINT.
The row expected a scope mismatch to produce an empty violation set — the silent
dead gate. Measured, with the branch disabled and the binary rebuilt, enabling
`trunk-based` at `tree` ALREADY failed to load: the module input-key check
catches it reading `input.call` on the tree surface. So this closes no hole. What
it buys is narrower and still worth having — the refusal precedes compilation and
names the PRESET a consumer enabled rather than a module inside the binary they
never wrote, and `scope` is a DECLARATION, which is the row's real gap ("a
consumer enabling a preset today cannot see which surface it decides") and one no
refusal can fix, because it is asked before anything is enabled. Shipping this as
"closes the silent dead gate" would have been a claim about a channel nobody
measured.

The four exemptions, with a disposition each, per the acceptance clause:

* the modules table — CLOSED, it is the manifest's `modules`;
* the verdict rows — CLOSED, they are the manifest's `verdicts`, and both
  registry directions are now askable for a preset: a token a module raises that
  the manifest does not declare, and a row no module raises, each fail;
* `scope` — DECLARED for the first time, and refused at load when a rule
  disagrees;
* the `[[pattern]]` inline-regex exemption — STILL OPEN and CLOUD-934's. The
  manifest carries `patterns` as the site its declaration would live in; listing
  ids makes the exemption countable rather than invisible. A preset still writes
  its literal inline, and it must: a preset reaches a consumer who wrote no
  `[[pattern]]` rows, so citing one resolves to undefined and decides nothing.

CLOUD-129's VERDICT IS UNCHANGED and restated at length in `preset.rs`'s header
and at `policy.rs`'s old table site. A manifest is a declaration format, not
permission to fetch one: `include_str!` at build time, no network, no registry,
no trust-on-first-use. What it buys CLOUD-970 is that the trust question becomes
ASKABLE — before this a third-party preset had no shape to arrive in.

`raised_in` reads the declared refusal shape (`"rule":` then `"verdict":`) rather
than every `"verdict":` it can see. The first version matched anywhere and its
comment claimed over-reading was the safe direction; the assertion refuted that
immediately, because `landing-loop`'s test fixtures build landing records with a
`verdict` COLUMN, so four record values read as raised classes. Over-reading
fails the raised-side assertion on modules that raise nothing — the noisy
direction, not the safe one.

`preset` is placed in `module-layering`'s table, below `policy` and `verdict`:
both read it and it reads neither, which is the manifest's whole shape. The rule
named the omission before a human did, which is that table's own recorded
property working again.

The published schema's preset enum is now alphabetical rather than
insertion-ordered — the only byte change `mise run fix` produced, and the same
six names.

Refs: CLOUD-1181

Admits: 9797dbd2fb83ef461476e75950a81447295dfd34ea48d3a1147b0301ec8cca17
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: .serena/memories/core.md
Admits-head: 24c3575
Admits-epoch: 0fe0905937edc9927e590d1b990039c4b21ffa35e29a360f57cc719c4fdabf77
Admits-author: alec@wenzowski.com
Admits-prev: 790a8c58bb43d71c543b70276b367f1b9750d26be38ab825e169c98ed6eb9f64
Admits-answer-lost: `preset.rs` would be a module the per-module map does not name, which is exactly the absence `module-map-check` exists to refuse — and the map is where a reader is sent to find out what a module owns and why it sits where it does.
Admits-answer-precondition: `module-map-check` refuses a new `crates/batten/src/*.rs` with no row in this map, and the map is the file being written. The surface verbs that maintain a memory (`write_memory`, `edit_memory`) address a memory as a whole; there is no verb that appends one module row, and the row must land in the same diff as the module it describes or the gate is red on the commit that adds it.
Admits-answer-rejected-route: `config read first` names the file being refused. `patch run first` (`git restore`) reverts the write rather than performing it, so it answers a different question: it is the route for an unwanted change, not for the one the gate is demanding.
… preset

The README's extension section was titled "three surfaces" while `RuleKind` had
TEN variants. It named `command` and nothing else — not `forbid`, `shape`,
`ratchet`, `receipt`, `pipeline`, `judge`, `secrets`, `document` or `policy`, and
not rego modules, bundles or presets. Neither starter config mentioned a preset
at all.

That is a defect rather than a docs backlog item because CLOUD-836 vendored the
presets on an explicit argument — a consumer should not have to author every
predicate from scratch — and THAT ARGUMENT WAS UNREALISED WHILE THE FRONT DOOR
DID NOT MENTION THEM. A consumer following the documented path wrote `command`
rules by hand, which is the state the presets were shipped to end.

The section now carries a row per kind, with the narrowest-kind guidance that
makes the table decidable: a `command` rule spawns a process and every other kind
is decided from facts the boundary already resolved, which is why only `command`
is barred from the read-only `check` surface. `exec_pattern` and
`fail_on_warning` move to their own table, because they are not rule kinds and
listing them beside ten that are was part of what made "three surfaces" sound
complete.

Both starter configs gain a worked preset row, commented out, with the scope
warning beside it — scope is the field to get wrong, and getting it wrong is
refused at load rather than quietly deciding nothing.

A CENSUS OVER THE ENUM, NOT A LIST — and the list is exactly what went stale
here. `RuleKind::ALL` and `preset::MANIFESTS` are read directly, so a kind or a
preset added later fails without anyone remembering to add a row. The heading's
own number is held to the enum too: it was the first thing that was wrong and the
last thing anyone would check.

THE FIRST VERSION OF THE CENSUS DID NOT DISCRIMINATE, AND ONLY THE PROBE SAID SO.
It searched the whole section for `` `policy` ``. Deleting the `policy` row from
the table — reproducing the exact defect this row was filed for — left the word in
the subsection heading below, and the probe came back GREEN over a guide a
consumer scanning the table could not use. Rewritten to assert the TABLE ROW,
which is the reach-for surface, it fails naming `["policy"]`. A gate whose first
run passes is what CLOUD-418 warns about, and this is that warning paying off.

The cases live in `extension_surfaces.rs` rather than a new file, because that
suite already EXECUTES every example in this section against the compiled binary
and pins the heading it documents — my first attempt was a second file that broke
its heading assertion. The documented preset row is executed there too, on that
suite's own discipline: a worked example is a claim about what the binary does.

Refs: CLOUD-936
`target-prune` refused the lap: `[prune.*.basis]` declared 152 tracked test files
against a live 164, twelve past a tolerance of 10. That is the gate working — the
floor it defends is `keep x stems x size`, and one taken against a smaller stem
count passes and then lets the build write more than it budgeted for, arriving as
a rustc IO error inside a test run rather than as a disk fault.

The bundle this rides on added two compiled-binary tiers, so the count moved
because the tree did.

THE FLOORS DELIBERATELY DO NOT MOVE, which is the 2026-09-01 entry's reasoning
unchanged rather than a shortcut. Free space was never the problem — the refusing
lap reported 10733MB free against a 7938MB warm floor — and both floors sit far
above what the grouped-target tree needs. A floor too high only refuses laps; one
too low fails silently. Moving them down needs the independent measurement the
basis block names (a build from an empty `target` for cold, a minimal post-prune
tree for warm), which is CLOUD-1158's and was not taken here. Refreshing the
count without claiming a floor measurement I did not take is the honest half of
the remedy, and the block says so in those words.

`count` and `measured` move together, as the block instructs: a count refreshed
without a new basis pointer is the same staleness wearing a newer number.

The two tiers that moved the count are `config_fault_class.rs` (CLOUD-1313) and
`preset_manifest.rs` (CLOUD-1181); the other ten stems are drift this basis had
not been refreshed for since 2026-09-01.

Refs: CLOUD-1181

Admits: cb24a92dc8d57532c8bdeaa21ca74cdeb19991a47e510056835dd03e7ee1f0cb
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-head: cbc1fad
Admits-epoch: 61e0bc9804b24107d5e35ed765eb8824404890b6ef57bf00754cc2e0b3f2a629
Admits-author: alec@wenzowski.com
Admits-prev: bfe4071d4e5e287d41ddb6e4aaf4ddf0204429d4dbf38e9bf082f307c7aa2b84
Admits-answer-lost: `mise run verify` cannot run at all, so this branch cannot be readied or landed. The floor being defended would also stay measured against a tree twelve test stems smaller, which is the staleness the basis block exists to surface.
Admits-answer-precondition: `target-prune` refused this branch because `[prune.*.basis]`'s declared count is twelve behind the live tree, and the block's own instruction is to move `count` and `measured` together. The basis lives in batten.toml and nowhere else; no surface verb re-measures it, and the gate that demands the change is the one reading the file.
Admits-answer-rejected-route: `config read first` names batten.toml, which is the file being refused. `patch run first` (`git restore`) reverts the write rather than performing it, and reverting is precisely what leaves the gate refusing.
@wenzowski
wenzowski force-pushed the claude/refusal-surface-bundle-5l3ovp branch from cbc1fad to 27228e2 Compare September 2, 2026 02:40
@wenzowski
wenzowski marked this pull request as ready for review September 2, 2026 02:40
@sonarqubecloud

sonarqubecloud Bot commented Sep 2, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 27228e2 into main Sep 2, 2026
19 of 20 checks passed
@wenzowski
wenzowski deleted the claude/refusal-surface-bundle-5l3ovp branch September 2, 2026 03:00
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