Skip to content

refactor(ci): retire hook-latency-drift and sbom-check - #830

Merged
wenzowski merged 3 commits into
mainfrom
claude/bundle-bats-retirements-fah2t0
Sep 2, 2026
Merged

refactor(ci): retire hook-latency-drift and sbom-check#830
wenzowski merged 3 commits into
mainfrom
claude/bundle-bats-retirements-fah2t0

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

Closes CLOUD-1270
Closes CLOUD-1318

Two retirements from the bats-cost bundle, one commit each. One branch and one PR rather than one per row, per AGENTS.md's "one commit one issue, one branch many rows, one PR all of it".

CLOUD-1270 — hook-latency-drift

mise-tasks/hook-latency-drift.sh (126 lines) and its 6-case suite are deleted. Disposition 3, decided on the row: the timing loop and the budget comparison become an inline step of .github/workflows/hook-latency-drift.yml, which is ungoverned and was already the program's only invoker. No new verb, no producer, nothing enters the core.

The program's own #MISE description settled it — "Report (never gate) … a question about the world, so it runs on a clock" — and .claude/rules/toolchain.md splits exactly there. hk absent stays could-not-look: the step exits 1 under set -euo pipefail rather than passing.

All eight ledger arms are withdrawn, and the coverage loss is stated rather than dressed up. Every case pinned arithmetic against a sleep-based hk stub; a workflow step is not drivable from crates/batten/tests/**, and a fixture re-running the same shell in a test would be a second implementation of the thing under test rather than coverage of it.

CLOUD-1318 — sbom-check

mise-tasks/sbom-check.sh (382 lines) and its 14-case suite are deleted. The scan stays outside — §5 makes check read and unable to spawn — so sbom.sh remains the producer and only the adjudication moves into policy/sbom-inventory.rego. sbom.sh survives deliberately (CLOUD-1159 owns its disposition).

Two of the ten predicates read no record at all, which keeps the producer's trusted surface narrow: the expected cargo count is Cargo.lock's own source = lines, and the action mapping is every SHA-pinned uses: against sbom-actions.tsv. Both are committed text, carried by line_sources, so the producer cannot get them wrong on the module's behalf. Only counts needing a derived document go through input.tree["tool-verdict"].

Three things the gates corrected during the work, recorded because each is a finding rather than a mechanic:

  • A false positive the load-time tier caught. Binding lock_lines separately does not make declared undefined when Cargo.lock was unread — a Rego comprehension over an undefined variable yields an empty array, so declared resolved to 0 and every honest count read as drift against a file nobody opened. The rule has to demand the array.
  • The verdict names are the vocabulary's. policy test refused four free-prose class names; a name is <subject> <action> <condition> drawn from [vocabulary], which is a measurement (9.9 tokens vs 3.0 curated). They add no words to the table.
  • The hk step name survives the migration. Renaming it to record-sbom reddened both tests/hk-selection.bats cases, which name the slow tier's steps literally — and that suite is governed, so shell edit refused offers no route. The step stays sbom-check and runs mise run record-sbom, which is the same convention claim-check, perf-pair, mutant and config-lint all kept.

Corpus

bench/suites/RESULTS.md regenerates at 116 suites with both retired suites absent (118 → 117 → 116).

The serial total is not comparable across these runs and should not be read as one. It reports 530.7s against 505.5s measured an hour earlier on this same container, after deleting ~50s of suites. Nothing in the tree explains that: it is machine noise between two unstamped measurements. The suite count is the only figure that carries across runs. That is CLOUD-1271's finding met twice in practice, and it makes the conditions stamp in CLOUD-1163 unit 7 worth more than its 0.6s suggests.

Refs: CLOUD-1270, CLOUD-1318, CLOUD-262, CLOUD-509, CLOUD-664, CLOUD-1149, CLOUD-1159, CLOUD-1176, CLOUD-1265, CLOUD-1271

🤖 Generated with Claude Code

https://claude.ai/code/session_01X6dcTC79zN5KBiMrZs6hac


Generated by Claude Code

@linear-code

linear-code Bot commented Sep 2, 2026

Copy link
Copy Markdown
CLOUD-1270 `hook-latency-drift` — 24.6s, 2.2% of the corpus, in no bundle and no row's scope: it is a REPORTER on a clock, not a gate, so the campaign has no disposition for it

DECIDED 2026-09-02 — disposition 3, retire whole; the measurement moves into the scheduled workflow

Owner's decision, taken by hand after the program and suite were read (mise-tasks/hook-latency-drift.sh, 126 lines, :70 the hk-absent exit, :91 the hk check --all --profile timing; tests/hook-latency-drift.bats, 24.3s at origin/main ee1c9f52).

Why not 2. The // ported: arm is refused by suite port held when the surviving subject is a governed, unretired program — the same weld CLOUD-1164 records for token-bench. Option 2 is not landable.

Why not 1. 24.3s is the largest non-landing-loop suite outside CLOUD-1318, and the program decides nothing — the seconds buy a reporter's regression suite, not a gate's.

What 3 is, narrowed. No new crates/batten verb and no perf-record-style producer: .github/workflows/hook-latency-drift.yml is ungoverned, so the hk check --all --profile "!$PROFILE" timing at :91 and the budget comparison become an inline step of that workflow (a shell step over hk, date, and the budget the workflow already reads). hk absent is still could-not-look there — the step fails rather than passes. Both mise-tasks/hook-latency-drift.sh and tests/hook-latency-drift.bats are deleted with // withdrawn: arms, since the successor is a workflow step and not a mechanism. CLOUD-1176's five-homes question is not reopened: nothing enters the core.

**Bump. **refactor(ci), no bump.

Dispatched in the bats-cost bundle on CLOUD-1151.


Why

tests/hook-latency-drift.bats is 24.6s, 2.2% of the 1097.1s corpus. The program is 126
lines with no #MUTANT rows. It is in no bundle and no row's active scope, and that
is the whole reason this exists.

How it got there, stated so the gap is legible rather than embarrassing: CLOUD-1170's table
lists it as a member of the lease/liveness class with the note "is a member but is a*
*measurement producer, costed separately" — costed separately from that row's 230.9s, and
then costed by nothing. The session that built the campaign's bundles excluded it from Bundle
A with the one-line reason "a measurement producer" and never gave it a home.

What it actually is, from its own header

#MISE description="Report (never gate) whether the fast pre-commit tier still costs what its
budget claims — a question about the world, so it runs on a clock"

That description settles most of the disposition question by itself, and it maps onto a split
this repository already made. .claude/rules/toolchain.md, on the lock-check split:

When writing a gate, ask which of the two it is — a property of the commit belongs in the**
**gate, a property of the world belongs on a clock.

hook-latency-drift is the second kind and says so. It measures the hk gate's own fast
pre-commit tier (CLOUD-509) by running hk check --all --profile "!$PROFILE" at :91, and
:70 exits if hk is not on PATH. Its commit-side sibling is mise run hook-profile-check,
which is in the hk gate and is a different program.

Its only invoker is .github/workflows/hook-latency-drift.yml:59 — a scheduled workflow,
ungoverned, so it repoints by task name without an admission.

The question this row has to answer

A reporter is not a completion gate, and Batten's scope reminder does not list benchmarking.
So the disposition is one of three, and none is obviously right:

  1. Keep as a task, recorded as NOT MIGRATING. The precedent exists and is recent:
    CLOUD-1202 settled container-preflight that way — "proxy and token-scope probing is not*
    *a completion gate's job, and that is a recorded verdict rather than a fact to wait for."
    Cheapest, honest, and leaves 24.6s on the clock permanently.
  2. Retire the SUITE only. The 24.6s is the bats suite, not the workflow. If the program
    stays as a scheduled reporter, the suite is what costs the corpus — and deleting it needs
    the subject to die, so it is CLOUD-1268's port-without-retirement marker or nothing.
  3. Retire it whole, with the measurement becoming a producer the way perf-record writes
    to refs/notes/perf. Most work, and it widens the core for something that decides nothing.

2 is the one worth pricing first, because it is the only one that recovers the seconds
without answering the scope question — and because the seconds are the reason this row exists.

Deliberately not asserted

Which of the three is right. This row's job is to give the number an owner, not to pick a
disposition from the same distance that produced "a measurement producer" as a reason to
exclude it. Read the program and its suite before recording the verdictCLOUD-1166 arm
4, which this campaign has now measured seven instances of.


Refinement — Ready (give an ownerless 24.6s a disposition)

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

  • Authority boundary (§1). mise-tasks/hook-latency-drift.sh (126 lines) and
    tests/hook-latency-drift.bats (24.6s). .github/workflows/hook-latency-drift.yml is
    ungoverned and repoints by task name. If the disposition is 2 or 3, both paths or the suite
    alone are deleted with the arms that shape requires; if it is 1, nothing is deleted and
    the verdict is recorded here and on CLOUD-1164.
  • Computable predicate (§2). Whichever disposition is taken, it is written down and the
    program is no longer absent from every bundle table. Under 1: a recorded NOT MIGRATING
    verdict, the shape CLOUD-1202 used. Under 2 or 3: the suite is gone and the measurement
    still runs on its clock.
  • Deliberately not in scope (§2). Changing what the tier's budget is or what
    hook-profile-check decides. Re-opening CLOUD-509. Widening the core to hold a benchmark —
    if disposition 3 is chosen, that is CLOUD-1176's five-homes question and it gets answered
    there, not assumed here.
  • Effect (§3). The program spawns hk and times it; it is on the spawning side and stays
    there. batten perf is the landed precedent for a measurement that runs outside check.
  • Output and exit (§5). Pointer-only: the tier, the budget, and the measured figure.
    Exit follows the 0/1/2/3 table; hk absent is could-not-look, which :70 already gets
    right and a successor must not collapse into a pass.
  • Commit / bump (§6). docs if the verdict is 1 — no bump. refactor(ci) if 2 or 3.
  • Test obligation (§7). Under 2 or 3, over the compiled binary in crates/batten/tests/;
    no .bats file added or edited. Under 1, no code and no test tier — the deliverable is the
    recorded verdict.
  • Blockers (§8). None for disposition 1. Disposition 2 is sequenced behind
    CLOUD-1268, which owns the port-without-retirement marker a suite whose subject survives
    needs. relatedTo CLOUD-1170 (whose table lists it and costed it separately), CLOUD-1164
    (the single-program units row it should appear in), CLOUD-509 (which built the measurement),
    CLOUD-1176 (the five homes), CLOUD-1144 (the reporter-vs-gate split, reached from the
    waiter's side).

Acceptance

  • One of the three dispositions is taken in writing, with the program read rather than
    classified.
  • If 2 or 3: bench/suites/RESULTS.md regenerates with tests/hook-latency-drift.bats absent
    and the serial total down by the measured amount.
  • If 1: the verdict is recorded here and on CLOUD-1164, so the 24.6s stops reading as
    unscheduled work.
  • The scheduled workflow still runs, whichever is chosen — the measurement is not lost.

Found by subtracting every dispatched bundle from bench/suites/RESULTS.md and asking what
was left with no owner. This was the largest such number.

CLOUD-1318 ONE PR: retire `sbom-check` onto the tool-verdict family — 1 program, 1 suite, 18.3s (3.6% of the corpus), 2 file arms + 14 case arms

THE token-bench HALF IS WELDED FOUR WAYS — corrected 2026-09-02, after reading the programs

The correction below cut this bundle from four units to two on a landed refutation. Reading
the surviving two end to end — which neither the filing nor that correction had done — cuts
it to one. Stated with the chain rather than as a verdict, because the chain is what a
future bundle has to break.

sbom-check (18.3s) stands and is the whole of this PR

tests/sbom-check.bats:2 is # subject: mise-tasks/sbom-check.sh — that program alone, so
the suite dies cleanly with two // carried: file arms. Its second input,
cargo metadata --format-version 1 --offline (sbom-check.sh:213), is
input.tree["tool-verdict"]'s shape and that family's producer has landed
(git cat-file -e origin/main:crates/batten/src/record.rs succeeds). sbom.sh survives as**
**the producer, which is what house style §5 requires anyway: check is read and cannot
spawn, so the successor reads a record something else wrote.

token-bench (8.2s) is NOT dispatchable, and it is a weld rather than a blocker

Four links, each checkable:

  1. tests/token-bench.bats:2 is
    # subject: mise-tasks/token-bench.sh mise-tasks/token-bench-check.shboth programs.
    SubjectFacts::died is .all(), so that suite is undeletable while either lives.
  2. The // ported: arm is the escape for a suite whose subject survives — and it refuses
    (suite port held) when the surviving subject is itself governed and unretired, which
    token-bench.sh is.
  3. That suite drives token-bench-check.sh at eight sites (:28, :38, :48, :55, :78,
    :100, :107, :117), and editing a governed suite is refused with no override.
  4. So token-bench-check cannot retire unless token-bench.sh retires too — and that takes
    down the generator of the tracked, published bench/tokens/RESULTS.md, plus method.toml,
    workloads.toml and thirteen tracked fixtures. Nothing else in the tree reads them.

That is a DISPOSITION question, not a wait: withdraw a published adoption proof, or port
the generator into engine source so the claim keeps its gate. It is recorded on CLOUD-1164
with both costs, and a bundle can take those 8.2s the moment it is answered.

And the mechanics in the clauses below are written against a stale tree

The branch this row was filed from is 121 commits behind origin/main
(git rev-list --count HEAD..origin/main = 121), and the retirement surface moved inside that
window. Corrected against origin/main 9ece058c:

this body says origin/main
tier in crates/batten/tests/*.rs crates/batten/tests/it/*.rs plus a mod line in it/main.rs — a new top-level target is refused by policy/test-targets.rego (CLOUD-1210)
four conserves arms five// ported: landed (CLOUD-1268) with a mandatory subject:
V-SHELL-RULE-EDITED / V-SHELL-RULE-ADDED prose tokens: shell edit refused, shell add refused, shell port missing, test port missing, suite port held
§5's could-not-look exit 3 2 in this family

The arms do not live in batten.toml. That file declares only the marker spellings
(batten.toml:3483-3491); the arms are bare // lines in the it/*.rs header. PR #809
touched batten.toml zero times — and it is the wrong template anyway, because it left a
phantom tests/config-lint.bats row in bench/suites/RESULTS.md. Copy aad25b1c
(hooks-wiring-check, CLOUD-1160): ten files, the complete shape, including the regenerated
cost corpus and the Admits-* trailer block a batten.toml write requires.

The corrected accounting

1 program, 1 suite, 2 deleted paths, 2 file arms, 14 case arms, 18.3s of a 503.1s corpus.
One $MUTANT_GATES entry (mise.toml:478), one #MUTANT row re-homed under a
#MUTANT-EXEMPT CLOUD-1267 line.

The class, and it is this row's third instance of it

The filing asserted four units unblocked. The first correction cut it to two on a landed PR's
refutation. This one cuts it to one on the programs' own bytes. Each cut came from reading
something that was always there — a merged PR body, then a # subject: line. CLOUD-1166 arm 4
demands a BLOCKED verdict quote its source; the mirror it does not state is that an UNBLOCKED
verdict owes the same, and "unblocked" was asserted three times here before anything was read.

Nothing else in the body below is changed by this note.


TWO OF THE FOUR UNITS ARE REFUTED BY A LANDED PR — corrected 2026-09-02, minutes after filing

PR #796 merged 2026-09-01 and its body already refuted the board half of this bundle. It
was not read before this row was written. Quoted rather than paraphrased:

"Part 1 (CLOUD-1194, ten board gates) is not dispatchable. input.tree.captured
reduces to one boolean, integer or bounded token per literal issue key hardcoded in***
committed config; the ten gates decide over a runtime-varying get_issue payload SET
*and, in graph-check
's case, the
blockedBy relation graph. A relation graph over an*
*unknowable id set is none of those three. The ledger is 346 case arms besides."

Confirmed against the tree, so this is not taken on the PR's word.
crates/batten/src/facts.rs:2699CaptureQuery is {id, key, node, reduce}; the key is
"an opaque string the CONSUMER supplies", authored into batten.toml, and the engine
"matches captures containing it and reduces the first in handle order". One id, one
capture, named in advance. A gate deciding over whichever rows a branch happened to file
cannot name its keys when the config is written.

What this removes from the bundle

unit s why it leaves
board graph — graph-check, ready-lint 27.0 the blockedBy relation graph is over an unknowable id set; and 346 case arms
board overlap — board-diff-overlap 12.8 decides over an issue key not knowable at config time

CLOUD-1316 does not rescue either, and that row has been re-aimed and moved to Backlog
accordingly: it proposed a fourth paths REDUCTION, and the reduction shape was never the
blocker. facts.rs:2715 also names a paths arm as the one that must not exist — "a closed*
set rather than an open expression language… an extract or matches arm would reopen
*exactly that" — a second objection that row never addressed.

What survives, and it is still one PR worth landing

sbom-check (18.3s) and the token-bench pair (8.2s) — 26.5s, 5.3% of the corpus, three**
**programs, three suites, six ledger arms. Neither reads a board payload; neither is touched
by any open PR; both blockers are landed and checkable. The unit table and every clause
below still hold for those two rows only — read the SBOM and token-bench lines and ignore
the two board lines.

Three further corrections to this body, each measured

The class, recorded because it is this session's own

CLOUD-1166 arm 4 says a BLOCKED verdict must quote its source. Every wrong claim above is
the mirror: an UNBLOCKED verdict asserted without checking the landed refutations of the
thing being unblocked. A merged PR is where those live, and the search that preceded this
bundle never looked at one.

Nothing else in the body below is changed by this note.


Why

Four retirements were each recorded as blocked on something. Read live against origin/main 9ece058c, none of the four blockers survives, and the one genuinely missing mechanism now has a row. This is the bundle that lands all of them together, because they share a file domain in every clause plan-fleet §3b asks for and none is worth a matrix of its own.

Every figure below is read from bench/suites/RESULTS.md at origin/main, which regenerated after hooks-wiring-check retired: the corpus is now 122 suites / 503.1s, not the 138/1097.1s or 124/739.1s that older bodies quote. Do not copy a second from any owning row — they are all stale, and CLOUD-1166 is the row about exactly that.

The bundle

unit programs suites s share owning row
SBOM check sbom-check.sh sbom-check.bats 18.3 3.6% CLOUD-1159
board graph graph-check.sh, ready-lint.sh graph-check.bats, ready-lint.bats, ready-lint-deferral.bats 27.0 5.4% CLOUD-1194 member + CLOUD-1221
board overlap board-diff-overlap.sh board-diff-overlap.bats 12.8 2.5% CLOUD-1162 unit 16
token bench token-bench.sh, token-bench-check.sh token-bench.bats, token-bench-check.bats 8.2 1.6% CLOUD-1164 member
6 programs 7 suites 66.3 13.2%

Membership is CITED, not restated: each unit's blocked/unblocked verdict, its home and its predicate belong to the row named in the last column. Re-deriving a member list into this body is CLOUD-1166 arm 3, which that row records as committed into a dispatch prompt once already.

The closure is checkable, and it terminates

Every one of the seven dying suites declares a # subject: that is inside this bundle's own six programs — so no suite is stranded and no program is left alive but untested (CLOUD-1130):

tests/sbom-check.bats            # subject: mise-tasks/sbom-check.sh
tests/graph-check.bats           # subject: mise-tasks/graph-check.sh
tests/ready-lint.bats            # subject: mise-tasks/ready-lint.sh
tests/ready-lint-deferral.bats   # subject: mise-tasks/ready-lint.sh
tests/board-diff-overlap.bats    # subject: mise-tasks/board-diff-overlap.sh
tests/token-bench.bats           # subject: mise-tasks/token-bench.sh mise-tasks/token-bench-check.sh
tests/token-bench-check.bats     # subject: mise-tasks/token-bench-check.sh

And no open PR touches any of the six. Verified by diffing every open head against origin/main#811's only match is .github/workflows/sbom-actions-currency.yml, a workflow and not a governed program.

Why ready-lint is in the bundle, which is the one membership this row DOES derive

graph-check is not a single-program unit and no row said so. mise-tasks/graph-check.sh:163 is lint="$(dirname "$0")/ready-lint.sh", and its todo-not-ready predicate is "Todo => ready-lint over it exits 0" — so the successor must reach that verdict, and a tree-scoped module structurally cannot spawn. graph-check therefore retires with ready-lint or not at all.

That costs nothing, because the successor already ships. crates/batten/src/ready.rs is on origin/main and batten ready lint is the verb this session has been running. mise-tasks/ready-lint.sh is 729 lines of second implementation of it — which is CLOUD-1221's entire finding, and CLOUD-1194 already blocks that row. So the board-graph unit is: delete both programs and all three suites, and let the module read the engine's own predicate as mechanism, never as a new verb.

graph-check.sh is 928 lines and ready-lint.sh 729 — the largest single reduction in this bundle, and the one whose 13 #MUTANT rows dominate its cost.

sbom-check retires and sbom.sh does not, deliberately

CLOUD-1159 records why: sbom.sh derives an artifact and decides nothing, so it is a producer rather than a gate and owes a REACHABILITY verdict on its own row, not a place here. sbom-check.sh:44's constructed-sibling spawn of it is an ADMITTED repointing under CLOUD-1149's landed arms 2 and 3, so the caller dies and the callee lives with nothing dragged in.

What each unit needs, and the order

  1. The paths reduction (CLOUD-1316) — crates/batten/src/facts.rs, the generated schema, crates/batten/tests/*.rs. Its only consumer is unit 3, which is why it belongs in this PR rather than ahead of it. Nothing blocks it.
  2. sbom-check — reads a tool-verdict record. The producer landed (crates/batten/src/record.rs).
  3. board-diff-overlap — reads the paths reduction from commit 1, plus input.tree["base-delta"] and input.tree.tracked.
  4. graph-check + ready-lint — read input.tree.captured (CLOUD-1188, Done) and the engine's own ready.rs.
  5. token-bench + token-bench-check — no blocker found on either.

Refinement — Ready (one PR, five commits, 13 deleted paths)

Refinement gate: Definition of Ready & Done. This body carries only specializations.
Groomed into the structured dialect 2026-09-02, and that is this row's fourth instance of its own class. The row was created at 01:01:11Z, after [ready] prose_dialect_required_from, so batten ready lint refuses it as claims-object-absent and claim check refuses the pull as a refinement-sequence failure. It has been sitting in Todo — the ready queue — while failing the predicate graph-check's todo-not-ready enforces. The §8 clause below reads "Blockers (§8). None. Every precondition has landed and is checkable at origin/main", which was false of the row itself: the three correction blocks above each caught an UNBLOCKED verdict asserted without reading something, and so does this.

The object states the SURVIVING unit only — sbom-check alone, per the top correction block. The prose clauses below still describe four units and are left standing as the record of how the scope narrowed; where they disagree with this object, the object is the specification.

{
  "source_of_truth": "policy/sbom-inventory.rego",
  "gate": { "task": "verify", "exits": [0, 2] },
  "commit_type": "refactor",
  "blockers": [],
  "tests": [
    {
      "file": "crates/batten/tests/it/sbom_inventory.rs",
      "mutation": "package-drift-unread"
    }
  ]
}
  • Authority boundary (§1). Commit 1 is crates/batten/src/facts.rs plus the generated schema. Commits 2 through 5 delete **6 programs and 7 suites — 13 paths — and land 13 **// carried: arms, one per deleted path (CLOUD-908), with successors in policy/*.rego, batten.toml rows and crates/batten/tests/*.rs. mise.toml loses **5 **$MUTANT_GATES entriesboard-diff-overlap, graph-check, ready-lint, sbom-check, token-bench-check. mise.toml and hk.pkl are ungoverned and repoint freely. **No new top-level **crates/batten verb, and no mise-tasks/*.sh outside the six is opened — V-SHELL-RULE-EDITED declares one route with no override.
  • Computable predicate (§2). Each successor decides what its predecessor decided, over the same corpus, invocable as batten check <rule-id>. Per-unit predicates are the owning rows'; this row does not restate them.
  • Fidelity is per unit and measured (§2). board-diff-overlap's three-row REPLAY must still report 1 crates/batten/src/git.rs for CLOUD-739 and CLOUD-740 and 1 mise-tasks/macos-link-check for CLOUD-737. graph-check's successor must reproduce its predecessor's frontier over the same payloads. sbom-check's must report a drifted SBOM and pass a clean one.
  • Deliberately not in scope (§2). sbom.sh — its disposition is CLOUD-1159's and is a REACHABILITY decision, not a retirement. The other nine members of CLOUD-1194's board family. Regenerating tests/fixtures/board-diff-overlap/tracked-at-filing.txt, which is carried byte-identicaltests/board-diff-overlap.bats:226, "a historical replay that tracks the present measures nothing." Changing what any gate decides.
  • Effect (§3). read throughout. Nothing in this PR spawns and nothing writes; evaluator-io-check stays the gate on that.
  • Generated artifacts (§4). schema/policy-input.schema.json regenerates for commit 1's reduction arm (CLOUD-879). mise run fix; never hand-edit. rules-drift then holds .claude/rules/policy-modules.md's key list to it.
  • Output and exit (§5). Pointer-only per unit: an artifact path and the field that differs; <issue-id>:<line> <rule>; a count and a tracked path — never issue prose and never an SBOM's contents. Exit follows the 0/1/2/3 table, and an unpopulated capture store is 3 per gate, never a false 0.
  • Commit / bump (§6). feat(facts) for commit 1 — patch below 0.1.0. refactor(ci) for commits 2 through 5 — no bump; refactor releases nothing at any version, per CLOUD-595's correction.
  • Test obligation (§7). Over the compiled binary in crates/batten/tests/; **no **.bats file is added or editedV-SHELL-RULE-ADDED refuses one at deny, and .claude/rules/policy-modules.md says a new module's second tier is crates/batten/tests/*.rs and never a .bats. Shown able to fail per CLOUD-418: each unit carries its deny case and its anti-vacuity mirror, without which a gate that fires on everything passes the first. **Mutated: 22 **#MUTANT rows re-homesbom-check 1, graph-check 13, ready-lint 4, board-diff-overlap 3, token-bench-check 1 — and **each takes a **#MUTANT-EXEMPT **naming **CLOUD-1267, because mise-tasks/mutant.sh:158 hardcodes tests/$gate.bats so no mutation applied to a Rego module can turn a suite red. Do not invent a new exemption reason and do not read the resulting green as coverage. mutant-census green.
  • Blockers (§8). None. Every precondition has landed and is checkable at origin/main — the capture-store fact, the commit-metadata projection, the successor-kind declaration, the constructed-sibling repointing arms, the tool-verdict producer, and ready.rs itself. relatedTo the four owning rows plus CLOUD-1151 (the wave owner), CLOUD-1267 (the mutation exemption), CLOUD-1130 (the surviving-subject rule), CLOUD-908, CLOUD-1080 and CLOUD-418.
  • Blockers (§8), second clause. VOID — see the correction block at the top of this body. CLOUD-1194 is In Progress and assigned, which claims its whole ten-gate family including graph-check. This bundle carves one member out. Either CLOUD-1194's assignee releases it here, or unit 2 drops and the bundle lands at 39.3s. claim-not-raced decides it at verify either way; do not discover it there.

Acceptance

  • 13 paths deleted, 13 // carried: arms, 5 $MUTANT_GATES entries dropped, no governed file outside the six edited.
  • bench/suites/RESULTS.md regenerates with all seven suites absent and the serial total down ~66.3s, from 503.1s to ~436.8s.
  • Every dying suite's declared # subject: is a path this PR deletes — asserted, not reviewed.
  • The frozen tracked-at-filing.txt fixture is byte-identical.
  • Each unit's anti-vacuity mirror observed; an unpopulated capture store is could-not-look per gate.
  • mutant-census green with 22 rows re-homed and exempted by name.
  • No new top-level verb on the emitted surface.

Filed after asking why a single PR covering these four could not be dispatched: three were dispatchable already and the fourth was recorded as blocked on a mechanism nobody owned, which is a grooming gap rather than a design boundary.

CLOUD-262 Cut an SBOM for every release with syft, derived from the tagged source, at zero added CI minutes

Why

Batten publishes seven archives plus batten.schema.json per release and has no SBOM of any kind — no SPDX, no CycloneDX, no cargo-sbom, no checksum file. A policy engine whose thesis is verifiable claims about what shipped cannot answer "what is in v0.0.38".

GitHub's own SBOM export is free on this plan — the plan-gated feature CLOUD-258 hit is the attestation store, not SBOM export — so this is not a cost decision. It is a capability one: GitHub's export describes "the current state of the dependency graph for your repository", SPDX only, with no way to target a tag or attach the result to a release. Derived in the schema job, the inventory comes from the tagged source instead, which is the only version of the claim worth publishing.

anchore/sbom-action is declined for the reason CLOUD-108 declined taiki-e/upload-rust-binary-action: CI must run the same mise tasks a maintainer runs locally. It is a Node24 action that downloads syft into the Actions tool cache; an aqua: pin instead lands a mise.lock checksum, which is stronger supply-chain hygiene than the action's internal download. What was taken from reading it: spdx-json as the sensible default, attach-to-release via the existing upload, and the dependency-snapshot idea (declined — see non-goals).

Measured 2026-08-09, syft 1.42.3, against this tree

  • 2 s per scan — and the same 2 s emits both formats from one scan.
  • 198 packages on a bare dir: scan = 156 cargo + 41 github-actions + 1 self. The 156 matches grep -c '^\[\[package\]\]' Cargo.lock exactly.
  • Only 18 of those 41 actions are Batten's. 23 come from the vendored tests/bats submodule, which actions/checkout in the schema job does not fetch. Excluding ./tests/bats and ./target gives 175 = 156 + 18 + 1, which is what CI sees. Without the exclude, a regenerate-and-compare gate passes locally and publishes a different tree — the defect this issue must not ship.
  • Two runs differ in exactly two leaves per format: .documentNamespace and .creationInfo.created (SPDX); .serialNumber and .metadata.timestamp (CycloneDX). Everything else is byte-identical.
  • Asset sizes: SPDX 339 KB, CycloneDX 199 KB.
  • rust-cargo-lock-cataloger is already on for dir: scans — passing +rust-cargo-lock-cataloger is a byte-identical no-op. The widely-repeated "disabled by default" advice applies to image scans.
  • aqua:anchore/syft is in the mise registry, so the pin is one line in the house backend.

Why the schema job and not a new one

GitHub rounds each job up to the nearest minute, and release-artifacts.yml's schema job already runs on every release: it already checks out the tag, already runs mise with cache: false, already holds contents: write, and already uploads exactly this shape of asset — one platform-independent file, for the reason its own header gives ("uploading it seven times would race seven writers onto one asset name for no gain"). A 2 s step there costs zero additional billed minutes; a new job costs one.

Being outside the dist matrix also means an SBOM failure cannot suppress the binaries — the ordering CLOUD-258 paid six silent releases to learn.

Note for anyone tempted to move this off CI entirely: generating the SBOM in a free agent container saves zero minutes against a step in an already-running job, while giving up the tagged-source guarantee, reproducibility, and gate coverage. The container is where the development and verification compute belongs — the measurements above were produced that way — not where the release artifact gets cut.

Definition of done

  • Every release carries batten.spdx.json and batten.cdx.json, derived from the tagged source, named without the version so releases/latest/download/ resolves (the batten.schema.json precedent that .taplo.toml already depends on), carrying the version inside via --source-version.
  • An SBOM that catalogs nothing cannot report green.
  • A missing SBOM asset is observable on a clock, not at the next audit.
  • The zero-added-minutes claim is checked against a real release run, not asserted.

Acceptance

  • mise run sbom on a clean checkout emits both formats in ~2 s; run twice, the only differences are the four fields named above.
  • mise run sbom-check exits 1 when the cargo purl count ≠ grep -c '^\[\[package\]\]' Cargo.lock (156 today), and still exits 1 on a genuine content change after normalization — the normalizer is negatively self-tested, so it cannot be quietly widened until everything passes.
  • mise run release-assets-check <tag> covers the non-target assets and exits 0 for the first release that carries them. Its "a gate that checks nothing must not report green" property holds for the new list too.
  • The published SBOM is labelled as the inventory of the repository at the tag, not of the shipped binary. Overclaiming is the failure CLOUD-132's closing note warns against; the binary-level claim is a separate issue, blocked on this one.

Refinement — Ready

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

  • Source of truth (§1). mise-tasks/sbom owns the syft invocation, the exclude list and the asset names — one definition, the way mise-tasks/dist owns archive naming. .github/workflows/release-artifacts.yml's schema job owns only the upload.
  • Computable predicate (§2). cargo purl count == grep -c '^\[\[package\]\]' Cargo.lock; plus normalized-bytes equality across two runs; plus asset presence at a tag.
  • Effect (§3). sbom writes only to a scratch path it owns; sbom-check never writes the tree it judges. Release-time upload only; no verb changes.
  • Output & exit (§5). Pointer-only (rule 4) — counts and path:line, never SBOM contents. 0 pass, 1 fail, 2 could-not-look.
  • Commit / bump (§6). build → no bump. Toolchain pin, two tasks, an hk step and a workflow step; nothing under crates/batten changes, so release-plz cuts no release for this work and the first release to carry the assets is whichever one lands next.
  • Test obligation (§7). tests/sbom-check.bats on the tests/schema-check.bats model: exit-code table per rule id, the negative normalizer self-test, byte-stability, idempotence, and a final real-tree case. The release path is not bats-reachable — a release-triggered workflow reaches no PR and no gate, exactly the blindness that hid six broken releases — so DoD names the tag whose release-assets-check passed.
  • Blockers (§8). None.

Implementation sketch

  • mise.toml [tools]: "aqua:anchore/syft" = "1.42" with the house trailing-comment justification; mise run lock-check to regenerate mise.lock (the only sanctioned writer).
  • Add syft to the install_args of any ci.yml job that invokes it — ci-tools-check plus MISE_TASK_RUN_AUTO_INSTALL=false make this mandatory, not optional.
  • mise-tasks/sbom (#MISE description="Effect: …") and mise-tasks/sbom-check (#MISE description="Gate: …") on the mise-tasks/schema-check model: set -euo pipefail, an SBOM_ROOT override for fixtures, mktemp + trap, two-line pointer error, one positive line naming the task.
  • hk.pkl: a ["sbom-check"] step with glob = List("Cargo.lock", ".github/workflows/*"), mirroring the schema-check declaration.
  • release-artifacts.yml's schema job: one step after the schema derivation, one gh release upload "$TAG" … --clobber.
  • mise-tasks/release-assets-check: extend beyond the sed-scraped matrix targets to a non-target asset list — batten.schema.json is uncovered today, so this closes two gaps at once.

Non-goals

  • Vulnerability scanning (grype, trivy) — Batten is not a scanner; the SBOM is an artifact of record.
  • The GitHub dependency submission API (POST …/dependency-graph/snapshots, syft's github-json output) — free, but it feeds GitHub's dependency graph rather than producing a release artifact, and adds GitHub coupling for a question the SBOM already answers.
  • A docs/ tree — no-docs-tree fails if any docs/ path is tracked.

Consequences recorded 2026-08-09 — stealth-preview framing

First adopters arrive while the repo is still private, and the broad-adoption question applies to the stable public release. Two consequences follow, neither changing this issue's design:

  • The first consumer arrives before the public flip, not after. Design partners in this segment run vendor security review even on preview tooling, so the SBOM is asked for during the private period. This raises the issue's priority; it does not alter the mechanism.
  • Publishing an SBOM invites scanners, and the noise is real. A consumer running grype against this SPDX gets flooded — the literature puts downstream false-positive rates near 92%, driven by flagging unreachable code. The EU CRA (SBOM obligations from 11 Dec 2027) requires manufacturers to hold an SBOM in technical documentation and produce it on request; it does not require publication. So "available as a release asset, not advertised as a vulnerability surface" is both compliant and lower-noise. A VEX posture is the follow-on if anyone actually scans it, not a prerequisite.

Downstream consumer, named so nobody builds one: ntia-conformance-checker (sbomcheck) validates SPDX against the NTIA minimum elements and the CISA/FSCT baseline attributes. Any "does our SBOM meet procurement minimums" follow-on adopts that tool rather than encoding the rules — note that CISA's 2026 Minimum Elements replaced the 2021 NTIA set, so the rules move and a hardcoded list would rot.

CLOUD-509 The pre-commit gate runs 111s of checks against a latency rule that ships no mechanism, so every step added since has been added against an unenforced budget

hk.pkl defines the step list once, as local gate, and hands the same mapping to three hooks: pre-commit (fix mode), check (what mise run ci drives via hk check --all) and fix. One list, 38 steps, three consumers — and the consumer with the tightest latency requirement gets the same list as the one with none.

Measured

Measurement Value Source
Whole gate 111.1s mise.toml:750-760
test:bats, in-gate at nproc=4 100.3s mise.toml:750-760
test:bats standalone (in-gate contention ≈1.11x) 90.26s mise.toml:750-771
batten-check, cold clone 9.47s hk.pkl:462-464 (CLOUD-212 run 1, D8)
batten-check, warm, 3 runs 133–141ms hk.pkl:462-464
Cheapest gate in the same run 0.16s CLOUD-212 run 1, D8
no-docs-tree ~95ms hk.pkl:58

The gate now contains three whole-workspace cargo invocations (cargo clippy --all-targets --all-features, cargo test --workspace, cargo run -p batten -- check), the 1039-case bats suite, two syft scan dir:. passes in sbom-check, a JVM start per pkl step, node for prettier, and python3 in macos-link-check. Seven further cargo run/cargo metadata invocations sit outside the serialised cargo-fmt → cargo-clippy → test → batten-check chain and can contend with it for the target-dir lock.

Of the 38 steps, 5 are receipt-gated (cargo-fmt, cargo-clippy, test, test:bats, batten-check). policy-budget, config-lint, schema-check, completions-check, skill-check, sbom-check, token-bench-check and macos-link-check are not, several of them cargo- or scan-shaped. Four steps carry no glob and run on every commit whatever it touched.

Why nothing catches it

The rule already exists and is prose. mem:toolchain-and-hooks carries a section headed "Keep the hooks fast — hk.pkl is living config", stating that the hook "runs on every commit, so its latency is a constant tax" and that whoever touches it must "re-check the hook is still optimal". The mechanism that section ships is hk-version, which fails on a version pin drifting between mise.toml and the amends URL — a real gate for a different property. Nothing measures a cost, and nothing fails on one.

That is non-negotiable 2 unmet in this repo's own gate config: a rule without a runnable check is half a change, and prose is feedforward only. Every step added since that section was written was added against a budget nothing enforces, which is why the number is 111s and no commit ever went red for it.

Moving a step out of pre-commit costs no coverage

This is the property that makes a tier split a latency change rather than a weakening of the gate, and it is structural rather than a promise:

  • mise run cihookshk check --all runs the same gate mapping, and hk check --all ignores globs. CI runs every step.
  • mise run verify runs ci and writes a SHA-keyed receipt.
  • ready-guard denies gh pr ready without a verify receipt for that exact HEAD; land depends on verified.

So a profiled step still runs before anything is readied or landed. The split decides when the cost is paid, never whether the check runs.

Mechanism

The obvious design is unavailable, and the reason is worth recording. A per-step tier annotation cannot exist: Pkl classes are closed, Step (hk 1.54.0 Config.pkl:308) is not open, and none of its 30 properties is a tier or a free-form field. Measured:

$ pkl eval -   # class Step { glob: String? } … new Step { tier = "fast" }
–– Pkl Error –– Cannot find property `tier` in object of type `Step`.

That fails the pkl gate step at check time. A second barrier sits behind it: Config.pkl's renderer spreads s.toMap() into a Dynamic, so an extra key would reach hk's Rust deserializer too. A sibling Mapping<String, String> filtered with gate.toMap().filter(…) evaluates, but writes every step name in two places, and a rename then drops a step out of its tier silently — the second-authority failure non-negotiable 6 names.

hk already ships the mechanism. Step.profiles: List<String>? is upstream's selector: a step runs only if its profile is enabled. hk exposes --profile, the -s/--slow shorthand, and HK_PROFILE/HK_PROFILES; Config.display_skip_reasons carries profile-not-enabled and profile-explicitly-disabled as first-class skip reasons. The tier is therefore not invented here — it is the upstream selector, adopted, with the selector logic staying upstream where it is already tested.

  • Each expensive step declares profiles = List("slow"): cargo-clippy, test, test:bats, batten-check, token-bench-check, sbom-check. Everything else is unprofiled and always selected.
  • pre-commit runs without that profile. check and fix — the hooks mise run ci and mise run fmt drive, and therefore verify and CI — enable it.
  • The profile sits on the step it governs, so there is no second table to drift.

One empirical question the implementation settles first, rather than assuming. Hook.env is documented as merging into each step's process environment, so whether HK_PROFILES set there feeds hk's selection before dispatch, or only reaches the child, is not settled by the schema text. Confirm with hk check --plan --why. If it only reaches the child, the fallback is the invocation site (hk check --all --profile slow in mise run hooks), which is equally single-authority.

The gate. mise-tasks/hook-profile-check, in the shared gate, globbed on hk.pkl, reading hk check --plan --json — confirmed working in this tree, emitting per step name, status (included/skipped), reasons[].kind and fileCount:

  • every step in the slow tier is still included under the check hook — the load-bearing assertion;
  • every step in that tier is skipped under --profile '!slow', asserted as data rather than inferred from a timing.

The tier set is derived, not declared (revised during implementation). The first draft of this clause said "every expensive step declares the profile". That is not computable: a hand-listed set of expensive steps would be a second authority over what hk.pkl already defines and would go stale in the silent direction, and "expensive" is a wall-clock property that a gate over committed bytes cannot see. The gate instead derives the tier — a step hk excludes for a missing profile is, by construction, one that declared it — and the cost question runs on the clock half.

Which direction is the false green, since the two failures are not symmetric: a tier that stops being skipped at pre-commit is merely slow, loud and self-correcting. A tier that stops being run by check removes clippy, the test suite and batten-check from mise run ci, verify and CI at once — green everywhere, nothing tested. Deleting the config-level profiles line does exactly that with no other symptom, which is what profiled-step-not-in-check exists to red.

No arithmetic at commit time. Whether the fast tier is still fast is a property of the world, so it runs on a clock: a scheduled re-measure reds on drift, the lock-complete/lock-currency and timeout-check/timeout-drift split. The ceiling is set from the measurement taken in the landing change.

The draft PR is pushed before verify

With the expensive steps off the commit path, this ordering carries the safety the gate used to. Drafts trigger no CI — ci-local-parity gates that property — so pushing early costs nothing, makes the work durable against a container reclaim, and gives issue-guard its artifact at the earliest computable moment. Commit often, push the draft, then verify.

Refinement — Ready

Specializations only; the shared clauses live in Definition of Ready & Done.

  • Source of truth (§1). hk.pkl's gate mapping. The profile lives on the step it governs, never in a second list — a separate table would be a second authority over a set hk.pkl already defines, and it would narrow silently the next time a step is added.
  • Mechanism as a computable predicate (§2). mise run hook-profile-check, in the shared gate, globbed on hk.pkl, over hk check --plan --json: the slow tier is non-empty (anti-vacuity, exit 2), every step in it is included under check, and every step in it is skipped under --profile '!slow'. The tier is derived from the plan, never listed in the gate. It is a task and not a [[rule]] because the predicate addresses nodes in a structured document, which no rule kind expresses — forbid sees bytes, command sees an exit code and is refused by batten check outright. CLOUD-452 is the linked capability gap, and this is the second consumer its own re-open predicate names.
  • Effect (§3). read — a check over committed bytes and gate configuration. No new command, no write.
  • Generated artifacts + drift gate (§4). Nothing generated. The scheduled re-measure is the drift sensor on the fast tier's wall clock, and it is the clock half rather than a gate on the commit.
  • Output & exit contract (§5). Pointer-only: hk.pkl:<line> and a rule id, never a step's output or a command line. Exit 1 = violation, 2 = could not read the input — the mise-tasks/*-check convention, which is the inverse of batten's own numbering (mem:toolchain-and-hooks).
  • Commit / bump (§6). cino bump.
  • Test obligation (§7). tests/hook-profile-check.bats for the gate's decision table, plus cases extending tests/hk-selection.bats rather than a second selection suite — it exists for CLOUD-224, already asserts selection through hk check --plan --json with jq '.steps[] | select(.name==$s) | .status', and its header already argues why plan JSON is the only form the negative case can take (a timing cannot tell a skipped step from a cached one). New cases: each expensive step is skipped under pre-commit and included under check; an unprofiled step is included in both. The case that regresses silently is a new expensive step with no profile.
  • Blockers (§8). None. Related: CLOUD-452, the capability gap this links per §2. Overlapping scopes to reference rather than re-file: CLOUD-224, CLOUD-424, CLOUD-398, CLOUD-439, CLOUD-386, CLOUD-488.

Acceptance

  • The pre-commit wall clock is measured and both numbers — before and after — are recorded on this issue. Reasoning about the improvement does not count.
  • The ceiling is derived from that measurement, not chosen.
  • Removing the config-level profile — the change that would silently drop the slow tier from CI — turns the gate red, mutation-checked rather than argued.
  • Every profiled step is proven still to run under verify and CI — read from plan JSON, not from a timing — so the split is shown to move cost rather than drop coverage.

Not in this issue

Per-step content-addressed receipts (CLOUD-424), glob selection for glob-less steps (CLOUD-224), core contention between chains (CLOUD-398, CLOUD-439), the shell suite as the pole (CLOUD-386), and the gate spent before git commit -F - is refused (CLOUD-488). Each of those makes the gate faster; none of them makes "too slow" fail, and none decides where a check belongs. The batten hook PreToolUse budget (CLOUD-207, CLOUD-479) is a different layer and keeps its own number.

CLOUD-664 The SBOM lists 244 components for 198 distinct things: one entry per workflow reference, the root package twice, and a spurious `./action`

Why

The SBOM reports 244 components for 198 distinct things, and every per-component conformance denominator is computed over the inflated number. Measured 2026-08-18 by regenerating the document from origin/main 41180c3 (v0.0.79).

Three separate causes, found while answering CLOUD-629 and CLOUD-630:

  1. One entry per workflow reference, not per action. The 53 pkg:github entries are 9 unique (name, version) pairs: actions/checkout appears 21 times, jdx/mise-action 20, Swatinem/rust-cache 4, actions/cache 2, actions/upload-artifact 2. syft's github-actions cataloger emits a component per reference site, so the inventory says this repository depends on actions/checkout twenty-one times.
  2. The root package is listed twice. batten appears once as pkg:cargo/batten@0.0.79 (sourceInfo: acquired package info from rust cargo manifest: /Cargo.lock) and again with no purl and no sourceInfo — two entries for the document's own subject, and the purl-less one carries supplier: NOASSERTION.
  3. A spurious ./action component. Name ./action, version UNKNOWN, supplier: "Organization: .", acquired from a local composite-action reference in .github/workflows/test.yml. It is a relative path in this repository, not a dependency of it. It can never be enriched, because there is nothing to enrich.

The honest inventory is 188 external crates + batten + 9 unique actions = 198.

Why this matters beyond tidiness

It is the denominator every sibling issue reasons about. no-license=244 is really 198; the non-cargo license gap is 9 unique actions, not 55 entries. A reader of the document cannot tell how many things this repository actually depends on, which is the one question an inventory exists to answer.

Not the same as narrowing the scan. CLOUD-608 explicitly declined shrinking the document's scope to buy conformance. This is not that: no real dependency leaves the inventory. A duplicate entry and a self-referential path were never things the repository depends on, so removing them corrects the claim instead of weakening it.

Refinement — Ready (2026-08-18)

  • Source of truth (§1). mise-tasks/sbom is the single authority for what the documents contain, and it already owns the scan invocation and the exclude list for exactly this class of reason — its header records the tests/bats exclusion as load-bearing rather than tidiness. Component identity is the triple (name, versionInfo, purl). Whether the fix is a syft configuration or a post-process over its output is an implementation call; the identity rule is not.
  • Predicate (§2). A sbom-check clause exits non-zero when any of three conditions holds in the produced document: two components share an identical (name, versionInfo, purl) triple; the root package appears more than once; or a component's name is a relative path (./…) or its versionInfo is UNKNOWN. A command and an exit code over one file; nothing is judged.
  • Effect (§3). mise-tasks/sbom already writes the documents and is classified as a producing task; no new subprocess and no network call. The sbom-check clause is read-only and joins the allowlist beside its siblings.
  • Output and exit (§5). Pointer-only per house-style §6: batten.spdx.json:0 sbom-components-inflated (entries=244 distinct=198 dup=<n> pathlike=<n>) — counts and the document path, never a component list. Exit contract unchanged: 0 clean, 1 finding.
  • Commit / bump (§6). fix(sbom)patch; the workspace is 0.0.79, below 0.1.0, where every release-worthy type collapses to a patch.
  • Test obligation (§7). Four cases.
    • A document carrying one action referenced from two workflows yields one component. Fails on today's output, which yields two.
    • The root package appears exactly once, and that once carries its purl.
    • A relative-path component name is refused, so the ./action shape cannot come back when a workflow gains another local action.
    • sbom-check's existing sbom-package-drift clause still passes byte-identical, and this is the case that bounds the change: that clause compares the pkg:cargo count against the root Cargo.lock, and the cargo entries are already distinct by (name, version) — the four names appearing twice (getrandom, hashbrown, syn, windows-sys) are genuinely two versions each. A dedup that changed the cargo count would have merged two real dependencies, which is the one way this fix could do damage.
  • Blockers (§8). None. relatedTo CLOUD-628, CLOUD-629 and CLOUD-630 (the three enrichment issues whose denominators this corrects) and CLOUD-631 (the conformance ceiling, which moves with the denominator).

CLOUD-1149 `repoints_at_the_declared_successor` cannot match how bash spells a sibling, and its own comment names the case it does not admit — 27 governed callers are unrepointable by a two-character gap

Why

policy/shell-retirement.rego:255 admits a sibling edit that repoints a caller at a retired program's declared successor:

repoints_at_the_declared_successor(line, removed) if {
	some was in removed
	some gone in delta.deleted
	contains(was, gone)
	some succ in successors_for(gone)
	replace(was, gone, succ) == line
}

gone is a member of delta.deleted, and a deleted path is repo-relative — the module says so itself ("A deleted path is classified BY PATH... mise-tasks/** minus the explicitly non-shell paths"), and governed_when_deleted is a startswith("mise-tasks/") test. So gone is mise-tasks/ready-lint.sh, and contains(was, gone) fires only on a line carrying that literal string.

Bash does not spell a sibling that way. The measured line:

mise-tasks/graph-check.sh:163   lint="$(dirname "$0")/ready-lint.sh"

contains("lint=\"$(dirname \"$0\")/ready-lint.sh\"", "mise-tasks/ready-lint.sh") is false. only_drops_a_retired_reference (:200-204) carries the identical requirement and fails identically. The edit is therefore V-SHELL-RULE-EDITED, which declares one route and no override and no bypass_env.

The clause's own comment names the case it cannot handle

policy/shell-retirement.rego:244-245:

"THE CAMPAIGN COULD NOT COMPLETE A RETIREMENT IT HAD ITSELF MANDATED without this... Measured on mise-tasks/graph-check.sh, whose one line resolves the readiness gate by path."

That is the exact line above, and the clause as written does not admit it. Comment and code disagree, and the comment is the half that has been believed. This is not a documentation nit: the comment is what a reader consults to decide whether a retirement is landable, and it says yes where the predicate says no.

Measured blast radius

41 relative-resolution call sites across 27 governed callers. Every one forces its caller into the same delta as its callee, because there is no admitted spelling for repointing it. Census against 2f1be52, 132 governed programs:

class count suite seconds recoverable
FREE (no inbound governed ref) 83 481.4
REPOINTABLE (literal path only) 7 29.6
BUNDLED (forced by this defect or a multi-subject header) 39 373.0
GENUINELY BLOCKED (runtime, not policy) 3 238.1

The largest forced bundle is the board-gate chain: ready-lintgraph-checkreleasedboard-sweep, plus bot-issue via a live invocation in its suite. Six programs, eleven deleted paths, ~45.5s of suite, none of which is individually blocked by anything but this.

mise-tasks/board-sweep.sh:229-230 is the hardest case and bounds any textual fix:

for gate in graph-check duplicate-close-check released in-progress-drain done-pr-check spec-ref-check; do
	[[ -x "$here/$gate.sh" ]] || {

The filename is computed from a task name, so no substitution of any kind reaches it. A fix that only widens string matching does not clear this one.

Two directions, and the row must pick one

  1. Widen the match. Admit a line that names gone's basename where the surrounding text resolves to the same path ($(dirname "$0"), $here, $BATS_TEST_DIRNAME/..). Cheap, but it loosens a clause whose narrowness is its safety argument — the module is explicit that byte-exactness is "what makes that structural rather than hoped".
  2. Normalise the call sites first. Land a one-line-per-caller normalisation to literal repo-relative paths as an ordinary change — which the gate does not currently permit either, since that edit is itself V-SHELL-RULE-EDITED. So this direction needs its own admitted shape.

Neither is obviously right and the row's job is to decide, with the board-sweep.sh computed-name case as the acceptance test for whichever is chosen.


Refinement — Ready (decide the admission's shape, with the computed-name case as the test)

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

  • **Authority boundary (§1). **policy/shell-retirement.rego's admission clauses and their comment block. policy/ is not governed — governed_at_head selects mise-tasks/ paths plus tests/**/*.bats and nothing else — so this module is an ordinary editable file and no retirement is required to change it. No mise-tasks/ program and no .bats file is edited or added.
  • Computable predicate (§2). A sibling line that resolves a retired program through $(dirname "$0"), $here or $BATS_TEST_DIRNAME/.. is either admitted when it repoints at the declared successor, or refused with a verdict that names why it cannot be spelled — never refused with a comment claiming the case is covered.
  • The comment is in scope and is not a nit (§2). Whichever direction is chosen, :238-245 must stop naming graph-check.sh as a case it handles if it does not. A remedy a reader cannot act on is CLOUD-1050's recorded class.
  • Deliberately not in scope (§2). Retiring any program. Weakening V-SHELL-RULE-EDITED to carry an override or a bypass_env — the no-override property is the design and this row does not touch it. The three runtime-blocked programs (ci-lease-precondition, land-lock, land), whose blocker is a from-trunk blob fetch and not this clause.
  • **Effect (§3). **read. A policy module decides; it writes nothing.
  • Output and exit (§5). Pointer-only: the refusing path and line, the retired path it names, never the line's contents. V-SHELL-RULE-EDITED's existing verdict token and route are unchanged unless the chosen direction adds a class, in which case the [[verdict]] registry gains a row — a module raising a token no row declares fails to load.
  • **Commit / bump (§6). **fix(policy)patch.
  • Test obligation (§7). Module test_ rules pin the predicate; crates/batten/tests/*.rs over the compiled binary is the tier that proves the engine builds the input the predicate reads. Shown able to fail per CLOUD-418, four cases: (a) a $(dirname "$0") repointing at the declared successor behaves as the chosen direction says; (b) a $(dirname "$0") line repointed at something the ledger does not declare is still refused — the anti-vacuity case, without which (a) is satisfied by admitting everything; (c) the board-sweep.sh:229-230 computed-name shape is handled or explicitly refused with a nameable verdict; (d) an unrelated edit smuggled into the same file is still refused. **Mutated: **shell-retirement is in $MUTANT_GATES and a mutation over the new conjunct must discriminate — a mutation some other conjunct already excludes will survive, and surviving is the only way you find out.
  • Blockers (§8). None. relatedTo CLOUD-1121 (which added the clause), CLOUD-1051 (which added the truncation clause one case earlier), CLOUD-843 (the campaign this gates), CLOUD-1132 (the two-shapes doctrine surface), CLOUD-1148.

Acceptance

  • The clause and its comment agree about mise-tasks/graph-check.sh, in whichever direction the row decides.
  • The board-sweep.sh computed-name case is either admitted or refused with a verdict a reader can act on — not silently unmatched.
  • The board-gate bundle (ready-lint, graph-check, released, board-sweep, bot-issue) is shown landable, or the row records exactly what still stops it.
  • No override and no bypass_env is added to V-SHELL-RULE-EDITED.

Found by censusing all 132 governed programs for retirement cost after a grooming session twice recorded the wrong reason for the same set of blocked programs — first "by-path callers make retirement impossible", then "repoints_at_the_declared_successor makes them repointable". Both were wrong; the discriminator is literal repo-relative path versus relative resolution.

CLOUD-1159 Retire the SBOM/release-artifact unit — `sbom-check` (38.5s) is UNBLOCKED, CLOUD-1265's producer LANDED; `sbom.sh` is a producer, not a gate, and owes a disposition rather than a fact

DECIDED 2026-09-02 — sbom.sh STAYS as the producer; its seconds are accepted

Owner's decision on the reachability question the 2026-09-02 correction block below poses. Of the two admissible answers, the first is taken: mise-tasks/sbom.sh stays as the producer whose output sbom-check's successor reads a record of. Its suite's cost (4.8s at origin/main ee1c9f52) is a cost the campaign accepts, not a retirement it waits on. No spawning producer verb is added — CLOUD-1176's growth is not bought for a program that decides nothing.

Consequence for the bundle: unchanged from the block below. sbom-check.sh + tests/sbom-check.bats retire alone under CLOUD-1318; sbom.sh is neither deleted nor edited, and sbom-check.sh:44's constructed-sibling read is an admitted repointing (CLOUD-1149 arms 2 and 3).


BOTH BLOCKERS IN THE TABLE BELOW HAVE RESOLVED, IN OPPOSITE DIRECTIONS — 2026-09-02

The table below records sbom-check blocked on CLOUD-1265 and sbom.sh blocked on
CLOUD-1251. Read live against origin/main 9ece058c, neither verdict still holds, and
the two moved for different reasons — which is why this note replaces both rather than
flipping one flag.

sbom-check (38.5s) is DISPATCHABLE. The producer exists.

CLOUD-1265 is In Review, which in this repository means landed on main, and the
mechanism is checkable rather than asserted:

git cat-file -e origin/main:crates/batten/src/record.rs   # exists

Its own header: "The write half of the two out-of-tree verdict stores (CLOUD-1265) … Both
shipped correct, and both shipped with the only writer in the tree being a test … This is***
***that producer."

That is the channel sbom-check.sh:213's cargo metadata --format-version 1 --offline
needs, and it settles the regenerate-and-compare half too: record.rs states the shape as
"a mise task or a CI step runs the tool, reduces its answer to <name> <token> lines,*
*and pipes them here" — so the successor never spawns, which is what house style §5 requires
of a check.

sbom.sh (12.1s) is NOT blocked on a fact, and never was. It is not a gate.

CLOUD-1251 DECIDED, 2026-09-01: answer 2. "A producer resolves the runtime-discovered
set outside the engine, and a module reads what was written. The engine does not grow a*
*glob." So the [[rule.external]] route the "sixth wrong verdict" section below sends
sbom.sh down is closed permanently rather than pending — crates/batten/src/rules.rs:3947
still refuses any path that is not a single relative path beneath its root, and that is
now the settled design.

The deeper reading is that out-of-root was the wrong axis for this program entirely.
mise-tasks/sbom.sh:2 is #MISE description="Effect: derive the SPDX and CycloneDX inventories of this tree…". It derives an artifact; it decides nothing. cargo_src_roots
and copyright_of exist to FILL the SPDX PackageOriginator field, not to adjudicate — so
no fact could ever have carried it, because a fact feeds a predicate and this program has
none.

That is the class the HOMES section below already assigns to dist.sh: "NOT POLICY AT ALL*
— it builds a release artifact. That is a build task, not a gate; it owes no successor and
**no ledger arm beyond its own deletion, if it is retired at all." *sbom.sh belongs in that
row, not the blocked one.

So the live question for sbom.sh is REACHABILITYCLOUD-1174's fourth axis, which asks
whether a unit's seconds need a retirement at all — and it is a decision this row owes in
writing, with two admissible answers:

  • it stays, as the producer whose output sbom-check's successor reads a record of, and
    its 12.1s is a cost the campaign accepts rather than a retirement it is waiting on; or
  • it becomes a spawning producer verb on CLOUD-1265's landed precedent, whose §3 is
    write"it spawns the validator and writes under $GIT_DIR… it is emphatically not
    check" — at the cost of one more top-level noun, which is the growth CLOUD-1176 and
    CLOUD-1194 exist to price.

What is NOT admissible is what this row does today: recording sbom.sh as blocked on a
fact family that has decided, in writing, that it will never carry it. That is CLOUD-1166
arm 4 in the direction this row has already been corrected in twice.

The consequence for a bundle

sbom-check.sh and tests/sbom-check.bats retire now, on their own. The caller/callee
direction is the harmless one: CLOUD-1149's landed arms 2 and 3 admit the constructed-sibling
repointing at sbom-check.sh:44, and sbom.sh surviving its caller drags nothing in**.****
**38.5s, one program, two deleted paths, two ledger arms.

Nothing else in the body below is changed by this note.


THE TITLE SAID "ONLY sbom.sh" AND THAT IS ONE PROGRAM SHORT — corrected 2026-08-31

sbom-check (38.5s, the unit's largest member) has a second input this row has never
recorded. mise-tasks/sbom-check.sh:213:

if ! meta=$(cargo metadata --format-version 1 --offline 2>/dev/null); then
	echo "::error:: sbom-check: could not read cargo metadata, so whether the document's originators agree with the manifests is unverified." >&2

That is a third-party tool's verdict keyed to a pinned version over a known input
precisely input.tree["tool-verdict"]'s shape, and precisely the channel that has no**
**producer: crates/batten/src/tools.rs reads .git/batten-tools/<tool>‖<version>‖<digest>
and the only writer in the tree is a test, so the fact resolves null on every real
checkout. That gap is CLOUD-1265, and sbom-check
is a named consumer of it.

The row's own header at :203-213 says why the read exists and cannot be dropped:
PackageOriginator is who WROTE a component, which only cargo metadata's authors
answers, and both halves of the supplier check are asserted.

So the blocked set is TWO, not one:

program s blocker evidence
sbom.sh 12.1 CLOUD-1251 :322 globs "$home"/registry/src/*/ — a discovered set
sbom-check 38.5 CLOUD-1265 :213 cargo metadata --format-version 1 --offline

That is 50.6s of the unit's 62.6s blocked, not 12.1s. The dispatchable remainder is six
programs and ~12.0s — release-assets-check 5.3, ntia-check 4.2, render/cli 0.9,
sbom-binary 0.8, checksums 0.6, dist 0.2 — which is a materially different bundle from
the one this row's title advertises.

One thing I claimed and am withdrawing before it propagates

I first recorded sbom-check.sh:44SBOM="$(cd "$(dirname "$0")" && pwd)/sbom.sh" — as a
second blocker, on the reasoning that it executes a blocked program. That is wrong and the**
**row already refutes it: CLOUD-1149 landed arms 2 and 3 of
is_retired_reference_by_text for exactly that constructed-sibling spelling, so the site is
an admitted repointing and no caller dies with its callee. sbom-check's blocker is :213
and nothing else.

The cause is the one this row already records twice. CLOUD-1166
arm 4 — a blocked verdict must quote its source — and the "sixth wrong verdict" section below
records that an UNBLOCKED verdict owes the same quotation. This is the seventh instance and
it ran in both directions inside one pass: sbom-check was cleared without reading :213,
then blocked on :44 without reading CLOUD-1149's landed arms.


THE TITLE ASSERTED THE GLUE ITS OWN BODY DISSOLVED — corrected 2026-08-31

The title read "9 programs, 59.8s, glued by six $(dirname "$0") resolutions through
sbom.sh
"
. The body's own CORRECTION section says that glue is gone: CLOUD-1149
landed arms 2 and 3 of is_retired_reference_by_text for exactly the constructed-sibling
spelling, so every one of the six glue sites is now an admitted repointing and no
caller dies with its callee. A correction block does not correct a title, and the
title is what a queue listing renders — so this row went on reading as a nine-program
monolith after it had stopped being one.

And #780 has landed, so the count and the seconds have both moved

The body says "PR #780 has not landed, so reference-check has not retired". It has.
Verified at origin/main 5b98174#780's head sha **is **main's head:

git cat-file -e origin/main:mise-tasks/reference-check.sh   # gone
git cat-file -e origin/main:tests/reference-check.bats      # gone

The unit is 8 programs, not 9, and its largest single member left with it
(reference-check.bats, 14.8s). That is also the second landed demonstration that this
unit slices — reference-check retired alone, in a bundle containing none of the other
eight, exactly as the correction predicted.

Current seconds, from bench/suites/RESULTS.md at 5b98174 (138 suites, 1097.1s),
not from this body: sbom-check 38.5 · sbom 12.1 · release-assets-check
5.3 · ntia-check 4.2 · signing-posture — not a member · render-cli 0.9 ·
sbom-binary 0.8 · checksums 0.6 · dist 0.2 = 62.6s, higher than the
59.8s this body quotes despite one member leaving, because the corpus was regenerated
after CLOUD-1198. Read RESULTS.md, never this body.

blockedBy CLOUD-1154 removed — the body already said it was satisfied

"Forge — ANSWERED … This row's blockedBy on it is satisfied." The relation outlived
the sentence; it is gone now.

What is genuinely still blocked, and it is ONE program

sbom.sh only, on CLOUD-1251:322 globs
"$home"/registry/src/*/ across discovered registries × ~280 package directories. The
"sixth wrong verdict" section below establishes that and is correct; it is the one part
of this row that should be read as current. Every other member is unblocked, and the
homes section already assigns each one.

So the dispatchable shape is: retire the seven, carve sbom.sh out with its reason
recorded, and let CLOUD-1251 return it — not a nine-program delta, and not a wait.

Nothing else in the body below is changed by this note.


Why

Unit 2 of the 83-unit partition of the governed shell. 9 programs, 9 suites, 59.8s — 4.8% of the 1244.6s serial suite. Fourth-largest unit in the tree.

Programs: sbom.sh, sbom-check.sh, sbom-binary.sh, ntia-check.sh, dist.sh, checksums.sh, render/cli.sh, release-assets-check.sh, reference-check.sh.

Suites: sbom-check.bats 26.1, reference-check.bats 14.8, sbom.bats 5.9, release-assets-check.bats 5.8, ntia-check.bats 4.2, render-cli.bats 1.1, sbom-binary.bats 0.9, checksums.bats 0.8, dist.bats 0.2.

5 of 9 in $MUTANT_GATES (sbom, sbom-check, ntia-check, reference-check, release-assets-check). 19 #MUTANT rows to re-home (sbom 12, ntia-check 4, sbom-check 1, release-assets-check 1, reference-check 1).

The glue — why these nine are one delta

Every admission in policy/shell-retirement.rego tests contains(was, gone) against the repo-relative path. A caller resolving a sibling through $(dirname "$0") carries no such literal, so no admission fires and the caller must die in the same delta.

  • sbom.shsbom-check.sh:44 (SBOM="$(cd "$(dirname "$0")" && pwd)/sbom.sh"), ntia-check.sh:74, release-assets-check.sh:80
  • render/cli.shreference-check.sh:50, release-assets-check.sh:85, and tests/reference-check.bats:37,48,118,123,130
  • sbom-binary.shrelease-assets-check.sh:104
  • checksums.shrelease-assets-check.sh:177
  • dist.shsbom-binary.sh:52

Self-contained — the closure terminates. install-check.sh:6,40,130 reaches dist.sh only by literal path, so it is droppable/repointable and does not join.

CORRECTION (2026-08-31) — THE GLUE IS DISSOLVED, AND ONE OF THE NINE IS ALREADY RETIRING WITHOUT THE OTHER EIGHT

The paragraph above is stale, and this row's own relatedTo predicted it: it lists
CLOUD-1149 as the row "whose fix would let five of the nine retire separately".
CLOUD-1149 is Done. It added arms 2 and 3 of is_retired_reference_by_text
(policy/shell-retirement.rego:390, :398) for exactly the constructed-sibling
spelling — $(cd "$(dirname "$0")" && pwd)/sbom.sh and $here/x.sh. Every one of the**
**six glue sites listed above is that spelling, so each caller's repointing is now an
ADMITTED ADDITION and the caller no longer has to die with the callee.

Measured rather than argued: reference-check is being retired right now, on its
own, in PR #780 ("retire config-deprecations, reference-check, schema-check and*
**skill-check"*) — a bundle that contains none of the other eight. Under this row's glue
argument that PR could not exist. It does, and it is one of this unit's largest suites
(reference-check.bats, 14.8s).

So unit 2 is not one delta. It slices along the six $(dirname "$0") edges, and the
remaining question per program is its HOME and its BLOCKER, not its co-deletion.

Both stated blockers have also landed

  • Out-of-root — THIS BULLET WAS WRONG AND IS CORRECTED IN THE SECTION BELOW. It read:
    "ANSWERED, and it is NOT the discovered-set class — sbom.sh:321 reads
    ${CARGO_HOME:-$HOME/.cargo}
    , a DECLARED path beneath a NAMED root variable … a
    *
    single path, so it does not hit CLOUD-1251's one-path-per-row limit. sbom.sh is
    **expressible." ***Do not act on that sentence. **CLOUD-1167 being Done is true;
    the verdict about sbom.sh is not.
  • **Forge — ANSWERED. **release-assets-check.sh:125 and checksums.sh:58 run
    gh release view/download; CLOUD-1154 is Done and input.tree.forge
    exists. This row's blockedBy on it is satisfied. The waiting half still never
    moves into the engine — that is CLOUD-1144's settled shape, not a blocker here.
  • The clause reading "the same gap hooks-wiring-check sits behind, and it has no row*
    ***yet" is stale twice over: CLOUD-1160 was filed for it and is now Done.

A SIXTH wrong verdict, found 2026-08-31 auditing the correction above — sbom.sh IS CLOUD-1251's class

The correction pass above cleared sbom.sh by reading ONE LINE of it, and the line below it is the answer. mise-tasks/sbom.sh:318–323:

# The unpacked source root. Several registries can be present; each package is
# looked up under all of them, so a vendored or alternate registry resolves too.
cargo_src_roots() {
	local home="${CARGO_HOME:-$HOME/.cargo}"
	printf '%s\n' "$home"/registry/src/*/
}

That is a glob, and the comment above it says why in as many words: "Several registries can be present; each package is looked up under all of them." The members are resolved at runtime. Downstream, copyright_of at :326 is called per <name>-<version> directory, and :308 states the search reads "every pinned byte" — measured at :311 as 280 external crates.

So the shape is registries (discovered) × ~280 package directories (per lockfile) × every file in each. That is exactly CLOUD-1251's one-path-per-[[rule.external]]-row limit, not the single declared path the bullet claimed. sbom.sh is still blocked — on the very row the bullet said it missed.

Two things this changes:

  1. sbom.sh stays out of any dispatchable set until CLOUD-1251 answers — and it is a second named consumer of that row alongside transcript-corpus-check, worth stating because CLOUD-1251 is sized against one consumer.
  2. A discovered-set fact may still not be enough here, and that is left OPEN rather than answered either way: this predicate reads the CONTENT of hundreds of out-of-root files and regexes it. Whether input.tree.external is meant to carry that volume is CLOUD-1251's to decide. Naming it open is the honest record; asserting either answer here would repeat the defect this section corrects.

The cause, stated because it is the same one twice. The bullet was written during the pass that corrected five other verdicts for exactly this — classifying without reading far enough — and it stopped one line short of the file's own comment. CLOUD-1166 arm 4 says a blocked verdict must quote its source; an UNBLOCKED verdict owes the same quotation, and this is the measured instance for that direction. A path:line is not evidence unless the code around it was read.

The "Successor feasibility" section below is SUPERSEDED in part — read this before it

It closes: "So this unit cannot retire whole until a fact projects a path outside the repo root. That is the same gap hooks-wiring-check sits behind, and it has no row yet." The conclusion holds — the unit cannot retire whole — but for the reason in the section immediately above (CLOUD-1251's discovered set), not the one it gives: an out-of-root fact DOES exist now (CLOUD-1167, Done), and "it has no row yet" was false when written.

Re-verified 2026-08-31 against origin/main 0683ce53

Every path:line this row cites still resolves, and all nine programs plus all nine suites are still tracked — PR #780 has not landed, so reference-check has not retired and the 59.8s has not moved:

  • mise-tasks/sbom.sh:321local home="${CARGO_HOME:-$HOME/.cargo}", and :322, the glob the correction missed
  • mise-tasks/checksums.sh:58gh release view --json tagName; :69 gh release download
  • mise-tasks/release-assets-check.sh:125gh release view --json tagName; :133 and :204 likewise
  • mise-tasks/reference-check.sh:80cargo run --quiet -p batten -- spec --format json

What this row still owes

Not a nine-program delta. Per program: its home (the HOMES section below is still**
good) and, for the two syft wrappers, whether a command rule is the disposition or
**whether CLOUD-1145's DELETE question applies — that row's §11 point, that a
comparator over a derivation which can never drift guards nothing, reaches
reference-check and render/cli directly.

And the 59.8s figure is going stale as this is written, because PR #780 removes
reference-check.bats. Read bench/suites/RESULTS.md as regenerated, never this body.

Read each program before recording why it cannot move. Three blocked verdicts on
CLOUD-1163 and five of six blocked classes on CLOUD-1164
were corrected the same day, all from the same cause: the program was classified from
its category rather than read.

Successor feasibility, stated because the campaign has been measuring only landability

This unit is partly blocked, and the split matters:

  • Expressible today: reference-check and render/cli shell out to batten itself (reference-check.sh:80 is cargo run -p batten -- spec --format json). Folding that away is the port — the same shape as CLOUD-1145, where the port is also the performance fix.
  • Blocked: sbom.sh:321 reads ${CARGO_HOME:-$HOME/.cargo} — a path outside the repo root, which no tree fact projects. release-assets-check.sh:125 and checksums.sh:58 are gh release view/download, which need CLOUD-1154's producer. dist.sh:149 is a build product.

So this unit cannot retire whole until a fact projects a path outside the repo root. That is the same gap hooks-wiring-check sits behind, and it has no row yet — it is named here rather than left implicit.

HOMES (2026-08-30): most of this unit is PRIOR ART, not a port

This row said *"successors land in *crates/batten/ and/or policy/*.rego" for all nine. CLOUD-1176 makes the home the first question, and §9 answers most of it: "Rules and actions are inspectable declarative config that name a command already on the operator's PATH — never a downloaded, executed binary."

  • **PRIOR ART — **sbom, sbom-binary, sbom-check, ntia-check, checksums. The SBOM itself is produced by syft (sbom.sh:682, sbom-binary.sh:120), already pinned and on PATH. These programs are wrappers that spawn a tool and adjudicate its output. The wrapper deletes; what survives is a [[rule]] of kind command naming syft, which is exactly §9's "universal extension surface". Batten does not grow an SBOM subsystem — the scope reminder lists neither SBOM nor supply chain, and CLOUD-455 is the row about not re-deriving a tool we already ship.
  • **EXISTING §2 VERB — **reference-check, render/cli. Both shell to batten … spec --format json (reference-check.sh:80) to compare a committed doc against what the binary renders. That is generate's territory under §11, the same shape as CLOUD-1145 — and it inherits CLOUD-1145's §11 question: if the committed reference doc is a derivation that "can never drift", the comparator guards nothing and the disposition is DELETE.
  • **NOT POLICY AT ALL — **dist. It builds a release artifact. That is a build task, not a gate; it owes no successor and no ledger arm beyond its own deletion, if it is retired at all.
  • **BLOCKED — **release-assets-check. gh release view/download needs CLOUD-1154's producer, and the waiting half never moves at all.

So the 19 #MUTANT rows do not all need re-homing — a mutation over a wrapper that deletes has nothing to move to, and CLOUD-1080's WITHDRAWAL arm is the spelling. Settle each program's home before counting arms.


Refinement — Ready (one delta, nine programs; blocked on an out-of-root fact)

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

  • Authority boundary (§1). The nine programs above and their nine suites are deleted. Each carries the home named in the section above — PRIOR ART, an existing §2 verb, or a withdrawal; no new crates/batten verb. 18 deleted paths; a // carried: arm where a successor exists and CLOUD-1080's withdrawal arm where none is owed. The five $MUTANT_GATES entries and 19 #MUTANT rows move to the successors' tier. hk.pkl's sbom-check/reference-check steps and .github/workflows/release-artifacts.yml's dist/sbom-binary/sbom/render:cli/checksums invocations are ungoverned and repoint freely.
  • Computable predicate (§2). Unchanged in substance: does the committed SBOM match a fresh scan, does the NTIA minimum-element set hold, do the published release assets match what dist produced, and does the committed reference doc match what the binary renders. Only where the predicate lives moves.
  • Deliberately not in scope (§2). Changing what an SBOM contains or which NTIA fields are required. Retiring install-check, which reaches dist.sh by literal path and is its own unit.
  • **Effect (§3). **read for the check half. dist, sbom-binary and checksums produce artifacts and keep their existing effect class — a successor that renders to a scratch directory and compares must not start writing over the tree.
  • Output and exit (§5). Pointer-only: the artifact path and the field that differs, never an SBOM's contents. Exit follows the 0/1/2/3 table; a could-not-look reading (no release, no network) is 3, never a false 2.
  • **Commit / bump (§6). **refactor(ci)no bump. Below 0.1.0 every release-worthy type collapses to a patch, but refactor is not one: it releases nothing at any version. CLOUD-595's correction.
  • Test obligation (§7). Over the compiled binary in crates/batten/tests/; no .bats file is added or edited (V-SHELL-RULE-ADDED refuses one at deny). One // carried: arm per deleted path — 18 paths, 18 arms — each naming where its cases went, since retires_with conserving files rather than logic is CLOUD-908's recorded failure. Shown able to fail per CLOUD-418: a deliberately drifted SBOM is reported, a missing NTIA element is reported, and the anti-vacuity mirror — a clean tree passes. Mutated: all 19 rows re-homed, mutant-census green across the move. Replayed: old and new run over the same tree and agree artifact-for-artifact.
  • Blockers (§8). Rewritten 2026-09-02. None. Every row this clause used to wait on has resolved — the forge producer is Done and its relation was removed, the tool-verdict producer has landed, and the out-of-root question has decided in a direction that closes sbom.sh's route rather than opening it, so sbom.sh owes a REACHABILITY decision on this row rather than a wait on another. The correction block at the top of this body names all three and quotes the evidence. relatedTo CLOUD-1151 (the wave owner), CLOUD-1149 (whose landed arms let the members retire separately), CLOUD-1265, CLOUD-1251, CLOUD-1174 (the reachability axis), CLOUD-1080 (the withdrawal spelling), CLOUD-908, CLOUD-418.

Acceptance

  • All 9 programs and 9 suites deleted, each with the correct arm for its home, no other governed file edited.
  • The SBOM half names syft through a command rule rather than re-deriving it — CLOUD-455's question answered in writing.
  • No new crates/batten verb; batten spec --format json emits exactly the committed row set.
  • An out-of-root fact exists for $CARGO_HOME, or sbom.sh is carved out with its reason recorded.
  • mutant-census green; all 19 mutations honoured at their new homes.
  • bench/suites/RESULTS.md regenerates with all nine suites absent and the serial total down ~59.8s.

Unit 2 of 83 from a full partition of the 132 governed programs into minimal landable units.

CLOUD-1176 The retirement campaign has one disposition — "port it into the core" — so it launders bash-era scope creep into `crates/batten`, and the ratchet's landed WITHDRAWAL arm has never been used

RESTORED TO TODO TWICE ON 2026-08-31 — 04:36:53 (Done) and 17:28:35 (In Review). Neither transition shipped anything of this row's.

The second one is the instructive one, because it was guarded and the guard did not reach. PR #789 carried DO-NOT-CLOSE CLOUD-1176 on its own line and closing-key-check passed exactly as designed — it closed CLOUD-1239 and CLOUD-1182 and stranded nothing. But two of #789's commits carry Refs: CLOUD-1176, and the tracker's attach-and-transition automation fires on the trailer, not on the body. So the row went Todo → In Progress at 16:19:14 (the PR event) → In Review at 17:28:35 (the merge), while DO-NOT-CLOSE sat in the body doing the only job it has.

DO-NOT-CLOSE is a closing-key-check **token, not a board-automation one. **mise-tasks/closing-key-check.sh defines it to answer "does this body decline to close a key it served" — a question about the PR body, decided locally, before the push. Nothing in this repository can stop the tracker acting on a Refs: trailer, so the marker bounds the gate's surface and not the board's. A session reading the marker as protection against a board move is reading a guarantee that was never offered, and that is what happened here.

The honest options are two, and neither is the marker: cite the row in prose without a Refs: trailer (what this PR's own memory commit ended up doing, for exactly this reason), or accept the In Review and restore it. #789 chose the trailer deliberately — the citation is honest, the row's finding IS partly mechanised by it — and paid this cost.

What #789 actually landed against this row, so a later reader is not misled by the attachment: has_policy_surface gained a preset arm (CLOUD-1239), and an engine-source successor now declares kind:verb or kind:mechanism with 113 landed arms annotated — 77 verb, 36 mechanism (CLOUD-1182). That closes this row's "a tree-wide search for CLOUD-1176 returns zero hits" finding and makes the one-disposition claim a number. It does not meet this row's acceptance: no gate reports a PR proposing an unspecced verb, and no row has been re-dispositioned.

The 04:36:53 Done was worse and is recorded below.

ORIGINAL NOTE — RESTORED TO TODO 2026-08-31. This row was marked Done at 04:36:53 by automation and nothing had shipped.

A grooming session put Refs: CLOUD-1176 in PR #751's body; the tracker attached the PR and completed the row. #751 is an unmerged draft. Nothing in this row's acceptance was met: no gate reports a PR proposing an unspecced verb, and no row has been re-dispositioned.

What #751 actually does is a fraction of one acceptance bullet — it writes three of the five homes into .claude/rules/toolchain.md's retirement section, and fixes has_policy_surface so a PRESET successor can be spelled at all (0 of 609 landed arms name one, because the arm did not exist). That is CLOUD-1182's territory more than this row's, and it is not a claim on this row.

Two rules were broken by the automation and are worth stating because both are already written down: AGENTS.md says Done means RELEASED and is yours to set, never the merge's — so no PR event may complete a row; and mem:workflow/board-states says a Done row's body is a record, so a row completed with nothing shipped is a false record that a later reader has no way to detect.

Why

Every row in this campaign assumes the successor is a thing built in
crates/batten. The rows say so in terms — CLOUD-1168's §2 requires the port
"conserve that logic unchanged, defect included***", and CLOUD-1145's §1 says
"the successor is a verb in crates/batten/
"*. No row asks whether the
predicate belongs in Batten at all.

That is a machine for moving bash-era scope creep into the core, and it
contradicts the governing documents in four places.

What the spec says, and what the corpus actually contains

§2's command surface is a DECLARED INTENT, not a closed list — and this row said the opposite. §2's own CLOUD-244 note settles it: "SURFACE is authoritative for what ships and this section for what is intended", and spec::tests::the_emitted_surface_is_exactly_the_committed_row_set "fails when a verb is added, renamed, or re-parented — the prompt to reconcile this section in the same change." That is a drift gate that demands reconciliation, never a prohibition on new verbs. §2 also states plainly that the doc-vs-binary comparison "is not yet a gate: this section is out of tree" (CLOUD-95).

Measured against the emitted spec, 2026-08-30 — 18 rows disagree, so "closed" is false empirically as well as textually:

  • §2 names, never shipped (6): gate, guard, main, pr, questions, scan
  • shipped, §2 never names (12): attribution, claim, commit, override, payload, perf, ready, receipt, semver, state, target, wiring
  • agreeing: 17

So the refusal this row is built on is §9 and rule 1, not §2. A new verb is not forbidden; a verb carrying consumer-specific behaviour is. Every conclusion this row draws survives on that footing — the board gates are CLOUD-*-shaped, hooks-wiring-check is hook-runner concern, ci-local-parity is a file-shape linter — and none of them needed §2 to be closed.

And §2 ALREADY NAMES the verbs this campaign has been re-inventing. pr create|ready|land|watch|dispatch (write), gate ("the one merge/ready bar"), main status|sync, scan. pr watch is the CI-wait shape, declared and unshipped; pr land is the landing loop; gate is the ready bar. A retirement whose successor is one of those is landing an intended verb, not widening the surface — which is the opposite of what this row told six others.

§9 and the document's closing line. "Consumer-specific behaviour is
reconstructed through extension surfaces, never baked into the core" — and
"Consumer-specific detail is intentionally absent — it belongs in the consumer's*
*own repo, never in the core."

Non-negotiable rule 1. No consumer-specific identifiers in crates/batten.
The board gates are CLOUD-*/Linear-shaped by construction, so porting them into
the core violates it outright rather than marginally.

The scope reminder. Batten is "not a hook runner, file-shape linter,*
**secret scanner, AST linter, or reference monitor." *hooks-wiring-check is
hook-runner concern; ci-local-parity is a YAML file-shape linter.

And §11 undercuts the single largest row. Completions and man pages are
derivations of the runtime-emitted spec "so the shipped binary and the generated*
**docs can never drift". derived-check is 210.8s — 16.9% of the bats corpus —*
**guarding a drift the design says cannot happen.

The mechanism already exists and the campaign does not use it

2026-08-30 — the missing half is now filed as CLOUD-1182, and it is narrower than this row. This row's predicate is "name your home from the five". CLOUD-1182's is one level down and is the reason the default has been "port it": shell-retirement's has_policy_surface (shell-retirement.rego:549-559) accepts a successor at policy/*.rego OR crates/batten/src/*.rs and does not distinguish them. So a CLI-verb port clears the retirement gate exactly as well as a module port, and the [rule.conserves] arm markers (// carried:, // subsumed:) record where the predicate went but never what kind of successor it became. Nine ports took the verb route and the ledger cannot tell you that.

The two rows are complementary, not duplicates: this one chooses the home before design; CLOUD-1182 makes the choice legible in the ledger afterwards, so the count is reviewable instead of emergent. CLOUD-1182 is unblocked and is the cheaper of the two.

Measured while filing it: a tree-wide search for CLOUD-1176 returns zero hits — not in .claude/, batten.toml, policy/*.rego, or crates/. That is expected for a Todo row, and it is also the thing to fix on landing: this row's doctrine has to end up somewhere a gate or a rules file can reach.

Related, and it is why the campaign's own numbers do not converge: bash-surface-not-growing forbids growth but has no descending ceiling, and batten.toml:2701 records the census going 141 → 148 across v0.0.106v0.0.113 while this campaign ran. CLOUD-1195 gives it a ceiling.

One home is now cheaper than this row assumed. batten check --rule <id> (surface.rs:797) already makes a [[rule]] row invocable by name — its doc comment says it exists precisely so "a gate ported out of mise-tasks/ … stays byte-identical and out of the changed-file set". Nineteen policy/*.rego modules ride it and none appears in surface.rs. So CONSUMER MODULE costs the surface nothing, which strengthens this row's case against porting.

conserves grew a WITHDRAWAL arm in CLOUD-1080 (Done), filed precisely
because "all three [arms] name a successor, so a deletion whose subject is gone*
*can only pass by lying or by a waiver config-lint refuses."

So DELETE is spellable today. What is missing is the doctrine that makes
anyone reach for it. Every row reaches for a port because the ratchet's shape
suggests one, which is the same defect one level up from CLOUD-908's
"retires_with conserves files, not logic".

The five homes

A disposition is chosen before a successor is designed:

home criterion where
PRESET generic policy any consumer wants crates/batten/src/policy/presets/** (CLOUD-836) — five bundles ship today
CONSUMER MODULE a real predicate carrying rule-1 identifiers policy/*.rego in this repo
A §2 VERB — SHIPPED OR INTENDED §2 already names it, whether or not it ships yet shipped: doctor hooks, check, config lint, payload field, perf. Intended and unshipped: pr watch, pr land, gate, scan, main sync — landing one is fulfilling §2, not widening it
PRIOR ART a command already on the operator's PATH does it §9; CLOUD-455's shape
DELETE the predicate should not exist CLOUD-1080's withdrawal arm

"Port into the core" is not on the list. Core work is confined to the fact*
*families the four live homes read — which is what CLOUD-1154/1167/1168/1170 are,
correctly understood: enabling substrate, not new surface.


Refinement — Ready (choose the disposition before designing the successor)

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

  • **Authority boundary (§1). .claude/rules/toolchain.md's retirement section
    **and whichever gate the predicate below lands in. No **mise-tasks/ program and
    ****no **tests/**/*.bats is edited or addedV-SHELL-RULE-EDITED and
    V-SHELL-RULE-ADDED refuse both.
  • Computable predicate (§2). A retirement PR names its subject's home from
    the five above. A PR whose successor is a crates/batten verb not in the emitted spec is a finding unless it names the §2 row it fulfils or the §2 change it asks for — checkable against batten spec --format json's row set. This is a reconciliation demand, not a refusal (see the correction above): §2 declares six verbs that do not ship, so "absent from the spec" and "forbidden" are different answers and the gate must not conflate them.
  • The negative half is the point (§2). Without the refusal, "port it" stays
    the default because the ratchet's admitted disposition implies one. A gate that
    only records a home is a log, not a gate (rule 2).
  • Deliberately not in scope (§2). Choosing any individual program's home —
    each is its own row. Changing SubjectFacts::died, the admission clauses, or
    CLOUD-1080's arm. Widening §2 — a row that needs a new verb argues for it there. Reconciling §2's 18-row disagreement with the emitted surface, which the correction above measures and which needs its own row: §2 itself says that comparison is not yet a gate because the section is out of tree.
  • **Effect (§3). **read.
  • Output and exit (§5). Pointer-only: the subject path and the home it
    declared, never a line of its body. Exit follows the 0/1/2/3 table.
  • **Commit / bump (§6). **feat(policy)patch until 0.1.0 if it lands a
    gate; docsno bump if the decision is doctrine-only. The row states which.
  • Test obligation (§7). Over the compiled binary in crates/batten/tests/; no
    .bats added or edited. Shown able to fail per CLOUD-418: a retirement
    declaring a home passes; one declaring a new verb absent from the emitted spec
    is reported; and the anti-vacuity mirror — a retirement whose successor IS an
    existing §2 verb is not reported, without which the first is satisfied by a gate
    that flags every retirement.
  • Blockers (§8). None. relatedTo CLOUD-1080 (the withdrawal arm this makes
    reachable), CLOUD-836 (presets), CLOUD-908 (conserves files not logic),
    CLOUD-455 (the prior-art question nobody asks), CLOUD-1145 and CLOUD-1168 (the
    two rows most changed by it), CLOUD-1151 (the wave owner), CLOUD-1174 (whose
    generated table gains the home column), CLOUD-418.

Acceptance

  • The five homes are written where a retiring author reads them, with DELETE
    named as CLOUD-1080's arm rather than left implicit.
  • A PR proposing a new crates/batten verb without a §2 row is reported.
  • At least one row is re-dispositioned as a consequence — the proof this is
    doctrine rather than a preamble. CLOUD-1145 is the candidate: §11 says its drift
    cannot happen.
  • batten spec --format json still emits exactly the committed row set.

Found by pressure-testing the campaign's own framing against the house style: every
filed row assumed a port, and four separate clauses say most of these predicates do
not belong in the core.

CLOUD-1265 Nothing WRITES a `tool-verdict` record, so `validator-verdict-clean` decides nothing on any real checkout — build the producer verb, and park three retirements (38.3s) behind it until it exists

Why

CLOUD-1171 landed the READ half of the third-party-validator fact and it is correct.
crates/batten/src/tools.rs resolves .git/batten-tools/<tool>‖<version>‖<digest> through
record_key/record_path/verdicts, keyed by the triple that makes a record stale by
construction, and policy/validator-verdict-clean.rego adjudicates what it finds — three
answers kept properly apart (null = could-not-look, absent = no record under this key,
present-and-empty = the tool ran and found nothing).

Nothing in the tree WRITES one. Checkable rather than asserted:

git grep -l 'batten-tools' -- crates/ policy/ mise-tasks/ .github/
#   crates/batten/src/tools.rs          <- the reader
#   crates/batten/tests/tool_verdict_facts.rs   <- a TEST, the only writer

So on every real checkout input.tree["tool-verdict"] resolves null, the module's
is_object guard declines, refused is empty, and the gate is byte-identical to a clean**
**tree on the decision surface. That is CLOUD-845's dead gate, shipped.

The load-time tier cannot see this, which is why it shipped green

policy/validator-verdict-clean.rego's own test_could_not_look_does_not_fault asserts
count(violation) == 0 with input as {"tree": {"tool-verdict": null}} — and passes,
correctly, over a repository where null is the permanent state. .claude/rules/policy-modules.md
names this exact failure: "a with input as case … fabricates the very shape the engine may*
be unable to produce, so it passes over a key nothing fills and over a channel nothing
*populates."

It is also the field instance that file records one level out: OpenTelemetry's weaver
printing ✔ No 'after_resolution' policy violation, exit 0, over a knowingly-broken registry,
because the module read a key the schema never built. Same shape, same silence, same green.

What it costs, in seconds that are otherwise landable today

Three retirements have no other blocker and cannot move until a producer exists. Measured
against bench/suites/RESULTS.md at origin/main:

gate s what its verdict is about
renovate-config-validator 32.3 renovate-config-validator's findings over renovate.json
pkl-check 5.4 pkl over hk.pkl
hook-profile-check 0.6 the hook profile validator
38.3

CLOUD-1199's filled second-input table records the same gap from the wrapper family's side;
CLOUD-1163 unit 6 (token-bench-drift) is a fourth consumer, an identity check the same
channel is the answer for.

What to build, and the one property that must not be got wrong

A producer verb — batten tools record — mirroring the shape receipt record and
state record already use. It runs the validator outside the engine (a mise task, a CI
step), because §5 makes check read and structurally incapable of spawning, and writes the
keyed record this reads back.

It must digest the input ITSELF, never accept a caller-supplied digest. That is the whole
anti-staleness property: tools.rs::verdicts reads the subject from root and digests it
there precisely "because the digest is what makes the record stale-by-construction and a*
*caller that supplied one could supply the wrong one." A producer taking a digest on the
command line hands that guarantee back to the caller and the family stops working.

There is one parser for the record's line shape — crate::forge::parse — and a second
would be two authorities over the same bytes.


Refinement — Ready (the write half of a landed read; no new fact, no new module)

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

  • **Authority boundary (§1). **crates/batten/src/tools.rs gains the write path; the verb is
    registered in crates/batten/src/surface.rs. policy/validator-verdict-clean.rego is
    unchanged — it is already correct and is the consumer, not the defect. No
    mise-tasks/ program and no tests/**/*.bats is added or edited.

  • Computable predicate (§2). After a producer run over a declared [[rule.tool]] row, a
    fresh batten check on the same tree resolves input.tree["tool-verdict"] to an object
    carrying that row's id — and to a different answer once the input's bytes change.

  • The discriminating pair (§2). A validator that found something must produce a REFUSAL
    through the module, and a clean one must not. Today both are silent, which is the whole
    bug: an assertion that only proves the module loads passes over the dead version.

  • Deliberately not in scope (§2). Changing what any validator decides. Making absence a
    refusal — validator-verdict-clean.rego's METADATA already records that as a deliberate
    direction and a separate predicate. Retiring the three gates above; each is its own row and
    each consumes this.

  • **Effect (§3). **write — it spawns the validator and writes under $GIT_DIR. It is on
    the spawning side of §5's split, where perf already sits, and it is emphatically not
    check.

  • Output and exit (§5). Pointer-only: the finding's NAME and a path:line, a count or a
    status token. Never the validator's report or its diagnostic prosetools.rs's own
    header says a validator's output is the likeliest place in this family for a secret to
    appear, so non-negotiable rule 4 is decided at the boundary rather than at the report.

  • **Commit / bump (§6). **feat(facts) — patch until 0.1.0.

  • Test obligation (§7). Over the compiled binary, and the second tier is the one that**
    **matters here — a with input as case is what let the dead version ship. Three
    discriminating observations, per CLOUD-418: the producer writes a record and a subsequent
    check REFUSES over a finding; the same over a clean verdict does NOT refuse (the
    anti-vacuity mirror); and a record written before the input changed is not found rather
    than found-and-wrong, which is the keying the whole family turns on.

  • Blockers (§8). None. It blocks the three retirements above. relatedTo CLOUD-1171
    (which landed the read half), CLOUD-845 (the dead-gate class), CLOUD-1253 (Done while
    the condition persists — this is an instance its own §2 predicate cannot reach, since the
    row carries no conserves arm).

  • Weakens (§8), groomed late and recorded as such. The producer's caller is a new inline mise.toml task body, which inline-task-bodies-not-growing counts (31 -> 32) and denies. The two dispositions its own no_fix_reason names are "migrate the predicate onto a rule kind, or waive the increase deliberately with a reason and an expiry" — and the predicate did migrate: what pkl-check decided is policy/validator-verdict-clean.rego's now, off the config-validator row. What is left inline decides nothing; it RUNS the validator, which §5 makes structurally impossible inside the engine, so there is no rule kind for that body to become. mise-tasks/pkl-check.sh and its 76-line suite leave the tree in the same commit, so the bash surface falls while a mise.toml-only count rises by one — the shape the row's own header warns about, where "a row that denies its own campaign's wins is worse than the hole it closes". Unnarrowed, because a ratchet finding's path is the glob plus the counts; expiring 2026-10-31, and expected to lapse unused, since base = "origin/main" makes the floor 32 the moment this lands.

    Recorded honestly: this clause was groomed after the work started, on 2026-08-31, once config-lint refused the change — the same sequence CLOUD-1164 records for its own Weakens clause. The alternative (collapsing the task body to a one-line shim) was examined and declined: it drops the /root/.ccr/ca-bundle.crt fallback carried verbatim out of the retired program, which is the arm this container actually takes, so a valid hk.pkl would fail as though malformed.

    **Weakens: **waiver-added at waiver[inline-task-bodies-not-growing]

  • Weakens (§8), the second clause, and it is a STRENGTHENING the gate cannot tell from a weakening. The producer needs a second declared subject to be proven on at all, so validator-verdict-clean gains a sibling [[rule.tools]] row (renovate-config, renovate-config-validator over renovate.json5) beside the landed config-validator row. config-lint reads any edit to an existing rule's predicate as rule-predicate-changed and refuses it against origin/main, which is correct by design: house style §8 loads policy out of band precisely so a branch cannot lower the bar it is judged by, and a gate that tried to distinguish "added a subject" from "removed one" would be judging intent rather than bytes. So the admission is groomed rather than argued at PR time.

    The alternative — a rule of its own, which hook-profile in this same change takes and which produces no smell — was examined and declined for the sibling: what counts as a clean verdict is the same question for both validators, and two rules over one module is two adjudications of one question. hook-profile is separate for the opposite reason, that it reads present-and-empty as a REFUSAL where a validator reads it as clean.

    Recorded honestly: groomed after the work started, on 2026-09-01, once config-lint armed against origin/main refused it under verify. An earlier unarmed run had reported 0 smell(s), which is what let it reach verify at all — the unarmed form does not compute the base-ref class, and reading its silence as a pass was my error rather than the gate's.

    **Weakens: **rule-predicate-changed at rule[validator-verdict-clean].tools

Acceptance

  • batten tools record exists, digests its input itself, and writes through the one parser.
  • A deliberately-broken subject produces a refusal end to end over the compiled binary; the
    same subject repaired produces none.
  • A record whose input has since changed is absent rather than answering.
  • At least one of the three gates above is shown unblocked as a consequence.
  • No validator output text appears in any finding, asserted.

Found while filling CLOUD-1199's second-input column: the tool-verdict class read as
unblocked because the fact had landed, and the fact had landed with nothing to fill it.

CLOUD-1271 `bench/suites/RESULTS.md` is not reproducible and records no conditions, so every campaign figure is unanchored — measured, `session-start` is 24x cheaper on a warm container than the committed corpus says

MEASURED 2026-08-31 — THE ROW BELOW IS REFUTED, AND WHAT IT IS WRONG ABOUT IS BIGGER THAN THE ROW

This row was filed claiming tests/session-start.bats costs 135.9s and needs a one-line
mise.toml fix. The premise was never measured. I measured it.

A full-corpus mise run test:bats in this container, against the committed
bench/suites/RESULTS.md. Both are full-corpus runs and comparable in kind:

suite RESULTS.md measured here ratio
corpus total 1097.1s 688.8s 0.63x
land-lock 195.1 145.6 0.75x
land 96.2 84.7 0.88x
helpers 36.1 26.1 0.72x
pre-commit-staging 23.3 4.5 0.19x
session-start 135.9 5.7 0.04x

Most suites land in a narrow 0.63–0.88x band — a faster machine, or different parallelism.
**Two do not. **session-start is 24x cheaper here; pre-commit-staging 5x.

The split confirms the mechanism and refutes the remedy

The reading was right: setup() stubs mise install at :59, only :150/:175 opt back
in, so the cost IS provisioning. But provisioning is already warm in this container, the
suite costs 5.7s, and there is nothing here to fix. The 135.9s is a cold-cache artifact of**
**whichever machine wrote the corpus — and nothing in RESULTS.md says which machine, or
whether it was warm, or what parallelism it ran under.

So this row is now the corpus finding, not the suite finding

bench/suites/RESULTS.md is the campaign's single cost authority. Every bundle, every
makespan argument, every "largest available retirement" is denominated in it. It is not**
**reproducible across containers and records no conditions, so a consumer cannot tell whether
two corpora are comparable — and for the provisioning-bound suites they demonstrably are not.

Two consequences that change live decisions rather than bookkeeping:

  • The makespan-floor argument is environment-dependent. Cold, Bundle A
    (CLOUD-1170) hands the floor to session-start at 135.9s. Warm, it hands it to
    land at 84.7s and session-start is nowhere near the top. This row's own Urgent**
    **justification, written below, holds in one environment and evaporates in the other, and
    nothing recorded which one CI is.
  • Every "retire it and save N seconds" claim is unanchored wherever N came from this file.

The fix, and the prior art for it

perf-record stamps metric=wall-clock into every series entry precisely so a later
instruction-count series can never be diffed against it and read as a step change. Same**
**defect, same fix: stamp the series. A corpus artifact carries the conditions it was measured
under, and two corpora with different stamps are refused rather than silently diffed.

§1 IS CONSTRAINED BY SHAPE, and this is the part an implementer will hit first

mise-tasks/suite-bench.sh is a governed program — two landable shapes, retire it whole or
leave it alone, and V-SHELL-RULE-EDITED has one route with no bypass. So "make suite-bench
*record its conditions" *is not a landable change on its own.

It is landable as part of CLOUD-1163 unit 7 (suite-bench-check + suite-bench;
both die in one delta, since tests/suite-bench-check.bats:2 declares two subjects). The**
**conditions land in unit 7's successor. That also gives unit 7 a reason to be sequenced ahead
of its siblings.

What replaces the clauses below

  • §2 — a computable predicate. The clause below says "costs materially less than 135.9s",
    which is a model verdict and not a threshold (non-negotiable rule 3). Replace with: a corpus
    artifact carries the conditions it was measured under — at minimum the commit, whether the
    toolchain was warm, and the parallelism — and a consumer comparing two corpora with
    different stamps is refused, not silently given a diff.
  • §4 — generated artifacts, omitted below and owed: bench/suites/RESULTS.md is generated
    and this changes its format. suite-bench-check's successor is the gate over it.
  • §7 — the discriminating pair: two corpora with different stamps must fail to compare;
    two with the same stamp must compare. Anti-vacuity mirror: an ordinary same-conditions
    regeneration still works.
  • Priority. Urgent below was justified by the makespan floor, which the measurement shows
    is environment-dependent. It stands on the corpus finding instead — an unanchored cost model
    under a campaign steered by cost — not on the 135.9s.

The eight rows that inherit this

CLOUD-1170, CLOUD-1268, CLOUD-1194, CLOUD-1164,
CLOUD-1159, CLOUD-1270,
CLOUD-1163 and CLOUD-1151 all carry figures from this corpus.
Deliberately not corrected one by one. Every dispatched prompt already carries "re-derive*
**every second from the tree"*, and restating one finding across eight bodies is CLOUD-1166's
defect. The finding lives here; those rows cite it.

How the wrong row got filed, since it is the mechanism and not an apology

ready-lint was run over this row and exited 0, and that was read as "groomed to Ready". It
lints only the clauses present and reported exactly three checks — cites-body, bump,
cites-blockers. It cannot see a §2 that is not computable, a §7 that is a procedure rather
than a discriminating case, or a §4 that is missing. Substituting a partial gate's silence**
**for the judgement it cannot make is the same shape as reading a dead gate's green as
coverage (CLOUD-845). The two-minute measurement that refutes the row was
available in the container the whole time and was written into a dispatch prompt as step 1 for
somebody else.


Everything below is the ORIGINAL filing and is superseded by the block above. It is kept
rather than deleted because the dating is the argument: the row was filed, linted green, and
refuted by its own first measurement inside one hour.


Why

tests/session-start.bats is 135.9s, 12.4% of the 1097.1s corpus — the second-largest**
**suite in the tree, behind only land-lock (195.1s). No row owns it.

Verified 2026-08-31, list_issues query:"session-start" project:Batten: every row naming
session-start is about the hook's behaviourCLOUD-517 (step ordering), CLOUD-218
(rustup race), CLOUD-1085 (bootstrap absent), CLOUD-1079 (user-level hooks). None owns the**
****suite's cost. **CLOUD-1156 is Done, and CLOUD-1268's title now states this suite is not one of
its members.

The measurement — read, not divided

An earlier pass recorded 135.9s over 10 cases as "~13.6s each, and that is provisioning."
That was arithmetic on the corpus total. Read at origin/main:

  • 10 @test cases.

  • :59setup() installs a mise stub that swallows the expensive verb:

    if [ "$1" = install ] && [ -z "${SESSION_START_REAL_INSTALL:-}" ]; then exit 0; fi
  • real_install_or_skip is defined at :74 and called at exactly two sites: :150 and
    :175.

So 8 of 10 cases never install anything. The whole 135.9s is two real mise install runs.
The per-case cost is nothing like uniform, and that single fact decides the disposition.

Three consequences

  1. It is not a retirement, and not CLOUD-1268's. A port to crates/batten/tests/*.rs
    runs the same two installs and pays the same wall clock — it moves 136 seconds from one
    lane to another. CLOUD-1268 struck it out for this reason; what that strike-out did not do
    is give it a home, which is what this row is.

  2. The remedy is one line, and the file is ungoverned. The suite states it itself at
    :73"Idempotent and warm, so the second install costs milliseconds." Warm
    mise install once in [tasks."test:bats"]. governed_at_head selects mise-tasks/
    paths carrying a shebang or #MISE description, plus any .bats under tests/, and
    mise.toml is in neither set — so no V-SHELL-RULE-EDITED, no conserves arm, no
    retirement. This is CLOUD-1198's shape exactly: build once, feed the seam the programs
    already declare.

    .claude/hooks/session-start.sh is ungoverned by the same predicate — ungoverned for edits
    and for deletion. Filing this suite under "the subject cannot die" is what sent three
    consecutive passes toward a retirement it never needed.

  3. **It becomes the makespan floor the moment Bundle A lands. **test:bats runs
    --no-parallelize-within-files, so the lane cannot finish faster than its longest single
    suite:

    • today — longest suite land-lock 195.1s, corpus 1097.1s

    • after Bundle A (CLOUD-1170) — longest suite session-start 135.9s, corpus 866.2s

    • after this row — longest suite land 96.2s (CLOUD-1148's), corpus ~866s

      Until this lands, no further bundle moves the makespan at all — they shrink the total
      and leave the floor standing. That is why this is Urgent despite being a one-line change.

Settle it before writing anything

Two minutes, and it decides whether this row is a one-liner or something larger:

mise install                                     # warm the toolchain
mise run test:bats -- tests/session-start.bats    # time it warm, against 135.9s

If it collapses, land the [tasks."test:bats"] change and record the measured delta. If it
does not, the two installing cases are doing something the stub cannot cover and the row
re-scopes on evidence rather than on this body.

How it came to be unowned, recorded because the mechanism is the point

This row exists because a grooming session created the orphan and then lost the note:

  1. Recorded "the largest number in the tree with no owning row. File one." — not filed.
  2. Found CLOUD-1156 owned the class, called that note wrong, and folded this suite into
    CLOUD-1268.
  3. Proved it does not belong in CLOUD-1268, struck it out, and wrote "file its remedy as*
    *its own row" — not filed.
  4. Rewrote the working plan file, deleting the "File one" note — the only surviving record.
  5. Folded the filing into a correction item, did the correction half, dropped the filing half,
    and reported the pass complete.

Net: the suite went from owned by a Done row to owned by nothing, and the trace was
removed. That is CLOUD-1166's class with a new destination — not a count or a membership list
going stale, but a deferral restated three times and deleted on the fourth. The rule that
covers it is the same one: a finding's home is a row, and a note that says "file one" is not**
**a home.


Refinement — Ready (measure it warm, then land one line)

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

  • Authority boundary (§1). mise.toml's [tasks."test:bats"]. Ungoverned — no
    mise-tasks/ program and no tests/**/*.bats is edited or added, and none may be:
    V-SHELL-RULE-EDITED refuses touching the suite, which is why the fix lives on the runner
    rather than in the cases.
  • Computable predicate (§2). tests/session-start.bats costs materially less than 135.9s
    in bench/suites/RESULTS.md after the change, with the two installing cases still
    exercising a real install rather than being stubbed out.
  • Coverage must not fall (§2). real_install_or_skip at :150 and :175 exists so those
    two cases test the real thing. A change that makes them cheap by making them stubs has
    deleted the coverage rather than the cost, and is the failure to refuse.
  • Deliberately not in scope (§2). Retiring the suite or the hook — neither is governed and
    neither needs to die. CLOUD-1268's ledger marker. Changing what the hook does (CLOUD-517,
    CLOUD-218, CLOUD-1085 each own a behaviour question here).
  • Effect (§3). read for the measurement; the change is a task-body edit.
  • Output and exit (§5). The delta is reported from RESULTS.md regenerated, never
    predicted from this body.
  • Commit / bump (§6). perf(ci) or fix(ci) — no bump. Nothing under crates/.
  • Test obligation (§7). The suite itself is the test and runs unedited — that is the
    discriminator: if it needs editing to get faster, the diagnosis in this row is wrong.
    Shown able to fail per CLOUD-418: the warm timing must be recorded before and after, and a
    run that does not exercise real_install_or_skip's two cases is not a pass.
  • Blockers (§8). None. It blocks the makespan benefit of every dispatched bundle once
    CLOUD-1170's Bundle A lands. relatedTo CLOUD-1198 (the precedent this copies), CLOUD-1268
    (which struck it out and left it homeless), CLOUD-1156 (Done, owns the class it was wrongly
    filed under), CLOUD-1170 (Bundle A, which hands it the floor), CLOUD-1140 (suite cost),
    CLOUD-1166 (the deferral-goes-stale class this is an instance of).

Acceptance

  • The warm-run timing is recorded on this row as a measurement, not a prediction.
  • bench/suites/RESULTS.md regenerates with tests/session-start.bats materially cheaper,
    and the suite file is unedited.
  • The two real_install_or_skip cases still perform a real install, asserted.
  • RESULTS.md reports the new longest suite after Bundle A lands, and it is not this one.
  • CLOUD-1268 links here, so its strike-out has a destination rather than a dead end.

Found by asking what happened to the number a previous pass said to file a row for, and
finding that nothing had.

Review in Linear

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 54 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: 348f5c29-b5c5-4a9e-b35a-6ea0e137d44d

📥 Commits

Reviewing files that changed from the base of the PR and between 8c95e02 and 9e7a9a9.

⛔ Files ignored due to path filters (1)
  • hk.pkl is excluded by !**/*.pkl
📒 Files selected for processing (12)
  • .github/workflows/hook-latency-drift.yml
  • batten.toml
  • bench/suites/RESULTS.md
  • crates/batten/tests/it/main.rs
  • crates/batten/tests/it/ratchet.rs
  • crates/batten/tests/it/sbom_inventory.rs
  • mise-tasks/hook-latency-drift.sh
  • mise-tasks/sbom-check.sh
  • mise.toml
  • policy/sbom-inventory.rego
  • tests/hook-latency-drift.bats
  • tests/sbom-check.bats

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.

…ays was

`mise-tasks/hook-latency-drift.sh` (126 lines) and `tests/hook-latency-drift.bats`
(6 cases) are deleted. CLOUD-1270's decided disposition 3: the timing at `:91` and
the budget comparison become an inline step of
`.github/workflows/hook-latency-drift.yml`, which is ungoverned and was already the
program's only invoker. No new `crates/batten` verb, no `perf-record`-style
producer, no rule row — nothing enters the core, so CLOUD-1176's five-homes
question is not reopened.

The program's own `#MISE description` settled this: "Report (never gate) whether
the fast pre-commit tier still costs what its budget claims — a question about the
world, so it runs on a clock". `.claude/rules/toolchain.md` splits exactly there,
and a reporter that gates no commit was buying a 24.2s regression suite for
arithmetic.

WHAT MOVED, verbatim rather than re-derived: the `hk check --all --profile
"!$PROFILE"` loop, the median of `$RUNS` integer samples, the `BUDGET + SLACK`
ceiling and the `BUDGET / LOOSE_FACTOR` floor, both drift directions and their
tokens. The four constants are `env:` entries on the step — the same
written-once-as-data placement `perf-assert`'s BUDGETS table uses — so the
measurement that derived them (8s, 7s, 8s on a session container against 275s with
the tier enabled) still reads beside the number it produced.

`hk` ABSENT STAYS COULD-NOT-LOOK. The retired program's `:70` exited 2; the step
exits 1 under `set -euo pipefail`, which fails the run rather than passing it. That
is the one property a successor could most easily lose, and it is now structural
rather than asserted.

THE COVERAGE LOSS IS REAL AND THE LEDGER SAYS SO. All eight arms are `withdrawn`,
because a workflow step is not drivable from `crates/batten/tests/**` and a fixture
re-running the same shell in a test would be a second implementation of the thing
under test rather than coverage of it. Six case arms name what each pinned and
where it now lives unasserted; the two file arms name each other as the path this
delta retires, which is what `withdrawn_subjects` demands.

THE SERIAL TOTAL WENT UP, AND THAT IS NOT THIS CHANGE. The corpus regenerates at
117 suites with `hook-latency-drift` absent — the checkable half of CLOUD-1270's
acceptance — but 505.5s against the committed 458.7s, because it was measured on a
different machine. The acceptance asks for "the serial total down by the measured
amount" and no honest reading of two corpora can produce that today: nothing stamps
the conditions a corpus was taken under, so the two numbers are not comparable and
the suite count is the only thing that is. That is CLOUD-1271's finding met in
practice rather than in theory, and CLOUD-1163 unit 7 is where the stamp lands.

Closes CLOUD-1270

Refs: CLOUD-1270, CLOUD-509, CLOUD-1176, CLOUD-1271, CLOUD-1080
Admits: b07dbc18c944e984110e469bccc2d5e7d2269f7a029d8f6649fb357e284f22d3
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: .github/workflows/hook-latency-drift.yml
Admits-head: b49639b
Admits-epoch: 6063b92bef084c720fdc6fb1fa0de1ebd751debe58cd14562dbd539fde96cc9c
Admits-author: alec@wenzowski.com
Admits-prev: -
Admits-answer-lost: CLOUD-1270's 24.3s stays on the corpus permanently, which is the largest non-landing-loop suite outside CLOUD-1318 and the whole reason the row exists. The alternatives are both refused rather than merely worse: disposition 2 (retire the suite alone) needs the // ported: arm, which 'suite port held' refuses when the surviving subject is a governed, unretired program — the same weld CLOUD-1164 records for token-bench — and disposition 1 leaves the seconds unowned, which is the state the row was filed to end. Without this write the program keeps a 6-case suite whose every case pays 3 x sleep 2 to test arithmetic, and the measurement it guards is a wall clock that no commit can regress.
Admits-answer-precondition: The workflow file IS the owning surface, and under CLOUD-1270's decided disposition 3 the change is unspellable anywhere else. The row's own decision block says so: '.github/workflows/hook-latency-drift.yml is ungoverned, so the hk check --all --profile timing at :91 and the budget comparison become an inline step of that workflow', with 'no new crates/batten verb and no perf-record-style producer' and 'nothing enters the core'. There is no batten verb that writes a workflow, and a mise task is exactly what is being retired — the successor has to be the step itself or the measurement is lost. The fact satisfying the precondition: the write is one steps[] entry gaining an env block and an inline run body, replacing a one-line 'mise run hook-latency-drift' invocation, landing in a pull request where actionlint and zizmor judge the result and the diff is in front of every reviewer.
Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: a workflow has no owning tool above it — there is no batten verb that writes .github/workflows, and the task layer that used to hold this body is the thing being retired, so routing through it would be re-creating the subject. R-RESTORE-IT does not apply either: nothing here is a mistaken write to undo; the file keeps its schedule, concurrency, permissions and both existing steps, and only the final step's body changes from a task invocation to the task's own contents.
`mise-tasks/sbom-check.sh` (382 lines) and `tests/sbom-check.bats` (14 cases) are
deleted. The scan stays outside — house style §5 makes `check` `read` and
structurally unable to spawn, so `syft` remains a command on PATH and
`mise-tasks/sbom.sh` remains the producer that derives the documents. What moves
in is the ADJUDICATION, which is the half that had no successor.

`sbom.sh` SURVIVES deliberately: it decides nothing, so it is a producer rather
than a gate, and its disposition is CLOUD-1159's rather than this row's.
`sbom-check.sh:44`'s constructed-sibling spawn of it is an admitted repointing
under CLOUD-1149's arms 2 and 3, so the caller dies and the callee lives with
nothing dragged in.

TWO OF THE TEN PREDICATES READ NO RECORD AT ALL, which is what keeps the
producer's trusted surface narrow rather than total. The expected cargo count is
`Cargo.lock`'s own `source = ` lines and the action mapping is every SHA-pinned
`uses:` against `sbom-actions.tsv`'s key column — both properties of committed
text, so `line_sources` carries them and the module decides them itself. Only the
counts that require opening a DERIVED document travel through
`input.tree["tool-verdict"]`. `an_unmapped_action_pin_is_refused_from_committed_text_alone`
drives that half with no record involvement at all.

THE KEY IS A TRIPLE and each leg refuses a different lie: the tool, its pinned
version (kept equal to `mise.toml`'s `aqua:anchore/syft` — CLOUD-664 is what a
syft bump does to this document), and the digest of `Cargo.lock`, taken rather
than declared, so a verdict goes stale by construction. Both are cases rather
than claims: `a_record_from_another_version_does_not_answer` and
`a_verdict_does_not_survive_its_input`.

THE VERDICT NAMES ARE THE VOCABULARY'S, NOT MINE. `policy test` refused the four
classes I first wrote as free prose — a name is `<subject> <action> <condition>`
drawn from `[vocabulary]`, which is a measurement (o200k_base: 9.9 tokens for the
old spelling against 3.0 curated, ~2,000 tokens a session at ~300 refusals). They
are `tool read broken`, `manifest count wrong`, `manifest state missing` and
`pin table missing`, and they add no words to the table.

A FALSE POSITIVE THE LOAD-TIME TIER CAUGHT, recorded because the fix is not the
obvious one. Binding `lock_lines` separately does NOT make `declared` undefined
when `Cargo.lock` was not read: a Rego comprehension over an undefined variable
yields an EMPTY array, so `declared` still resolved to 0 and every honest cargo
count read as drift against a file nobody opened. The rule has to DEMAND the
array (`is_array(lock_lines)`), and `test_an_unreadable_lockfile_reports_no_drift`
is what turned red. The action-pin half carries the same guard for the same
reason: an unreadable table is could-not-look, not "nothing is mapped".

THREE ANSWERS, AND EMPTY IS A FINDING. Absent is could-not-look — the ordinary
state of a checkout whose globs never fired, where refusing would deny every
clone until a producer runs. Present-and-empty is the producer having recorded
nothing, which would let every count pass over an absent key, so it refuses.
That is `hook-profile`'s reading rather than `validator-verdict-clean`'s, and for
`hook-profile`'s reason.

`hk.pkl` keeps the `slow` profile and the same two globs and points at
`record-sbom` instead. The scan's cost therefore stays exactly where it was: what
comes off the corpus is the bats suite, not the gate.

Closes CLOUD-1318

Refs: CLOUD-1318, CLOUD-262, CLOUD-664, CLOUD-667, CLOUD-1149, CLOUD-1159, CLOUD-1265, CLOUD-1171

Admits: 29c87ce5ed2d90a981b7e7128f9acdbf338f5775236cb45086a3633e3b1418a3
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-head: 5c589fe
Admits-epoch: 6063b92bef084c720fdc6fb1fa0de1ebd751debe58cd14562dbd539fde96cc9c
Admits-author: alec@wenzowski.com
Admits-prev: ce6e64e46cde0664400115cd5f9d6dea72ec6206bc32273171582fb007164b17
Admits-answer-lost: CLOUD-1318 cannot land and mise-tasks/sbom-check.sh stays a 382-line shell program whose suite is the largest non-landing-loop cost in the corpus outside the landing loop itself, measured 25.4s of 505.5s on this container. Worse than the seconds: the predicate it carries has no successor without these rows, so the published SBOM would keep being judged by a program the retirement campaign has already frozen — shell edit refused declares one route and no override, so the gate cannot be repaired in place if syft changes shape again, which is exactly what CLOUD-664 and CLOUD-1149 record happening twice already.
Admits-answer-precondition: batten.toml IS the owning surface this class names, and every part of the write is unspellable anywhere else by construction. A [[rule]] row is the only way to register policy/sbom-inventory.rego at all; [[verdict]] rows are the only place a raised token may be declared, and a module raising an undeclared token FAILS TO LOAD, so without them the successor does not exist rather than merely misbehaving; a [[rule.tools]] row is the only route to a tool-verdict record; and .claude/rules/policy-modules.md refuses an inline regex outright, so the action-pin shape can only be a [[pattern]] row. house style section 8 leaves batten.toml with no writer: batten config reads it and nothing writes it. The fact satisfying the precondition: the write is one rule row, one tools row, one pattern row and four verdict rows, landing in a pull request where the diff is in front of every reviewer and review bot, with config-lint judging the result against origin/main and policy-test loading the module.
Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: batten.toml has no owning surface above it, unlike .serena/memories/**, whose [[redirect]] row names write_memory/edit_memory/rename_memory. Its own [[redirect]] row says the remedy for a protected path is to change it in a pull request, which is what this is: branch claude/bundle-bats-retirements-fah2t0, closing CLOUD-1318. R-RESTORE-IT does not apply either: these are additions registering a new module, not a mistaken write to undo, and restoring the path would discard the deliverable.
Admits: df871b0dba25d6faaa441ff40013ff2cc15fd5b10a18b2e74c772d641a2bfd84
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-head: 5c589fe
Admits-epoch: 874a6cf485f641f090454db9acfe0be21b0d0a4cd2d5549922ebd71309d17405
Admits-author: alec@wenzowski.com
Admits-prev: 29c87ce5ed2d90a981b7e7128f9acdbf338f5775236cb45086a3633e3b1418a3
Admits-answer-lost: policy/sbom-inventory.rego cannot decide sbom-action-unmapped at all. That predicate is the one CLOUD-667 added as the last conformance gap and the one a renovate bump breaks: a pin moves, the committed licence table no longer describes it, and the published document silently degrades. Without the row the module either fails to load, or -- if the clause were dropped -- the retirement would land a successor quietly weaker than the program it replaces, which is the laundering the campaign exists to refuse.
Admits-answer-precondition: The [[pattern]] registry in batten.toml is the ONLY place a regex may be declared for a policy module: .claude/rules/policy-modules.md makes an inline regex a LOAD-TIME refusal, so policy/sbom-inventory.rego cannot carry this literal itself and the module does not load without the row. This is the second of three writes for one change (CLOUD-1318); the first registered the rule and its tool row, the third declares the verdict tokens the module raises. The fact satisfying the precondition: one [[pattern]] row naming the SHA-pinned uses: shape, landing in the same pull request as its only consumer, where config-lint judges it against origin/main.
Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: batten.toml has no owning tool above it and its own [[redirect]] row names a pull request as the remedy for a protected path, which is what this is. R-RESTORE-IT does not apply: this is an addition its consumer needs, not a mistaken write to undo.
Admits: 62ad48e9a300ac431744b2460ee6af0bac0e45171b75dbd4e8404cecd7c57e12
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-head: 5c589fe
Admits-epoch: 302c5fe4b3f62558d19b9eb325df566f8289d3a0b7bb9716e08c3ac18a86b8eb
Admits-author: alec@wenzowski.com
Admits-prev: df871b0dba25d6faaa441ff40013ff2cc15fd5b10a18b2e74c772d641a2bfd84
Admits-answer-lost: The successor does not load, so CLOUD-1318 cannot land and mise-tasks/sbom-check.sh stays frozen -- a 382-line program the campaign will not let anyone edit, guarding a published document that vendor reviewers read and nobody here does. The four classes are also the only place the REMEDY is written down: which of a bad scan, a drifted count, an unenriched field and a moved action pin an author is looking at, and what to do about each. CLOUD-1050 records why that cannot go back into a message 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.
Admits-answer-precondition: A [[verdict]] row is the ONLY place a raised token may be declared, and a module raising an undeclared token FAILS TO LOAD -- so without these four rows policy/sbom-inventory.rego does not exist as a gate rather than merely misbehaving. The refusal is symmetric: a row nothing raises also fails the load, so the four are exactly the four the module raises and no more. This is the third and last write for one change (CLOUD-1318), after the rule row and the pattern row. The fact satisfying the precondition: four [[verdict]] rows, each carrying the remedy prose that a msg field could not be checked for, landing in the same pull request as the module that raises them.
Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: batten.toml has no owning tool above it and its own [[redirect]] row names a pull request as the remedy, which is what this is. R-RESTORE-IT does not apply: these are additions the module cannot load without, not a mistaken write to undo.
Admits: 429a281a19eab3cbec4fdf2044053ba424dff7b0d36c75e8c203c5de20daf0c1
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: policy/sbom-inventory.rego
Admits-head: 5c589fe
Admits-epoch: 7d56f6b652defbc775a9889e512531d2d344a0b4cfdde962f80b8af2b877d13f
Admits-author: alec@wenzowski.com
Admits-prev: -
Admits-answer-lost: The module ships a FALSE POSITIVE that fires on exactly the tree it is meant to protect. Measured by its own case test_an_unreadable_lockfile_reports_no_drift: a Rego comprehension whose body references an undefined variable yields an EMPTY array rather than undefined, so declared resolves to 0 whenever Cargo.lock was not read, and every honest cargo count reads as package drift. That is a gate that refuses a clean tree for a file nobody opened -- the could-not-look answer dressed as a finding, and the direction that gets a gate switched off.
Admits-answer-precondition: policy/sbom-inventory.rego IS the owning surface: it is the module whose predicate is wrong, and a predicate can only be repaired in the module that states it. It became protected in this same change, when the [[rule]] row registering it landed -- 'a registered module is protected because enabling policy protects it'. There is no batten verb that edits a module; mise run policy-test is what checks the edit, and it is what FOUND this defect. The fact satisfying the precondition: one guard line added to one rule body, plus its comment, landing in the same pull request that introduces the module, with policy-test green over it before it is pushed.
Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: batten.toml's [[redirect]] for policy/** names the remedy as 'change it in a pull request -- mise run policy-test is what checks the edit before it lands', which is exactly this. R-RESTORE-IT does not apply: restoring would return the module to the state whose own test is red, not to a working one.
`mise run verify` refused this branch at `target-prune`, and the refusal was
correct rather than a disk fault: `[prune.warm]`/`[prune.cold]` declared a basis
of 164 test files against a live 175, eleven past the basis and one outside the
tolerance of 10. Free space was never the constraint — the same lap reported
21077MB against a 7938MB declared warm floor.

This branch caused the eleventh: `crates/batten/tests/it/sbom_inventory.rs` is
the compiled-binary tier CLOUD-1318's retirement owes. The other ten arrived on
`main` under the tolerance, which is the trend the gate exists to surface.

The remedy is the block's own, taken in the half it sanctions: `count` moves to
the live reading with `measured`, and both floors are untouched. Re-deriving a
floor downward would claim an independent measurement — a build from an empty
`target` for cold, a minimal post-prune tree for warm — that I did not take, and
the block warns against exactly that in the two entries above this one. A floor
too high only refuses laps; one too low fails silently inside a test run.

The count written is `batten target prune`'s own `live` reading of 175, not
`git ls-files`'s 174 over the same glob. The gate is what the basis is compared
against, so a basis refreshed from a second reader would red on the next lap
while reading as correct in review. The one-file disagreement is recorded in the
block as a pointer for CLOUD-1158's floor re-derivation rather than resolved
here.

Refs: CLOUD-1318

Admits: 46a463f581d16ffa4d84ab106776bbcec114cf850fbc935c869e4799252af068
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-head: 257c1df
Admits-epoch: fa50e77c82d3f41005c192f1935bac452017d8506862b31e5ba1b7205b9521cc
Admits-author: alec@wenzowski.com
Admits-prev: 62ad48e9a300ac431744b2460ee6af0bac0e45171b75dbd4e8404cecd7c57e12
Admits-answer-lost: The protected-path gate's assurance that no unreviewed hand edits `batten.toml`. What is written is two integers and no floor: `count = 164` -> `175` in both basis tables, `measured` unchanged at today's date, and a block comment recording the move. Floors are untouched, so nothing this write does can lower a threshold.
Admits-answer-precondition: The refusal is `[prune.warm]`/`[prune.cold]`'s own basis-drift gate, which fires exactly as its block instructs: `declared 164, live 175, tolerance 10`, and its stated remedy is to move `count` and `measured` together in `batten.toml`. The gate's remedy IS a write to the protected file, so the admission is the route rather than a way around it.
Admits-answer-rejected-route: Raising `tolerance` would silence the trend counter rather than refresh it. Lowering the floors would claim an independent measurement (a build from an empty `target`) that I did not take, which this block explicitly warns against. `BATTEN_HOOK_BYPASS` would skip the gate rather than answer it.
@wenzowski
wenzowski marked this pull request as ready for review September 2, 2026 18:56
@wenzowski
wenzowski force-pushed the claude/bundle-bats-retirements-fah2t0 branch from b5ed8e8 to 9e7a9a9 Compare September 2, 2026 18:56
@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 9e7a9a9 into main Sep 2, 2026
11 of 12 checks passed
@wenzowski
wenzowski deleted the claude/bundle-bats-retirements-fah2t0 branch September 2, 2026 19:15
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