The refusal ABI's five unreachable consumers, connected - #821
Conversation
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, {"rule":"filed-over-own-diff","remediation":null,"path":null,"line":null}
{"rule":"prose-only","remediation":null,"path":null,"line":null}and on stderr: Both are The path that reports it says it cannot happen
// `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 Why it matters more than one warning line
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 The verdict still refuses correctly — Not the neighbouring rowsCLOUD-1087 is provenance on Refinement — Ready (join the verdict registry's remedy to the module's finding) Refinement gate: Definition of Ready & Done. This body carries only specializations.
Acceptance
Found while reading why 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 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:
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 oneStated on the thread (#721,
That makes it a real row rather than a real blocker — and a row about identity, not about sinks. Shape of the fix
Cost, measured rather than guessed: 15 Refinement — Ready Refinement gate: Definition of Ready & Done. This body carries only specializations.
Acceptance
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 Nothing mints one. Grepping the surface, What made this bite. CLOUD-98 landed CLOUD-97's finding is unaffected: it is state-anchored, so landing the work Rejected alternative Making the bypass finding self-clear instead — for example by dropping it once Definition of done
Acceptance
Notes for whoever picks this up The verb needs a Probe plan
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
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
A So a config fault is the one refusal class in this engine that Found by hitting the wall it puts upCLOUD-1189's §5 required its gate to ship a 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 questionThe registry's two directions are what make it honest, and they are what block the naive fix:
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.
Acceptance
Provenance. Filed after PR #807 merged and from a clean tree — 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 The corrected measurement, which is why this row exists rather than the one CLOUD-1284 described
The one distinction a reader can ACT on is
Nothing is broken today. Both remedies live in the Refinement — Ready (a refusal names which head it could not see tested) Refinement gate: Definition of Ready & Done. This body carries only specializations.
Acceptance
Provenance. The deferral CLOUD-1284 recorded and did not file. Filed while 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
**Measured against ** pub struct SpecDocument {
#[serde(flatten)]
pub command: CommandSpec,
pub read_only_allowlist: Vec<String>,
}Two keys. So a consumer that reads the spec today has:
This is the ordinary versioning obligation that CLOUD-1179's window makes cheap: §2 says "no back-compatibility surface and no deprecation aliases" pre- Why this is now its own rowThis 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 What "stable id" has to mean here, or it buys nothing
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 ** That is also what makes the id checkable: Refinement — Ready (version and identify the emitted spec) Refinement gate: Definition of Ready & Done. This body carries only specializations.
Acceptance
Split out of the former "Preset package and capability discovery" while recovering the 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
Three consequences, each measurable:
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 tablesThe 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 boundary — this row does not open the networkCLOUD-129 rejected remote policy fetch, and Refinement — Ready (declare a preset, once, instead of exempting it three times) Refinement gate: Definition of Ready & Done. This body carries only specializations.
Acceptance
Split out of CLOUD-969 while recovering the 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
Same gap in the config a new repository actually starts from. Why this is a defect rather than a docs backlog itemCLOUD-836 vendored the presets on an explicit argument: "A consumer adopting Batten got an empty That argument is unrealised while the front door does not mention them. A consumer following the README authors Distinct from the adjacent rows, checked
The mechanism question, which is what makes this filable
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 Refinement — Ready Refinement gate: Definition of Ready & Done. This body carries only specializations.
Acceptance
|
|
Warning Review limit reachedNext included review available in 12 minutes. View limit detailsLimit 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. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Free Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (45)
Note 🎁 Summarized by CodeRabbit FreeYour 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 |
…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.
cbc1fad to
27228e2
Compare
|
❌ The last analysis has failed. |
|
/fast-forward |
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. Fiveconsumers could not read the registry, and each was a separate hole in the same
wall.
Findingfrom a Rego modulefindings::recordFinding's ownerDisposition, so an event-anchored finding could never be settledUsageError(String)— no class at all, across ~172 sitesPlus 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
batten enforceon this repository reports zero findings carrying noremediation. It reported two when this started.
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:
explainwent dark exactly when a config broke. It loaded the config toanswer, 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.
silent dead gate; with the new check disabled and the binary rebuilt, the
module input-key check already caught it. The manifest's
scoperefusesearlier 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.
The module raises from one
violationbody; a class per conjunct isunbuildable, because a job failing any conjunct is not a finding at all. The
two arms of
selects_outside_headare the real split, and they have differentremedies — a lane told to test
head_repository.full_nameon anissue_commentevent is sent to a field its payload does not carry.README census matched the whole section, so deleting the
policytable 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 raisedclasses. Both were rewritten after the probe, not before.
Shown able to fail
Every gate here was probed, and three probes changed the gate:
every_native_class_is_raised_by_production_codeNativeevery_load_time_validator_refuses_under_a_declared_classmarkers::validatemarkersand the class its reader can no longer reachlane guard/lane resolvesplitevery_declared_path_has_an_id/no_id_is_declared_twicepolicytable rowNotes for review
BREAKING CHANGEfooters (UsageError's shape,Finding'sowner field,
SpecDocument/CommandSpec/FlagSpec's new fields). All arepre-
0.1.0andmise run semverdecided each.batten.tomland.serena/memories/core.mdwrites each carry a spentAdmits:block in the commit that makes them.mise run fix, never hand-edited.so a branch named for one ticket would move that one and strand the rest.
Deliberately out of scope, said rather than left implicit
parse_ungatedvalidators outside theVALIDATED_AT_LOADcensus stillrefuse classless. The census is CLOUD-1313's stated predicate; widening it here
would be scope the row did not buy.
[[pattern]]inline-regex exemption stays open and is CLOUD-934's. Themanifest carries
patternsas the site its declaration would live in, whichmakes the exemption countable rather than invisible.
reader does not take "manifest" as permission to fetch one.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KG7BZ3zkHufrDsC4vrJgR5
Generated by Claude Code