Skip to content

fix(policy): name the DRY home, and make a retirement declare what engine source is (CLOUD-1239, CLOUD-1182) - #789

Merged
wenzowski merged 4 commits into
mainfrom
claude/issue-751-investigation-po45j7
Aug 31, 2026
Merged

fix(policy): name the DRY home, and make a retirement declare what engine source is (CLOUD-1239, CLOUD-1182)#789
wenzowski merged 4 commits into
mainfrom
claude/issue-751-investigation-po45j7

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

has_policy_surface could not name a preset, so the one generic-by-construction successor home was unspellable in a retirement ledger — and once it could, the ledger still could not say whether an engine-source successor was a new CLI verb or mechanism. Both halves are the same defect at two depths, so they land together.

The finding

policy/shell-retirement.rego admitted two successor shapes:

has_policy_surface(path) if { ... startswith(name, "policy/");            endswith(name, ".rego") }
has_policy_surface(path) if { ... startswith(name, "crates/batten/src/"); endswith(name, ".rs")   }

A preset lives at crates/batten/src/policy/presets/** and is a .rego: wrong prefix for the first arm, wrong suffix for the second. It satisfied neither.

Measured over the landed ledger at this branch's base — restated from the tree rather than quoted, per CLOUD-1239's acceptance: of 725 arms, 0 name a preset, 113 name engine source, 21 of those retiring a whole file. The same count at 5d38e0c2 read 609/0/110/18, so the preset column has stayed empty across 116 further arms. That is not a taste for the core — it is a gate that had one.

Why the second half is not a separate PR

Widening the arm changes no landed decision on its own. CLOUD-1239 puts re-dispositioning the 113 explicitly out of scope, so shipped alone it is an enabling change nothing yet uses.

CLOUD-1182 is what uses it, and it needs 1239 first: its whole predicate is "declare what an engine-source successor is", and while a preset never reaches that arm the distinction cannot be drawn. Measured while implementing it: of the 113 engine-source arms, 0 also name a module, so the derive-where-you-can half relieves none of them and the declaration is the only thing that can answer.

What changed

file what
policy/shell-retirement.rego the preset arm; kinds_for / names_engine_source; V-SUCCESSOR-KIND-UNDECLARED; 6 new test_ cases
batten.toml the retirement-kind-field pattern, the V-SUCCESSOR-KIND-UNDECLARED verdict and its route, and a stale V-SUCCESSOR-NO-SURFACE class naming two shapes where the arm now admits three
.claude/rules/toolchain.md the three homes in preference order, and the kind field with its example row
crates/batten/tests/shell_retirement.rs 4 cases over the compiled binary
crates/batten/tests/retirement_doctrine.rs pins prose against predicate in both directions
14 ledger files 113 arms annotated: 77 kind:verb, 36 kind:mechanism

kind:module is not a value, and CLOUD-1182's body asked for it. That row reached for module-versus-verb on the premise that "a module's Rust second tier also lives there" — it does not. A module's second tier is crates/batten/tests/*.rs, which is has_binary_test's column and a different field on the same row. So the honest split for crates/batten/src/ is the one .claude/rules/toolchain.md already draws for its third home: mechanism, or a verb. The Ready block's intent is unchanged; only the value names are.

77 of 113. That is CLOUD-1176's "one disposition" finding as a number rather than an impression, and it is now readable off the ledger instead of reconstructed from SURFACE. It also closes that row's zero-hits finding — CLOUD-1176 is now cited where a gate can reach it.

Verification

  • mise run verify: fast-forward-green — rebased on latest main, ci + cross + commit-lint all pass.
  • policy test: 408 passed, 0 failed (404 before the 1182 cases).
  • Rust tier: 3395 passed, 0 failed.
  • Shown able to fail, not asserted. Inverting test_a_mapping_naming_only_a_preset_is_admitted gives 403 passed, 1 failed naming that case — which is also what proves it runs at all, since policy test prints counts and never names a passing rule.
  • Anti-vacuity in both tiers: a declared verb successor is admitted, a declared mechanism successor is admitted, and a module successor owes no field — without those, the refusal is satisfied by a rule that refuses every engine-source retirement, which CLOUD-1182 puts explicitly out of scope.

No declared #MUTANT row, and the reason is the module's own exemption. shell-retirement carries #MUTANT-EXEMPT CLOUD-931: mutant resolves a gate's suite as tests/$gate.bats, and this row's whole subject is that a migration ships no new bats suite — so there is no named case a mutation could turn red. CLOUD-1182 §7's discriminating-mutation obligation is met by the anti-vacuity pair above instead, which is the same discrimination decided over the compiled binary.

Two things this PR corrects about its own history

It is not #751 and not that branch. claude/stage-2-3-grooming-uqk71k already headed merged #722 (2026-08-28, an unrelated fix(mise) change) and was never deleted; a third story on one name is branch-age-check's reused property, which only branch deletion clears. #751 was also 50 commits behind main and had never had CI run on it — every check on its head was skipped, which is draft behaviour, not a failure. commit-lint then refused its first commit for carrying no key trailer at all, which is a defect the draft had been hiding.

Refs: CLOUD-1176 stays on the commits and does not close that row. An earlier revision of #751's body carried one, and the tracker attached the PR and set CLOUD-1176 — an Urgent Todo row — to Done at 04:36:53 with nothing merged. It was restored to Todo. AGENTS.md is explicit that Done means released and is never the merge's to set. The citation is honest and stays; the DO-NOT-CLOSE line below is what stops closing-key-check stranding it.

DO-NOT-CLOSE CLOUD-1176 — this branch cites that row and mechanises part of its finding, but does not implement it.

Closes CLOUD-1239
Closes CLOUD-1182


Generated by Claude Code

@linear-code

linear-code Bot commented Aug 31, 2026

Copy link
Copy Markdown
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 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-1239 `has_policy_surface` cannot name a PRESET, so the one generic-by-construction home is unspellable — 0 of 609 landed arms name one, and the gate's incentive runs into the core

Why

has_policy_surface (policy/shell-retirement.rego:549-559) admits two successor shapes:

has_policy_surface(path) if { ... startswith(name, "policy/");            endswith(name, ".rego") }
has_policy_surface(path) if { ... startswith(name, "crates/batten/src/"); endswith(name, ".rs")   }

A preset lives at crates/batten/src/policy/presets/** and is a .rego. It fails the first arm on the prefix and the second on the suffix, so it satisfies neither. The one successor shape .claude/rules/policy-modules.md calls generic-by-construction — and CLOUD-1176's five-homes table lists first — cannot be spelled in a retirement ledger at all.

Measured over the landed ledger at 5d38e0c2: of 609 arms, 0 name a preset, 110 name engine source, 18 of those retiring a whole file.

That distribution is not a taste for the core; it is a gate that had one. An author choosing the DRY home was refused and an author choosing crates/batten/src/*.rs was not, so the 110 are a habit the ledger shaped rather than 110 considered choices. This is CLOUD-1176's scope creep arriving through the mechanism meant to bound it, and it is why that row's "port it into the core is not on the list" has been losing to the ratchet's own shape.

The prose half, which is how this stayed invisible

Three sites describe the obligation as "a policy surface and a compiled-binary test", full stop — .claude/rules/toolchain.md (from c049a68c), the module's own withdrawn-arm comment, and CLOUD-1219's body. All three read as "a Rego module".

Under that reading CLOUD-1199's whole disposition — eleven gates retiring onto an existing verb, no module — has no spellable arm, and the author's only passing move is the false subsumed CLOUD-1080 exists to remove. Measured: a grooming session took two wrong turns in one turn on that sentence, including a false "the dispatched bundle is broken" conclusion, before reading the predicate instead of the prose.

Distinct from CLOUD-1182, which is the adjacent row

CLOUD-1182 makes a retirement declare its successor KIND so a verb port is reviewable. This row makes the preset shape expressible at all. 1182 assumes the arm set is right and adds a declaration on top of it; without this row its Rust arm still cannot distinguish a preset from engine source, because a preset never reaches that arm. They compose and neither subsumes the other.


Refinement — Ready (make the DRY home spellable, and say which home is which)

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

  • **Authority boundary (§1). **policy/shell-retirement.rego, .claude/rules/toolchain.md's retirement section, and crates/batten/tests/retirement_doctrine.rs for the mechanism. No mise-tasks/ program and no tests/**/*.bats is added or editedV-SHELL-RULE-EDITED and V-SHELL-RULE-ADDED refuse both, and this row must not violate the ratchet it is repairing.
  • Computable predicate (§2). A retirement arm whose only policy surface is a preset (crates/batten/src/policy/presets/**, .rego) is admitted; one naming no policy surface at all is still refused. That pair is the whole change — the arm is widened, never loosened.
  • **The prose half is a second predicate (§2). **.claude/rules/toolchain.md names all three homes in preference order — consumer module → preset → engine source — with the test on the third being non-negotiable rule 1: a predicate naming a consumer fact may not land in the core. The gate cannot decide which home a retirement should have taken, so the ordering is written where the author reads it and the mechanism only holds the prose to the predicate.
  • Deliberately not in scope (§2). Declaring successor KIND (CLOUD-1182). Re-dispositioning any of the 110 engine-source arms — this row makes the alternative expressible, it does not re-litigate landed ports. Changing has_binary_test, the admission clauses, or SubjectFacts::died.
  • Effect (§3). read — a load-time predicate, nothing written.
  • Output and exit (§5). Unchanged: pointer-only, the retiring path and the missing obligation, never a line of a body. The 0/1/2/3 table is untouched and no new verdict token is raised — this widens an existing admission rather than declaring a class.
  • Commit / bump (§6). fix(policy)patch until 0.1.0.
  • Test obligation (§7). Both tiers. The module's own test_ rules pin the predicate; crates/batten/tests/retirement_doctrine.rs over the compiled binary holds the prose to it in both directions. Shown able to fail per CLOUD-418, three observed: (a) an arm naming only a preset is admitted — the case the arm exists for; (b) an arm naming no policy surface is still refused — the anti-vacuity mirror, without which (a) is satisfied by an arm admitting everything; (c) removing a home from the prose, or an arm from the module, turns the doctrine test red.

Acceptance

  • A retirement whose only policy surface is a preset loads clean.
  • A retirement naming no policy surface is still refused.
  • .claude/rules/toolchain.md names three homes with the rule-1 test on engine source, and a test fails if any is dropped.
  • The 0-of-609 measurement is restated from the ledger at landing time rather than quoted from this body.

Found while pressure-testing a dispatch prompt: the prompt said "no module needed", a reader objected that the ledger demands a policy surface, and reading has_policy_surface showed both were half right — engine source qualifies, and the preset home does not exist.

CLOUD-1182 A retirement does not declare its successor KIND, so porting a gate as a new CLI verb satisfies `shell-retirement` exactly as well as porting it as a policy module — which is why nine ports became nine top-level nouns

Why

shell-retirement's has_policy_surface (policy/shell-retirement.rego:549-559) accepts a retired gate's successor at policy/*.rego or crates/batten/src/*.rs, and does not distinguish them. So landing a gate as a brand-new top-level CLI verb clears the retirement gate exactly as well as landing it as a policy module that costs the surface nothing.

Nine ports took the first route, and the surface records it: claim check, commit check, ready lint, semver check, attribution check, payload field, target prune, wiring reclaim, perf pair are each a new top-level noun dispatching to exactly one leaf. Measured on main 2026-08-30, batten spec --format json: 29 top-level rows, 50 leaves, 38 distinct verbs, 30 of them used exactly once, 12 nouns with a single leaf.

The campaign is not finished. mise-tasks/ holds 134 programs, 54 of them *-check.sh files, and a 20-gate sample classified 10 as needing a CLI verb under the current disposition. Ported blind that is roughly another twenty-five commands.

The alternative already exists and is documented. batten check --rule <id> (surface.rs:797, CHECK_RULE) was built for exactly this, and its doc comment says so:

"A gate ported out of mise-tasks/ into a [[rule]] row loses its task name, and every lifecycle step that invoked it by name breaks… This is the narrowing that makes a migrated row invocable by name instead, so a caller stays byte-identical and out of the changed-file set."

Nineteen policy/*.rego modules already ride that path and none appears in surface.rs. So the campaign has a zero-surface-cost route and no mechanism steering ports onto it.

Why this is the row that matters

CLOUD-1176 names this finding — "the retirement campaign has one disposition" — and a tree-wide search for CLOUD-1176 returns zero hits: not in .claude/, not in batten.toml, not in policy/*.rego, not in crates/. The finding has no mechanism, which is non-negotiable rule 2 unmet for the very issue that diagnosed the problem. This row is that mechanism.

The recorded taxonomy is also at the wrong granularity to help. .claude/rules/toolchain.md:27-44 states two shapes — "retire it whole" or "leave the file alone" — which is about how to touch a file, not what a gate becomes. [rule.conserves]'s arm_markers (shell-retirement.rego:442) can already express // carried:, // subsumed:, // changed:, // withdrawn: — but those annotate a completed retirement, and neither carried nor subsumed says whether the successor was a module or a verb.


Refinement — Ready (declare the successor kind, and gate on it)

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

  • **Authority boundary (§1). **policy/shell-retirement.rego, its [[rule]] row and [[verdict]] rows in batten.toml, and crates/batten/tests/ for the second tier. **No **mise-tasks/ **program and no **tests/**/*.bats is added or editedV-SHELL-RULE-EDITED and V-SHELL-RULE-ADDED refuse both, and this row is the one that must not violate the rule it is strengthening.
  • Computable predicate (§2). A [rule.conserves] arm declares its successor kind as well as its path: a new marker distinguishing a module successor (policy/*.rego) from a verb successor (a new SURFACE row). A retirement whose arm names a verb successor without declaring it as one fails to load, the same way an unregistered verdict token does.
  • The kind is derived where it can be, declared where it cannot. A successor at policy/*.rego is unambiguously a module; a successor at crates/batten/src/*.rs may be either, because a module's Rust second tier also lives there. So the declaration is required only for the Rust arm — which keeps the marker honest rather than ceremonial.
  • Deliberately not in scope (§2). Forbidding a verb successor. A verb is sometimes correct — a gate needing stdin, spawning with its own arguments, or performing a write cannot be a tree-scoped module, and four of the nine were right on those grounds. This row makes the choice visible and reviewable, never automatic. Also out of scope: re-litigating the nine already landed, and the surface redesign itself.
  • **Effect (§3). **read — a load-time refusal, no state written.
  • Output and exit (§5). Pointer-only: the retiring rule's id and which arm is undeclared. Exit follows the 0/1/2/3 table; a 2 is the verdict.
  • **A **[[verdict]] row ships with it (§5). The token names the class and its routes: declare the arm's kind, or re-land the successor as a module. A refusal naming no remedy is CLOUD-1050's defect class.
  • **Commit / bump (§6). **feat(policy) — patch.
  • Test obligation (§7). The module's own test_ rules pin the predicate; crates/batten/tests/*.rs over the compiled binary proves the engine builds the input the predicate reads — never a .bats, which shell-retirement itself refuses. Shown able to fail per CLOUD-418, three observed: (a) a Rust-arm successor with no declared kind is reported; (b) a declared verb successor is NOT reported — the anti-vacuity mirror, and the case proving this gate does not simply ban verbs; (c) a policy/*.rego successor needs no declaration and is not reported.
  • A declared mutation that discriminates (§7). Per .claude/rules/policy-modules.md, choose a mutation no other conjunct already excludes — a mutation on the Rust-arm condition survives if the module-arm condition alone would have excluded the input.
  • Blockers (§8). None. relatedTo CLOUD-1176 (the finding this mechanises), CLOUD-418.

Acceptance

  • A [rule.conserves] Rust arm without a declared successor kind fails to load, naming the rule and the arm.
  • A declared verb successor loads clean — the gate makes the choice reviewable, it does not ban it.
  • The nine existing ports are annotated with their kind, so the count is readable from the ledger rather than by inspecting the surface.
  • CLOUD-1176 is cited somewhere a gate can reach, closing the zero-hits finding above.

Found while designing the surface redesign: the nine singleton nouns were traced to their cause, and the cause was that nothing distinguished the two successor shapes.

Review in Linear

@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 42 minutes.

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: 65ba707c-9521-434c-af92-b99d2fd022fd

📥 Commits

Reviewing files that changed from the base of the PR and between 53e9673 and 540b84b.

📒 Files selected for processing (20)
  • .claude/rules/toolchain.md
  • .serena/memories/workflow/board-states.md
  • batten.toml
  • crates/batten/tests/board_receipts.rs
  • crates/batten/tests/board_record.rs
  • crates/batten/tests/checks_green.rs
  • crates/batten/tests/claim.rs
  • crates/batten/tests/contract_drift.rs
  • crates/batten/tests/filed_here.rs
  • crates/batten/tests/perf_pair.rs
  • crates/batten/tests/pr_watch.rs
  • crates/batten/tests/prebuilt_lint.rs
  • crates/batten/tests/ready.rs
  • crates/batten/tests/retirement_doctrine.rs
  • crates/batten/tests/semver_gate.rs
  • crates/batten/tests/shell_retirement.rs
  • crates/batten/tests/stop_posture.rs
  • crates/batten/tests/surface.rs
  • crates/batten/tests/target_prune.rs
  • policy/shell-retirement.rego

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 Pro by visiting https://app.coderabbit.ai/settings/billing.

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

`mem:workflow/board-states` carried the facts this follows from — "In Review means
already merged", "Done means released" — and never drew the consequence for
grooming: a Todo row has shipped nothing, so its body is the spec and you correct
it in place; an In Review or Done row's body describes code already on main, so a
wrong design there gets a new row and a superseded reference, never a rewrite.

The cut is landed-ness, not readership. Rewriting a shipped row's body makes the
record disagree with the code, invisibly — the CLOUD-994 class. The opposite error
had no name until now, and it cost a session two wrong turns on an Urgent,
unclaimed, Todo row whose central premise was false: twice left uncorrected in
favour of a spin-off row plus a supersede note, inventing a second authority over a
design nothing had built yet and leaving the ready queue holding the wrong spec.

An earlier revision of this commit carried NO key trailer, on the reasoning that a
`Refs:` would drag the row it cites into In Progress on the PR event. That
reasoning was sound about the row it had in mind and wrong as a conclusion:
`commit-lint` refuses a commit claiming no issue at all, and the original branch
never learned that because it stayed a draft and CI never ran on it. The trailer
names CLOUD-1239 instead — a row this branch actually serves, already In Progress
and closed by this PR — so the citation is honest and moves nothing that should not
move. CLOUD-1152 stays uncited for the original reason.

Refs: CLOUD-1239
…s which

`has_policy_surface` admitted a consumer module or engine source and nothing else,
so a preset -- which lives at `crates/batten/src/policy/presets/**` and is a
`.rego` -- failed the first arm on its prefix and the second on its suffix. The one
successor shape `.claude/rules/policy-modules.md` calls generic-by-construction
could not be spelled at all.

The gate's incentive therefore ran the wrong way. Measured over the landed ledger
at this branch's base: of 725 arms, 0 name a preset while 113 name engine source,
21 of those retiring a whole file. An author who picked the DRY home was refused
and one who picked the core was not -- CLOUD-1176's scope creep arriving through
the mechanism meant to bound it, and the reason those 113 are a habit rather than
113 considered choices. The shape is stable as the ledger grows: the same count
taken at 5d38e0c read 609/0/110/18, so the preset column has stayed empty across
116 further arms.

So this adds the preset arm, and names the choice where the author meets it.
`.claude/rules/toolchain.md` carried "each names a policy surface and a
compiled-binary test" with no discriminator, which reads as "a Rego module" and
makes every retirement onto an existing verb look unspellable -- a misreading that
cost a session two wrong turns in one turn, including a false alarm that a
dispatched bundle was broken. It now names three homes in preference order and
gives the test for the third: engine source is mechanism only, and non-negotiable
rule 1 refuses a predicate there that names a consumer fact.

`retirement_doctrine.rs` pins both directions, as it already does for the
classifiers: the prose names three homes and the module declares three arms. The
clause checks are whitespace-normalised, because these are prose and `mise run fmt`
reflows them.

Refs: CLOUD-1176
…t exists

The arm landed with textual coverage only: `retirement_doctrine.rs` counts three
`has_policy_surface` definitions and greps the module for the presets prefix. Both
pass over an arm carrying the WRONG prefix, so what shipped was evidence that a
third arm exists and none that it decides anything.

`test_a_mapping_naming_only_a_preset_is_admitted` is the behavioural half: a
retirement whose only policy surface is `crates/batten/src/policy/presets/**` is
admitted. `test_mapping_without_a_policy_surface_is_refused` directly above is its
anti-vacuity mirror, so an arm admitting everything fails one or the other.

Shown able to fail rather than asserted: inverting the expectation names the case
and moves the tier to 312 passed / 1 failed, which is also what proves the case
runs at all -- `policy test` prints counts and never lists a passing rule, so a
green tier is not evidence that a newly added rule was among them.

Refs: CLOUD-1176
…rb or mechanism

`has_policy_surface` admits `crates/batten/src/*.rs`, and until now it could not
say WHAT that source is. A port landing a new top-level CLI verb and a port
landing mechanism something else reads spell the successor identically, so the
ledger recorded both the same way and the count of which happened was readable
only by inspecting SURFACE and comparing by hand. That is why nine ports became
nine singleton nouns without anyone deciding they should -- CLOUD-1176's "one
disposition" arriving through the mechanism meant to bound it.

So an engine-source arm now carries `kind:verb` or `kind:mechanism`, marked and
validated the way CLOUD-1219's invocation field is, and excluded from
`path_successors_for` for the same reason: the token can never satisfy
`has_policy_surface`, never satisfy `has_binary_test`, and never be substituted
in where a path belongs. Every obligation a retirement already owed it still owes.

`kind:module` is NOT a value, and the Ready block asked for one. It reached for
module-versus-verb on the premise that "a module's Rust second tier also lives
there" -- it does not. A module's second tier is `crates/batten/tests/*.rs`,
which is `has_binary_test`'s column and a different field on the same row. The
honest split for `crates/batten/src/` is the one `.claude/rules/toolchain.md`
already draws for its third home: mechanism, or a verb.

The derive-where-you-can half relieves nothing, which is worth recording rather
than assuming: of the 113 landed engine-source arms, ZERO also name a module, so
there is no arm whose kind the path already decides. All 113 are annotated here --
77 `kind:verb`, 36 `kind:mechanism` -- which is what makes CLOUD-1176's finding a
number a reviewer reads off the ledger instead of an impression.

The gate does not forbid a verb, and both tiers carry the anti-vacuity mirror
that proves it: a declared verb successor is admitted, a declared mechanism
successor is admitted, and a module successor owes no field at all. Without those
the refusal is satisfied by a rule refusing every engine-source retirement, which
this row puts explicitly out of scope -- a gate needing stdin, spawning with its
own arguments, or performing a write cannot be a tree-scoped module.

No declared #MUTANT row: `shell-retirement` carries `#MUTANT-EXEMPT CLOUD-931`,
because `mutant` resolves a gate's suite as `tests/$gate.bats` and this rule's
whole subject is that a migration ships no new bats suite. There is no named case
a mutation could turn red, so the discriminating obligation is met by the
anti-vacuity pair over the compiled binary instead.

`V-SUCCESSOR-NO-SURFACE`'s class text is corrected in the same change: it named
two successor shapes where the arm now admits three.

Refs: CLOUD-1176
@wenzowski
wenzowski marked this pull request as ready for review August 31, 2026 16:54
@wenzowski
wenzowski force-pushed the claude/issue-751-investigation-po45j7 branch from 61a4e91 to 540b84b Compare August 31, 2026 16:54
@sonarqubecloud

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 540b84b into main Aug 31, 2026
10 of 11 checks passed
@wenzowski
wenzowski deleted the claude/issue-751-investigation-po45j7 branch August 31, 2026 17:28
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