From d1b76e4b75aff1a17155f503f0c437ba58393e98 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sun, 30 Aug 2026 20:00:37 +0000 Subject: [PATCH 1/3] docs(memory): state decides whether a row's body is spec or record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .serena/memories/workflow/board-states.md | 27 ++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.serena/memories/workflow/board-states.md b/.serena/memories/workflow/board-states.md index c6d81bcbb..7f9d64a9f 100644 --- a/.serena/memories/workflow/board-states.md +++ b/.serena/memories/workflow/board-states.md @@ -125,7 +125,32 @@ The tell that you are in this case: the prescription names a mechanical edit ("does not paper over", "no scope moves"). A mechanical edit can always be performed; whether it produces the property is the question the edit does not ask. -## Two things that trip agents up +## Three things that trip agents up + +0. **A row's STATE decides whether its body is a spec or a record — so it decides + whether you fix the row or file a new one.** This follows from the table above, + and nothing here used to say it. + + | state | the body is | a wrong design is fixed by | + | ----------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------- | + | Backlog, **Todo** | a **spec** — nothing has been built from it | **editing the row in place.** Correct it fully; that is what grooming is | + | In Progress | a spec somebody is executing | editing, but tell the assignee — they may already have built from it | + | **In Review**, **Done** | a **record** — the code is on `main` (In Review) or released (Done) | **a NEW row**, with a superseded reference on the old one. Never a rewrite | + + The cut is **landed-ness, not readership.** In Review means already merged (item + 2 below), so its body describes shipped behaviour: rewriting it makes the record + disagree with the code, and the disagreement is invisible. A Todo row has shipped + nothing, so its body is still the only statement of intent, and editing it is the + whole point of the ready queue. + + **The failure mode is treating a Todo row as untouchable.** Measured on CLOUD-1152 + (Urgent, Todo, unclaimed, 2026-08-30): a session found its central premise false, + declined to correct it, and planned 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. It reached for that shape twice before the + rule was stated. The opposite error is the more familiar one and is already + covered by the CLOUD-994 class: quietly rewriting a shipped row so the record + matches whatever got built. 1. **"Ready" is not a status.** It is the **Ready block** — text inside the issue authored during refinement, "the mechanism specified as a computable From 963bff95d42c5af67c959ad59d215f483ab0a5c2 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Mon, 31 Aug 2026 03:56:40 +0000 Subject: [PATCH 2/3] fix(policy): let a retirement name the DRY home, and say which home is 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 --- .claude/rules/toolchain.md | 26 ++++++++++ crates/batten/tests/retirement_doctrine.rs | 56 ++++++++++++++++++++++ policy/shell-retirement.rego | 24 ++++++++++ 3 files changed, 106 insertions(+) diff --git a/.claude/rules/toolchain.md b/.claude/rules/toolchain.md index d46d5a5a4..0f02e3ad3 100644 --- a/.claude/rules/toolchain.md +++ b/.claude/rules/toolchain.md @@ -66,6 +66,32 @@ Where the dying suite's declared `# subject:` is still standing at head, the ledger must name it too (CLOUD-1130): the arms say where the CASES went, and a surviving subject that no arm mentions is a program left alive and untested. +**"A policy surface" is three shapes, and picking between them is the whole +anti-laundering rule.** `has_policy_surface` admits a consumer module +(`policy/*.rego`), a preset, or engine source (`crates/batten/src/*.rs`). It cannot +tell which one a retirement SHOULD have taken — the arm is byte-checkable and the +choice is not — so this is where CLOUD-1176's scope creep enters the core, and the +order below is the default rather than a menu: + +1. **Consumer module** — the predicate names THIS repo's facts: a check roster, + branch names, tracker keys, path conventions. Deny-only, so a consumer can raise + it and never weaken it (house-style §8). +2. **Preset** — the predicate stays generic once the consumer's facts are pulled + out into `batten.toml`. `.claude/rules/policy-modules.md`'s "module or preset" + section owns that test; don't re-derive it here. +3. **Engine source** — **mechanism only**: a parser, a projection, a fact the + boundary acquires, a verb's behaviour. Never a judgement. The test is + non-negotiable rule 1 — a predicate naming a consumer fact may not land here. + +**The measured pressure runs toward the core, which is why the order is stated as +a default.** Over the landed ledger, **0 of 609 arms name a preset** and 110 name +engine source, 18 of those retiring a whole file. That is not a taste for the core +so much as a gate that had one: until `has_policy_surface` grew a preset arm the +DRY home was **unspellable**, so an author who chose it was refused and an author +who chose engine source was not. The arm exists now; the habit it shaped does not +undo itself, and a campaign retiring ~130 more programs will make that choice ~130 +more times. + **One edit is admitted, and only one**, so it is not rediscovered as an exception: `only_drops_a_retired_reference` — a sibling file cleaning up after a path this same change retires. Retiring a program requires editing whatever declares and diff --git a/crates/batten/tests/retirement_doctrine.rs b/crates/batten/tests/retirement_doctrine.rs index 2306fc084..468818887 100644 --- a/crates/batten/tests/retirement_doctrine.rs +++ b/crates/batten/tests/retirement_doctrine.rs @@ -151,6 +151,62 @@ fn the_rules_say_what_a_retirement_owes_and_which_edit_is_admitted() { } } +/// "A policy surface" is three shapes, and the prose has to say which is which. +/// +/// `has_policy_surface` admits a consumer module, a preset, or engine source, and +/// the arm is byte-checkable where the CHOICE between them is not — so the only +/// thing standing between CLOUD-843's ~130 remaining programs and CLOUD-1176's +/// scope creep is a reader knowing the order. A page that says "a policy surface" +/// and stops reads as "a Rego module", which makes every retirement onto an +/// existing verb look unspellable; that misreading cost this session two wrong +/// turns in one turn. +/// +/// Both directions, for `the_rules_carry_the_edit_versus_deletion_asymmetry`'s +/// reason: the prose names the three homes, and the module still declares three +/// arms. Dropping the preset arm — the one no landed row has used — is the failure +/// this pins, because it is the arm whose absence pushes an author toward the core. +#[test] +fn the_rules_name_the_three_homes_and_the_module_admits_them() { + let text = squashed(&rules_text()); + for clause in [ + "Consumer module", + "**Preset**", + "Engine source", + "mechanism only", + ] { + assert!( + text.contains(&squashed(clause)), + "{RULES} must name `{clause}` — `has_policy_surface` admits three \ + successor shapes and cannot decide which one a retirement SHOULD have \ + taken, so a page naming fewer than three leaves that choice to be \ + guessed at exactly the moment it is being made" + ); + } + + let module = squashed(&fs::read_to_string(at_root(MODULE)).expect("the module is committed")); + assert_eq!( + module.matches("has_policy_surface(path) if {").count(), + 3, + "{MODULE} must declare all three `has_policy_surface` arms — a preset lives \ + at `crates/batten/src/policy/presets/**` and is a `.rego`, so it fails the \ + module arm on the prefix and the engine-source arm on the suffix. Without \ + its own arm the one generic-by-construction home cannot be spelled, while \ + the core can, and the gate's incentive runs toward the thing {RULES} tells \ + an author to avoid" + ); + assert!( + module.contains("crates/batten/src/policy/presets/"), + "{MODULE}'s preset arm must match the path presets actually live at" + ); +} + +/// Whitespace-insensitive, because these clauses are prose: `mise run fmt` runs +/// prettier over Markdown and a reflow that moved a line break would otherwise +/// turn a live assertion into a false failure. +fn squashed(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + /// The sentence the measured session cost, which is the one a reader acts on at /// the moment the mistake is available to them. #[test] diff --git a/policy/shell-retirement.rego b/policy/shell-retirement.rego index bc7f1bfa7..ad79e4298 100644 --- a/policy/shell-retirement.rego +++ b/policy/shell-retirement.rego @@ -743,6 +743,12 @@ arm_markers := ["// carried:", "// subsumed:", "// changed:", "// withdrawn:"] # something which does not hold the predicate — the false `subsumed` in this # module's vocabulary. # +# "A POLICY SURFACE" IS THREE SHAPES, not a Rego module — a consumer module, a +# preset, or engine source, per `has_policy_surface` below. Reading it as "a +# module" makes every retirement onto an existing verb look unspellable, which is +# a wrong turn this file's own prose has caused; `.claude/rules/toolchain.md` is +# where the choice between the three is argued. +# # So this arm trades those two obligations for two others, and it is strictly # narrower than a `[[waiver]]` over the path, because it is spent one file at a # time and only once the subject went with it. @@ -878,6 +884,24 @@ has_policy_surface(path) if { endswith(name, ".rs") } +# THE PRESET ARM, AND IT WAS MISSING RATHER THAN DECLINED. A preset lives at +# `crates/batten/src/policy/presets/**` and is a `.rego`, so it failed the first +# arm on the prefix and the second on the suffix — the ONE successor shape +# `.claude/rules/policy-modules.md` calls generic-by-construction could not be +# spelled at all. +# +# That is not a cosmetic gap, because the gate's incentive ran the wrong way. +# Measured over the landed ledger at `5d38e0c2`: 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 — which is +# CLOUD-1176's scope creep arriving through the mechanism meant to bound it, and +# it is the reason those 110 are a habit rather than 110 considered choices. +has_policy_surface(path) if { + some name in path_successors_for(path) + startswith(name, "crates/batten/src/policy/presets/") + endswith(name, ".rego") +} + has_binary_test(path) if { some name in path_successors_for(path) startswith(name, "crates/batten/tests/") From 8b5b8831b426ad01ad684003bea3fb34e13677ed Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Mon, 31 Aug 2026 04:14:41 +0000 Subject: [PATCH 3/3] test(policy): prove the preset arm ADMITS a preset, not merely that it 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 --- policy/shell-retirement.rego | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/policy/shell-retirement.rego b/policy/shell-retirement.rego index ad79e4298..86567c79c 100644 --- a/policy/shell-retirement.rego +++ b/policy/shell-retirement.rego @@ -1164,6 +1164,20 @@ test_mapping_without_a_binary_test_is_refused if { }} } +# THE POSITIVE HALF OF THE PRESET ARM, and it is the case the arm was added for. A +# preset satisfies neither of the other two arms — wrong prefix for the module arm, +# wrong suffix for the engine-source one — so before this arm existed a retirement +# onto the one generic-by-construction home was refused, and the author's only +# passing move was to name the core instead. `test_mapping_without_a_policy_surface_is_refused` +# above is the mirror that keeps this from being satisfied by an arm admitting +# anything. +test_a_mapping_naming_only_a_preset_is_admitted if { + count(violation) == 0 with input as {"tree": { + "base-delta": {"added": [], "edited": [], "deleted": ["mise-tasks/old-gate.sh"]}, + "lines": {"crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh crates/batten/src/policy/presets/hygiene/old-gate.rego crates/batten/tests/old_gate.rs"]}, + }} +} + # --- the fourth arm (CLOUD-1080) -------------------------------------------- # # The positive case: the subject dies in the same delta, the row carries a reason,