Skip to content

fix(policy): let a retirement name the DRY home, and say which home is which - #751

Closed
wenzowski wants to merge 3 commits into
mainfrom
claude/stage-2-3-grooming-uqk71k
Closed

fix(policy): let a retirement name the DRY home, and say which home is which#751
wenzowski wants to merge 3 commits into
mainfrom
claude/stage-2-3-grooming-uqk71k

Conversation

@wenzowski

@wenzowski wenzowski commented Aug 29, 2026

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 the prose describing the obligation said "a policy surface" while meaning three different things.

The finding

policy/shell-retirement.rego:549-559 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 5d38e0c2: of 609 arms, 0 name a preset, 110 name engine source, 18 of those retiring a whole file. That is not a taste for the core — it is a gate that had one. Choosing the DRY home was refused; choosing crates/batten/src/*.rs was not.

Why the prose needed saying out loud

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

Under that reading CLOUD-1199's whole disposition — eleven gates retiring onto an existing verb, no module — has no spellable arm, and the only passing move is the false subsumed CLOUD-1080 exists to remove. It cost this session two wrong turns in one turn, including a false "the dispatched bundle is broken" alarm, before anyone read the predicate instead of the sentence.

The corrected prose names 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.

What changed

file what
policy/shell-retirement.rego the preset arm, the "three shapes" comment correction, and test_a_mapping_naming_only_a_preset_is_admitted
.claude/rules/toolchain.md the three homes in preference order, with the rule-1 test on engine source
crates/batten/tests/retirement_doctrine.rs pins prose against predicate in both directions
.serena/memories/workflow/board-states.md a row's STATE decides whether its body is a spec or a record

The memory commit rides along as it always did. It carries no key trailer deliberately — this branch cites CLOUD-1152 without implementing it, and a Refs: would drag that Urgent Todo row into In Progress on the PR event.

Verification

  • Rust tier: 3282 pass.
  • Prose mutation — delete the preset home from the rules file → FAIL at retirement_doctrine.rs:177.
  • Predicate mutation — delete the preset arm from the module → FAIL at :187, left: 2, right: 3.
  • policy test: 313 pass, and the new case shown able to fail — inverting it gives 312 passed, 1 failed naming test_a_mapping_naming_only_a_preset_is_admitted.

Both mutations were run with .git/batten-receipts cleared, for a reason worth recording: two mise run test:cargo invocations returned exit 0 having run no tests, reporting receipt 6a9ac634760b already covers these exact inputs — the key unchanged across an edit to .claude/rules/toolchain.md, and unchanged again with that file staged.

step-receipt.sh:150 gives test:cargo specs="$rust_specs", and :130 is crates Cargo.toml Cargo.lock batten.toml — no .claude/ path, no AGENTS.md. Four test files read the committed prose at runtime and assert over it (retirement_doctrine, scanner_taxonomy, rules_builtin_claims, task_prose), so the task that runs them cannot observe the files they police. CI is unaffected (the cache neither hits nor records there), making it a local-only false green. The same class was already fixed once in that same file — batten.toml is in rust_specs because tests/cli.rs's the_committed_* cases read it at runtime, "Measured, not hypothesised" — and the prose files were never added. Recorded on CLOUD-725.

Separately, policy test prints counts and never names a passing rule, so a green tier is not evidence a newly added rule was among them — which is why the new case was shown able to fail rather than assumed present. Unowned; adjacent to CLOUD-313.

Two board corrections this PR's own history forced

Closes CLOUD-1150 is gone, and it was wrong when written. That row's §1 owns the gate — retiring mise-tasks/rules-drift.sh and its suite — and says so outright: "This row owns the gate, not the sentence." This PR retires nothing. CLOUD-1150 is already In Review via #774.

A Refs: CLOUD-1176 in an earlier revision of this body completed an Urgent Todo row. The tracker attached this PR and set CLOUD-1176 to Done at 04:36:53 with nothing merged. It has been restored to Todo and the trigger removed. AGENTS.md is explicit that Done means released and is never the merge's to set, let alone a body edit's.

The commit that body described — e7ed838a, the sibling-edit admission count — is no longer in this PR. Main landed the same correction independently and now names four predicates where that commit named three, so it was dropped during rebase.

Closes CLOUD-1239

@linear-code

linear-code Bot commented Aug 29, 2026

Copy link
Copy Markdown
CLOUD-1150 `.claude/rules/toolchain.md` says one sibling-edit admission is allowed where the module carries three, and `rules-drift` cannot see a restated rule-NAME set

Why

.claude/rules/toolchain.md:69-73 — the doctrine every session is routed to before touching a mise-tasks/*.sh or a tests/**/*.bats — says:

One edit is admitted, and only one, so it is not rediscovered as an exception: only_drops_a_retired_reference — a sibling file dropping a declaration row that names a path this same change retires, adding nothing.

policy/shell-retirement.rego carries three admissions: only_drops_a_retired_reference (:190), truncates_a_retired_reference (:269, added by CLOUD-1051) and repoints_at_the_declared_successor (:255, added by CLOUD-1121). The file is two behind, and the sentence does not merely omit them — it asserts a closed count, "and only one", and pre-empts correction with "so it is not rediscovered as an exception."

A stale parenthetical is a typo. A stale closed count that forbids looking further is a false premise delivered with the authority of the rule. That is rules-drift's own charter sentence, and this claim is inside its stated scope and outside its reach.

Measured cost, in this repository, this week

A grooming session read that sentence, concluded three governed programs were permanently unretirable, and wrote that conclusion into two dispatched agent prompts and five issue bodies (CLOUD-1100, CLOUD-761, CLOUD-1092, CLOUD-1113, CLOUD-1111). CLOUD-1100's scope was cut on it, with the row recording *"the program stays in the tree, dormant behind the compiled authority, and that is the honest state rather than a shim." *CLOUD-761's consumer half was blocked on it outright. Both dispatched sessions were running by the time it was caught and could not be recalled.

The correction that followed was also wrong, in the other direction — see CLOUD-1149. Three passes over one sentence, two of them landing on the board.

The mechanism half, and its coupling

rules-drift already owns this class. Its header: "Gate: a value .claude/rules/*.md restates still agrees with the mechanism that owns it (CLOUD-506)", and its stated bound is the sharp half:

"WHAT THIS MUST NOT DO... demand that a value be restated. The file's rule is the opposite... This fails a claim that is PRESENT AND WRONG, never one that is absent."

"One edit is admitted, and only one" is present and wrong. It is not an absent-value case, so gating it does not invert the file's own anti-restatement discipline. The gap is only in shape: rules-drift's anchors are value-shaped (`VAR` (N)), and this is a rule-name set restated with a closed count.

mise-tasks/rules-drift.sh is itself governed, so extending it is not an edit — it is a retirement, or it is nothing. The census classes it FREE (272 lines, tests/rules-drift.bats 3.1s, in $MUTANT_GATES, 1 #MUTANT, no inbound governed reference), so the retirement is among the cheapest available. This row must not assume an in-place edit; the two landable shapes apply to it exactly as to everything else.

The prose half is landing separately and immediately

.claude/rules/toolchain.md is ungoverned. The corrected paragraph — three admissions named with a pointer to the module rather than paraphrased bodies, the literal-path-versus-relative-resolution discriminator from CLOUD-1149, and the from-trunk-blob-fetch pattern that is a genuine blocker — is being landed now, ahead of this row, because every session reading the file today reads the false premise. This row owns the gate, not the sentence.


Refinement — Ready (gate the restated rule-name set; retire rules-drift to do it)

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

  • Authority boundary (§1). The successor to mise-tasks/rules-drift.sh, in crates/batten/ or as a policy/*.rego module. The program and tests/rules-drift.bats are deleted, two paths and two // carried: ledger arms, and the $MUTANT_GATES entry moves with it. .claude/rules/*.md and .serena/memories/** stay the surfaces read; the existing value-shaped predicates are conserved unchanged.
  • Computable predicate (§2). A .claude/rules/*.md sentence that names a set of identifiers the mechanism owns — and asserts a closed count over them — disagrees with the mechanism when the counts or the names differ. The identifier set comes from the module or source that defines them, never from a second list.
  • Do not invert the file's own discipline (§2). Conserved from rules-drift.sh's header and load-bearing: this fails a claim that is present and wrong, never one that is absent. Prose stays free to name a rule without being made to enumerate its siblings. A gate pushing toward completeness would be the defect, not the fix.
  • Deliberately not in scope (§2). Deciding CLOUD-1149's admission shape — this row gates the claim about the admission set, whatever that set turns out to be. Widening to prose surfaces beyond .claude/rules/** and .serena/memories/**. Gating AGENTS.md, which is a different surface with a different rule.
  • **Effect (§3). **read. It reads committed text and decides.
  • Output and exit (§5). Pointer-only: path:line, the name, and the two counts — never the sentence, since a rules file quotes command lines and env names. Conserved from the existing gate. Exit 0/1 with no exit 2: it reads only committed text, so there is no could-not-look state — also conserved, and stated so the port does not invent one.
  • **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. That is CLOUD-595's correction.
  • Test obligation (§7). Over the compiled binary in crates/batten/tests/; no .bats file is added or edited, since V-SHELL-RULE-ADDED refuses a new one at deny. Shown able to fail per CLOUD-418: the exact toolchain.md sentence at its pre-fix bytes must be reported, and the corrected sentence must pass — the second is the anti-vacuity half, without which the first is satisfied by a gate that fails every rules file. A third case: a sentence naming one rule without claiming a count is not reported, which is the discipline clause above made observable. **Mutated: **rules-drift declares #MUTANT restated-default-drifts; that mutation and the $MUTANT_GATES entry move to the successor's tier and mutant-census stays green across the move. Replayed: old and new run over the same .claude/rules/** tree and must agree finding-for-finding.
  • Blockers (§8). None. relatedTo CLOUD-506 (which created this gate for the identical failure one shape down), CLOUD-770 (which widened it to the memory surface), CLOUD-1119 (three stale instruction-surface claims), CLOUD-1132 (the two-shapes doctrine surface), CLOUD-1149 (the admission clause this claim is about).

Acceptance

  • The corrected toolchain.md paragraph passes; its pre-fix bytes are reported.
  • A rules sentence naming one mechanism without a closed count is not reported.
  • mise-tasks/rules-drift.sh and tests/rules-drift.bats are deleted with one ledger arm each, and no other governed file is edited.
  • mutant-census green, #MUTANT restated-default-drifts honoured at its new home.
  • The existing value-shaped findings are unchanged, replayed against the same tree.

Found because the sentence this row is about caused two dispatched agent sessions and five issue bodies to be written on a false premise, and the gate that exists to catch exactly this could not see it.

CLOUD-1152 Batten adjudicates for six harnesses and documents itself for one: every load-bearing rule lives in `.claude/rules/`, as prose, which is rule 2's own prohibition

Why

Harness (crates/batten/src/hook.rs:62) names six: ClaudeCode, Cursor, CopilotCli, GeminiCli, CodexCli, and the neutral ExitCode contract for any host whose only decision channel is an exit status.

The repository's own operating doctrine lives in .claude/rules/. Five of those six cannot read it.

The root document already solved this and the fix is one line of git ls-files:

100644 AGENTS.md   ← the real file
120000 CLAUDE.md   ← a symlink to it

Neutral content, vendor-named alias. Correct. Then AGENTS.md's own routing table sends every rule that binds a session — rust.md, toolchain.md, commits.md, scanning.md — into a directory named after one vendor. .serena/memories/** is the same shape for a second vendor, and mem:core is the routing root for the whole memory graph.

A completion gate whose threat model is honest error, shipping as a consumer-installable product, cannot have its own rules readable by one sixth of the hosts it adjudicates for. That is not a tidiness complaint; it is the same class as non-negotiable rule 1. Rule 1 forbids consumer-specific identifiers in the core so the core stays portable. Nothing forbids vendor-specific instruction surfaces, and the result is a project whose portability claim is true of its binary and false of its doctrine.

The deeper violation is rule 2, not the folder name

Rules ship with their mechanism. A new rule without a runnable gate (a check with an exit code) is half a change. Prose is feedforward only; a log without a gate is sensor only.

.claude/rules/toolchain.md is ~260 lines of pure prose carrying the two-shapes retirement rule, the sibling-edit admission set, the lifecycle contract, the landing loop's invariants and the ci-wait terminality rule. Every one of those has a mechanism elsewhere. The file is the largest instance of rule 2's prohibition in the tree, and it is the file that states the rule.

Measured, this week, in this repository

CLOUD-1150: toolchain.md asserted a closed count of one sibling-edit admission where policy/shell-retirement.rego carries three. It stayed wrong for two releases. A grooming session read it, concluded three governed programs were permanently unretirable, and wrote that into two dispatched agent prompts and five issue bodies before it was caught. One of the three — hooks-wiring-check.sh, 84.4s of suite — is blocked by nothing at all.

The response was to write better prose in the same folder, then file CLOUD-1150 proposing a gate to keep the vendor prose honest. That is a mechanism for maintaining the wrong artifact, and this row supersedes that framing: 1150's gate is worth having for whatever prose remains, but it is downstream of deciding what should be prose at all.

This is not the first instance. CLOUD-358 (Backlog) is the same class one level down — "a rule shipped without its gate, in the file that forbids exactly that." CLOUD-994 is a third. CLOUD-1119 was a fourth. The pattern recurs because the surface invites it.

The structural cause, which is why this needs a decision rather than a cleanup

There is nowhere harness-neutral to put doctrine, and that is enforced:

  • Non-negotiable rule 7 forbids a docs/ tree outright — no-docs-tree fails if any docs/ path is tracked.
  • AGENTS.md is deliberately budgeted; CLOUD-683 capped it, which is why the routing table exists at all.
  • .claude/rules/ has a loading mechanism (frontmatter paths: triggers) that no neutral location has.

So the vendor folder was the only unforbidden surface with a trigger. .claude/rules/ did not win on merit; it won by elimination. CLOUD-1132 then closed "the two-shapes rule exists only in a rego header" by adding prose to the vendor folder — the opposite of moving the rule to its mechanism — which is the pattern running in the wrong direction under its own momentum.

What the answer is not

Not "copy .claude/rules/ to five sibling folders." Five stale copies is worse than one, and rules-drift would then police a cartesian product.

CORRECTION (2026-08-30) — "nowhere neutral" is false, and it changes the deliverable

The structural-cause section above says .claude/rules/ has a loading mechanism no neutral location has. Batten is that mechanism, and this row went hunting for a neutral instruction LOCATION when the project's architecture is a neutral instruction CHANNEL. Asking for a folder is what made the answer look like a folder.

Every affordance the vendor folder offers has a shipped neutral equivalent:

vendor-folder affordance Batten's neutral equivalent
frontmatter paths: trigger glob on a [[rule]] row — trigger and mechanism become one object
prose that advises without refusing severity = "warn" (CLOUD-1138 fixed warn refusing like deny on the mediated call)
the rule's text the [[verdict]] registry — 175 rows in batten.toml, the prose once, where a gate can read it, with routes and preconditions
loaded at session start delivered at the act, per event, via AdvisoryReach.delivered_on

**It reaches 1 of 6 hosts. **AdvisoryReach (crates/batten/src/hook.rs:834), read from the tree:

host delivered_on declared
ClaudeCode PreToolUse, PostToolBatch, SessionStart, Stop Yes
GeminiCli empty Yes — the host has the channel and Batten does not emit into it
Cursor, CopilotCli empty Unknown — unprobed
CodexCli empty No — advertised, "parsed but not supported yet"

The GeminiCli row's own comment names the cause and the owner: "the gap is Batten's rather than the host's. CLOUD-44*'s per-host emitter shim is what would close it." *CLOUD-44 is Done (2026-08-11, PR #265) and landed the parsers and the decision emitter. The advisory emitter was never part of it, and nothing has owned it since. CLOUD-601 solved this exact defect one channel over — ask reachability being event- and envelope-scoped — so AskReach is the template rather than something to re-derive.

So the vendor folder did not win by elimination against nothing. It tied with the neutral channel on reach — one harness either way — and beat it on effort. That is a far more actionable diagnosis than "nowhere neutral exists", and it inverts the fix: the blocking question is the advisory emitter, not a folder. Relocating doctrine first moves it from a folder one harness reads to a channel one harness receives, and buys nothing. Closing GeminiCli takes it to 2/6 and is a pure Batten gap with no host uncertainty in the way; Cursor and CopilotCli need a primary-doc probe first, which is CLOUD-209's shape.

One bound to preserve in any emitter work: AdvisoryReach's doc comment states that an unreachable escalation degrades to a refusal while an unreachable advisory degrades to silence — a shim that inverted that would turn a missing channel into a deny on four hosts.

The two other constraints in the structural-cause section stand and are unaffected: rule 7 still forbids docs/, and AGENTS.md is still budgeted (CLOUD-683). They bound where a routing table lives, not where doctrine does.

The move is not a git mv: the repo-agnostic core hard-codes the vendor path (2026-08-30)

Measured while checking what the relocation actually costs. crates/batten/tests/** carries the literal .claude/rules in at least four files, so moving the directory reds the core crate's suite:

file what it does
rules_builtin_claims.rs:70 const RULES_DIR: &str = ".claude/rules"; — a named constant
memory_injection.rs:84-89,148-153 seven path literals; asserts a per-file injection census
hk_fix_selection.rs:58,71,232 writes .claude/rules/toolchain.md into a fixture and asserts line_sources names it
ambient_authority.rs:37 cites .claude/rules/rust.md as a co-authority with clippy.toml

Two of those are gates over the prose, living in the core craterules_builtin_claims.rs holds policy-modules.md's claims to the builtins, and scanner_taxonomy.rs asserts scanning.md still names an instrument per question class. So the repo-agnostic core contains tests asserting the CONTENT of one vendor's instruction files, which is this row's thesis one layer deeper than the folder name.

Two consequences for §1. First, crates/batten/tests/** belongs in the authority boundary — it is currently absent, and an implementer honouring §1 as written would discover the coupling mid-move with a red suite and no sanctioned file to fix it in. Second, the classification in §2 gains a fourth question per rule: does a test in the core crate assert this file's content, and does that test move, get re-pathed, or get deleted? A rule whose only mechanism is a core-crate test asserting its prose is not class (a) — the mechanism does not own the rule, it owns the sentence.

Cosmetic and worth noting only so it is not mistaken for the above: src/policy/presets/shell-hygiene/sibling-resolves.rego:178-179 uses .claude/hooks/... as test-fixture data inside a shipped preset. The rule itself hardcodes no prefix — its own comment says that is the bug it avoids — so this is a fixture-naming choice, not a rule 1 violation, and it should not be used to inflate the count above.


Refinement — Ready (decide the neutral instruction surface, then move what is prose-only)

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

  • Authority boundary (§1). The instruction surface itself: AGENTS.md, .claude/rules/**, whatever neutral home this row decides on, **and **crates/batten/tests/** — four of its files hard-code the vendor path, two of them as gates over the prose, so the move reds the core suite without them. See the section above. **No **mise-tasks/ **program and no **tests/**/*.bats is edited or addedrules-drift.sh is governed, so any change to its globs is a retirement (CLOUD-1150's coupling), not an edit, and this row does not assume otherwise.
  • Computable predicate (§2), and it is the row's real deliverable. Each rule currently in .claude/rules/** is classified into exactly one of three: (a) mechanism-owned — the authority is a module, task header or source comment, and the prose becomes a pointer; (b) genuinely vendor-specific — Claude Code's frontmatter triggers, .claude/settings.json hook wiring — and stays; (c) harness-neutral doctrine with no mechanism — which is rule 2's half a change, and each one either gets a gate or is recorded as a known gap. The classification is the artefact; the moves follow from it.
  • The blocking question is the ADVISORY EMITTER, not a folder (§2). Per the correction above, the neutral channel exists and reaches 1 of 6 hosts because CLOUD-44 landed the decision emitter and not the advisory one. Close GeminiCli first — the one host declared Yes with an empty delivered_on, so a pure Batten gap and 1/6 → 2/6 with a single shim. If that work is larger than this row, split it out and make this row blockedBy it; do not relocate prose ahead of the channel meant to carry it.
  • The neutral home is the FALLBACK, not the blocking question (§2). A root rules/ directory is permitted (no-docs-tree bounds docs/ only), but it has no trigger mechanism, so a rule there is read by nobody unless something routes to it. The alternatives — expanding AGENTS.md against its budget, or pushing doctrine down into mechanism headers where shell-retirement.rego's already lives — trade discoverability against currency. Pick one and say why; a row that surveys and does not decide is the punt this repository names.
  • Sequencing against the fact-family bundle (§2). This row moves .claude/rules/policy-modules.md. The eight-row substrate bundle (CLOUD-1167, 856, 1200, 1203, 1187, 1188, 1154, 1171, 1172) ends with a single commit updating that file's input.tree.* key lists for every new family, and CLOUD-1206 edits the rules-drift gate over the same file. Three changes, one small file, one of them a relocation. This row goes AFTER the bundle lands, or the bundle is told the path is moving — not both discovered at a rebase. Whichever way it is settled, the key-list content is the bundle's and the path is this row's; neither may quietly take the other's half.
  • Deliberately not in scope (§2). Relaxing rule 7's docs/ prohibition. Retiring rules-drift. Migrating .serena/memories/**, which is a second vendor surface with its own loading contract and its own row's worth of work — name it as inherited debt rather than folding it in. Writing gates for every (c)-class rule; this row classifies and files.
  • **Effect (§3). **read. Prose and routing only; no gate's decision changes.
  • Output and exit (§5). No exit contract moves. If a pointer replaces prose, the mechanism it points at is unchanged — a rule's meaning must not shift in a move that is supposed to relocate it.
  • **Commit / bump (§6). **docsno bump.
  • Test obligation (§7). The move itself is provable only by what survives it: **every rule this row relocates must still be reachable from **AGENTS.md in one hop, and no rule may be dropped in transit. That is CLOUD-994's recorded failure — an evicted sentence nothing detects — so the classification table is the fixture and the acceptance is that it is complete. Any (c)-class rule that gets a gate carries the ordinary shown able to fail obligation (CLOUD-418) on its own row, not here.
  • Blockers (§8). None. relatedTo CLOUD-1150 (the measured instance, whose gate is downstream of this decision), CLOUD-1132 (which moved content the wrong way), CLOUD-358 and CLOUD-994 (the same class, unowned), CLOUD-209 (the harness capability matrix behind the neutrality claim), CLOUD-1119.

Acceptance

  • Every rule in .claude/rules/** is classified (a)/(b)/(c), with the mechanism named for each (a) and a filed row for each (c).
  • Every core-crate test asserting a rules file's content is re-pathed, relocated or deleted with a stated reason, and mise run test:cargo is green after the move — the coupling is named on this row so it cannot be discovered mid-implementation.
  • The neutral home is decided, with the trigger question answered rather than deferred — a rule nothing routes to is not relocated, it is deleted.
  • A session running under Cursor, CopilotCli, GeminiCli, CodexCli or the bare ExitCode contract can reach the two-shapes rule, the admission set and the lifecycle contract.
  • AdvisoryReach.delivered_on is non-empty for more than one host, and the count is stated. A relocation that leaves it at 1/6 has moved prose rather than fixed reach, and no host's declared value moves without a primary-doc citation.
  • .claude/rules/** retains only class (b), or the row records exactly what it could not move and why.
  • No rule is lost in the move.

Filed after the owner asked why a project whose entire point is agent-neutrality keeps its own binding rules in one vendor's folder. The first answer given was that no neutral surface with a loading mechanism exists. That was wrong — corrected 2026-08-30 when the owner asked why Batten's own hook surface was not the answer. It is: the channel is built and measured, and it reaches one host because the advisory half of CLOUD-44 never shipped.

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

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.

Review in Linear

@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 37 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: 3962911f-0bbb-4fb2-aff0-07cd9e5a1a79

📥 Commits

Reviewing files that changed from the base of the PR and between 0683ce5 and 8b5b883.

📒 Files selected for processing (4)
  • .claude/rules/toolchain.md
  • .serena/memories/workflow/board-states.md
  • crates/batten/tests/retirement_doctrine.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.

@wenzowski
wenzowski force-pushed the claude/stage-2-3-grooming-uqk71k branch 4 times, most recently from 0e5f793 to 8aff541 Compare August 30, 2026 20:04
@wenzowski wenzowski changed the title docs(rules): name all three sibling-edit admissions, and what actually blocks a retirement docs: name all three sibling-edit admissions, and record that a row's STATE decides spec-vs-record Aug 30, 2026
@wenzowski
wenzowski force-pushed the claude/stage-2-3-grooming-uqk71k branch from 8aff541 to 6e0e7f6 Compare August 30, 2026 22:31
@wenzowski
wenzowski force-pushed the claude/stage-2-3-grooming-uqk71k branch from 6e0e7f6 to 4bc67f3 Compare August 31, 2026 03:57
@wenzowski wenzowski changed the title docs: name all three sibling-edit admissions, and record that a row's STATE decides spec-vs-record fix(policy): let a retirement name the DRY home, and say which home is which Aug 31, 2026
@wenzowski
wenzowski force-pushed the claude/stage-2-3-grooming-uqk71k branch from c836d01 to 7774f22 Compare August 31, 2026 06:37
`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 this 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.

No key trailer: this branch does not implement that row, it only cites it, and a Refs trailer would drag it to In Progress on the PR event. The branch is keyed by its first commit.
…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:
of 609 arms, 0 name a preset while 110 name engine source, 18 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 110 are a habit rather than 110 considered choices.

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 this 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 -- it did, on this very change.

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
@wenzowski
wenzowski force-pushed the claude/stage-2-3-grooming-uqk71k branch from 7774f22 to 8b5b883 Compare August 31, 2026 15:53
@sonarqubecloud

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

Copy link
Copy Markdown
Contributor Author

Superseded by #789, and closing rather than racing it — claim-race-check refuses two open PRs on one row, and this one still claimed CLOUD-1239.

Why this branch was not simply rebased:

#789 carries this work rebased on current main, verified green (verify: fast-forward-green, 3395 Rust tests, policy test 408), and bundled with CLOUD-1182 — which is what the widened arm is actually for. The measurement is restated at landing time: 725 arms, 0 preset, 113 engine source, and of those 113, 77 landed a new CLI verb.

CLOUD-1176 is untouched and stays Todo.


Generated by Claude Code

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