From 3aabb17260a72dc54504399eacdd6d0c54ff634a Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 20:26:57 +0000 Subject: [PATCH 1/8] feat(policy): the punt sweep gets an exit code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stop_nudges` rule 5 has asked the right question at the end of every turn since CLOUD-1051 — is this row genuinely independent work, or a punt you could close here? It is a nudge, so an agent reasons past it: answering wrongly costs nothing and the answer dies with the turn. Measured 2026-09-01: four deferrals in one session, each with a principled-sounding blocker, every one of them false. Three were invisible to `filed-over-own-diff` because their §1 named paths outside the diff, which `cites_only` exempts by design and rightly so for a refusal about proximity. The fourth was caught only because it named the diff. The detector was a human asking twice. `filed-and-left-open` is the third arm. It reads the rows this branch put on the board, subtracts the ones the PR body closes and the ones recorded before the branch's base, and reports what is left. It classifies nothing: whether a spin-off was legitimate is still the judgement no gate makes, and the author still makes it — in an admission whose articulation CLOUD-1278 binds into the commit message, where a reviewer reads it. CLOUD-514 ruled this half out in terms that were right on the day. The premise is obsolete rather than the reasoning: the shape it lacked — deny over an object, with the only exit an explanation written into history — exists since CLOUD-1051 and CLOUD-1278. Partitioned, not nested. `filed-over-own-diff` requires `not cites_only(id)` and this requires `cites_only(id)`, so no row earns both and a reviewer never sees two findings for one cause. Drafted without that requirement the arm was strictly wider than the proximity one, which broke the module header's own invariant. Three could-not-looks guard it, each a different question: an unread PR body (the closing remedy has nowhere to be written yet), an empty diff (a branch holding nothing open deferred nothing), and a record with no §1 column (the partition cannot be evaluated, so the row stays judged as before). `closes -` and `closes 0` stay distinct, which is what `zero-is-a-count` exists for. Refs: CLOUD-1311 Admits: 067783fc36bd1a49703ad0dc249fb95875ef1f9f60e06505e51a155e603e4509 Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: policy/filed-here.rego Admits-head: be5a33c1ef7ce806f200a07cb2f7265f0b9810a5 Admits-epoch: 781d35649dedb150ccb69e890c6aa482c7393233f5ffe1dce100609afd6150fb Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: CLOUD-1311 cannot be built at all. The punt sweep stays `stop_nudges` rule 5 — a nudge with no exit code — which is the defect measured on 2026-09-01: four deferrals, each with a principled-sounding blocker, every one of them false, and the detector was a human asking twice rather than any mechanism. Admits-answer-precondition: A registered .rego module has no owning verb: the module file IS the surface that declares the predicate, so there is no route that adds a third `violation` arm to policy/filed-here.rego except writing the file. The write lands in a reviewed PR for CLOUD-1311, where `mise run policy-test`, the compiled tier in crates/batten/tests/it/filed_here.rs and the declared `#MUTANT` rows all judge it before it can bind anything. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply because no such surface exists for this object: batten declares no verb that writes a predicate into a registered module, and the redirect's own remedy is to change it in a pull request, which is what this is. R-RESTORE-IT does not apply because nothing was destroyed or retracted — this is an addition to a module that keeps both existing arms intact, with the second arm narrowed only so the third cannot subsume it. Stated for the reviewer because it is the reason this class is protected at all: the arm being added judges the author who is adding it, so the diff and not my account of it is the thing to read. Admits: 4265150829ab6e4bb6ac8d3976cda321d94ca094835872fd4dceb6759672c6c1 Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: batten.toml Admits-head: be5a33c1ef7ce806f200a07cb2f7265f0b9810a5 Admits-epoch: 781d35649dedb150ccb69e890c6aa482c7393233f5ffe1dce100609afd6150fb Admits-author: alec@wenzowski.com Admits-prev: bc0321f2fc7cbb652a826c1d4f8928972e3d7c212a817ed94c9af37e90c74973 Admits-answer-lost: CLOUD-1311's arm is unloadable and therefore unlandable. The module edit already made is dead code until this row exists, so declining here leaves the tree strictly worse than not starting: a module that fails to load disarms `filed-unrefined` and `filed-over-own-diff` too. Admits-answer-precondition: A `[[verdict]]` row is only expressible in batten.toml: the registry IS the surface, and `policy/filed-here.rego`'s new arm cannot load at all until `V-FILED-AND-LEFT-OPEN` is declared there — a module raising a token no row declares is refused at load. So the write to the authority is not merely the shortest route, it is the only one, and it lands in the reviewed PR for CLOUD-1311 where `mise run config-lint` and `mise run batten-check` judge it. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply because batten.toml IS the owning surface for a verdict class — there is no verb that registers one, and the path's own redirect says to change it in a pull request, which is what this is. R-RESTORE-IT does not apply because nothing was destroyed: this adds one `[[verdict]]` row with its routes and touches no existing row. The write is additive and strictly raise-only — a new deny class with a declared override precondition — so it cannot weaken any gate, which is the property house-style §8 asks of a config change and the one a reviewer should check in the diff. --- batten.toml | 61 ++++++++++ crates/batten/tests/it/filed_here.rs | 155 +++++++++++++++++++++++- policy/filed-here.rego | 170 +++++++++++++++++++++++++-- 3 files changed, 372 insertions(+), 14 deletions(-) diff --git a/batten.toml b/batten.toml index 61abe8c8c..ed438cce8 100644 --- a/batten.toml +++ b/batten.toml @@ -6519,6 +6519,67 @@ id = "R-OVERRIDE-FILED-HERE" kind = "override" precondition = "the row DOCUMENTS the change being landed, so naming its files is the point rather than a deferral" +# CLOUD-1311. The punt sweep had a question and no exit code. +# +# `stop_nudges` rule 5 has asked the right thing at the end of every turn since +# CLOUD-1051 — "is it genuinely independent work, or a punt you could close here?" +# — and an agent reasons past it, because a nudge costs nothing to answer wrongly +# and the answer dies with the turn. Measured 2026-09-01: four deferrals in one +# session, each with a principled-sounding blocker, every one of them false. Three +# were invisible to `V-FILED-OVER-OWN-DIFF` because their §1 named paths outside +# the diff, which `cites_only` exempts by design. The detector was a human asking +# twice. +# +# WHY CLOUD-514'S EXCLUSION NO LONGER BINDS. That issue built the record and ruled +# this half out in terms that were right on the day: "deciding whether a given +# spin-off was legitimate — the judgement the gate must never make." The premise +# is now obsolete rather than the reasoning. The gate still makes no such +# judgement; it reports a SET, and the author pays for the classification through +# an admission whose articulation CLOUD-1278 made durable by binding it into the +# commit message. Deny over an object, with the only exit an explanation written +# into history — the shape CLOUD-514 lacked. +# +# THE OVERRIDE IS THE POINT, NOT THE LEAK. An honest split-out is never refused; +# it costs one articulation naming the row and saying why it is independent work, +# which is free for a row you genuinely could not close and expensive for one you +# could. That is CLOUD-514's own "the friction must sit only on the impulsive +# path", spent where it belongs. +[[verdict]] +id = "V-FILED-AND-LEFT-OPEN" +gloss = "a row this branch put on the board is neither closed here nor closed by the body" +class = """ +The punt the other two refusals cannot see. `V-FILED-UNREFINED` prices \ +refinement and is payable in typing; `V-FILED-OVER-OWN-DIFF` prices proximity \ +and is silent by design on a row whose declared source of truth lies outside \ +this diff — which is exactly where a deferral hides, because the cheapest punt \ +names somebody else's file. This reads the set of rows the branch filed, \ +subtracts the ones the PR body closes and the ones recorded before the branch's \ +base, and reports what is left. It classifies nothing and compares no \ +semantics: whether a spin-off was legitimate is still the judgement no gate \ +makes, and it is still not made here — the author makes it, in an articulation \ +a reviewer reads in the commit message. +""" + +[[verdict.route]] +id = "R-FIX-IT-HERE" +kind = "command" +target = "close the row you filed and fix it in this diff" + +[[verdict.route]] +id = "R-CLOSE-IT-IN-THE-BODY" +kind = "command" +target = "name it in closing form in the PR body, so the merge lands it" + +[[verdict.route]] +id = "R-FILE-IT-AFTER-LANDING" +kind = "command" +target = "file it from a clean tree, when it is no longer your branch's deferral" + +[[verdict.route]] +id = "R-OVERRIDE-FILED-AND-LEFT-OPEN" +kind = "override" +precondition = "the row is work this branch could not have done — it needs a decision, a mechanism, or an artifact that does not exist yet — rather than work you declined to do while holding the file open" + [[verdict]] id = "V-SHELL-RULE-EDITED" gloss = "an authored shell rule or bats suite was edited in place rather than migrated" diff --git a/crates/batten/tests/it/filed_here.rs b/crates/batten/tests/it/filed_here.rs index 7921884a3..a28a9585d 100644 --- a/crates/batten/tests/it/filed_here.rs +++ b/crates/batten/tests/it/filed_here.rs @@ -261,6 +261,7 @@ fn pointers(root: &Path) -> Vec { const UNREFINED: &str = "filed-unrefined"; const OVER_DIFF: &str = "filed-over-own-diff"; +const LEFT_OPEN: &str = "filed-and-left-open"; // --------------------------------------------------------------------------- // The pass side first: without it every refusal below is satisfied by a module @@ -407,8 +408,12 @@ fn a_row_naming_a_file_this_branch_is_changing_stops_the_lap() { assert_eq!(verdicts(&root), vec![OVER_DIFF.to_owned()]); } +/// A path outside the diff is not a punt against it — for the PROXIMITY refusal, +/// which is the only one this case was ever about. `filed-and-left-open` takes it +/// instead, and asserting the exact verdict rather than "not empty" is what makes +/// the partition falsifiable from this tier. #[test] -fn a_recorded_path_the_branch_does_not_change_is_not_reported() { +fn a_recorded_path_the_branch_does_not_change_is_not_a_proximity_refusal() { let root = repo( "elsewhere", "work", @@ -418,10 +423,7 @@ fn a_recorded_path_the_branch_does_not_change_is_not_reported() { )], &["closes 0"], ); - assert!( - verdicts(&root).is_empty(), - "a path outside the diff is not a punt against it" - ); + assert_eq!(verdicts(&root), vec![LEFT_OPEN.to_owned()]); } /// ONE POINTER PER PATH, as the shell emitted, so a reviewer sees which file @@ -597,6 +599,149 @@ fn a_six_field_record_with_no_sec1_column_is_judged_exactly_as_before() { ); } +// --------------------------------------------------------------------------- +// `filed-and-left-open` (CLOUD-1311). The set refusal: a row this branch put on +// the board that it is not landing. +// +// Its whole reason for existing is the class the two arms above cannot see — a +// row filed while the branch was open whose §1 points somewhere else, which +// `cites_only` exempts from the proximity refusal by design. Three of the four +// deferrals that motivated this issue sat exactly there. +// --------------------------------------------------------------------------- + +#[test] +fn a_row_the_branch_filed_and_does_not_close_stops_the_lap() { + let root = repo( + "left-open", + "work", + &["src/a.rs"], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/b.rs" + )], + &["closes 0"], + ); + assert_eq!(verdicts(&root), vec![LEFT_OPEN.to_owned()]); +} + +/// NO PR YET IS COULD-NOT-LOOK. `verify` runs before the PR exists on most laps, +/// and refusing there would name a remedy — "close it in the body" — with no body +/// to write it in. The absent record is the signal; there is nothing to tune. +#[test] +fn an_unread_pr_body_leaves_the_set_unjudged() { + let root = repo( + "no-body", + "work", + &["src/a.rs"], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/b.rs" + )], + &[], + ); + assert!( + verdicts(&root).is_empty(), + "the forge's answer has not been captured, so the set is not judged" + ); +} + +/// AND A FETCH WHOSE KEY READER COULD NOT RUN IS THE SAME ANSWER, which is the +/// distinction `zero-is-a-count` exists to preserve: `closes 0` is a measurement +/// and `closes -` is not. +#[test] +fn an_unreadable_closing_key_column_leaves_the_set_unjudged() { + let root = repo( + "unreadable-body", + "work", + &["src/a.rs"], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/b.rs" + )], + &["closes -"], + ); + assert!(verdicts(&root).is_empty(), "`-` is could-not-look"); +} + +/// ANTI-VACUITY ON THE EXEMPTION: one closing key must not buy the whole set. +/// Without this, an author closes the cheapest row they filed and the gate goes +/// quiet about every other one — which is the arm switched off by its own remedy. +#[test] +fn closing_one_row_does_not_close_the_set() { + let root = repo( + "close-one", + "work", + &["src/a.rs"], + &[ + &format!("issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/b.rs"), + &format!("issue CLOUD-2 {AFTER} ready 1,src/a.rs - 1,src/b.rs"), + ], + &["closes 1:CLOUD-1"], + ); + assert_eq!(verdicts(&root), vec![LEFT_OPEN.to_owned()]); + assert!( + pointers(&root).iter().any(|line| line.contains("CLOUD-2")), + "the row still open is the one reported: {:?}", + pointers(&root) + ); +} + +/// POINTER, NEVER PAYLOAD (rule 4) for this arm too. The recorder wrote no title +/// and no body, and this is the assertion that keeps a later edit from adding +/// one — an articulation's prose especially, which is the one thing this class +/// collects that a finding must never carry. +#[test] +fn the_set_refusal_carries_the_id_and_nothing_else() { + let root = repo( + "left-open-pointer", + "work", + &["src/a.rs"], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/b.rs" + )], + &["closes 0"], + ); + let rendered = pointers(&root).join("\n"); + assert!(rendered.contains("CLOUD-1"), "the id is the pointer"); + assert!( + !rendered.contains("src/b.rs"), + "a §1 path is not this arm's subject: {rendered}" + ); +} + +/// A BRANCH HOLDING NOTHING OPEN DEFERRED NOTHING, so there is no diff for the +/// row to have been filed instead of. Not a dodge: an empty branch cannot land. +#[test] +fn a_branch_with_no_diff_judges_no_row() { + let root = repo( + "left-open-empty", + "work", + &[], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/b.rs" + )], + &["closes 0"], + ); + assert!( + verdicts(&root).is_empty(), + "nothing is open, so nothing was deferred" + ); +} + +/// A record from an older recorder has no §1 column, so the partition cannot be +/// evaluated and the row stays judged exactly as it was before this arm existed. +#[test] +fn a_record_with_no_sec1_column_is_outside_this_arm() { + let root = repo( + "left-open-six-field", + "work", + &["src/a.rs"], + &[&format!("issue CLOUD-1 {AFTER} ready 0 -")], + &["closes 0"], + ); + assert!( + verdicts(&root).is_empty(), + "could-not-look on §1 is not a refusal" + ); +} + /// ANTI-VACUITY over the whole file: the row this suite exercises is the one the /// committed config declares, so a rename or a scope change reddens here rather /// than leaving every case above passing over a module nothing runs. diff --git a/policy/filed-here.rego b/policy/filed-here.rego index 3382bb2e1..f752fbf26 100644 --- a/policy/filed-here.rego +++ b/policy/filed-here.rego @@ -10,10 +10,20 @@ # complete Ready block is what flips that, without anything judging whether a # given spin-off was lazy. # -# TWO REFUSALS, AND NEITHER SUBSUMES THE OTHER. `filed-unrefined` prices -# REFINEMENT; `filed-over-own-diff` prices PROXIMITY. A row can earn both — -# "never groomed to Ready" and "names code this branch is holding open" are -# different facts — so they are separate predicates over one parse. +# THREE REFUSALS, AND NO ONE OF THEM SUBSUMES ANOTHER. `filed-unrefined` prices +# REFINEMENT; `filed-over-own-diff` prices PROXIMITY; `filed-and-left-open` prices +# a row this branch opened and simply LEFT OPEN. A row can earn the first +# alongside either of the others — "never groomed to Ready" and "names code this +# branch is holding open" are different facts — so they are separate predicates +# over one parse. +# +# THE LAST TWO ARE PARTITIONED RATHER THAN NESTED, and that is a correction rather +# than a taste. `filed-over-own-diff` requires `not cites_only(id)` and +# `filed-and-left-open` requires `cites_only(id)`, so no row can earn both and a +# reviewer never sees two findings for one cause. Drafted without that +# requirement the third arm was strictly WIDER than the second — every row the +# second refused, the third refused too — and the sentence above stopped being +# true of the module it heads. # # The second exists because the first turned out to be payable in typing. A Ready # block is prose, and prose is the one currency an agent has without limit: @@ -50,6 +60,8 @@ #MUTANT-SUITE crates/batten/tests/it/filed_here.rs #MUTANT unrefined-row-unread|s@^\tlatest\[id\].verdict == "unready"$@\tfalse@|an_unready_create_stops_the_lap #MUTANT closing-row-still-priced|s@^\tnot id in closes$@\ttrue@|a_row_the_pr_closes_is_exempt +#MUTANT left-open-arm-unpartitioned|s@^\tcites_only(id)$@\ttrue@|a_row_recorded_after_the_base_whose_sec1_names_the_diff_still_refuses +#MUTANT left-open-judges-an-unread-body|s@^\tbody_read$@\ttrue@|an_unread_pr_body_leaves_the_set_unjudged # METADATA # description: | @@ -66,6 +78,8 @@ rules contains "filed-unrefined" rules contains "filed-over-own-diff" +rules contains "filed-and-left-open" + # The record, or nothing. ABSENT IS NOT EMPTY: a branch whose recorder never ran # has no key here at all, Rego reads that as *does not hold*, and every rule below # is silent. An empty list would be a measured nothing and would say the branch @@ -150,6 +164,26 @@ closes contains key if { some key in split(substring(columns[1], indexof(columns[1], ":") + 1, -1), ",") } +# THE BODY WAS READ, so "this PR closes nothing" is a MEASUREMENT rather than an +# absence — the third state the `pr-closes` recorder writes `zero-is-a-count` for. +# +# `closes` alone cannot carry this. An empty `closes` set has three causes that a +# set-membership test flattens into one: the body closes nothing, no PR exists +# yet, and the key reader could not run. The first is a reading and the other two +# are could-not-look, and `filed-and-left-open` refuses over the WHOLE SET rather +# than over a row's properties, so flattening them would refuse every row a branch +# ever filed the first time `verify` runs before the PR is opened — for a remedy +# ("name it in closing form in the PR body") that has nowhere to be written yet. +# +# `-` is the recorder saying it could not read the keys and leaves the set +# unjudged; `0` is a measured nothing and judges it. +body_read if { + some raw in input.tree.records["pr-closes"] + columns := split(raw, " ") + columns[0] == "closes" + columns[1] != "-" +} + # `filed-unrefined`: a row this branch created was never groomed to Ready. # # `ready` passes and so does `-`; only the tracker's own `unready` refuses. @@ -238,6 +272,43 @@ violation contains { not cites_only(id) } +# `filed-and-left-open`: a row this branch filed, that this branch does not close. +# +# THE ARM THE MEASUREMENT ASKED FOR (CLOUD-1311). Three of one session's four +# deferrals were invisible to `filed-over-own-diff` precisely BECAUSE their §1 +# named paths outside the diff — `cites_only` exempts those by design, and rightly +# so for a refusal about proximity. Nothing then priced them at all, and the +# detector was a human asking twice. +# +# IT CLASSIFIES NOTHING, which is what keeps non-negotiable rule 3 satisfied. It +# reports a SET: the rows this branch put on the board that it is not landing. The +# author closes one, lets the body close it, or spends an admission whose +# articulation says why it is independent work — and that articulation is +# hash-bound into the commit message, where a reviewer reads it, rather than into +# a turn that ends. +# +# THREE COULD-NOT-LOOKS GUARD IT, and each is a different question. +# * `body_read` — the forge's answer has not been captured, so the closing +# remedy is unreachable and the set is unjudged rather than refused. +# * a non-empty `changed` — a branch holding nothing open has fixed nothing and +# deferred nothing, so "you filed instead of fixing" is a claim about a diff +# that does not exist. It is not a dodge: an empty branch cannot land either. +# * `cites_only`'s own `-` — a record from an older recorder has no §1 column, +# so the partition cannot be evaluated and the row stays judged as it was +# before this arm existed. +violation contains { + "rule": "filed-and-left-open", + "verdict": "V-FILED-AND-LEFT-OPEN", + "subjects": [{"artifact": id}], +} if { + some id, _ in latest + body_read + count(changed) > 0 + not id in closes + not predates_the_branch(id) + cites_only(id) +} + # The predicate's own tests. The SILENT cases are the load-bearing half: every # skip above is a pass-side property, and a rule that fired on every row would # satisfy the denies while deciding nothing. @@ -320,11 +391,89 @@ test_a_row_written_before_the_branch_cannot_be_its_punt if { ) } -# CITING IS NOT CLAIMING. The row names the path in its body but its declared -# source of truth is somewhere else entirely. +# CITING IS NOT CLAIMING — for the PROXIMITY refusal, which is the only one it +# was ever about. The row names the path in its body but its declared source of +# truth is somewhere else entirely, so `filed-over-own-diff` is silent. +# +# IT IS NOT SILENT ALTOGETHER, AND THAT IS THE PARTITION. This is the exact shape +# of the three punts nothing caught: a row filed while the branch was open, whose +# §1 points somewhere else, so proximity exempts it and — before this arm — no +# refusal reached it. The case asserts the SET of verdicts rather than a count, so +# a later change collapsing the two arms back together reddens here. test_a_row_that_only_cites_the_path_is_not_claiming_it if { + verdicts := {v.verdict | some v in violation} with input as with_diff( + ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs"], + ["closes 0"], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) + verdicts == {"V-FILED-AND-LEFT-OPEN"} +} + +# NO PR BODY YET IS COULD-NOT-LOOK, not a measured nothing. Without this the arm +# refuses every row a branch filed the first time `verify` runs before the PR is +# opened, naming a remedy that has nowhere to be written. +test_an_unread_body_leaves_the_set_unjudged if { count(violation) == 0 with input as with_diff( ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs"], + [], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) +} + +# AND A FETCH WHOSE KEY READER COULD NOT RUN IS THE SAME ANSWER, which is what +# `zero-is-a-count` exists to keep distinct from `closes 0`. +test_an_unreadable_closing_key_column_leaves_the_set_unjudged if { + count(violation) == 0 with input as with_diff( + ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs"], + ["closes -"], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) +} + +# A BRANCH HOLDING NOTHING OPEN DEFERRED NOTHING. `changed` is empty, so there is +# no diff for the row to have been filed instead of. +test_a_branch_with_no_diff_judges_no_row if { + count(violation) == 0 with input as with_diff( + ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs"], + ["closes 0"], + [], + "2026-01-01T00:00:00Z", + ) +} + +# THE CLOSING REMEDY REACHES THIS ARM TOO, and a body closing a DIFFERENT row does +# not — the anti-vacuity half, without which one closing key buys the whole set. +test_a_left_open_row_the_body_closes_is_exempt if { + count(violation) == 0 with input as with_diff( + ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs"], + ["closes 1:CLOUD-1"], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) +} + +test_closing_one_row_does_not_close_the_set if { + verdicts := {v.verdict | some v in violation} with input as with_diff( + [ + "issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs", + "issue CLOUD-2 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs", + ], + ["closes 1:CLOUD-1"], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) + verdicts == {"V-FILED-AND-LEFT-OPEN"} +} + +# A ROW WRITTEN BEFORE THE BRANCH IS EXEMPT FROM THIS ARM ON THE SAME GROUND it is +# exempt from the proximity one: it cannot be a deferral of a diff that did not +# exist. +test_a_left_open_row_predating_the_branch_is_exempt if { + count(violation) == 0 with input as with_diff( + ["issue CLOUD-1 2025-12-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs"], ["closes 0"], ["src/a.rs"], "2026-01-01T00:00:00Z", @@ -350,14 +499,17 @@ test_an_unanswered_overlap_passes if { ) } -# A NAMED PATH THIS BRANCH IS NOT TOUCHING IS NOT A PUNT AGAINST ITS DIFF. -test_a_row_naming_a_path_outside_the_diff_passes if { - count(violation) == 0 with input as with_diff( +# A NAMED PATH THIS BRANCH IS NOT TOUCHING IS NOT A PUNT AGAINST ITS DIFF — and +# that is still true of the proximity refusal, which stays silent here. It is a +# row left open, so the third arm takes it. +test_a_row_naming_a_path_outside_the_diff_is_not_a_proximity_refusal if { + verdicts := {v.verdict | some v in violation} with input as with_diff( ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/z.rs - 1,src/z.rs"], ["closes 0"], ["src/a.rs"], "2026-01-01T00:00:00Z", ) + verdicts == {"V-FILED-AND-LEFT-OPEN"} } # COULD NOT READ THE BASE DATE LEAVES EVERY ROW JUDGED AS BEFORE, rather than From 8cc3fed5ca4ed113133994ad825a5df0e7fcf8e6 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 20:33:13 +0000 Subject: [PATCH 2/8] test(policy): the engine tier cannot build an empty delta, so say so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cases failed on one cause, and it is the fixture rather than the arm: `install_module` writes `policy/filed-here.rego` into the scratch working tree and `base_delta` walks the tree rather than the index, so every fixture here has a non-empty delta by construction. `a_branch_with_no_diff_judges_no_row` therefore cannot exist at this tier. It stays as the module's own `test_` rule and this file records why the compiled tier cannot hold it — the mirror of the rule that a `with input as` case cannot prove the engine builds a shape. Deleting the assertion without the note would have left the guard untested in both tiers while looking covered in one. `a_row_recorded_before_the_file_was_touched_is_still_caught` was asserting silence before the touch. With the third arm that record is a row left open, so the case now asserts the row MOVING between the two arms — `filed-and-left-open` before the file is touched, `filed-over-own-diff` after — exactly one finding either side. That is the partition on one record, which is a better statement of the property than the silence it replaces. 3753/3753 green. Refs: CLOUD-1311 --- crates/batten/tests/it/filed_here.rs | 48 +++++++++++++++------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/crates/batten/tests/it/filed_here.rs b/crates/batten/tests/it/filed_here.rs index a28a9585d..1154d92ac 100644 --- a/crates/batten/tests/it/filed_here.rs +++ b/crates/batten/tests/it/filed_here.rs @@ -550,16 +550,23 @@ fn a_row_recorded_before_the_file_was_touched_is_still_caught() { )], &["closes 0"], ); - assert!( - verdicts(&root).is_empty(), - "nothing is in the diff yet, so nothing intersects" + // THE ROW MOVES BETWEEN THE TWO ARMS RATHER THAN APPEARING OUT OF SILENCE, + // which is the partition made visible on one record. Before the file is + // touched its §1 names nothing in the diff, so proximity is silent and the + // set refusal takes it; touching the file moves it to proximity and the set + // refusal goes quiet. Exactly one finding either side — a reviewer never sees + // two for one row. + assert_eq!( + verdicts(&root), + vec![LEFT_OPEN.to_owned()], + "nothing intersects yet, so this is a row left open rather than a row over the diff" ); fs::create_dir_all(root.join("src")).expect("src"); fs::write(root.join("src/a.rs"), "now\n").expect("touch the file"); assert_eq!( verdicts(&root), vec![OVER_DIFF.to_owned()], - "the same record refuses once the file is open" + "the same record refuses on proximity once the file is open" ); } @@ -706,24 +713,21 @@ fn the_set_refusal_carries_the_id_and_nothing_else() { ); } -/// A BRANCH HOLDING NOTHING OPEN DEFERRED NOTHING, so there is no diff for the -/// row to have been filed instead of. Not a dodge: an empty branch cannot land. -#[test] -fn a_branch_with_no_diff_judges_no_row() { - let root = repo( - "left-open-empty", - "work", - &[], - &[&format!( - "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/b.rs" - )], - &["closes 0"], - ); - assert!( - verdicts(&root).is_empty(), - "nothing is open, so nothing was deferred" - ); -} +// A BRANCH HOLDING NOTHING OPEN DEFERRED NOTHING — `test_a_branch_with_no_diff_ +// judges_no_row`, and it is in the MODULE's tier rather than here on purpose. +// +// THIS TIER CANNOT BUILD THAT INPUT, and the reason is the fixture itself: +// `install_module` writes `policy/filed-here.rego` into the working tree, and +// `base_delta` walks the tree rather than the index, so an untracked file is an +// added path. Every fixture below therefore has a non-empty delta by +// construction — measured, this case failed here reporting exactly the one +// finding it asserted the absence of. +// +// Stated rather than dropped, because the pair is the general rule +// `.claude/rules/policy-modules.md` gives for the two tiers: a `with input as` +// case cannot prove the ENGINE builds a shape, and this tier cannot construct a +// shape the engine's own scaffolding excludes. Neither replaces the other, and +// silently deleting the assertion would have left the guard untested in both. /// A record from an older recorder has no §1 column, so the partition cannot be /// evaluated and the row stays judged exactly as it was before this arm existed. From e1b04287b9014b6cd3c26e2e70193c9bdde0bb06 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 21:10:06 +0000 Subject: [PATCH 3/8] fix(claim): one branch carries as many claims as it has rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branching model is one commit one issue, one branch as many issues as the work needs, one PR everything done with no punts. Nothing in the instruction surface said so, and the one sentence that came near it said the opposite: `.claude/rules/toolchain.md` explained the branch-keyed claim receipt as attesting "a decision about an ISSUE that every commit on the branch continues to serve" — singular. The mechanism agreed with the wrong prose. `mint` takes a SLICE and line 1 of the receipt has always been an id LIST, so the many-row shape was expressible in one invocation — but the write is `fs::write`, so a second `claim check` INVOCATION replaced the first row's claim and said nothing. Measured 2026-09-01: reading that sentence, an agent declined to pull a second row onto an open branch and reported the storage key as the rule. So the ids union, guarded by the recorded base. A restarted branch (`git checkout -B origin/main`) keeps the receipt because the file is keyed by NAME, and carrying ids across a changed base is exactly the stale claim CLOUD-516 measured sitting through four unrelated stories — so a changed or unresolvable base starts a fresh list. Could-not-look drops the list rather than carrying it: a lost claim costs one re-run, a carried stale one is the defect. Four cases, over the real `mint`: the join, no duplicate on re-claim, a restart resetting, and an unresolvable base carrying nothing either way. AGENTS.md now states the model where it binds every turn, with both failure modes named — a branch per row is the batching `land`'s lap loop exists to prevent, and stopping at one row is the punt `filed-and-left-open` prices. Refs: CLOUD-472 Admits: 4265150829ab6e4bb6ac8d3976cda321d94ca094835872fd4dceb6759672c6c1 Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: batten.toml Admits-head: be5a33c1ef7ce806f200a07cb2f7265f0b9810a5 Admits-epoch: 781d35649dedb150ccb69e890c6aa482c7393233f5ffe1dce100609afd6150fb Admits-author: alec@wenzowski.com Admits-prev: bc0321f2fc7cbb652a826c1d4f8928972e3d7c212a817ed94c9af37e90c74973 Admits-answer-lost: CLOUD-1311's arm is unloadable and therefore unlandable. The module edit already made is dead code until this row exists, so declining here leaves the tree strictly worse than not starting: a module that fails to load disarms `filed-unrefined` and `filed-over-own-diff` too. Admits-answer-precondition: A `[[verdict]]` row is only expressible in batten.toml: the registry IS the surface, and `policy/filed-here.rego`'s new arm cannot load at all until `V-FILED-AND-LEFT-OPEN` is declared there — a module raising a token no row declares is refused at load. So the write to the authority is not merely the shortest route, it is the only one, and it lands in the reviewed PR for CLOUD-1311 where `mise run config-lint` and `mise run batten-check` judge it. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply because batten.toml IS the owning surface for a verdict class — there is no verb that registers one, and the path's own redirect says to change it in a pull request, which is what this is. R-RESTORE-IT does not apply because nothing was destroyed: this adds one `[[verdict]]` row with its routes and touches no existing row. The write is additive and strictly raise-only — a new deny class with a declared override precondition — so it cannot weaken any gate, which is the property house-style §8 asks of a config change and the one a reviewer should check in the diff. Admits: 5e94b114846dc2b86da06b29535aa4639351988b9ccd597ade5e803363af7ef2 Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: batten.toml Admits-head: 8cc3fed5ca4ed113133994ad825a5df0e7fcf8e6 Admits-epoch: 78cca6d566d252da1ec49eae537f22670ac4ff47cb79a3a4d52e2451ff71c412 Admits-author: alec@wenzowski.com Admits-prev: 4265150829ab6e4bb6ac8d3976cda321d94ca094835872fd4dceb6759672c6c1 Admits-answer-lost: CLOUD-472's ratchet cannot load, and `ready lint` is left refusing every payload on an unresolvable grammar token. The escape stays open: the claims object that CLOUD-453 built and CLOUD-418 gave its `mutation` field remains opt-in, so a §7 naming three obligations in prose keeps linting green — measured 2026-09-01 on CLOUD-1306 and on CLOUD-1311's own block. Admits-answer-precondition: A `[[pattern]]` row is only expressible in batten.toml — the registry IS the surface, and `Grammar::assemble` resolves `ready-prose-dialect-exempt` by id with a LOUD failure, so `batten ready lint` cannot run at all until the row exists. The threshold is a consumer fact about this repository's own key space (non-negotiable rule 1), so it could not live in the crate even if there were a route. It lands in the reviewed PR for CLOUD-472 where `mise run config-lint` and the compiled tier in crates/batten/tests/it/ready.rs judge it. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply because batten.toml IS the owning surface for a `[[pattern]]` row; no verb registers one, and the path's own redirect says to change it in a pull request, which is what this is. R-RESTORE-IT does not apply because nothing was destroyed — this adds one pattern row and touches no existing one. The row is deliberately set ABOVE every key that exists today, so it refuses nothing currently on the board and cannot darken the ready frontier the way CLOUD-858 measured; that is the property a reviewer should check in the diff, since a threshold set too low is the one way this change does harm. Admits: 051b1f7234d5470e5dc0cfa2d30dcc6d57924594018ed39c45f34a1fe972f4ec Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: .serena/memories/workflow/board-states.md Admits-head: 8cc3fed5ca4ed113133994ad825a5df0e7fcf8e6 Admits-epoch: 08df746a010060ba1c781d4de6935d9751f3cf01839b0aa0ca5c09e4982d7775 Admits-author: alec@wenzowski.com Admits-prev: 14eb1973fd24f18fe1092e42014b0cac84beef053e935f202b0aab519cb80b01 Admits-answer-lost: The correction stays a one-line rule with no record of why it was needed. Both measured failures — the singular sentence in `.claude/rules/toolchain.md` that pointed the wrong way, and `claim::mint` silently replacing the previous row's claim — would be undocumented, so the next reader re-derives the wrong model from the same two sources that produced it this time. Admits-answer-precondition: The memory IS the owning surface for this content: AGENTS.md is at its `policy-budget` ceiling (measured this commit at 3618 tokens of 3500, which refused the first attempt), so the rationale for the branching model cannot live there and the repo's own split puts on-demand content in `.serena/memories/`. The write was made through `mcp__serena__edit_memory`, the route the redirect names, so this records the change rather than authorising a route around it. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE is what was taken, not rejected: the edit went through Serena's `edit_memory`, and this admission exists because `commit check` records every protected path in the diff regardless of the route that wrote it. R-RESTORE-IT does not apply because nothing was destroyed — the edit inserts two paragraphs ahead of the `claim-check` ordering section and changes no existing sentence. --- .claude/rules/toolchain.md | 13 +- .serena/memories/workflow/board-states.md | 24 ++++ AGENTS.md | 4 +- batten.toml | 23 ++++ crates/batten/src/claim.rs | 143 +++++++++++++++++++++- crates/batten/src/ready.rs | 54 +++++++- crates/batten/tests/it/ready.rs | 87 +++++++++++++ 7 files changed, 336 insertions(+), 12 deletions(-) diff --git a/.claude/rules/toolchain.md b/.claude/rules/toolchain.md index ac3cd4e6c..d7f3b6e6c 100644 --- a/.claude/rules/toolchain.md +++ b/.claude/rules/toolchain.md @@ -511,9 +511,16 @@ call` with no `CLOUD-*` key **in that same paragraph** stops the lap. Two open the current branch carries no claim receipt. `claim-check` still mints that receipt on its pullable path, under `.git/batten-receipts/`, and the engine reads the same file: keyed by **branch**, not by SHA like `ready-guard`'s, - because a claim attests to a decision about an _issue_ that every commit on the - branch continues to serve, and a SHA-keyed one would demand a re-claim per - commit. The naive form ("refuse unless a `CLOUD-` is In Progress") is not + because a claim attests to a decision that every commit on the branch continues + to serve, and a SHA-keyed one would demand a re-claim per commit. + **THE KEY IS STORAGE, NOT CARDINALITY, and this clause used to imply + otherwise** — it read "a decision about an _issue_ that every commit on the + branch continues to serve", singular, which is the only sentence in the whole + instruction surface that touches issue-per-branch and it pointed the wrong way. + A branch carries **as many claims as it has rows**; AGENTS.md's board section + is the model. Measured 2026-09-01: reading this sentence, an agent declined to + pull a second row onto an open branch and reported the receipt as forbidding + it, when the receipt is a file name. The naive form ("refuse unless a `CLOUD-` is In Progress") is not computable in a hook at all: no tracker credential exists there, which is why `claim-check` is a pure function of piped stdin. Scratch work is excluded structurally rather than by tuning — git-ignored, out-of-repo and `.git` paths diff --git a/.serena/memories/workflow/board-states.md b/.serena/memories/workflow/board-states.md index 719ca6936..afa7d0c51 100644 --- a/.serena/memories/workflow/board-states.md +++ b/.serena/memories/workflow/board-states.md @@ -245,6 +245,30 @@ which is what this wanted: the predicate needs a network call, and no rule kind can make one on a mediated call (CLOUD-446). `verify` is the earliest surface that still sits on every path to a published PR. +**ONE COMMIT ONE ISSUE; ONE BRANCH MANY ISSUES; ONE PR EVERYTHING, NO PUNTS.** +AGENTS.md carries the rule; this is why it needs saying at all. **The commit is +the unit of work and the branch is not a row.** A second row you find mid-branch +is claimed and worked THERE — cutting a fresh branch for it is precisely the +batching `land`'s lap loop exists to prevent (each lap rebases onto a little more +landed work, so conflicts arrive one resolvable increment at a time), and +stopping at the first row is the punt `filed-and-left-open` prices. Drafts run no +CI, so the PR is finished before a runner is spent; readying to "get a first +signal" spends a matrix on work you already know is incomplete. + +**Nothing keys work to a branch except the claim receipt's FILENAME, and that is +storage rather than the model.** The distinction is worth spelling out because +both the prose and the mechanism used to point the other way. `.claude/rules/toolchain.md` +explained the branch keying as attesting "a decision about an _issue_ that every +commit on the branch continues to serve" — singular, and the only sentence in the +whole instruction surface touching issue-per-branch cardinality. Meanwhile +`claim::mint` took a slice and wrote line 1 as an id LIST, but wrote the file +with `fs::write`, so a second `claim check` INVOCATION replaced the first row's +claim silently. Measured 2026-09-01: an agent read the receipt as forbidding a +second row on an open branch and reported the filename as the rule. Both halves +are fixed — the ids union now, guarded by the recorded `base` so a restarted +branch (`git checkout -B origin/main`, which keeps the name and discards +the commits) starts a fresh list rather than carrying CLOUD-516's stale claim. + **`claim-check` runs BEFORE the board move, not after — and the order is not interchangeable.** It refuses `not-todo`, so once the issue is In Progress it refuses the very claim you just made, and it cannot tell your own move from a diff --git a/AGENTS.md b/AGENTS.md index f2c16a70f..47c7ac67f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,8 +37,8 @@ stopping short to ask is the deviation. **The gates ARE your authorization** — you run them yourself, and they halt you by _failing_, not by needing a blessing. So **`git commit` needs no asking** — local, reversible, and commit early and often, since a sprawling uncommitted tree is what this kills. Establish base -state first (`git fetch origin main`), work on a short-lived branch, never author -on `main`, and carry the lifecycle without stopping between steps to report. +state first (`git fetch origin main`), never author on `main`, and work ONE +short-lived branch: one commit one issue, one branch many rows, one PR all of it. **When you SHOULD still stop** (real exceptions, not an escape hatch): a gate fails and the fix is genuinely ambiguous; a rebase conflict needs a human diff --git a/batten.toml b/batten.toml index ed438cce8..5fc2a9236 100644 --- a/batten.toml +++ b/batten.toml @@ -1295,6 +1295,29 @@ regex = '(?i)(deferred?|deferring|defers) (it |that |this )?to|owned by|belongs id = "ready-issue-key" regex = 'CLOUD-[0-9]+' +# THE PROSE-DIALECT THRESHOLD (CLOUD-472). Which rows may still write a Ready +# block as prose rather than as the fenced claims object. +# +# A KEY RANGE IS A CREATION-ORDER CUTOVER, exactly, because the tracker mints keys +# in order — and it carries none of the timezone, format or clock-skew hazard a +# date literal would. It is the consumer's own key space, which is why it is here +# and not in the crate (rule 1, and `no-tracker-key-in-core` refuses the token +# there outright). +# +# THE CEILING IS DELIBERATELY ABOVE EVERY KEY THAT EXISTS TODAY. The highest live +# row when this landed was CLOUD-1311, so nothing currently on the board is +# refused and the ready frontier cannot go dark — CLOUD-858 measured what happens +# when it does, three rows taking `graph-check` down over the whole board. The +# headroom is the migration window, not slack: moving the ceiling down is how this +# ratchet advances, and every step of it costs somebody a body to groom. +# +# Anchored at both ends so `CLOUD-14000` cannot match through the `1[0-3][0-9]{2}` +# arm. The arms are the two live key widths; a fifth digit is past the threshold by +# construction, which is the direction a miss must fail in. +[[pattern]] +id = "ready-prose-dialect-exempt" +regex = '^CLOUD-([0-9]{1,3}|1[0-3][0-9]{2})$' + # The tracker serialises a mention as `KEY`, so the markup is # stripped and the stored and rendered forms become one case. A pattern written # against the rendered form never matches the stored one, and an exemption tested diff --git a/crates/batten/src/claim.rs b/crates/batten/src/claim.rs index 1cab08690..91d23e330 100644 --- a/crates/batten/src/claim.rs +++ b/crates/batten/src/claim.rs @@ -575,6 +575,41 @@ pub fn receipt_name(branch: &str) -> String { format!("claim.{}", branch.replace('/', "-")) } +/// The ids an existing receipt for this branch still speaks for. +/// +/// Empty for every reason that is not "the same branch, still on the same base": +/// no receipt, an unreadable one, one with no `base` line, or one whose base is +/// not the base being claimed against now. **Could-not-look drops the list rather +/// than carrying it**, which is the safe direction here — a lost claim costs one +/// re-run of `claim check`, while a carried-over stale one is the defect +/// CLOUD-516 measured, where a receipt sat on a restarted branch through four +/// unrelated stories reporting nothing. +/// +/// `-` never matches, because [`mint`] writes it for a base that did not resolve +/// and two unresolvable bases are not evidence of the same branch. +fn carried_ids(receipt: &Path, base: Option<&str>) -> Vec { + let Some(base) = base else { + return Vec::new(); + }; + let Ok(existing) = std::fs::read_to_string(receipt) else { + return Vec::new(); + }; + let same_base = existing + .lines() + .filter_map(|line| line.strip_prefix("base ")) + .any(|recorded| recorded == base && recorded != "-"); + if !same_base { + return Vec::new(); + } + existing + .lines() + .next() + .unwrap_or_default() + .split_whitespace() + .map(str::to_owned) + .collect() +} + /// Write the claim receipt. /// /// **Only on the pullable path**, which is what makes it a claim rather than a @@ -601,11 +636,39 @@ pub fn mint( base: Option<&str>, claimed_at: &str, ) -> Result { - let mut body = String::new(); + let dest = receipts.join(receipt_name(branch)); + // LINE 1 IS THE ID LIST, exactly where it has always been, so any reader that // did parse it still finds it. Everything below is read BY KEY for the same // reason: a line added here must not move one somebody else counts on. - let ids: Vec<&str> = issues.iter().map(|issue| issue.id.as_str()).collect(); + // + // A SECOND CLAIM ON AN OPEN BRANCH ADDS TO THE LIST RATHER THAN REPLACING IT + // (CLOUD-472). `mint` has always taken a SLICE, so the many-row shape was + // expressible in one invocation — but the write is `fs::write`, so a second + // INVOCATION dropped the first row's claim on the floor. That is the model + // backwards: one commit is one issue, one branch is as many issues as the + // work needs, and a second row found mid-branch is claimed and worked there. + // + // Measured 2026-09-01: an agent read the branch-keyed receipt as forbidding a + // second row, declined to pull one onto an open branch, and reported the + // storage key as the rule. The prose that pointed it there is corrected in + // `.claude/rules/toolchain.md`; this is the half that makes the correction + // true rather than merely stated. + // + // THE BASE IS WHAT MAKES THE UNION SAFE, and it is CLOUD-516's arm reused + // rather than a new judgement. A branch NAME outlives the branch it described + // — `git checkout -B origin/main` discards the commits while this file, + // keyed by the name, survives — so ids carry over only when the recorded base + // still matches. A restarted branch starts a fresh list, which is exactly the + // stale-claim defect CLOUD-516 records rather than a case this widens. + let mut ids: Vec = carried_ids(&dest, base); + for issue in issues { + if !ids.iter().any(|held| held == &issue.id) { + ids.push(issue.id.clone()); + } + } + + let mut body = String::new(); body.push_str(&ids.join(" ")); body.push('\n'); if request.bypass_sequence { @@ -661,7 +724,6 @@ pub fn mint( // only record and it names something that no longer exists. writeln!(body, "branch {branch}")?; - let dest = receipts.join(receipt_name(branch)); std::fs::create_dir_all(receipts) .and_then(|()| std::fs::write(&dest, body)) .map_err(|_| { @@ -820,6 +882,81 @@ mod tests { } } + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join("batten-claim-tests").join(name); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// Mint through the real function, so these cases exercise the write the + /// engine actually performs rather than a hand-rolled file. + fn mint_one(receipts: &Path, id: &str, base: Option<&str>) -> String { + let dest = mint( + receipts, + "user/branch", + &[issue(id, "Todo")], + &Verdict::default(), + &Request::default(), + base, + "2026-09-01T00:00:00Z", + ) + .unwrap(); + std::fs::read_to_string(dest).unwrap() + } + + /// ONE BRANCH, MANY ISSUES (CLOUD-472). The receipt's first line has always + /// been an id LIST and `mint` has always taken a slice, but the write is a + /// whole-file replace — so a second `claim check` INVOCATION dropped the first + /// row's claim silently. That is the branching model backwards: a second row + /// found mid-branch is claimed and worked there. + #[test] + fn a_second_claim_on_an_open_branch_joins_the_first() { + let receipts = scratch("second-claim"); + mint_one(&receipts, "CLOUD-1", Some("abc123")); + let body = mint_one(&receipts, "CLOUD-2", Some("abc123")); + assert_eq!( + body.lines().next().unwrap(), + "CLOUD-1 CLOUD-2", + "the branch speaks for both rows:\n{body}" + ); + } + + /// ANTI-VACUITY: the union must not turn a re-claim into a duplicate, or the + /// list grows without bound across the laps a long branch makes. + #[test] + fn re_claiming_the_same_row_does_not_duplicate_it() { + let receipts = scratch("re-claim"); + mint_one(&receipts, "CLOUD-1", Some("abc123")); + let body = mint_one(&receipts, "CLOUD-1", Some("abc123")); + assert_eq!(body.lines().next().unwrap(), "CLOUD-1", "{body}"); + } + + /// A RESTARTED BRANCH STARTS A FRESH LIST, which is CLOUD-516's arm reused + /// rather than widened. `git checkout -B origin/main` discards the + /// commits while the receipt, keyed by the NAME, survives — so carrying ids + /// across a changed base is exactly the stale claim that sat through four + /// unrelated stories reporting nothing. + #[test] + fn a_branch_restarted_on_a_new_base_carries_no_earlier_ids() { + let receipts = scratch("restarted"); + mint_one(&receipts, "CLOUD-1", Some("abc123")); + let body = mint_one(&receipts, "CLOUD-2", Some("def456")); + assert_eq!(body.lines().next().unwrap(), "CLOUD-2", "{body}"); + } + + /// COULD-NOT-LOOK DROPS THE LIST rather than carrying it. `mint` writes `-` + /// for a base that did not resolve, and two unresolvable bases are not + /// evidence of the same branch — a lost claim costs one re-run, a carried + /// stale one is the defect. + #[test] + fn an_unresolvable_base_carries_nothing_in_either_direction() { + let receipts = scratch("no-base"); + mint_one(&receipts, "CLOUD-1", None); + let body = mint_one(&receipts, "CLOUD-2", None); + assert_eq!(body.lines().next().unwrap(), "CLOUD-2", "{body}"); + } + #[test] fn the_trackers_own_spelling_is_extracted_and_the_authors_is_too() { // BOTH, and the first is the one that decides whether this ships dead. diff --git a/crates/batten/src/ready.rs b/crates/batten/src/ready.rs index b44613137..3054915ba 100644 --- a/crates/batten/src/ready.rs +++ b/crates/batten/src/ready.rs @@ -257,6 +257,13 @@ pub struct Grammar { unanchored_clause: Regex, open_questions: Regex, legacy_clause_notation: Regex, + /// The keys still allowed to write a Ready block as prose (CLOUD-472). + /// + /// **A THRESHOLD, NOT A SWITCH.** Issue keys are minted in order, so a key + /// pattern IS a creation-order cutover — and it is one the consumer can read + /// and move, in the consumer's own key space, with none of the timezone, + /// format or clock-skew hazard a date literal carries. + prose_dialect_exempt: Regex, bump_label: Regex, commit_type: Regex, bump_token: Regex, @@ -391,6 +398,7 @@ impl Grammar { blocks_tail: find("ready-blocks-tail")?, relatedto_tail: find("ready-relatedto-tail")?, defer_verb: find("ready-defer-verb")?, + prose_dialect_exempt: find("ready-prose-dialect-exempt")?, key: find("ready-issue-key")?, mention_markup: find("ready-issue-mention-markup")?, }) @@ -690,15 +698,53 @@ pub fn lint(grammar: &Grammar, payload: &Payload, root: &Path) -> Result // can adjudicate. let structured = check_claims(grammar, payload, root, &block, ready_start, &mut report)?; - // THE DIALECT, AS A FACT RATHER THAN A VERDICT. A prose-only block still - // PASSES — every issue Ready today stays Ready, which is what lets the - // corpus converge deliberately instead of in one sweep — and is named, so a - // caller can find the ones still to convert without re-reading any body. + // THE DIALECT, AS A FACT. Named so a caller can find the blocks still to + // convert without re-reading any body — and it is the sensor the ratchet + // below reads, rather than a second derivation of the same question. report.emissions.push(format!( "dialect {}", if structured { "json" } else { "prose" } )); + // THE PROSE DIALECT IS A LEGACY, NOT AN ALTERNATIVE (CLOUD-472). + // + // This clause used to say a prose-only block "still PASSES — every issue + // Ready today stays Ready, which is what lets the corpus converge + // deliberately instead of in one sweep". The first half is still true below + // the threshold. The second half was left to intent, **and intent did not + // converge it**: measured 2026-09-01 over the 50-row Todo queue, the object + // was used by nothing, and CLOUD-1306 — filed that day — carried a §7 naming + // three obligations in prose, none of them joinable to anything. A sensor + // with no ratchet on it reports a defect forever. + // + // WHY THE OBJECT IS THE THING BEING DEMANDED, rather than a new grammar: + // `REQUIRED_CLAIMS` already forces `tests`, and `check_claimed_tests` + // already forces `file` AND `mutation` on every entry — CLOUD-418's + // obligation as a field, where an entry that cannot name the mutation which + // would kill it cannot be written. That mechanism landed and was simply + // unreachable, because `check_claims` returns `false` on an absent fence and + // the caller falls back here. + // + // A RATCHET RATHER THAN A FLIP, and the cost is why. `graph-check` enforces + // `Todo ⇒ ready-lint exits 0`, so refusing every prose block at once takes + // the board's whole ready frontier dark in one step — CLOUD-858's measured + // shape, where three rows did exactly that. + // + // COULD-NOT-LOOK PASSES, and it is the id that decides. A payload carrying + // no readable key cannot be placed against the threshold at all, so it is + // judged exactly as it was before this clause existed. Reading "no key" as + // "past the cutover" would turn a verdict about the payload into a verdict + // about the row. + if !structured + && grammar.key.is_match(&payload.id) + && !grammar.prose_dialect_exempt.is_match(&payload.id) + { + report.findings.push(Finding { + line: ready_start, + rule: "claims-object-absent".to_owned(), + }); + } + if !structured { check_bump(grammar, root, &block_lines, &line_of, &mut report)?; } diff --git a/crates/batten/tests/it/ready.rs b/crates/batten/tests/it/ready.rs index 23140e663..95bf8d342 100644 --- a/crates/batten/tests/it/ready.rs +++ b/crates/batten/tests/it/ready.rs @@ -273,6 +273,93 @@ fn claims_payload(object: &serde_json::Value, blocked_by: &[&str]) -> String { ) } +/// A payload under a chosen key, for the threshold cases below. +/// +/// Every other fixture here is `CLOUD-999` — three digits, below the committed +/// ceiling — which is why the whole prose corpus above stays clean and why these +/// cases have to name their own key rather than reusing the shared builder. +fn keyed_payload(id: serde_json::Value, description: &str) -> String { + serde_json::json!({ + "id": id, + "description": description, + "relations": { "blockedBy": [] }, + }) + .to_string() +} + +// --------------------------------------------------------------------------- +// CLOUD-472: the prose dialect is a LEGACY, not an alternative. +// +// `REQUIRED_CLAIMS` and `check_claimed_tests` already force a `mutation` onto +// every declared obligation — CLOUD-418's field. That mechanism was unreachable, +// because an absent fence dropped the author onto the prose path, and measured +// 2026-09-01 the object was used by nothing at all. +// --------------------------------------------------------------------------- + +#[test] +fn a_prose_block_past_the_threshold_is_refused() { + let dir = with_tasks("ready-prose-past-threshold"); + let output = lint( + &dir, + &keyed_payload( + serde_json::json!("CLOUD-9999"), + &block("* **Test obligation (§7).** Three discriminating observations.\n"), + ), + ); + assert_eq!(code(&output), 2, "{}", stderr(&output)); + assert!( + stderr(&output).contains("claims-object-absent"), + "the refusal must name the class, or the author cannot act on it: {}", + stderr(&output) + ); +} + +#[test] +fn a_claims_object_past_the_threshold_is_clean() { + // The remedy has to be REACHABLE from the refusal above, or the ratchet is a + // wall. Same key, same fixture, the object supplied. + let dir = with_tasks("ready-object-past-threshold"); + let object = serde_json::to_string_pretty(&complete_claims()).expect("encodable"); + let output = lint( + &dir, + &keyed_payload(serde_json::json!("CLOUD-9999"), &claims_block(&object)), + ); + assert_eq!(code(&output), 0, "{}", stderr(&output)); +} + +/// THE ANTI-VACUITY MIRROR, and without it the arm is satisfied by a check that +/// refuses every prose block — which is the change that takes the board's ready +/// frontier dark in one step (CLOUD-858's measured shape). +#[test] +fn a_prose_block_below_the_threshold_is_clean() { + let dir = with_tasks("ready-prose-below-threshold"); + let output = lint( + &dir, + &keyed_payload( + serde_json::json!("CLOUD-999"), + &block("* **Test obligation (§7).** Three discriminating observations.\n"), + ), + ); + assert_eq!(code(&output), 0, "{}", stderr(&output)); +} + +/// COULD-NOT-LOOK PASSES. A payload carrying no readable key cannot be placed +/// against the threshold at all, so it is judged exactly as it was before this +/// clause existed. Reading "no key" as "past the cutover" would turn a verdict +/// about the payload into a verdict about the row. +#[test] +fn a_payload_with_no_readable_key_is_judged_as_before() { + let dir = with_tasks("ready-no-key"); + let output = lint( + &dir, + &keyed_payload( + serde_json::Value::Null, + &block("* **Test obligation (§7).** Three discriminating observations.\n"), + ), + ); + assert_eq!(code(&output), 0, "{}", stderr(&output)); +} + // --------------------------------------------------------------------------- // §453: the checkable half as data, and the prose path it does not disturb. // --------------------------------------------------------------------------- From 3c63c4ee7c938c3ba9977e0435f6384bc0959791 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 21:38:03 +0000 Subject: [PATCH 4/8] feat(ready): the prose dialect becomes a legacy, on a declared cutover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-453 built a fenced claims object whose `REQUIRED_CLAIMS` forces `tests`, and CLOUD-418 gave every entry a `mutation` field — an obligation that cannot name the mutation which would kill it cannot be written. That mechanism landed and was UNREACHABLE: `check_claims` returns false on an absent fence and the caller drops to the prose path, so the whole thing was opt-in. Measured 2026-09-01 over the 50-row Todo queue: nothing used it. CLOUD-1306, filed that day, carries a §7 naming three obligations in prose, none joinable to anything. `ready.rs` has emitted `dialect prose` per run all along, with a comment saying the corpus would "converge deliberately instead of in one sweep" — the sensor was right there and nothing ratcheted on it. A RATCHET, NOT A FLIP. `graph-check` enforces `Todo => ready-lint exits 0`, so refusing every prose block at once takes the whole ready frontier dark in one step, which is CLOUD-858's measured shape. TWO WRONG SURFACES PRECEDED THE RIGHT ONE, and both are recorded where the next author will look. First a `[[pattern]]` row spelling the exempt range as a regex over the key: the registry gives one concept one spelling and arithmetic is not a concept, and it decides on key TEXT, which this consumer already declares `ready-issue-mention-markup` for because the tracker rewrites a bare key into `` markup. Then a key ORDINAL, which reaches no consumer literal and still requires keys that are numeric AND monotonic with creation order — true of three popular trackers, false of a slug- or UUID-keyed one, where it would resolve to nothing and fail SILENTLY. A creation instant assumes nothing. Every tracker stamps one, the payload already carried it, and `policy/filed-here.rego`'s `predates_the_branch` already compares tracker timestamps this way: both sides fixed-width ISO-8601 UTC, so lexical order is chronological order. Three gates caught the plumbing rather than me. `resolve` refuses a field with no declared provenance layer; `trust` refuses a config field that does not say what weakening means for it; `policy-budget` refused the first attempt to state the branching model in AGENTS.md at its own ceiling. The weakening kind is `ready-cutover-relaxed` — later exempts more rows, and dropping the key is that move taken to its limit, since absent reads as could-not-look. Also here, from the same review: `.claude/rules/toolchain.md` states the retirement of by-path hook registration as a DIRECTION rather than a one-off, because a capability declared under a harness's own directory exists for one of five wired harnesses and is invisible to the other four; and `.claude/rules/policy-modules.md` records both wrong surfaces above as a rule about never building a predicate on text a round trip rewrites. 3762/3762 green. Refs: CLOUD-472 Admits: 62e398b8a6bd7a69ad128dc0dbc985bd085f8b36c29b27adf10386a855195e87 Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: batten.toml Admits-head: e1b04287b9014b6cd3c26e2e70193c9bdde0bb06 Admits-epoch: 08df746a010060ba1c781d4de6935d9751f3cf01839b0aa0ca5c09e4982d7775 Admits-author: alec@wenzowski.com Admits-prev: 5e94b114846dc2b86da06b29535aa4639351988b9ccd597ade5e803363af7ef2 Admits-answer-lost: The threshold stays spelled as `^CLOUD-([0-9]{1,3}|1[0-3][0-9]{2})$`, which is the wrong surface on two counts the repository already knows about: the pattern registry exists so one CONCEPT has one spelling and arithmetic is not a concept, and the decision would turn on key TEXT that the tracker demonstrably rewrites — this file declares `ready-issue-mention-markup` precisely because a bare key returns wrapped in `` markup. Admits-answer-precondition: Both halves of this edit are only expressible in batten.toml: it removes the `[[pattern]] ready-prose-dialect-exempt` row and adds the `[ready]` table that replaces it, and neither a pattern row nor a config table has any owning verb — the file IS the surface. `Grammar::assemble` resolves pattern ids with a LOUD failure, so leaving the stale row while the code no longer reads it would be dead config, and removing it without adding `[ready]` leaves the ratchet unreachable. It lands in the reviewed PR for CLOUD-472 where `mise run config-lint` and the compiled tier judge it. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply because batten.toml IS the owning surface for both a `[[pattern]]` row and a config table; no verb registers either, and the path's own redirect says to change it in a pull request. R-RESTORE-IT does not apply in the usual sense but half of this edit IS a restore: it removes a row added earlier in this same branch rather than one that ever landed, so the net effect against origin/main is one new `[ready]` table. The threshold is set above every key that exists today, so nothing on the board is refused and the ready frontier cannot go dark the way CLOUD-858 measured — that is the property to check in the diff, since a threshold set too low is the one way this does harm. Admits: e21549fcda550f97b7487fa035daa0f02606801a168f44f2f5b8ec7c0cc74feb Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: batten.toml Admits-head: e1b04287b9014b6cd3c26e2e70193c9bdde0bb06 Admits-epoch: 3a81b526ea895c28ff9a18819a9251bedd1f695f8c1146f88ca33f86464cdddb Admits-author: alec@wenzowski.com Admits-prev: 62e398b8a6bd7a69ad128dc0dbc985bd085f8b36c29b27adf10386a855195e87 Admits-answer-lost: The prose-dialect ratchet cannot be reached at all. `Grammar::with_prose_threshold` receives `None`, which is could-not-look by design, so `ready lint` exempts every row and the claims object stays opt-in — the exact state measured on 2026-09-01, where the mechanism CLOUD-453 built and CLOUD-418 gave its `mutation` field was used by nothing across the whole 50-row Todo queue. Admits-answer-precondition: This is the second half of one replacement and the file IS the surface for it: the `[[pattern]]` row was removed under the previous admission, and `[ready] prose_dialect_exempt_below` is what the code now reads. No verb registers a config table, and leaving the tree between the two writes is strictly worse than either end state — the ratchet would be unreachable while `config.ready` is `None`, which reads as could-not-look and exempts every row. Split across two admissions only because each covers one write; it should have been one edit, and that is my error rather than a property of the change. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply because batten.toml IS the owning surface for a config table; there is no verb that writes one, and the path's own redirect says to change it in a pull request, which is what this is. R-RESTORE-IT does not apply because nothing was destroyed — this adds one table and touches no existing one. The value is set above every key that exists today (highest live row CLOUD-1311), so nothing on the board is refused; a reviewer should check that number specifically, because setting it too low is the one way this change takes the ready frontier dark the way CLOUD-858 measured. Admits: 8164d00fb3d38d7017b27ed86e88026385f9529023dee50bf1b1ffa6afb28753 Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: .serena/memories/workflow/agent-fanout.md Admits-head: e1b04287b9014b6cd3c26e2e70193c9bdde0bb06 Admits-epoch: 04d65e929fcb8962b43da408ccb71d1f8d5a63c839a9492142bf7009f372e12a Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: Three corrections stay unrecorded and get re-derived wrongly, as they were this session. The WIP cap keeps reading as a bound on tickets rather than on builds, so an agent splits work that belongs in one PR. Bundling keeps being justified by rebase amortisation, which the same file's caps section already refutes — a rebase costs no model turn, so there is nothing to amortise — and that argument gets weaker as automation improves, which is the tell it was never the reason. And nothing records that this repository caps no PR size, so an invented threshold recurs. Admits-answer-precondition: The memory IS the owning surface: AGENTS.md is at its `policy-budget` ceiling (3618 of 3500 tokens, measured when it refused this session's first attempt), so fan-out rationale cannot live there, and the repo's own split puts on-demand content in `.serena/memories/`. The write was made through `mcp__serena__edit_memory`, the route the redirect names; this records the change rather than authorising a route around it. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE is what was taken, not rejected: the edit went through Serena's `edit_memory`, and this admission exists because `commit check` records every protected path in the diff regardless of the route that wrote it. R-RESTORE-IT does not apply because nothing was destroyed — the edits replace two wrong rationales with the measured ones and add the maximise-the-bundle direction; every existing measurement, including the owner-set caps of 6 and 2, is left exactly as it stands. --- .claude/rules/policy-modules.md | 23 +++++++ .claude/rules/toolchain.md | 23 ++++++- .serena/memories/workflow/agent-fanout.md | 70 +++++++++++++++++-- batten.toml | 60 ++++++++++------ crates/batten/src/claim.rs | 7 ++ crates/batten/src/config.rs | 43 ++++++++++++ crates/batten/src/lib.rs | 8 ++- crates/batten/src/ready.rs | 79 +++++++++++++++++---- crates/batten/src/resolve.rs | 14 ++++ crates/batten/src/trust.rs | 84 +++++++++++++++++++++++ crates/batten/tests/it/ready.rs | 77 +++++++++++++-------- schema/batten.schema.json | 25 +++++++ 12 files changed, 439 insertions(+), 74 deletions(-) diff --git a/.claude/rules/policy-modules.md b/.claude/rules/policy-modules.md index 8f9108d31..c75b5045e 100644 --- a/.claude/rules/policy-modules.md +++ b/.claude/rules/policy-modules.md @@ -84,6 +84,29 @@ The measured reason: one concept was spelled 19 different ways across 17 shell programs before the registry existed. A convention would not have stopped that; a load-time refusal does. +**NEVER BUILD A PREDICATE ON TEXT A ROUND TRIP REWRITES, and never spell a +threshold as a pattern.** Two failures, one root: reaching for the registry +because it is the nearest declaration surface rather than because the thing being +declared is a concept with one spelling. + +A tracker sanitises what it stores. This consumer already declares +`ready-issue-mention-markup` **because** a bare issue key comes back wrapped in +`` markup — so a rule matching key text is matching the one thing the +round trip is known to mangle, and it will pass in a fixture and fail in +production. Measured 2026-09-01: a prose-dialect ratchet was drafted as +`^CLOUD-([0-9]{1,3}|1[0-3][0-9]{2})$`, a key range in alternation. Wrong twice — +arithmetic is not a concept, so a range is unreadable and unmovable in a regex, +and the decision turned on rewritten text. It is a **value** now, in `[ready]`. + +Its replacement carried a subtler form of the same error and is worth the +sentence: a key ORDINAL — trailing digits, no separator assumed — reaches no +consumer literal and passes `no-tracker-key-in-core`, yet still requires keys +that are numeric AND monotonic with creation order. Three popular trackers give +that and a slug- or UUID-keyed one does not, where it would resolve to nothing +and **fail silently**. Prefer a fact every tracker actually stamps: the row's +creation instant, compared as fixed-width ISO-8601, which is what +`filed-here.rego`'s `predates_the_branch` already does. + **A PRESET IS EXEMPT, AND IN A PRESET YOU WRITE THE LITERAL INLINE.** This paragraph told authors the opposite — that the exemption was "a hole rather than a design" and to "write the row" anyway — and following it produces a **dead diff --git a/.claude/rules/toolchain.md b/.claude/rules/toolchain.md index d7f3b6e6c..95586dc81 100644 --- a/.claude/rules/toolchain.md +++ b/.claude/rules/toolchain.md @@ -29,7 +29,22 @@ retired, and `crates/batten/tests/session_provisioning.rs` carries both its ledger and the tier that proves the door does what the rows say. Add a provisioning step by adding a task and a row — never by putting a second step inside an existing task's body, which is the shape that made the script -unreadable from the committed authority. Not `hk +unreadable from the committed authority. + +**AND NO NEW MECHANISM GOES BACK INTO A HARNESS'S OWN DIRECTORY.** That retirement +was a direction, not a one-off: `batten.toml` is the authority and `batten hook` +is the one entry, so a capability declared under `.claude/` — a hook, an agent +definition, a command — exists for **one** of the five wired harnesses and is +invisible to the other four, which is the reach the engine was built to have. +Measured 2026-09-01: designing a way to record that a pressure-test subagent had +actually run, an agent proposed `.claude/agents/*.md` as the prompt's home, which +would have bound the whole mechanism to Claude Code while `hook.rs` already +normalises a spawn to `Operation::Subagent` across three harnesses and reports +could-not-look on the two that declare no spelling. The declaration belongs in +`batten.toml` over a tracked file; the harness's directory is where a capability +goes to be unavailable. A harness that offers no spelling for something must read +as **unanswered**, never as absent-and-therefore-fine, which is a property only +the engine can hold. Not `hk install`: its generated hook calls `hk` bare, which does not resolve where mise's shims are off PATH, so the installed body is `.claude/hooks/git-hook.sh` — which also refuses to re-enter a gate that is already running, the recursion @@ -517,8 +532,10 @@ call` with no `CLOUD-*` key **in that same paragraph** stops the lap. Two open otherwise** — it read "a decision about an _issue_ that every commit on the branch continues to serve", singular, which is the only sentence in the whole instruction surface that touches issue-per-branch and it pointed the wrong way. - A branch carries **as many claims as it has rows**; AGENTS.md's board section - is the model. Measured 2026-09-01: reading this sentence, an agent declined to + A branch carries **as many claims as it has rows** — AGENTS.md states the model + in its autonomous-workflow paragraph rather than its board one, because + `policy-budget` refused the fuller wording at its own ceiling, and + `mem:workflow/board-states` carries the rationale. Measured 2026-09-01: reading this sentence, an agent declined to pull a second row onto an open branch and reported the receipt as forbidding it, when the receipt is a file name. The naive form ("refuse unless a `CLOUD-` is In Progress") is not computable in a hook at all: no tracker credential exists there, which is why diff --git a/.serena/memories/workflow/agent-fanout.md b/.serena/memories/workflow/agent-fanout.md index 5863f36ac..97a19a5f1 100644 --- a/.serena/memories/workflow/agent-fanout.md +++ b/.serena/memories/workflow/agent-fanout.md @@ -123,10 +123,31 @@ it were one, which is how a number nobody approved becomes a standing constraint The measurement below is unaffected and is NOT the cap: N ≈ 2.9 prices _land contention_, and the lever that measurement argues for is still "serialise the landing, shorten the lap, quiet `main`". The cap is a separate, owner-set bound on -how many implementers may hold a claim at once, and the two must not be conflated +how many BUILDS may be in flight at once, and the two must not be conflated again — if the arithmetic below argues for a different number, that is an argument to bring to the owner, not a licence to edit this one. +**THE UNIT IS A BUILD, NOT A TICKET, AND THIS FILE SAID THE WRONG ONE.** It read +"how many implementers may hold a claim at once", which is the PR/issue +conflation one layer down: a branch carries as many rows as the work needs, so a +PR closing ten tickets is **WIP 1**. It contends for the lease once, rebases +once, runs `verify` once. Counting claims makes the cap punish exactly the +bundling the section below tells you to maximise — an eight-row bundle in one +domain would read as WIP 8 while costing the trunk what WIP 1 costs. + +Measured 2026-09-01: reading "enforced at claim time", an agent reported the WIP +cap as a bound on how many tickets it could take, twice. + +**AND THE MECHANISM COUNTS THE WRONG THING TOO**, so this is not merely a wording +fix: `mise-tasks/graph-check.sh` emits +`wip $(jq -r '[.[] | select(.status == "In Progress")] | length')` — one per +ISSUE. The board-computable count of builds is the distinct PR attachments among +In Progress rows plus the In Progress rows carrying none; that over-counts a +pre-PR bundle and never under-counts, which is the safe direction. `graph-check` +is governed shell, so the fix is a retirement row rather than an edit +(`.claude/rules/toolchain.md`), and until it lands the emitted `wip` number reads +high for anybody who bundles. + Past the cap the binding constraint is **land contention**, not compute: every land forces siblings to rebase and re-run `verify`, so N ≈ time-between-lands ÷ verify-duration. **A rising re-verify rate is NOT the stop signal** — an @@ -261,10 +282,49 @@ procedure; this section owns why it is shaped that way. **Dispatch bundles, not single tickets.** A session handed one ticket stops when it lands, and its container plus its warm context are thrown away. A session -handed an ordered chain in one file domain keeps going, and — the part that -matters for the cap above — amortises several commits over one rebase cost -instead of paying that cost per ticket. Bundling is what raises the ceiling; -adding sessions is not. +handed an ordered chain in one file domain keeps going instead. Bundling is what +raises the ceiling; adding sessions is not. + +**AND THE REASON IS NOT REBASE AMORTISATION — that argument is refuted by this +file's own next section.** It used to read "amortises several commits over one +rebase cost instead of paying that cost per ticket", which contradicts the +caps section directly: _"a fast-forward refusal rebases and re-verifies with no +model turn, so a moved base costs CPU and wall-clock, both of which are free +here, and zero tokens. Re-verifying is the loop working."_ You cannot amortise a +free thing. `land` laps unattended and an agent absorbs rebases without a turn, +so a rebase is not a cost that bounds anything. + +**What bundling actually saves is the METERED half of AGENTS.md's three costs.** +Local execution — a build, a rebase, the whole suite — is free. A CI run costs +real minutes and a model call is metered in the same category. Ten rows in one +PR buy **one** CI matrix, one review, one lease acquisition and one landing +sequence, where ten PRs buy ten of each. That is a real multiple on the only +costs that are real, and it does not weaken as the fleet gets faster — where the +rebase argument got weaker the better the automation got, which is the tell that +it was never the reason. + +**SO MAXIMISE THE BUNDLE, subject to file-domain coherence and nothing else.** +The direction is not "a few is better than one" — it is _as many related rows as +the domain holds_. Every extra row in a bundle is one more thing landed per +rebase, per `verify`, per CI run and per lease acquisition, so it makes the +measured constraint smaller rather than larger. A bundle of eight in one domain +lands faster than four bundles of two, and the four bundles also contend with +each other. + +**THERE IS NO PR-SIZE CAP HERE, AND NONE SHOULD BE INFERRED.** The cap of 2 and +the WIP cap of 6 are bounds on concurrent LANDING; neither says anything about +how large a diff may be. Nothing in this repository caps lines changed, and a +reviewer reading a coherent domain-scoped diff is reading one story either way. +Measured 2026-09-01: an agent invented a "2500 lines is big" threshold, cited it +as a reason to split work across PRs, and it appears nowhere in this repository — +inventing a size limit is how the amortisation above gets thrown away by an agent +being careful about the wrong thing. The context window is not the binding +constraint on a frontier model, and treating it as one costs laps. + +The real bound on a bundle is the one already stated: **it must be one file +domain**, read off open PRs' file lists rather than their titles. Two rows that +sound unrelated and both edit `mise-tasks/land.sh` belong in the same bundle; +two that sound related and touch disjoint trees do not. Order within a bundle by real dependency: the ticket whose gate the next one needs goes first, and the ticket that _replaces_ what an earlier one fixed goes diff --git a/batten.toml b/batten.toml index 5fc2a9236..30e7cd8b2 100644 --- a/batten.toml +++ b/batten.toml @@ -1295,28 +1295,14 @@ regex = '(?i)(deferred?|deferring|defers) (it |that |this )?to|owned by|belongs id = "ready-issue-key" regex = 'CLOUD-[0-9]+' -# THE PROSE-DIALECT THRESHOLD (CLOUD-472). Which rows may still write a Ready -# block as prose rather than as the fenced claims object. -# -# A KEY RANGE IS A CREATION-ORDER CUTOVER, exactly, because the tracker mints keys -# in order — and it carries none of the timezone, format or clock-skew hazard a -# date literal would. It is the consumer's own key space, which is why it is here -# and not in the crate (rule 1, and `no-tracker-key-in-core` refuses the token -# there outright). -# -# THE CEILING IS DELIBERATELY ABOVE EVERY KEY THAT EXISTS TODAY. The highest live -# row when this landed was CLOUD-1311, so nothing currently on the board is -# refused and the ready frontier cannot go dark — CLOUD-858 measured what happens -# when it does, three rows taking `graph-check` down over the whole board. The -# headroom is the migration window, not slack: moving the ceiling down is how this -# ratchet advances, and every step of it costs somebody a body to groom. -# -# Anchored at both ends so `CLOUD-14000` cannot match through the `1[0-3][0-9]{2}` -# arm. The arms are the two live key widths; a fifth digit is past the threshold by -# construction, which is the direction a miss must fail in. -[[pattern]] -id = "ready-prose-dialect-exempt" -regex = '^CLOUD-([0-9]{1,3}|1[0-3][0-9]{2})$' +# THE PROSE-DIALECT THRESHOLD (CLOUD-472) IS `[ready]`, NOT A `[[pattern]]` ROW. +# It was drafted as one — a regex over the exempt key range — and that is the +# wrong surface twice over: this registry gives one CONCEPT one spelling, and +# arithmetic is not a concept, so a range spelled in alternation is unreadable +# and unmovable. Worse, it decides on key TEXT, which the tracker rewrites — the +# `ready-issue-mention-markup` row exists precisely because a bare key comes back +# wrapped in `` markup, so matching key text matches the one thing the +# round trip is known to mangle. The threshold is a number, in `[ready]` below. # The tracker serialises a mention as `KEY`, so the markup is # stripped and the stored and rendered forms become one case. A pattern written @@ -5494,6 +5480,36 @@ email = "alec@wenzowski.com" # The cost is stated rather than discovered: a pin bump now moves the epoch and # invalidates receipts, exactly as an AGENTS.md edit already does. That is the # intended direction — a toolchain change IS a change in what decided the check. +# The refinement gate's thresholds (CLOUD-472). +# +# WHAT IT CLOSES. A Ready block may be written in two dialects: prose, validated +# for the clauses that ARE present, or the fenced claims object, whose +# `REQUIRED_CLAIMS` forces `tests` and whose every entry must name a `file` AND +# the `mutation` that would kill it — CLOUD-418's obligation as a field, where an +# entry that cannot name its discriminating mutation cannot be written. The +# object landed and was UNREACHABLE: an absent fence drops the author onto the +# prose path, so the whole mechanism was opt-in. Measured 2026-09-01 over the +# 50-row Todo queue, nothing used it, and CLOUD-1306 — filed that day — carried a +# §7 naming three obligations in prose, none joinable to anything. +# +# A RATCHET, NOT A FLIP. `graph-check` enforces `Todo ⇒ ready-lint exits 0`, so +# refusing every prose block at once takes the whole ready frontier dark in one +# step — CLOUD-858 measured exactly that, three rows bringing the board down. +# +# THE CUTOVER IS AFTER EVERY ROW THAT EXISTS TODAY, so nothing currently on the +# board is refused. The headroom is the migration window rather than slack: +# moving this stamp later is the only direction that tightens, and each step +# costs somebody a body to groom. +# +# AN INSTANT RATHER THAN A KEY ORDINAL, and the reason is portability rather than +# taste. This value is the consumer's, but the ENGINE reading it must assume +# nothing: an ordinal threshold needs keys that are numeric and monotonic with +# creation order, which this tracker gives and a slug- or UUID-keyed one does +# not — and there it would fail silently rather than loudly. Every tracker stamps +# a creation time. +[ready] +prose_dialect_required_from = "2026-09-02T00:00:00.000Z" + [epoch] # `.mcp.json` is here for the agent-context record (CLOUD-579), which covers the # agent config a repository governs itself with through the epoch rather than by diff --git a/crates/batten/src/claim.rs b/crates/batten/src/claim.rs index 91d23e330..3efbc98d5 100644 --- a/crates/batten/src/claim.rs +++ b/crates/batten/src/claim.rs @@ -484,6 +484,12 @@ fn is_ready( relations_present: false, blocked_by: Vec::new(), all_relations: Vec::new(), + // Same split, same direction (CLOUD-472). This gate reads a `claim check` + // payload rather than a full `get_issue` one, so it has no creation + // instant to place against the prose-dialect cutover — could-not-look, + // and the row is judged on the clauses this gate CAN see. A claim is + // never refused for a field the caller did not fetch. + created_at: None, }; let report = crate::ready::lint(grammar, &payload, root)?; Ok(report.findings.is_empty()) @@ -869,6 +875,7 @@ pub fn adopt( } #[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index 388ceec05..4ec0a7508 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -170,6 +170,11 @@ pub struct Config { /// [`Config::unlanded`]. CLOUD-31's config-trust diff defends this set. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub protected: Vec, + /// The refinement gate's consumer-set thresholds (CLOUD-472). Absent means + /// this file does not speak to them, which every reader takes as + /// could-not-look rather than as a default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ready: Option, /// Programs that only ever READ the operands they are given, so naming a /// [`Config::protected`] path is not a mutation (CLOUD-1141). /// @@ -491,6 +496,40 @@ pub struct Config { pub trust: Option, } +/// The `[ready]` table: the refinement gate's consumer-set thresholds. +/// +/// # Why a value and not a `[[pattern]]` row (CLOUD-472) +/// +/// The first draft of the prose-dialect ratchet spelled its threshold as a +/// regex over the exempt key range. That is wrong twice. The pattern registry +/// exists so that one CONCEPT has one spelling — arithmetic is not a concept, +/// and a range encoded in alternation is unreadable and unmovable. And it makes +/// the decision turn on key TEXT, which the tracker is known to rewrite: this +/// consumer already declares `ready-issue-mention-markup` because a bare key +/// comes back wrapped in `` markup, so matching key text is matching +/// the one thing the round trip mangles. +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Ready { + /// From which creation instant a Ready block must carry the fenced claims + /// object rather than prose. An ISO-8601 UTC stamp, compared verbatim + /// against the tracker's own `createdAt`. + /// + /// **A RATCHET: moving it later is the only direction that tightens.** + /// Absent is could-not-look and exempts everything, so a consumer that has + /// not opted in is never refused for a question it did not ask. + /// + /// **An instant rather than a key ordinal, and that is a portability + /// decision.** An ordinal reaches no consumer literal — the trailing digits, + /// no separator assumed — so it passes `no-tracker-key-in-core`. It still + /// requires keys that are numeric AND monotonic with creation order, which + /// three popular trackers give and a slug- or UUID-keyed one does not, and it + /// would fail SILENTLY there rather than loudly. Every tracker stamps a + /// creation time, so this assumes nothing about how a consumer spells a key. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prose_dialect_required_from: Option, +} + /// The `[trust]` table: what `--config-from` may do when the ref is unreachable. /// /// House style §4 requires the authority to degrade safely rather than fail @@ -1231,6 +1270,10 @@ impl Config { verdicts: Vec::new(), scope: Vec::new(), protected: Vec::new(), + // Declaring nothing means declaring no threshold either, which the + // reader takes as could-not-look and exempts everything — the same + // direction every other field here grants. + ready: None, // No protected paths means the unknown-program clause has nothing to // guard, so an empty reader set costs nothing here and is the honest // value: a config declaring nothing declares no readers either. diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 2d686454d..459e863f3 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -2146,7 +2146,13 @@ fn render_findings(findings: &[checks_green::Finding]) -> String { /// module exists to avoid. fn board_grammar(overrides: &Overrides) -> Result { let config = resolve::resolve(Path::new("."), overrides)?; - ready::Grammar::resolve(&config.patterns) + Ok( + ready::Grammar::resolve(&config.patterns)?.with_prose_threshold( + config + .ready + .and_then(|ready| ready.prose_dialect_required_from), + ), + ) } fn run_claim( diff --git a/crates/batten/src/ready.rs b/crates/batten/src/ready.rs index 3054915ba..0defea78e 100644 --- a/crates/batten/src/ready.rs +++ b/crates/batten/src/ready.rs @@ -94,6 +94,11 @@ pub struct Payload { pub description: String, /// Whether the payload carried a `relations` key at all. pub relations_present: bool, + /// When the tracker says the row was created, verbatim, or `None` where the + /// payload carried none. Never parsed into a date type: it is compared + /// against another fixed-width ISO-8601 UTC stamp, so lexical order is + /// chronological order and a parser would only add a way to disagree. + pub created_at: Option, /// The `blockedBy` edges, for the §8 cross-check. pub blocked_by: Vec, /// Every edge in any direction, for the deferral cross-check. A deferral is @@ -149,6 +154,10 @@ impl Payload { .and_then(serde_json::Value::as_str) .unwrap_or("?") .to_owned(), + created_at: value + .get("createdAt") + .and_then(serde_json::Value::as_str) + .map(str::to_owned), description, relations_present, blocked_by, @@ -257,13 +266,33 @@ pub struct Grammar { unanchored_clause: Regex, open_questions: Regex, legacy_clause_notation: Regex, - /// The keys still allowed to write a Ready block as prose (CLOUD-472). + /// From which creation instant a Ready block must carry the claims object + /// rather than prose (CLOUD-472). `None` is could-not-look and exempts + /// everything. + /// + /// # Two wrong shapes preceded this, and the second is the instructive one + /// + /// It was first a `[[pattern]]` row spelling the exempt range as a regex over + /// the key. That is wrong twice: the registry gives one CONCEPT one spelling + /// and arithmetic is not a concept, and it decides on key TEXT, which this + /// consumer already declares `ready-issue-mention-markup` for because the + /// tracker rewrites a bare key into `` markup on the round trip. + /// + /// It was then a key ORDINAL — the trailing digits, no separator assumed, so + /// no consumer literal reached the crate. That passes + /// `no-tracker-key-in-core` and is still a consumer assumption smuggled in: + /// it requires keys that are numeric AND monotonic with creation order. Three + /// popular trackers satisfy that and a slug- or UUID-keyed one does not — and + /// it would fail SILENTLY there, resolving `None` and never ratcheting, which + /// is the dead-gate shape this module exists to avoid. /// - /// **A THRESHOLD, NOT A SWITCH.** Issue keys are minted in order, so a key - /// pattern IS a creation-order cutover — and it is one the consumer can read - /// and move, in the consumer's own key space, with none of the timezone, - /// format or clock-skew hazard a date literal carries. - prose_dialect_exempt: Regex, + /// **A creation instant assumes nothing.** Every tracker stamps one, the + /// payload already carries it, and `policy/filed-here.rego`'s + /// `predates_the_branch` already compares tracker timestamps this way with + /// the reasoning written out: both sides are fixed-width ISO-8601 UTC, so + /// lexical order IS chronological order. Moving it later is the only + /// direction that tightens, which makes it a ratchet rather than a switch. + prose_dialect_required_from: Option, bump_label: Regex, commit_type: Regex, bump_token: Regex, @@ -359,6 +388,20 @@ impl Grammar { }) } + /// Apply the consumer's prose-dialect threshold (CLOUD-472). + /// + /// Separate from [`Self::assemble`] because it is not a `[[pattern]]` and + /// must not become one: the registry holds concepts with one spelling, and a + /// number is neither. Absent on [`Self::from_compiled`]'s path by design — + /// the recorder resolves a grammar to answer an `{authority:…}` column and + /// has no consumer config in hand, so it gets could-not-look rather than a + /// threshold guessed from somewhere else. + #[must_use] + pub fn with_prose_threshold(mut self, from: Option) -> Self { + self.prose_dialect_required_from = from; + self + } + /// A row the consumer's table does not declare. /// /// **Could-not-look, and it says so** — a clause whose anchor has no @@ -398,7 +441,7 @@ impl Grammar { blocks_tail: find("ready-blocks-tail")?, relatedto_tail: find("ready-relatedto-tail")?, defer_verb: find("ready-defer-verb")?, - prose_dialect_exempt: find("ready-prose-dialect-exempt")?, + prose_dialect_required_from: None, key: find("ready-issue-key")?, mention_markup: find("ready-issue-mention-markup")?, }) @@ -730,14 +773,22 @@ pub fn lint(grammar: &Grammar, payload: &Payload, root: &Path) -> Result // the board's whole ready frontier dark in one step — CLOUD-858's measured // shape, where three rows did exactly that. // - // COULD-NOT-LOOK PASSES, and it is the id that decides. A payload carrying - // no readable key cannot be placed against the threshold at all, so it is - // judged exactly as it was before this clause existed. Reading "no key" as - // "past the cutover" would turn a verdict about the payload into a verdict - // about the row. + // COULD-NOT-LOOK PASSES, TWICE OVER, and both are the same posture. A + // consumer that declares no cutover has not asked for the ratchet, and a + // payload carrying no creation instant cannot be placed against one — so each + // leaves the row judged exactly as it was before this clause existed. + // Reading either as "past the cutover" would turn a verdict about the + // environment into a verdict about the row. + // + // Both sides are fixed-width ISO-8601 UTC as the tracker stamps them, so a + // lexical comparison IS a chronological one — the same reading, and the same + // reasoning, as `policy/filed-here.rego`'s `predates_the_branch`. if !structured - && grammar.key.is_match(&payload.id) - && !grammar.prose_dialect_exempt.is_match(&payload.id) + && let Some(from) = grammar.prose_dialect_required_from.as_deref() + && payload + .created_at + .as_deref() + .is_some_and(|created| created >= from) { report.findings.push(Finding { line: ready_start, diff --git a/crates/batten/src/resolve.rs b/crates/batten/src/resolve.rs index 7aa0244cd..acd6eda6a 100644 --- a/crates/batten/src/resolve.rs +++ b/crates/batten/src/resolve.rs @@ -494,6 +494,9 @@ pub struct Resolved { /// **added**. §8's "add protected paths" verbatim; adding to an include-only /// set can only guard more. pub protected: Vec, + /// The refinement gate's thresholds (CLOUD-472), from the committed + /// authority alone. `None` is could-not-look and asks for no ratchet. + pub ready: Option, /// Programs that only read their operands, so naming a protected path is not /// a mutation (CLOUD-1141). /// @@ -1597,6 +1600,12 @@ fn assemble( // local file adding one would weaken the protected gate, which is // exactly what house style §8's raise-only clause forbids. protected_readers: repo.protected_readers.clone(), + // COMMITTED AUTHORITY ONLY, for `protected_readers`' reason one layer + // over: the threshold is a RATCHET, so a local file setting it would be + // setting it HIGHER — exempting rows the committed authority refuses — + // and house style §8 admits only raises. Lowering it is a change to the + // committed file, where a reviewer sees it. + ready: repo.ready.clone(), unlanded: paths.unlanded, epoch: repo.epoch.clone(), contract: repo.contract.clone(), @@ -1679,6 +1688,11 @@ fn attribution( "protected_readers", authority_set(!repo.protected_readers.is_empty()), ), + // AUTHORITY-ONLY for `protected_readers`' reason (CLOUD-472): the + // prose-dialect cutover is a ratchet, so a local file could only move it + // LATER — exempting rows the committed authority refuses — which is a + // weakening dressed as a setting, and §8 admits only raises. + ("ready", authority_set(repo.ready.is_some())), ("unlanded", paths.unlanded_source.clone()), ("epoch", authority_set(repo.epoch.is_some())), ("contract", authority_set(repo.contract.is_some())), diff --git a/crates/batten/src/trust.rs b/crates/batten/src/trust.rs index c030831b2..9f6151650 100644 --- a/crates/batten/src/trust.rs +++ b/crates/batten/src/trust.rs @@ -537,6 +537,13 @@ pub enum WeakeningKind { /// A path is gone from `epoch.tracked`, so the `config_epoch` attributes /// less than it did (CLOUD-32). EpochPathRemoved, + /// The prose-dialect cutover moved LATER, or stopped being declared, so + /// Ready blocks that owed the claims object no longer do (CLOUD-472). + /// + /// The direction is the whole of it: this is a ratchet, and later exempts + /// MORE rows. Removing the key entirely is the limit case of moving it + /// later — could-not-look exempts everything — so both reach one kind. + ReadyCutoverRelaxed, /// A `[[verb]]` row is gone, so a mutating tool call is no longer mediated /// at the `PreToolUse` boundary (CLOUD-36). VerbRemoved, @@ -789,6 +796,7 @@ impl WeakeningKind { WeakeningKind::RulePredicateChanged, WeakeningKind::MinVersionLowered, WeakeningKind::EpochPathRemoved, + WeakeningKind::ReadyCutoverRelaxed, WeakeningKind::VerbRemoved, WeakeningKind::PatternRemoved, WeakeningKind::VerdictOverrideAdded, @@ -840,6 +848,7 @@ impl WeakeningKind { WeakeningKind::RulePredicateChanged => "rule-predicate-changed", WeakeningKind::MinVersionLowered => "min-version-lowered", WeakeningKind::EpochPathRemoved => "epoch-path-removed", + WeakeningKind::ReadyCutoverRelaxed => "ready-cutover-relaxed", WeakeningKind::VerbRemoved => "verb-removed", WeakeningKind::PatternRemoved => "pattern-removed", WeakeningKind::VerdictOverrideAdded => "verdict-override-added", @@ -955,6 +964,10 @@ pub const CENSUS: &[FieldCoverage] = &[ field: "protected_readers", coverage: Coverage::Compared(&[WeakeningKind::ProtectedReaderAdded]), }, + FieldCoverage { + field: "ready", + coverage: Coverage::Compared(&[WeakeningKind::ReadyCutoverRelaxed]), + }, FieldCoverage { field: "unlanded", coverage: Coverage::Compared(&[WeakeningKind::UnlandedRemoved]), @@ -1584,6 +1597,36 @@ fn entry_weakenings(base: &Config, working: &Config) -> Vec { "epoch.tracked", )); + // The refinement gate's prose-dialect cutover (CLOUD-472). A ratchet, so + // LATER is weaker: it exempts more rows from owing the claims object, and + // dropping the key altogether is that move taken to its limit, since absent + // reads as could-not-look and exempts every row. Compared as strings because + // both sides are fixed-width ISO-8601 UTC, which is the same reading + // `policy/filed-here.rego` takes of a tracker stamp. + { + let cutover = |config: &Config| { + config + .ready + .as_ref() + .and_then(|ready| ready.prose_dialect_required_from.clone()) + }; + if let Some(was) = cutover(base) { + let now = cutover(working); + // Absent renders as the same could-not-look token every other + // three-valued read in this tree uses, so a reader of the finding + // sees WHICH move was made rather than an empty string. + let relaxed = now.as_ref().is_none_or(|now| now > &was); + if relaxed { + found.push(Weakening::new( + WeakeningKind::ReadyCutoverRelaxed, + "ready.prose_dialect_required_from", + was, + now.unwrap_or_else(|| "-".to_owned()), + )); + } + } + } + // The mutating-verb table: a removed row un-gates a tool call at the // `PreToolUse` boundary, which is the most consequential of these. found.extend(removed_entries( @@ -3004,6 +3047,47 @@ mod tests { assert!(weakenings(&working, &base).is_empty()); } + /// CLOUD-472. The direction is the whole of it, so all four arms are here: + /// later relaxes, absent is later taken to its limit, earlier tightens, and + /// a base that never declared a cutover has no bar to lower. + #[test] + fn moving_the_prose_dialect_cutover_later_is_a_weakening() { + let base = config("[ready]\nprose_dialect_required_from = \"2026-09-02T00:00:00.000Z\"\n"); + let later = config("[ready]\nprose_dialect_required_from = \"2027-01-01T00:00:00.000Z\"\n"); + assert_eq!( + only(&base, &later), + Weakening::new( + WeakeningKind::ReadyCutoverRelaxed, + "ready.prose_dialect_required_from", + "2026-09-02T00:00:00.000Z", + "2027-01-01T00:00:00.000Z", + ) + ); + + // DROPPING THE KEY IS THE LIMIT CASE, not a separate one: absent reads as + // could-not-look and exempts EVERY row, which is further than any date + // could move it. Reporting it as a no-op is how a ratchet gets removed + // rather than relaxed. + assert_eq!( + only(&base, &config("")), + Weakening::new( + WeakeningKind::ReadyCutoverRelaxed, + "ready.prose_dialect_required_from", + "2026-09-02T00:00:00.000Z", + "-", + ) + ); + + // Earlier is a TIGHTENING — it refuses more rows — and is not reported. + assert!(weakenings(&later, &base).is_empty()); + + // And a base with no cutover has no bar to lower, so ADDING one is not a + // weakening either. Without this arm the comparison would fire on every + // branch that adopts the ratchet, which is the direction that makes a + // gate get switched off. + assert!(weakenings(&config(""), &base).is_empty()); + } + #[test] fn removing_a_declared_pattern_is_a_weakening() { // NOT A LOAD FAILURE, which is the whole reason this is on the table. diff --git a/crates/batten/tests/it/ready.rs b/crates/batten/tests/it/ready.rs index 95bf8d342..4bf893315 100644 --- a/crates/batten/tests/it/ready.rs +++ b/crates/batten/tests/it/ready.rs @@ -228,7 +228,15 @@ fn with_tasks(name: &str) -> PathBuf { // none gets could-not-look naming the first missing id rather than a // verdict — the right answer for such a repository, and not what these // cases are about. `repo` above opts in for the same reason. - .config(&format!("version = 1\n\n{}", declared_patterns())) + // + // The prose-dialect threshold is DECLARED rather than defaulted + // (CLOUD-472). A fixture omitting it gets `None` — could-not-look — and + // every threshold case below would then pass for the wrong reason, which + // is the shape a dead gate and a clean tree share. + .config(&format!( + "version = 1\n\n[ready]\nprose_dialect_required_from = \"2026-06-01T00:00:00.000Z\"\n\n{}", + declared_patterns() + )) .file( "Cargo.toml", "[workspace.package]\nversion = \"0.0.125\"\n\n[workspace.dependencies]\nserde = \"1\"\n", @@ -273,18 +281,27 @@ fn claims_payload(object: &serde_json::Value, blocked_by: &[&str]) -> String { ) } -/// A payload under a chosen key, for the threshold cases below. +/// A row created after the fixture's cutover, so the prose dialect is refused. +const AFTER_CUTOVER: &str = "2026-07-01T00:00:00.000Z"; +/// A row created before it, so the prose dialect still passes. +const BEFORE_CUTOVER: &str = "2026-01-01T00:00:00.000Z"; + +/// A payload carrying a chosen creation instant, for the cutover cases below. /// -/// Every other fixture here is `CLOUD-999` — three digits, below the committed -/// ceiling — which is why the whole prose corpus above stays clean and why these -/// cases have to name their own key rather than reusing the shared builder. -fn keyed_payload(id: serde_json::Value, description: &str) -> String { - serde_json::json!({ - "id": id, +/// Every other fixture here omits `createdAt` entirely, which is could-not-look +/// and exempt — that is why the whole prose corpus above stays clean, and why +/// these cases have to state their own instant rather than reusing the shared +/// builder. +fn dated_payload(created_at: Option<&str>, description: &str) -> String { + let mut value = serde_json::json!({ + "id": "CLOUD-999", "description": description, "relations": { "blockedBy": [] }, - }) - .to_string() + }); + if let Some(created_at) = created_at { + value["createdAt"] = serde_json::json!(created_at); + } + value.to_string() } // --------------------------------------------------------------------------- @@ -297,12 +314,12 @@ fn keyed_payload(id: serde_json::Value, description: &str) -> String { // --------------------------------------------------------------------------- #[test] -fn a_prose_block_past_the_threshold_is_refused() { - let dir = with_tasks("ready-prose-past-threshold"); +fn a_prose_block_past_the_cutover_is_refused() { + let dir = with_tasks("ready-prose-past-cutover"); let output = lint( &dir, - &keyed_payload( - serde_json::json!("CLOUD-9999"), + &dated_payload( + Some(AFTER_CUTOVER), &block("* **Test obligation (§7).** Three discriminating observations.\n"), ), ); @@ -315,14 +332,14 @@ fn a_prose_block_past_the_threshold_is_refused() { } #[test] -fn a_claims_object_past_the_threshold_is_clean() { +fn a_claims_object_past_the_cutover_is_clean() { // The remedy has to be REACHABLE from the refusal above, or the ratchet is a // wall. Same key, same fixture, the object supplied. - let dir = with_tasks("ready-object-past-threshold"); + let dir = with_tasks("ready-object-past-cutover"); let object = serde_json::to_string_pretty(&complete_claims()).expect("encodable"); let output = lint( &dir, - &keyed_payload(serde_json::json!("CLOUD-9999"), &claims_block(&object)), + &dated_payload(Some(AFTER_CUTOVER), &claims_block(&object)), ); assert_eq!(code(&output), 0, "{}", stderr(&output)); } @@ -331,29 +348,31 @@ fn a_claims_object_past_the_threshold_is_clean() { /// refuses every prose block — which is the change that takes the board's ready /// frontier dark in one step (CLOUD-858's measured shape). #[test] -fn a_prose_block_below_the_threshold_is_clean() { - let dir = with_tasks("ready-prose-below-threshold"); +fn a_prose_block_before_the_cutover_is_clean() { + let dir = with_tasks("ready-prose-before-cutover"); let output = lint( &dir, - &keyed_payload( - serde_json::json!("CLOUD-999"), + &dated_payload( + Some(BEFORE_CUTOVER), &block("* **Test obligation (§7).** Three discriminating observations.\n"), ), ); assert_eq!(code(&output), 0, "{}", stderr(&output)); } -/// COULD-NOT-LOOK PASSES. A payload carrying no readable key cannot be placed -/// against the threshold at all, so it is judged exactly as it was before this -/// clause existed. Reading "no key" as "past the cutover" would turn a verdict -/// about the payload into a verdict about the row. +/// COULD-NOT-LOOK PASSES. A payload carrying no creation instant cannot be +/// placed against the cutover at all, so it is judged exactly as it was before +/// this clause existed. Reading "no stamp" as "past the cutover" would turn a +/// verdict about the payload into a verdict about the row — and this is the arm +/// that keeps every other fixture in this file, none of which sets `createdAt`, +/// passing for the RIGHT reason rather than by accident. #[test] -fn a_payload_with_no_readable_key_is_judged_as_before() { - let dir = with_tasks("ready-no-key"); +fn a_payload_with_no_creation_instant_is_judged_as_before() { + let dir = with_tasks("ready-no-stamp"); let output = lint( &dir, - &keyed_payload( - serde_json::Value::Null, + &dated_payload( + None, &block("* **Test obligation (§7).** Three discriminating observations.\n"), ), ); diff --git a/schema/batten.schema.json b/schema/batten.schema.json index 4b0b8b8e6..42befad6b 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -253,6 +253,17 @@ } ] }, + "ready": { + "description": "The refinement gate's consumer-set thresholds (CLOUD-472). Absent means\nthis file does not speak to them, which every reader takes as\ncould-not-look rather than as a default.", + "anyOf": [ + { + "$ref": "#/$defs/Ready" + }, + { + "type": "null" + } + ] + }, "recorder": { "description": "Records written from the tool result that earned them (CLOUD-1051).\n\nThe third selector on the post-tool event, and the one that can carry a\nvalue another gate decided. A `[[mint]]` renders a template over the\npayload; a `[[recorder]]` may additionally run a declared program and\nrecord its verdict, which is what a board write's refinement column IS.\n\nConsumer-owned for the same reason `[[mint]]` is, and more so: the column\nnames, the verdict tokens and the programs are all a tracker's vocabulary,\nso a grep of `crates/batten` for any of them returns nothing and every one\nof them lives here.", "type": "array", @@ -2136,6 +2147,20 @@ } ] }, + "Ready": { + "description": "The `[ready]` table: the refinement gate's consumer-set thresholds.\n\n# Why a value and not a `[[pattern]]` row (CLOUD-472)\n\nThe first draft of the prose-dialect ratchet spelled its threshold as a\nregex over the exempt key range. That is wrong twice. The pattern registry\nexists so that one CONCEPT has one spelling — arithmetic is not a concept,\nand a range encoded in alternation is unreadable and unmovable. And it makes\nthe decision turn on key TEXT, which the tracker is known to rewrite: this\nconsumer already declares `ready-issue-mention-markup` because a bare key\ncomes back wrapped in `` markup, so matching key text is matching\nthe one thing the round trip mangles.", + "type": "object", + "properties": { + "prose_dialect_required_from": { + "description": "From which creation instant a Ready block must carry the fenced claims\nobject rather than prose. An ISO-8601 UTC stamp, compared verbatim\nagainst the tracker's own `createdAt`.\n\n**A RATCHET: moving it later is the only direction that tightens.**\nAbsent is could-not-look and exempts everything, so a consumer that has\nnot opted in is never refused for a question it did not ask.\n\n**An instant rather than a key ordinal, and that is a portability\ndecision.** An ordinal reaches no consumer literal — the trailing digits,\nno separator assumed — so it passes `no-tracker-key-in-core`. It still\nrequires keys that are numeric AND monotonic with creation order, which\nthree popular trackers give and a slug- or UUID-keyed one does not, and it\nwould fail SILENTLY there rather than loudly. Every tracker stamps a\ncreation time, so this assumes nothing about how a consumer spells a key.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, "ReceiptKey": { "description": "Which git fact a receipt is keyed to, and therefore what invalidates it.\n\nThe distinction is not a tuning knob, it is what the receipt *attests*.\nA `head` receipt claims something about those exact bytes, so an amend or a\nrebase must expire it. A `branch` receipt claims a decision about the work,\nwhich every commit on the branch continues to serve, so a SHA-keyed one\nwould demand a re-claim per commit — the false-positive rate that gets a\nguard bypassed. Both spellings are carried from the shell layer that proved\nthem (`ready-guard` keys by SHA, `claim-check` by branch).\n\n**`ValueEnum` because the CLI selects the same keying** (CLOUD-741). A\n`receipt` rule is pinned to [`RuleScope::MediatedCall`], so `batten check`\ncan never evaluate one and `verify` cannot reach this predicate through the\nengine — which left `verify` re-implementing it in shell, weakly enough that\nCLOUD-516's own incident passed. `receipt status --key branch` is how the\ntree surface reaches the one implementation instead, so config and CLI must\nname the keying with the same tokens or the two surfaces disagree about what\nthey asked for. `clap`'s and serde's renames both land on `head`/`branch`;\nthe `clap(rename_all)` is stated rather than inferred so a future variant\ncannot drift them apart.", "oneOf": [ From dda2bdbe3e91fb2e2adc413306b61e84fa863e14 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 23:13:11 +0000 Subject: [PATCH 5/8] feat(policy): the plan a branch declared, held to its own end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness task list is the most legible statement an agent makes about what it believes is outstanding, it is displayed every turn, and nothing in the tree could see it. So a branch landed with half its list `pending` and every gate stayed green. That is this repository's recurring defect rather than a new one — a sensor with no ratchet on it. `stop_nudges` rule 5 asks the right question with no exit code; `ready.rs` emits `dialect prose` with nothing downstream; `graph-check` counts a `wip` in the wrong unit. Measured 2026-09-01: three items sat `pending` while the session reported the work as planned, and the only detector was a human asking. A VERB WRITES THE STORE, NOT A HOOK, and that is the design rather than a convenience. Recording from the harness's own todo tool needs a spelling per host — `TaskCreate`/`TaskUpdate` here, `write_todos` on Gemini CLI, `todowrite` on OpenCode, `update_plan` on Codex — and fails three different ways that are indistinguishable at the gate: an unsurveyed harness, a tool a setting switched off, and a compliant agent all record nothing. OpenCode makes it concrete by denying `todowrite` to subagents at session creation whatever the config says. `batten record plan` inverts the direction: the agent tells the engine, so a missing record REFUSES, identically everywhere, with no survey and no setting that can disarm it. Two arms, and the second is not optional. `plan-unfinished` refuses an entry left in flight. `plan-unrecorded` refuses a branch that recorded nothing at all — without it the first arm is satisfied completely by silence, which is the vacuity `mutate` already refuses by REPORTING a declared mutation whose named case does not exist rather than counting it. THE VACUITY ARM'S FIRST DRAFT WAS TOO WIDE, and the measurement is why it is keyed on the claim receipt now. Asking only for a non-empty diff is true of every scratch fixture and every consumer checkout: it reddened four `cli.rs` cases whose only business was exercising unrelated rules. A rule that fires on any dirty tree makes the committed config unusable over a test repository, and a rule like that gets switched off. `input.tree.records` already reads `.git/batten-receipts/.`, so no new `Fact` was needed — but `recorder_records` read only the DECLARED stores, so a verb-written one was invisible whatever a module asked for. It now unions `record::VERB_WRITTEN`, unconditionally: the engine owns both ends, so there is no declaration for a consumer to forget and no unrelated table to make a gate's liveness depend on. Five gates caught halves I would otherwise have shipped: the leaf-verb dispatch census, the emitted row set, the mutation census, the pointer-only disposition, and the derived man/completions artifacts. 3773/3773 green. Refs: CLOUD-472 Admits: aba1e62b2c5357a2f1c428f8043c4360b5eb3c467e39a3b88b34237f6b474abb Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: batten.toml Admits-head: 3c63c4ee7c938c3ba9977e0435f6384bc0959791 Admits-epoch: 04d65e929fcb8962b43da408ccb71d1f8d5a63c839a9492142bf7009f372e12a Admits-author: alec@wenzowski.com Admits-prev: e21549fcda550f97b7487fa035daa0f02606801a168f44f2f5b8ec7c0cc74feb Admits-answer-lost: The plan store has a writer and no reader, which is exactly the dead-gate class `crates/batten/src/record.rs` exists because of — its header records two landed readers with no writer, two `deny` rows deciding nothing. Here it would be the mirror: `batten record plan` writes a store no rule reads, so an agent could land with half its declared work in flight and every gate stays green, which is the defect measured on this very session. Admits-answer-precondition: A `[[rule]]` row and its `[[verdict]]` classes are only expressible in batten.toml — the file IS the registry, no verb writes either, and `policy/plan-complete.rego` cannot load at all until `V-PLAN-UNFINISHED` and `V-PLAN-UNRECORDED` are declared, because a module raising a token no row declares is refused at load. The path's own redirect says to change it in a pull request, which is what this is, and `mise run config-lint`, `mise run policy-test` and the compiled tier judge it before it binds. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply because batten.toml IS the owning surface for a rule row and a verdict class; there is no verb that registers either. R-RESTORE-IT does not apply because nothing was destroyed — this adds one `[[rule]]` row and two `[[verdict]]` classes with their routes, and touches no existing row. The addition is strictly raise-only: two new deny classes, each with a declared override precondition, so it cannot weaken any gate. What a reviewer should check is the `plan-unrecorded` arm, because it refuses a branch that recorded nothing and therefore has the widest blast radius of anything in this change; it is gated on a non-empty diff and satisfied by an empty record, so the remedy is one call rather than a fabricated entry. Admits: 8137dd28e8352d32c3b5fd36e7a5f7152b979223890f2f11c5add2c1528c16ee Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: batten.toml Admits-head: 3c63c4ee7c938c3ba9977e0435f6384bc0959791 Admits-epoch: 1893a9cd9dbd090116123001501b72aa60042d6906885362d4b43c0f6cfad073 Admits-author: alec@wenzowski.com Admits-prev: aba1e62b2c5357a2f1c428f8043c4360b5eb3c467e39a3b88b34237f6b474abb Admits-answer-lost: The rule row cannot load, and a module that fails to load takes its whole bundle with it. The plan store keeps a writer and no reader — the dead-gate class `record.rs` was written because of, in mirror image. Admits-answer-precondition: Second half of one change, split only because an admission covers one write. The `[[rule]]` row landed under the previous admission; `policy/plan-complete.rego` raises `V-PLAN-UNFINISHED` and `V-PLAN-UNRECORDED`, and a module raising a token no `[[verdict]]` row declares is refused at LOAD — so the tree between the two writes does not merely lack a feature, the module fails to load. batten.toml is the registry and no verb writes a verdict class. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply because batten.toml IS the owning surface for a verdict class, and the path's redirect says to change it in a pull request. R-RESTORE-IT does not apply because nothing was destroyed — two classes and their routes are added, no existing row is touched, and the change is raise-only. The route to check in review is `R-OVERRIDE-PLAN-UNFINISHED`'s precondition: it must be answerable only for work that genuinely is not this branch's to finish, or the gate becomes payable in typing, which is the failure `V-FILED-UNREFINED` measured when a Ready block turned out to be the cheapest thing an agent can produce. Admits: 1e57c511055e0626046bd28c09c30389ad540aea48e85b0abecdf534385b9966 Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: policy/plan-complete.rego Admits-head: 3c63c4ee7c938c3ba9977e0435f6384bc0959791 Admits-epoch: 02358ef15596a3d6bbc0781748ec5d6637cad09abc0c7e143f2eebeb2770950d Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: The arm stays too wide and the committed config becomes unusable over any scratch tree. Measured just now: `plan-unrecorded` keyed only on a non-empty diff reddened four `cli.rs` cases — `the_committed_delegating_rule_spawns_nothing_when_its_glob_misses`, `a_tracked_instruction_may_not_prescribe_the_denied_commit_identity`, `the_committed_portability_rules_fire_on_every_banned_shape` and one more — whose only business was exercising unrelated rules over a fixture repository. A rule that fires on every dirty tree is one that gets switched off. Admits-answer-precondition: A registered .rego module has no owning verb — the file IS the surface that declares the predicate, so narrowing `plan-unrecorded`'s precondition can only be done by writing it. The write lands in the reviewed PR for CLOUD-472, where `mise run policy-test`, the compiled tier in crates/batten/tests/it/plan_complete.rs and the declared `#MUTANT` rows judge it before it binds anything. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply because no verb writes a predicate into a registered module; the module file is the owning surface and the path's redirect says to change it in a pull request. R-RESTORE-IT does not apply because nothing is being restored — this NARROWS a refusal I added minutes ago in this same branch, which is a tightening of the change rather than a reversal of anything landed. The property to check in review is that the new precondition is the claim receipt: it must select branches doing tracked work and nothing else, because keying it any wider is what the measurement above refutes and keying it narrower would let an unclaimed branch escape the gate entirely. --- batten.toml | 80 +++++ completions/batten.bash | 73 +++- completions/batten.fish | 58 +++- completions/batten.zsh | 55 +++ crates/batten/src/cli.rs | 9 + crates/batten/src/record.rs | 87 +++++ crates/batten/src/rules.rs | 45 ++- crates/batten/src/spec.rs | 6 + crates/batten/src/surface.rs | 16 + crates/batten/tests/it/main.rs | 1 + crates/batten/tests/it/plan_complete.rs | 318 ++++++++++++++++++ crates/batten/tests/it/pointer_only.rs | 19 ++ .../it__snapshots__golden_json_schema.snap | 7 + man/batten-record-plan.1 | 13 + man/batten-record.1 | 3 + mise.toml | 2 +- policy/plan-complete.rego | 238 +++++++++++++ 17 files changed, 995 insertions(+), 35 deletions(-) create mode 100644 crates/batten/tests/it/plan_complete.rs create mode 100644 man/batten-record-plan.1 create mode 100644 policy/plan-complete.rego diff --git a/batten.toml b/batten.toml index 30e7cd8b2..39daa6a15 100644 --- a/batten.toml +++ b/batten.toml @@ -4211,6 +4211,24 @@ severity = "deny" # paths it was going to accept anyway — a gate reading a pre-filtered input cannot # tell "nothing was added" from "the filter removed it". The depth test lives in # the module, where a reader can check it against Cargo's autodiscovery rule. +# CLOUD-472. The plan a branch declared, held to its own end. +# +# `delta_sources` is the whole tree because the vacuity arm asks whether this +# branch is holding ANYTHING open, and a narrower delta would hand it a +# pre-filtered answer — a gate that cannot tell "nothing changed" from "the +# filter removed it" is the class `test-targets` below states for its own reason. +# +# NO `line_sources`: the subject is the record `batten record plan` wrote and the +# delta the engine already resolved. This row opens no file. +[[rule]] +id = "plan-complete" +kind = "policy" +scope = "tree" +base = "origin/main" +delta_sources = ["**"] +module = "policy/plan-complete.rego" +severity = "deny" + [[rule]] id = "test-targets" kind = "policy" @@ -6583,6 +6601,68 @@ precondition = "the row DOCUMENTS the change being landed, so naming its files i # which is free for a row you genuinely could not close and expensive for one you # could. That is CLOUD-514's own "the friction must sit only on the impulsive # path", spent where it belongs. +# CLOUD-472. The agent's own declared work, held to its end. +# +# THE SENSOR WAS ALWAYS THERE. A task list is displayed every turn and is the +# most legible statement of what an agent believes is outstanding — and nothing +# in the tree could see it, so a branch landed with entries `pending` and every +# gate stayed green. That is this repository's recurring defect rather than a new +# one: `stop_nudges` rule 5 asked the right question with no exit code, +# `ready.rs` emitted `dialect prose` with no ratchet, and `graph-check` counts a +# `wip` in the wrong unit. A reporting surface with nothing downstream of it. +[[verdict]] +id = "V-PLAN-UNFINISHED" +gloss = "an entry this branch declared is neither completed nor withdrawn" +class = """ +The agent said it would do this and is landing without having done it. The gate \ +reads a status TOKEN and nothing else: it does not judge whether the entry was \ +worth doing, whether its text is honest, or whether the work behind `completed` \ +happened — those are model verdicts and no gate here makes one. Three exits, and \ +two of them are free: finish it, withdraw it deliberately so the store records \ +that the decision was made, or spend an admission saying why it is not this \ +branch's to finish. The store is written by `batten record plan` rather than \ +scraped from a harness's todo tool, because a hook needs a spelling per host and \ +records nothing where one is unsurveyed or switched off — a missing verb call \ +refuses everywhere instead. +""" + +[[verdict.route]] +id = "R-FINISH-IT" +kind = "command" +target = "do the entry, then re-record the plan with it completed" + +[[verdict.route]] +id = "R-WITHDRAW-IT" +kind = "command" +target = "batten record plan" + +[[verdict.route]] +id = "R-OVERRIDE-PLAN-UNFINISHED" +kind = "override" +precondition = "the entry is work this branch could not have done — it needs a decision, a mechanism, or an artifact that does not exist yet — rather than work you declared and declined to finish while holding the file open" + +# The anti-vacuity half, and it is not optional: a refusal over unfinished +# entries is satisfied completely by never recording one, so silence is the +# cheapest route past it and must be priced. Same shape as `mutate` REPORTING a +# declared mutation whose named case does not exist rather than counting it. +[[verdict]] +id = "V-PLAN-UNRECORDED" +gloss = "this branch is holding work open and declared no plan at all" +class = """ +Not an exhortation to plan: it closes the hole the other class would otherwise \ +leave wide open, because a gate over entries left in flight costs nothing to \ +satisfy if you simply never record an entry. Gated on a NON-EMPTY diff, so it \ +prices work rather than existence and a fresh checkout is never refused — and \ +satisfied by an EMPTY record, so a genuinely trivial change costs one call \ +saying so rather than a fabricated entry, which is what a gate demanding a \ +non-empty list would have bought. +""" + +[[verdict.route]] +id = "R-RECORD-THE-PLAN" +kind = "command" +target = "batten record plan" + [[verdict]] id = "V-FILED-AND-LEFT-OPEN" gloss = "a row this branch put on the board is neither closed here nor closed by the body" diff --git a/completions/batten.bash b/completions/batten.bash index 0c1a68cb7..744247460 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -541,6 +541,9 @@ _batten() { batten__subcmd__help__subcmd__record,forge) cmd="batten__subcmd__help__subcmd__record__subcmd__forge" ;; + batten__subcmd__help__subcmd__record,plan) + cmd="batten__subcmd__help__subcmd__record__subcmd__plan" + ;; batten__subcmd__help__subcmd__record,tool) cmd="batten__subcmd__help__subcmd__record__subcmd__tool" ;; @@ -748,6 +751,9 @@ _batten() { batten__subcmd__record,help) cmd="batten__subcmd__record__subcmd__help" ;; + batten__subcmd__record,plan) + cmd="batten__subcmd__record__subcmd__plan" + ;; batten__subcmd__record,tool) cmd="batten__subcmd__record__subcmd__tool" ;; @@ -757,6 +763,9 @@ _batten() { batten__subcmd__record__subcmd__help,help) cmd="batten__subcmd__record__subcmd__help__subcmd__help" ;; + batten__subcmd__record__subcmd__help,plan) + cmd="batten__subcmd__record__subcmd__help__subcmd__plan" + ;; batten__subcmd__record__subcmd__help,tool) cmd="batten__subcmd__record__subcmd__help__subcmd__tool" ;; @@ -3613,7 +3622,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__record) - opts="tool forge" + opts="tool forge plan" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3640,6 +3649,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__record__subcmd__plan) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__record__subcmd__tool) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -5425,7 +5448,7 @@ _batten() { return 0 ;; batten__subcmd__record) - opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help tool forge help" + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help tool forge plan help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -5485,7 +5508,7 @@ _batten() { return 0 ;; batten__subcmd__record__subcmd__help) - opts="tool forge help" + opts="tool forge plan help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -5526,6 +5549,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__help__subcmd__plan) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__record__subcmd__help__subcmd__tool) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -5540,6 +5577,36 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__plan) + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__record__subcmd__tool) opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index 5f41b7e74..f296dcbff 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -2031,30 +2031,31 @@ complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_sub complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "migrate" -d 'Upgrade the findings store to this binary\'s record version' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "list" -d 'List stored findings and the refs they were observed in' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' quiet\t'Suppress ordinary progress; keep warnings' normal\t'The default' verbose\t'Explain what is being checked' debug\t'Add resolution detail' trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -f -a "plan" -d 'Record this branch\'s plan, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from tool" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -2097,8 +2098,30 @@ complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from forge" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from forge" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from forge" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "plan" -d 'Record this branch\'s plan, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand wiring; and not __fish_seen_subcommand_from reclaim help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' @@ -2231,4 +2254,5 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from state" -f -a "list" -d 'List stored findings and the refs they were observed in' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "plan" -d 'Record this branch\'s plan, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from wiring" -f -a "reclaim" -d 'Remove non-batten hook registrations from this host\'s merged surfaces' diff --git a/completions/batten.zsh b/completions/batten.zsh index d87590d5d..e77dd3162 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -3618,6 +3618,35 @@ trace\:"Add everything"))' \ ':ref -- The ref or sha the verdict was taken against:_default' \ && ret=0 ;; +(plan) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ ":: :_batten__subcmd__record__subcmd__help_commands" \ @@ -3638,6 +3667,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(plan) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -4393,6 +4426,10 @@ _arguments "${_arguments_options[@]}" : \ (forge) _arguments "${_arguments_options[@]}" : \ && ret=0 +;; +(plan) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 ;; esac ;; @@ -5393,6 +5430,7 @@ _batten__subcmd__help__subcmd__record_commands() { local commands; commands=( 'tool:Record a declared tool row'\''s verdict, read as \` \` lines on stdin' \ 'forge:Record the forge'\''s check verdicts for one commit, read as \` \` lines on stdin' \ +'plan:Record this branch'\''s plan, read as \` \` lines on stdin' \ ) _describe -t commands 'batten help record commands' commands "$@" } @@ -5401,6 +5439,11 @@ _batten__subcmd__help__subcmd__record__subcmd__forge_commands() { local commands; commands=() _describe -t commands 'batten help record forge commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__record__subcmd__plan_commands] )) || +_batten__subcmd__help__subcmd__record__subcmd__plan_commands() { + local commands; commands=() + _describe -t commands 'batten help record plan commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__record__subcmd__tool_commands] )) || _batten__subcmd__help__subcmd__record__subcmd__tool_commands() { local commands; commands=() @@ -5929,6 +5972,7 @@ _batten__subcmd__record_commands() { local commands; commands=( 'tool:Record a declared tool row'\''s verdict, read as \` \` lines on stdin' \ 'forge:Record the forge'\''s check verdicts for one commit, read as \` \` lines on stdin' \ +'plan:Record this branch'\''s plan, read as \` \` lines on stdin' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten record commands' commands "$@" @@ -5943,6 +5987,7 @@ _batten__subcmd__record__subcmd__help_commands() { local commands; commands=( 'tool:Record a declared tool row'\''s verdict, read as \` \` lines on stdin' \ 'forge:Record the forge'\''s check verdicts for one commit, read as \` \` lines on stdin' \ +'plan:Record this branch'\''s plan, read as \` \` lines on stdin' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten record help commands' commands "$@" @@ -5957,11 +6002,21 @@ _batten__subcmd__record__subcmd__help__subcmd__help_commands() { local commands; commands=() _describe -t commands 'batten record help help commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__help__subcmd__plan_commands] )) || +_batten__subcmd__record__subcmd__help__subcmd__plan_commands() { + local commands; commands=() + _describe -t commands 'batten record help plan commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__help__subcmd__tool_commands] )) || _batten__subcmd__record__subcmd__help__subcmd__tool_commands() { local commands; commands=() _describe -t commands 'batten record help tool commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__plan_commands] )) || +_batten__subcmd__record__subcmd__plan_commands() { + local commands; commands=() + _describe -t commands 'batten record plan commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__tool_commands] )) || _batten__subcmd__record__subcmd__tool_commands() { local commands; commands=() diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index f634cc467..12f88f065 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -821,6 +821,12 @@ pub enum RecordCommand { /// The ref or sha the verdict was taken against. reference: String, }, + /// Record this branch's plan: ` ` per line, on stdin. + /// + /// No argument, for [`RecordCommand::Tool`]'s reason one layer over: the + /// branch is the key and the engine reads it, so a caller cannot record + /// against a branch it is not on. + Plan, } /// Subcommands of `receipt`. @@ -1477,6 +1483,9 @@ fn record_of(matches: &ArgMatches) -> Option { ("forge", matches) => Some(RecordCommand::Forge { reference: matches.get_one::("ref")?.clone(), }), + // No positional to read: the branch is the key and the engine resolves + // it, so this arm takes the sub-verb and nothing else. + ("plan", _) => Some(RecordCommand::Plan), _ => None, } } diff --git a/crates/batten/src/record.rs b/crates/batten/src/record.rs index 627c6a3ed..2dc499c39 100644 --- a/crates/batten/src/record.rs +++ b/crates/batten/src/record.rs @@ -199,5 +199,92 @@ pub fn run(command: crate::cli::RecordCommand, overrides: &Overrides) -> Result< match command { crate::cli::RecordCommand::Tool { id } => run_tool(&id, overrides), crate::cli::RecordCommand::Forge { reference } => run_forge(&reference, overrides), + crate::cli::RecordCommand::Plan => run_plan(), } } + +/// The record names this crate's own VERBS write, as opposed to the ones a +/// `[[recorder]]` row mints from a tool envelope (CLOUD-472). +/// +/// # Why a verb writes this at all, which is the whole design decision +/// +/// A hook mediates a call the agent makes to somebody ELSE's tool, so it is +/// per-harness by nature: `TaskCreate`/`TaskUpdate` here, `write_todos` on +/// Gemini CLI, `todowrite` on `OpenCode`, `update_plan` on Codex. Recording from +/// those envelopes needs a spelling per host, and its failure mode is the one +/// this whole module exists to name — an unsurveyed harness, a tool a setting +/// switched off, and a compliant agent all produce NOTHING, so the gate reads +/// clean. `OpenCode` makes that concrete: `todowrite` is denied to subagents at +/// session creation regardless of configuration. +/// +/// A verb inverts the direction. The agent TELLS the engine, so a missing record +/// refuses on every harness identically — no survey, no per-host spelling, and no +/// setting that can quietly disarm it. Discovery still has a job (reporting which +/// native surface exists, so a mirror can be kept for the human's benefit), but +/// the gate reads this store and only this store. +/// `claim` is here for a second reason worth stating: `claim check` writes it and +/// nothing read it from a module before, but it is the honest signal for "this +/// branch is doing tracked work". A gate that demands a plan from EVERY tree with +/// a diff refuses every scratch fixture and every consumer checkout — measured, +/// it reddened four `cli.rs` cases that only wanted to exercise other rules. +/// Keyed to a claim, it asks the question exactly where the answer is owed. +pub const VERB_WRITTEN: &[&str] = &["claim", "plan"]; + +/// The statuses a plan entry may carry. +/// +/// The vocabulary four harnesses already converged on, which is what makes a +/// mirror possible in either direction — but the tokens are the ENGINE's, not any +/// host's, so a harness that spells them differently is translated at the mirror +/// rather than teaching this store a dialect. +const PLAN_STATUSES: [&str; 4] = ["pending", "in_progress", "completed", "deleted"]; + +/// Record this branch's plan: one ` ` line per entry. +/// +/// # Errors +/// +/// A [`UsageError`] when a line is not ` `, when a status is not one +/// of [`PLAN_STATUSES`], or when there is no branch to key on — a detached HEAD +/// has nothing to record against, exactly as the claim receipt has nothing to key +/// on there. An internal error when the store cannot be written. +pub fn run_plan() -> Result { + let raw = verdict_lines()?; + for (index, line) in raw.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let mut words = line.split_whitespace(); + let (Some(_id), Some(status)) = (words.next(), words.next()) else { + return Err(UsageError::raise(format!( + "plan line {} is not ` `", + index + 1 + ))); + }; + // THE TOKEN, NEVER THE LINE (rule 4). An entry's id is the agent's own + // text and a status is a closed vocabulary, so the closed half is what a + // diagnostic may echo. + if !PLAN_STATUSES.contains(&status) { + return Err(UsageError::raise(format!( + "plan line {} carries an unknown status; one of {}", + index + 1, + PLAN_STATUSES.join(", ") + ))); + } + } + + let root = Path::new("."); + let git_dir = git::git_dir(root).map_err(|_| { + UsageError::raise( + "record plan: not a git repository, so there is nothing to key on".to_owned(), + ) + })?; + let Ok(Some(branch)) = git::current_branch(root) else { + return Err(UsageError::raise( + "record plan: a detached HEAD has no branch to key the plan on".to_owned(), + )); + }; + store( + &crate::recorder::record_path(&git_dir, "plan", &branch), + &raw, + )?; + Ok(ExitCode::Success) +} diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index ea3eb518e..a8c30470e 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -5616,13 +5616,19 @@ fn run( // recorder is config: the fact is "what this repository's recorders // accumulated", so a repository declaring none has nothing to read and a // per-rule declaration would be a second place for the same answer to live. - let records = if recorders.is_empty() { - BTreeMap::new() - } else { - match (crate::git::git_dir(root), crate::git::current_branch(root)) { - (Ok(git_dir), Ok(Some(branch))) => recorder_records(&git_dir, &branch, recorders), - _ => BTreeMap::new(), - } + // VERB-WRITTEN STORES ARE READ UNCONDITIONALLY, which is why the guard above + // is no longer the whole answer (CLOUD-472). A `[[recorder]]` store exists + // because config declared one, so a repository declaring none has nothing to + // read. `crate::record::VERB_WRITTEN` is different in kind: the ENGINE owns + // both the writer and the reader, so there is no declaration for a consumer + // to forget and no config to make the gate conditional on. Reading them only + // when some unrelated recorder happened to be declared would make a gate's + // liveness depend on a table it has nothing to do with. + let records = match (crate::git::git_dir(root), crate::git::current_branch(root)) { + (Ok(git_dir), Ok(Some(branch))) => { + recorder_records(&git_dir, &branch, recorders, crate::record::VERB_WRITTEN) + } + _ => BTreeMap::new(), }; let inputs = RunInputs { @@ -5879,20 +5885,31 @@ fn recorder_records( git_dir: &std::path::Path, branch: &str, recorders: &[crate::recorder::Declared], + verb_written: &[&str], ) -> BTreeMap> { let mut found: BTreeMap> = BTreeMap::new(); - for recorder in recorders { - if found.contains_key(&recorder.record) { + // The declared stores first, then the engine's own. Order decides nothing — + // the names cannot collide, because a `[[recorder]]` naming a verb-written + // record would be a second writer for one store and `config lint` refuses it + // — but reading declared config first keeps the consumer's table the one a + // reader looks at when a name is ambiguous. + let names = recorders + .iter() + .map(|recorder| recorder.record.as_str()) + .chain(verb_written.iter().copied()); + for name in names { + if found.contains_key(name) { continue; } - let path = crate::recorder::record_path(git_dir, &recorder.record, branch); + let path = crate::recorder::record_path(git_dir, name, branch); + // ABSENT STAYS ABSENT, and that is the three-valued read this whole + // surface rests on: an unreadable store leaves the key out of the map so + // a module sees *does not hold*, where an empty file is a key whose value + // is the empty list — "nothing was recorded" rather than "nothing looked". let Ok(text) = std::fs::read_to_string(&path) else { continue; }; - found.insert( - recorder.record.clone(), - text.lines().map(str::to_owned).collect(), - ); + found.insert(name.to_owned(), text.lines().map(str::to_owned).collect()); } found } diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index ee3bbfc58..ffd6f57d7 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -653,6 +653,12 @@ mod tests { // a third row spelled the old way would be a third row to invert. "record".to_owned(), "record forge".to_owned(), + // CLOUD-472. Stated here rather than absorbed, which is what + // this list is for: the plan store's writer is a VERB because a + // recorder on the harness's own todo tool needs a spelling per + // host and records nothing where one is unsurveyed or switched + // off — so the surface grows by one row and says why. + "record plan".to_owned(), "record tool".to_owned(), // The API-compatibility noun (CLOUD-1050), ported off // `mise-tasks/semver.sh` when CLOUD-1059 made editing a shell diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index 6f07da362..59331fac3 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -3082,6 +3082,22 @@ pub const SURFACE: &[CommandDecl] = &[ "The ref or sha the verdict was taken against", )], }, + // CLOUD-472. A VERB rather than a `[[recorder]]` on the harness's own todo + // tool, and the direction is the point: a hook mediates a call to somebody + // else's tool and is per-harness by nature, so an unsurveyed host, a tool a + // setting disabled, and a compliant agent all record nothing and the gate + // reads clean. Telling the engine fails closed everywhere instead. + // + // No positional: the branch is the key and the engine resolves it, so a + // caller cannot record against a branch it is not on — `record tool`'s + // anti-staleness argument, applied to a different key. + CommandDecl { + path: "record plan", + about: "Record this branch's plan, read as ` ` lines on stdin", + data_channel: false, + effect: Effect::Write, + flags: &[], + }, // A NEW NOUN rather than a flag on an existing verb, and two shapes were // considered and died on the same rule (CLOUD-893). `generate hooks --write` // and `doctor hooks --repair` both hang the effect off a FLAG, where §5 hangs diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index cb0f5193f..8dc263a25 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -129,6 +129,7 @@ mod narrow_adoption; mod perf_pair; mod pinned_programs; mod pipeline_shapes; +mod plan_complete; mod pointer_only; mod policy_engine_count; mod policy_input_narrowing; diff --git a/crates/batten/tests/it/plan_complete.rs b/crates/batten/tests/it/plan_complete.rs new file mode 100644 index 000000000..52e0f6d4f --- /dev/null +++ b/crates/batten/tests/it/plan_complete.rs @@ -0,0 +1,318 @@ +//! `plan-complete`, over the engine that builds its input (CLOUD-472). +//! +//! # The seam this tier owns, and why the module's own suite cannot reach it +//! +//! `policy/plan-complete.rego`'s `test_` rules pin the predicate against a +//! fabricated document. They cannot answer the question that actually decides +//! whether this gate is alive: does the ENGINE put `batten record plan`'s output +//! at `input.tree.records.plan` at all? +//! +//! That question has a specific reason to be asked here rather than assumed. +//! Every other record on that surface is minted by a `[[recorder]]` row, and +//! `recorder_records` used to read **only** the declared ones — so a store +//! written by a verb was invisible no matter what any module asked for. A +//! `with input as` case would have passed over that for the same reason it +//! passes over any key nothing fills, which is `.claude/rules/policy-modules.md`'s +//! whole warning about the two tiers. +//! +//! So these cases drive the real writer where they can, and `run_static` over a +//! real fixture repository otherwise. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::fs; +use std::path::{Path, PathBuf}; + +use batten::rules::{self, Rule, RuleKind, RuleScope}; + +/// A fixture repository whose base is one commit back, with the plan record on +/// disk exactly where `batten record plan` writes it. +/// +/// `origin/main` is a local ref at the base commit: `base_delta` resolves a rev, +/// and a fetch would make every case below depend on the network for a question +/// that is entirely local. +fn repo(name: &str, changed: &[&str], plan: Option<&[&str]>) -> PathBuf { + claimed_repo(name, changed, plan, true) +} + +/// The same fixture, with the claim receipt under the caller's control. +/// +/// `claimed` is the population `plan-unrecorded` asks about — a branch that +/// pulled a row — so a case about an UNCLAIMED tree needs to build one, and that +/// case is what keeps the committed config usable over a scratch repository. +fn claimed_repo(name: &str, changed: &[&str], plan: Option<&[&str]>, claimed: bool) -> PathBuf { + let root = common::scratch(name); + common::git_in(&root, &["init", "--quiet", "--initial-branch", "work"]); + common::git_in(&root, &["config", "user.email", "t@example.com"]); + common::git_in(&root, &["config", "user.name", "t"]); + fs::write(root.join("seed.txt"), "seed\n").expect("seed"); + common::git_in(&root, &["add", "-A"]); + common::git_in(&root, &["commit", "--quiet", "-m", "base"]); + let base = common::git_in(&root, &["rev-parse", "HEAD"]); + common::git_in(&root, &["update-ref", "refs/remotes/origin/main", &base]); + + for path in changed { + let full = root.join(path); + if let Some(parent) = full.parent() { + fs::create_dir_all(parent).expect("scratch parent"); + } + fs::write(full, "changed\n").expect("write changed file"); + } + + install_module(&root); + if claimed { + write_record(&root, "claim", &["CLOUD-1"]); + } + if let Some(lines) = plan { + write_record(&root, "plan", lines); + } + root +} + +/// Write the record the way the verb does — through the engine's own +/// `record_path`, so a change to the naming breaks this tier rather than +/// silently pointing the reader and the writer at different files. +fn write_record(root: &Path, record: &str, lines: &[&str]) { + let git_dir = common::git_in(root, &["rev-parse", "--absolute-git-dir"]); + let path = batten::recorder::record_path(Path::new(git_dir.trim()), record, "work"); + fs::create_dir_all(path.parent().unwrap()).expect("receipts dir"); + let body = if lines.is_empty() { + String::new() + } else { + format!("{}\n", lines.join("\n")) + }; + fs::write(path, body).expect("write the plan record"); +} + +fn install_module(root: &Path) { + let source = common::at_root("policy/plan-complete.rego") + .canonicalize() + .expect("the committed module is where the row says it is"); + fs::create_dir_all(root.join("policy")).expect("scratch policy dir"); + fs::copy(source, root.join("policy/plan-complete.rego")).expect("install committed module"); +} + +fn row() -> Rule { + serde_json::from_value(serde_json::json!({ + "id": "plan-complete", + "kind": "policy", + "scope": "tree", + "base": "origin/main", + "delta_sources": ["**"], + "module": "policy/plan-complete.rego", + "severity": "deny", + })) + .expect("the loader accepts the committed row's shape") +} + +fn scan(root: &Path) -> rules::Scan { + let verdicts = common::verdicts_in(root); + rules::run_static( + &[row()], + &[], + batten::policy::Vocabulary { + patterns: &[], + verdicts: &verdicts, + recorders: &[], + }, + root, + ) + .expect("the read surface runs a policy row") +} + +fn verdicts(root: &Path) -> Vec { + scan(root) + .findings + .into_iter() + .map(|finding| finding.rule) + .collect() +} + +fn pointers(root: &Path) -> Vec { + scan(root) + .findings + .into_iter() + .map(|finding| finding.path) + .collect() +} + +const UNFINISHED: &str = "plan-unfinished"; +const UNRECORDED: &str = "plan-unrecorded"; + +// --------------------------------------------------------------------------- +// THE READ SEAM. Without these two the whole module is a `with input as` suite +// over a key nothing fills — the shape a dead gate and a clean tree share. +// --------------------------------------------------------------------------- + +/// The engine reads a store NO `[[recorder]]` declares. This is the assertion +/// that would have failed before `recorder_records` learned to read the +/// verb-written names, with every module test still green. +#[test] +fn the_engine_reads_a_verb_written_plan_store() { + let root = repo("plan-read-seam", &["src/a.rs"], Some(&["1 pending"])); + assert_eq!( + verdicts(&root), + vec![UNFINISHED.to_owned()], + "the record the verb writes must reach the predicate" + ); +} + +/// And the empty store is DISTINGUISHABLE from an absent one across the engine +/// boundary, not just inside the module. Absent refuses on the vacuity arm; +/// empty is an answer and is clean. If the projection collapsed the two, the +/// remedy for a trivial branch would be unreachable. +#[test] +fn an_empty_store_and_an_absent_one_reach_different_arms() { + let empty = repo("plan-empty", &["src/a.rs"], Some(&[])); + assert!( + verdicts(&empty).is_empty(), + "an empty record is the branch saying there is nothing to track: {:?}", + verdicts(&empty) + ); + + let absent = repo("plan-absent", &["src/a.rs"], None); + assert_eq!( + verdicts(&absent), + vec![UNRECORDED.to_owned()], + "no record at all is the vacuity the other arm cannot see" + ); +} + +// --------------------------------------------------------------------------- +// `plan-unfinished`. +// --------------------------------------------------------------------------- + +#[test] +fn an_unfinished_entry_stops_the_lap() { + let root = repo( + "plan-unfinished", + &["src/a.rs"], + Some(&["1 completed", "2 in_progress"]), + ); + assert_eq!(verdicts(&root), vec![UNFINISHED.to_owned()]); + assert!( + pointers(&root).iter().any(|line| line.contains('2')), + "the refusal names the entry: {:?}", + pointers(&root) + ); +} + +#[test] +fn a_wholly_completed_plan_is_clean() { + let root = repo( + "plan-done", + &["src/a.rs"], + Some(&["1 completed", "2 deleted"]), + ); + assert!( + verdicts(&root).is_empty(), + "finished and withdrawn are both terminal: {:?}", + verdicts(&root) + ); +} + +/// ONE FINDING PER ENTRY, so finishing one does not clear another and a reviewer +/// sees which item rather than a count to reconstruct. +#[test] +fn every_unfinished_entry_is_reported() { + let root = repo( + "plan-many", + &["src/a.rs"], + Some(&["1 pending", "2 completed", "3 pending"]), + ); + assert_eq!( + verdicts(&root), + vec![UNFINISHED.to_owned(), UNFINISHED.to_owned()] + ); +} + +/// POINTER, NEVER PAYLOAD (rule 4). The store holds an id and a status token and +/// no description, so there is no prose here to leak — and this is the assertion +/// that keeps a later edit from adding one. +#[test] +fn the_refusal_carries_no_entry_prose() { + let root = repo("plan-pointer", &["src/a.rs"], Some(&["1 pending"])); + let rendered = pointers(&root).join("\n"); + assert!(rendered.contains('1'), "the id is the pointer: {rendered}"); + assert!( + !rendered.contains("pending"), + "a status token is not a pointer: {rendered}" + ); +} + +// --------------------------------------------------------------------------- +// `plan-unrecorded` — the anti-vacuity arm. +// --------------------------------------------------------------------------- + +#[test] +fn a_branch_that_recorded_no_plan_is_refused() { + let root = repo("plan-none", &["src/a.rs"], None); + assert_eq!(verdicts(&root), vec![UNRECORDED.to_owned()]); +} + +/// A branch holding nothing open has nothing to have planned. Without that the +/// arm fires on every fresh checkout, which is how a gate gets switched off. +/// +/// The fixture always writes the module into the tree, so `changed` is never +/// truly empty here — the case that needs a genuinely empty delta lives in the +/// module's own suite, and this one records why it cannot live here. Same split, +/// and same reason, as `filed_here.rs`'s empty-delta note. +#[test] +fn the_engine_tier_cannot_build_an_empty_delta() { + let root = repo("plan-fresh", &[], None); + assert_eq!( + verdicts(&root), + vec![UNRECORDED.to_owned()], + "installing the module is itself a change, so this tier always has a diff" + ); +} + +/// AN UNCLAIMED BRANCH OWES NO PLAN, and this is the case that keeps the +/// committed config usable over a scratch repository. The first draft of the +/// vacuity arm keyed only on a non-empty diff, which is true of every fixture — +/// measured, it reddened four `cli.rs` cases whose only business was exercising +/// unrelated rules. Asserted at THIS tier and not only in the module, because +/// the population it selects is a record the engine has to actually read. +#[test] +fn an_unclaimed_branch_is_not_this_gates_business() { + let root = claimed_repo("plan-unclaimed", &["src/a.rs"], None, false); + assert!( + verdicts(&root).is_empty(), + "a branch that pulled no row owes no plan: {:?}", + verdicts(&root) + ); +} + +/// ANTI-VACUITY over the whole file: the row this suite exercises is the one the +/// committed config declares, so a rename or a scope change reddens here rather +/// than leaving every case above passing over a module nothing runs. +#[test] +fn the_committed_row_is_the_one_these_cases_exercise() { + let committed: Vec = batten::config::load(&common::at_root("batten.toml")) + .expect("the committed config loads") + .rules; + let declared = committed + .iter() + .find(|rule| rule.id == "plan-complete") + .expect("the committed config declares the row this suite exercises"); + assert_eq!(declared.kind, RuleKind::Policy); + assert_eq!(declared.scope, RuleScope::Tree); + assert_eq!( + declared.module.as_deref(), + Some("policy/plan-complete.rego") + ); +} + +/// The verb and the reader must agree on the store's name and keying. Asserted +/// against `record::VERB_WRITTEN` rather than a literal, so adding a store +/// without teaching the engine to read it cannot pass. +#[test] +fn the_plan_store_is_declared_as_verb_written() { + assert!( + batten::record::VERB_WRITTEN.contains(&"plan"), + "the engine must read the store the verb writes: {:?}", + batten::record::VERB_WRITTEN + ); +} diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index 3c9cf6a78..f270a58f0 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -432,6 +432,13 @@ fn forge_verdict() -> String { format!("{} failure\n", canary("concluded")) } +/// Plan entries read on stdin by `record plan`. The id carries the canary, so a +/// refusal that echoed an entry back — the one thing this store must never put in +/// a diagnostic, since an id is the agent's own text — fails the census. +fn plan_entries() -> String { + format!("{} pending\n", canary("entry")) +} + /// A ledger row read on stdin by `defects add -n`. The caller wrote it, so its /// bytes are a declaration. fn incoming_record() -> String { @@ -469,6 +476,7 @@ enum Stdin { DesignClaims, ToolVerdict, ForgeVerdict, + PlanEntries, } struct Verb { @@ -1143,6 +1151,16 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::ForgeVerdict, disposition: Disposition::PointerOnly, }, + // CLOUD-472. The entry id piped in carries the canary, because an id is the + // AGENT's own text and is the one thing a refusal here must never echo — a + // malformed line is reported by its NUMBER and the closed status vocabulary, + // which is `record tool`'s discipline over a different payload. + Verb { + path: "record plan", + args: &[], + stdin: Stdin::PlanEntries, + disposition: Disposition::PointerOnly, + }, ]; /// Every path of [`SURFACE`] that RUNS — the object this census must be total @@ -1214,6 +1232,7 @@ fn run_in(corpus: &Corpus, args: &[&str], stdin: Stdin) -> Run { Stdin::DesignClaims => design_claims(), Stdin::ToolVerdict => tool_verdict(), Stdin::ForgeVerdict => forge_verdict(), + Stdin::PlanEntries => plan_entries(), }; // A BROKEN PIPE HERE IS THE CHILD BEING FAST, NOT A FAILURE. This corpus runs // every verb, and a verb that reads no stdin may exit before the write lands — diff --git a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap index 06fbce79f..fb705955b 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap @@ -1389,6 +1389,13 @@ expression: stdout_of(&output) ], "subcommands": [] }, + { + "path": "record plan", + "about": "Record this branch's plan, read as ` ` lines on stdin", + "effect": "write", + "flags": [], + "subcommands": [] + }, { "path": "record tool", "about": "Record a declared tool row's verdict, read as ` ` lines on stdin", diff --git a/man/batten-record-plan.1 b/man/batten-record-plan.1 new file mode 100644 index 000000000..0dde5ab9d --- /dev/null +++ b/man/batten-record-plan.1 @@ -0,0 +1,13 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-record-plan 1 batten +.SH NAME +batten\-record\-plan \- Record this branch\*(Aqs plan, read as ` ` lines on stdin +.SH SYNOPSIS +\fBbatten record plan\fR [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Record this branch\*(Aqs plan, read as ` ` lines on stdin +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-record.1 b/man/batten-record.1 index e80454911..d311a86c3 100644 --- a/man/batten-record.1 +++ b/man/batten-record.1 @@ -19,5 +19,8 @@ Record a declared tool row\*(Aqs verdict, read as ` ` lines on stdi batten\-record\-forge(1) Record the forge\*(Aqs check verdicts for one commit, read as ` ` lines on stdin .TP +batten\-record\-plan(1) +Record this branch\*(Aqs plan, read as ` ` lines on stdin +.TP batten\-record\-help(1) Print this message or the help of the given subcommand(s) diff --git a/mise.toml b/mise.toml index 517dc3f05..cbdf441b7 100644 --- a/mise.toml +++ b/mise.toml @@ -475,7 +475,7 @@ CI_FANIN_WORKFLOW = ".github/workflows/ci.yml" # which is a property of the world and belongs on a clock (`lock-complete`). REGORUS_OPA_COMPLIANCE = "1.2.0" REGORUS_OPA_COMPLIANCE_FOR = "0.11" -MUTANT_GATES = "alive,attestation-check,awk-regex-check,bats-invocation,batten-glob-check,board-diff-overlap,board-payloads,board-sweep,bot-issue,branch-age-check,cap-drift,ci-drift,ci-hygiene,ci-lease-precondition,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-race-check,claimed-keys,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,forge-verdict-required,graph-check,harness-grant,harness-wiring,hk-fix-selection,hook-matcher-check,hook-pin-check,hooks-wiring-check,in-progress-drain,install-check,land,land-divergence-assert,land-lock,land-lock-check,landed-check,landing-loop,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,merged-pr-keys,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,no-doctests,nonverdict-assert,ntia-check,perf-assert,perf-compare,perf-gate,pinned-toolchain,pipefail-grep-check,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reclaim-census,release-assets-check,release-due,release-tag-shape,release-tracking-check,released,remedy-authorship,report-only-check,review-answered,run-shape,run-shape-guard,rust-paths-check,sbom,sbom-check,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,verified,weakens-declared" +MUTANT_GATES = "alive,attestation-check,awk-regex-check,bats-invocation,batten-glob-check,board-diff-overlap,board-payloads,board-sweep,bot-issue,branch-age-check,cap-drift,ci-drift,ci-hygiene,ci-lease-precondition,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-race-check,claimed-keys,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,forge-verdict-required,graph-check,harness-grant,harness-wiring,hk-fix-selection,hook-matcher-check,hook-pin-check,hooks-wiring-check,in-progress-drain,install-check,land,land-divergence-assert,land-lock,land-lock-check,landed-check,landing-loop,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,merged-pr-keys,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,no-doctests,nonverdict-assert,ntia-check,perf-assert,perf-compare,perf-gate,pinned-toolchain,pipefail-grep-check,plan-complete,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reclaim-census,release-assets-check,release-due,release-tag-shape,release-tracking-check,released,remedy-authorship,report-only-check,review-answered,run-shape,run-shape-guard,rust-paths-check,sbom,sbom-check,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,verified,weakens-declared" # --- GitHub reachability behind an egress proxy (Claude Code web sandbox etc.) --- # mise resolves every tool's release through GitHub's *API* host, api.github.com. diff --git a/policy/plan-complete.rego b/policy/plan-complete.rego new file mode 100644 index 000000000..4af8dbc5c --- /dev/null +++ b/policy/plan-complete.rego @@ -0,0 +1,238 @@ +# The plan a branch declared, held to its own end (CLOUD-472). +# +# WHY THIS IS A SEPARATE MODULE FROM `filed-here`. That one's subject is the +# BOARD — rows this branch put on the tracker — and its header rests on three +# refusals none of which subsumes another. A plan entry is not a board row: it is +# the agent's own declared work, in the agent's own words, and folding it in +# would make that invariant unreadable. Same shape, different subject, different +# store. +# +# THE SENSOR EXISTED AND HAD NO RATCHET, which is this repository's recurring +# defect rather than a new one. An agent's task list is displayed every turn and +# is the most legible statement of what it believes is outstanding — and nothing +# in the tree could see it, so a branch could land with half its list `pending` +# and every gate stayed green. Measured 2026-09-01: three items sat `pending` +# while the session reported the work as planned, and the only detector was a +# human asking. +# +# A VERB WRITES THIS STORE, NOT A HOOK, and the direction is the whole design. +# Recording from the harness's own todo tool needs a spelling per host — +# `TaskCreate`/`TaskUpdate`, `write_todos`, `todowrite`, `update_plan` — and +# fails the same way in three different ways: an unsurveyed harness, a tool a +# setting switched off, and a compliant agent all record nothing, so the gate +# reads clean. `OpenCode` makes it concrete by denying `todowrite` to subagents at +# session creation whatever the config says. `batten record plan` inverts it: the +# agent tells the engine, and a missing record REFUSES, identically everywhere. +# +# WHAT IT DOES NOT DO (rule 3): it reads a status token and nothing else. It does +# not judge whether an entry was worth doing, whether its text is honest, or +# whether the work behind `completed` happened. Those are model verdicts and no +# gate here makes one. The author closes the entry, drops it, or spends an +# admission whose articulation says why it is not this branch's to finish — and +# that articulation is hash-bound into the commit message, where a reviewer reads +# it. +# +# POINTER, NEVER PAYLOAD (rule 4): a refusal names the entry's id and its status +# token. The id is the agent's own text, so the finding carries it as an +# `artifact` subject rather than as prose, and the entry's description never +# enters the store at all. +#MUTANT-SUITE crates/batten/tests/it/plan_complete.rs +#MUTANT unfinished-entry-unread|s@^\tnot done(entry.status)$@\tfalse@|an_unfinished_entry_stops_the_lap +#MUTANT no-plan-at-all-unpriced|s@^\tcount(changed) > 0$@\tfalse@|a_branch_that_recorded_no_plan_is_refused + +# METADATA +# description: | +# Bound to the TREE surface: this row is `scope = "tree"`, so it reads +# `input.tree` and never the mediated call. +# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`. +# schemas: +# - input: schema["policy-input.schema"] +package batten.plan_complete + +import rego.v1 + +rules contains "plan-unfinished" + +rules contains "plan-unrecorded" + +# The store, or nothing. ABSENT IS NOT EMPTY, and the two reach different arms +# below on purpose: an empty file is "I recorded a plan and it holds no entries", +# while no file at all is "this branch never told the engine anything" — which is +# the vacuity `plan-unrecorded` exists to price rather than to pass. +recorded := input.tree.records.plan + +# One entry per line, ` `. A line this reader cannot parse is skipped +# rather than judged, matching every other record reader here: the writer already +# refused a malformed line, so anything unparseable at read time is a torn store +# and not an author's claim. +entry contains row if { + some raw in recorded + columns := split(raw, " ") + count(columns) >= 2 + columns[0] != "" + row := {"id": columns[0], "status": columns[1]} +} + +# The two terminal statuses. `deleted` is terminal because withdrawing an entry +# is a decision the author is entitled to make and the store records that they +# made it; what the gate refuses is an entry left in flight, not one closed. +done(status) if { + status in {"completed", "deleted"} +} + +# The branch's own diff, as the engine resolved it — the same reading +# `filed-here` takes, and `null` when the base does not resolve, so `changed` +# stays empty and every arm below goes quiet rather than fabricating a verdict. +delta := input.tree["base-delta"] + +changed contains path if { + some path in delta.added +} + +changed contains path if { + some path in delta.edited +} + +changed contains path if { + some path in delta.deleted +} + +# `plan-unfinished`: an entry the branch declared and left in flight. +violation contains { + "rule": "plan-unfinished", + "verdict": "V-PLAN-UNFINISHED", + "subjects": [{"artifact": entry_row.id}], +} if { + some entry_row in entry + not done(entry_row.status) +} + +# A BRANCH THAT CLAIMED WORK. `claim check` writes this store on its pullable +# path, so its presence is the branch saying "I pulled a row and I am working +# it" — precisely the population that owes a plan. +claimed if { + some _ in input.tree.records.claim +} + +# `plan-unrecorded`: A CLAIMED BRANCH THAT DECLARED NO PLAN AT ALL. +# +# WITHOUT THIS ARM THE GATE IS WORTHLESS, and that is not hypothetical — a +# refusal over "entries left in flight" is satisfied completely by never +# recording an entry, so the cheapest route past it is silence. Same vacuity +# `mutate` already refuses by REPORTING a declared mutation whose named case does +# not exist rather than counting it. +# +# THE CLAIM IS THE PRECONDITION, AND THE FIRST DRAFT GOT THIS WRONG. It asked +# only for a non-empty diff, which is true of every scratch fixture and every +# consumer checkout — measured, that version reddened four `cli.rs` cases whose +# only business was exercising unrelated rules over a fixture repository. A rule +# that fires on any dirty tree makes the committed config unusable over a test +# repo, and a rule like that gets switched off. Keying on the claim asks the +# question where the answer is owed: a branch that pulled a row is doing tracked +# work; one that did not is not this arm's business. +# +# A NON-EMPTY DIFF IS STILL REQUIRED, so the arm prices work rather than +# existence: a claimed branch that has not started has nothing to have planned. +# An empty RECORD satisfies it — the store exists, so the branch spoke — which +# keeps the remedy honest for a genuinely trivial change: one call saying so, +# rather than a fabricated entry. +violation contains { + "rule": "plan-unrecorded", + "verdict": "V-PLAN-UNRECORDED", + "subjects": [{"count": count(changed)}], +} if { + claimed + not recorded + count(changed) > 0 +} + +# The predicate's own tests. The SILENT cases are the load-bearing half here for +# the usual reason: both arms are refusals, so a module that fired on everything +# would satisfy every deny case while deciding nothing. + +# Both builders carry a claim, because both arms are about a branch doing tracked +# work and a fixture without one would exercise the wrong population. +plan(lines, changed_paths) := {"tree": { + "records": {"plan": lines, "claim": ["CLOUD-1"]}, + "base-delta": {"added": changed_paths, "edited": [], "deleted": [], "code-changed": []}, +}} + +no_plan(changed_paths) := {"tree": { + "records": {"claim": ["CLOUD-1"]}, + "base-delta": {"added": changed_paths, "edited": [], "deleted": [], "code-changed": []}, +}} + +test_an_unfinished_entry_is_refused if { + some v in violation with input as plan(["1 pending"], ["src/a.rs"]) + v.verdict == "V-PLAN-UNFINISHED" +} + +test_an_in_progress_entry_is_refused if { + some v in violation with input as plan(["1 in_progress"], ["src/a.rs"]) + v.verdict == "V-PLAN-UNFINISHED" +} + +test_a_completed_entry_is_clean if { + count(violation) == 0 with input as plan(["1 completed"], ["src/a.rs"]) +} + +# WITHDRAWING AN ENTRY IS A DECISION, AND THE STORE RECORDS THAT IT WAS MADE. +# The gate refuses work left in flight, never work the author decided against. +test_a_deleted_entry_is_clean if { + count(violation) == 0 with input as plan(["1 deleted"], ["src/a.rs"]) +} + +# ONE FINDING PER ENTRY, so a reviewer sees which item rather than a count they +# have to reconstruct — and finishing one does not clear another. +test_every_unfinished_entry_is_named if { + ids := {v.subjects[0].artifact | some v in violation} with input as plan( + ["1 completed", "2 pending", "3 in_progress"], + ["src/a.rs"], + ) + ids == {"2", "3"} +} + +# THE ANTI-VACUITY ARM. Never recording is the cheapest way past a refusal over +# unfinished entries, so silence is priced. +test_a_branch_that_recorded_no_plan_is_refused if { + some v in violation with input as no_plan(["src/a.rs"]) + v.verdict == "V-PLAN-UNRECORDED" +} + +# AN EMPTY RECORD IS AN ANSWER. The branch spoke and said there is nothing to +# track, which is the honest remedy for a trivial change — as against a +# fabricated entry, which is what a gate demanding a non-empty list would buy. +test_an_empty_record_satisfies_the_vacuity_arm if { + count(violation) == 0 with input as plan([], ["src/a.rs"]) +} + +# A BRANCH HOLDING NOTHING OPEN HAS NOTHING TO HAVE PLANNED, so a fresh checkout +# is never refused for a plan it had no occasion to write. +test_a_branch_with_no_diff_is_never_refused if { + count(violation) == 0 with input as no_plan([]) +} + +# COULD NOT READ THE BASE leaves the vacuity arm silent rather than firing on +# every branch whose base does not resolve — a verdict about the environment is +# not a verdict about the branch. +test_an_unresolvable_delta_leaves_the_vacuity_arm_silent if { + count(violation) == 0 with input as {"tree": { + "records": {"claim": ["CLOUD-1"]}, + "base-delta": null, + }} +} + +# AN UNCLAIMED BRANCH IS NOT THIS ARM'S BUSINESS, and this is the case that keeps +# the committed config usable over a scratch tree. Without it the arm fires on +# every fixture repository that runs the whole config to exercise some unrelated +# rule — measured at four such cases before the claim became the precondition. +test_an_unclaimed_branch_owes_no_plan if { + count(violation) == 0 with input as {"tree": { + "records": {}, + "base-delta": {"added": ["src/a.rs"], "edited": [], "deleted": [], "code-changed": []}, + }} +} + +test_a_line_this_reader_cannot_parse_is_skipped if { + count(violation) == 0 with input as plan(["", "nonsense"], ["src/a.rs"]) +} From 310cb21cb9dc16b888c4d1c87ff16797b35526db Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 23:48:34 +0000 Subject: [PATCH 6/8] fix(policy): the plan mutation named a variable the arm does not bind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `unfinished-entry-unread` mutated `not done(entry.status)` while the arm binds `entry_row`, so the sed matched nothing and `mutate sweep` reported `inert-mutation` rather than counting it. INERT AND SURVIVED ARE DIFFERENT DEFECTS, which is why the runner keeps them apart: a survivor altered reachable code and its named case could not observe the change, while an inert row never altered anything at all. Read as coverage, both are the same lie — the census counts the module enforced while the sweep has proved only that a no-op leaves the suite green, which is CLOUD-418's finding reproduced inside the mechanism built to catch it. Sweep after: `plan-complete` reports neither row, and the totals are back to the 13 survivors and 1 could-not-look that stood on `main` before this branch. Refs: CLOUD-472 Admits: f67a29e4dfe3e0b673c8ee323b505f75b6723efc16306b55c9b55899715b7dfc Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: policy/plan-complete.rego Admits-head: dda2bdbe3e91fb2e2adc413306b61e84fa863e14 Admits-epoch: 02358ef15596a3d6bbc0781748ec5d6637cad09abc0c7e143f2eebeb2770950d Admits-author: alec@wenzowski.com Admits-prev: 1e57c511055e0626046bd28c09c30389ad540aea48e85b0abecdf534385b9966 Admits-answer-lost: `plan-unfinished` ships with a declared mutation that does nothing, which is worse than declaring none: the census counts the module as enforced while the sweep proves only that a no-op leaves the suite green. That is exactly CLOUD-418's finding — a test that cannot discriminate shipping as coverage — reproduced inside the mechanism built to catch it. Admits-answer-precondition: A `#MUTANT` row lives in the module beside the predicate it mutates — there is no other surface that can carry it, and a registered module has no owning verb. The row is currently INERT: its expression names `entry.status` while the arm binds `entry_row.status`, so the sed matches nothing and `mutate sweep` reports it rather than counting it. Fixing it means writing the file. It lands in the reviewed PR for CLOUD-472 where the sweep itself is the check. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply because no verb writes a `#MUTANT` row; the module is the surface. R-RESTORE-IT does not apply because nothing was destroyed — this corrects an expression added in this same branch so that it actually alters reachable code. What a reviewer should check is the sweep output rather than the diff: the row must move from `inert-mutation` to caught, because a mutation that applies and is still not observed by its named case is a different defect wearing the same word. --- policy/plan-complete.rego | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policy/plan-complete.rego b/policy/plan-complete.rego index 4af8dbc5c..9c5e13d7b 100644 --- a/policy/plan-complete.rego +++ b/policy/plan-complete.rego @@ -37,7 +37,7 @@ # `artifact` subject rather than as prose, and the entry's description never # enters the store at all. #MUTANT-SUITE crates/batten/tests/it/plan_complete.rs -#MUTANT unfinished-entry-unread|s@^\tnot done(entry.status)$@\tfalse@|an_unfinished_entry_stops_the_lap +#MUTANT unfinished-entry-unread|s@^\tnot done(entry_row.status)$@\tfalse@|an_unfinished_entry_stops_the_lap #MUTANT no-plan-at-all-unpriced|s@^\tcount(changed) > 0$@\tfalse@|a_branch_that_recorded_no_plan_is_refused # METADATA From 0896a301292aa09f3ab24758ba2e3af6fb50d95e Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 00:05:31 +0000 Subject: [PATCH 7/8] feat(hook): a host's plan surface is surveyed, unsurveyed, or measured none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `operation_of` is a static table from one survey (CLOUD-209), and its own comment records the trap: Gemini and Copilot carry no spellings because the survey did not record them, which is an absence of DATA that reads as an absence of CAPABILITY. I made exactly that inference earlier in this branch — treating a config gap as proof those hosts had no todo tool, and designing around an abstention that was never established. So the capability matrix gains a column, and it has two variants for a three-valued fact. `Surveyed(&[])` is a measured "this host offers none"; `Surveyed(&[..])` carries spellings that were FETCHED; `Unsurveyed(owner)` is nobody having looked, and is never reported as none. Fetched 2026-09-01 from vendor docs: Gemini CLI `write_todos` (on by default, disableable via `useWriteTodos`), Codex CLI `update_plan`, and this host's own `TaskCreate`/`TaskUpdate`. Cursor was SEARCHED and is still `Unsurveyed`: it has a Todos feature since 1.2, but the results were forum threads and third-party articles rather than a documented tool name, and a spelling taken from those is what CLOUD-209's rule refuses. BATTEN DOES NOT GATE ON THIS COLUMN. `plan-complete` reads a store `batten record plan` writes, so it fails closed on every host whatever the table says. What the column buys is the mirror — keeping the human's native todo view in step — and an honest report of hosts where that view does not exist. `doctor` reports it, and the check's shape is the interesting half. A check that reddened on every unsurveyed host would be permanently red here, and a diagnostic that never goes green stops being run. So an unsurveyed row must NAME the row that owes the survey, and naming one changes no exit code — `#MUTANT-OWNER`'s bargain one layer over, where the declaration buys that the gap is stated rather than that it is forgiven. What it catches is a harness added with neither a fetch nor an owner, which is the moment the gap goes invisible. 3775/3775 green. Refs: CLOUD-472 --- crates/batten/src/doctor.rs | 49 ++++++++++++++- crates/batten/src/hook.rs | 101 +++++++++++++++++++++++++++++++ crates/batten/tests/it/doctor.rs | 20 ++++-- 3 files changed, 165 insertions(+), 5 deletions(-) diff --git a/crates/batten/src/doctor.rs b/crates/batten/src/doctor.rs index 5a713034b..814b9718c 100644 --- a/crates/batten/src/doctor.rs +++ b/crates/batten/src/doctor.rs @@ -129,6 +129,9 @@ const CONFIG: &str = "config"; /// against it, so a checkout where this fails is one where those silently have /// nothing to stand on. const GIT_REPO: &str = "git-repo"; +/// This harness's plan/todo surface has been SURVEYED — which is a different +/// question from whether it has one (CLOUD-472). +const PLAN_SURFACE: &str = "plan-surface"; /// Every `command`-kind rule names a program that resolves on `PATH`. /// /// A missing binary is otherwise discovered at `enforce` time, mid-run, as a @@ -277,6 +280,44 @@ pub fn diagnose(dir: &Path) -> Report { ), ); + // THE HOST'S PLAN SURFACE, REPORTED AND NEVER GATED ON (CLOUD-472). + // + // `plan-complete` reads a store `batten record plan` writes, so it fails + // closed on every host and this check decides nothing about it. What it + // answers is whether the human's NATIVE todo view can be kept in step — + // and, more importantly, it makes an unsurveyed host say so out loud. + // + // `Unsurveyed` is a FAILED check rather than a passed one, which is the + // whole reason the column has two variants. An absence of data reading as + // an absence of capability is the trap `hook::Harness::operation_of` + // records for Gemini and Copilot, and a diagnostic that reported "no plan + // tool" for a host nobody has looked at would be repeating it in the one + // place an operator goes to find out what is true. + // OVER THE TABLE, NOT OVER THE RUNNING HOST, because `diagnose` takes a + // directory: it answers for the checkout in front of it and has no harness + // to ask. Inferring one from the environment would be manufacturing the + // fact this check exists to report honestly. + // AN UNSURVEYED HOST MUST NAME WHO OWES THE SURVEY, and naming one changes + // no exit code. That is `#MUTANT-OWNER`'s bargain: the declaration buys that + // the gap is STATED, never that it is forgiven, and a check that reddened on + // every unsurveyed host would be permanently red on this repository — which + // is how a diagnostic stops being run at all. + // + // What it does catch is a harness added with neither a fetch nor an owner, + // which is the moment the gap becomes invisible. + checks.push( + if crate::hook::Harness::ALL.iter().any(|harness| { + matches!( + harness.capabilities().plan_tools, + crate::hook::PlanTools::Unsurveyed(owner) if owner.is_empty() + ) + }) { + Check::failed(PLAN_SURFACE, "harness-unsurveyed-and-unowned") + } else { + Check::passed(PLAN_SURFACE) + }, + ); + // The working-tree authority: `doctor` diagnoses the checkout in front of // it, so it does not take a base ref. let config_epoch = crate::epoch::compute(dir, None).ok(); @@ -1703,7 +1744,13 @@ mod tests { .collect(); assert_eq!( names, - vec![CONFIG, GIT_REPO, COMMAND_PROGRAMS, HOOK_HANDLERS] + vec![ + CONFIG, + GIT_REPO, + COMMAND_PROGRAMS, + HOOK_HANDLERS, + PLAN_SURFACE + ] ); } diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 084ff822a..0547cafe9 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -309,6 +309,34 @@ impl Harness { } } +/// How a host spells the agent's plan/todo tool, or that nobody has looked +/// (CLOUD-472). +/// +/// **Two variants for a THREE-valued fact, and the third value is +/// `Surveyed(&[])`.** Collapsing "surveyed and this host has none" into the same +/// answer as "nobody checked" is the exact trap [`Harness::operation_of`]'s own +/// comment warns about, where Gemini and Copilot carry no spellings because the +/// CLOUD-209 survey did not record them — an absence of DATA that reads as an +/// absence of CAPABILITY. A reader who cannot tell those apart will report a +/// host as having no todo tool when the truth is that nobody asked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum PlanTools { + /// Fetched from this host's own documentation. An empty slice is a measured + /// "this host offers none", which is an answer. + Surveyed(&'static [&'static str]), + /// Nobody has looked, carrying the row that OWES the survey. NOT the same as + /// none, and never reported as none. + /// + /// **The key changes no exit code, and that is deliberate** — it is + /// `#MUTANT-OWNER`'s bargain, one layer over: a declaration that suppressed + /// the finding would be the laundering the runner exists to refuse, so what + /// the key buys is that the gap is STATED rather than that it is forgiven. A + /// new harness added without a survey has to name who owes one, which is the + /// moment an author either does the fetch or admits they did not. + Unsurveyed(&'static str), +} + /// What one host can and cannot do (CLOUD-45). /// /// A **host × capability** table, not a list of Claude-only events — the survey @@ -334,6 +362,22 @@ impl Harness { pub struct Capabilities { /// The events this host emits, so Batten can be invoked on them. pub events: &'static [Event], + /// How this host spells the agent's own plan/todo tool (CLOUD-472). + /// + /// **A column about what the AGENT can call, where the rest of this table is + /// about what the ENGINE can reach** — and it is here anyway, because this is + /// the one authority on host facts and a second table would be a second place + /// for the same answer to drift. + /// + /// Batten does not gate on it. The plan record is written by + /// [`crate::record::run_plan`], a verb, so the gate fails closed on every + /// host regardless of what this says. What the column buys is the MIRROR — + /// keeping the human's native todo view in step with the store — and an + /// honest report of hosts where that view does not exist. + /// + /// Every spelling here was FETCHED, per CLOUD-209's rule that anything + /// re-derived without one should be assumed wrong. + pub plan_tools: PlanTools, /// Where an escalate-to-human verdict is actually reachable on this host /// (CLOUD-601). /// @@ -1288,6 +1332,8 @@ impl Harness { pub const fn capabilities(self) -> Capabilities { match self { Harness::ClaudeCode => Capabilities { + // Fetched: this session's own tool surface. + plan_tools: PlanTools::Surveyed(&["TaskCreate", "TaskUpdate"]), events: CLAUDE_EVENTS, // Documented, and merged most-restrictive-first by the host // itself (`deny > defer > ask > allow`), so an ask cannot @@ -1402,6 +1448,11 @@ impl Harness { }, }, Harness::Cursor => Capabilities { + // Searched 2026-09-01 and NOT fetched: the host has a Todos + // feature from 1.2, but no vendor-documented tool spelling was + // found, and a name taken from a forum post is exactly what + // CLOUD-209's "assume it wrong without a fetch" refuses. + plan_tools: PlanTools::Unsurveyed("CLOUD-209"), events: CONVERGED_EVENTS, // The row that forced this column to become event-scoped // (CLOUD-601). M1 records the verdict vocabulary as @@ -1438,6 +1489,9 @@ impl Harness { capture: UNSURVEYED_CAPTURE, }, Harness::CopilotCli => Capabilities { + // Not fetched, like the rest of this host's tool surface — the + // same survey gap `operation_of` records for it. + plan_tools: PlanTools::Unsurveyed("CLOUD-209"), events: CONVERGED_EVENTS, // `Unknown`, not `No`, and not `Yes` either: M1 confirms the // verdict exists and names the `preToolUse` output *fields* @@ -1468,6 +1522,9 @@ impl Harness { capture: UNSURVEYED_CAPTURE, }, Harness::GeminiCli => Capabilities { + // Fetched 2026-09-01 from the vendor docs: `write_todos`, on by + // default and disableable with `"useWriteTodos": false`. + plan_tools: PlanTools::Surveyed(&["write_todos"]), events: CONVERGED_EVENTS, // Allow/deny only. A policy wanting confirmation must hard-deny // here — degrading to *allow* would turn "ask a human" into "go @@ -1502,6 +1559,8 @@ impl Harness { capture: UNSURVEYED_CAPTURE, }, Harness::CodexCli => Capabilities { + // Fetched 2026-09-01: `update_plan`, the built-in plan tool. + plan_tools: PlanTools::Surveyed(&["update_plan"]), events: CONVERGED_EVENTS, // Advertised in the output schema, marked "parsed but not // supported yet" in the docs. Advertised is not available, and @@ -1524,6 +1583,8 @@ impl Harness { capture: UNSURVEYED_CAPTURE, }, Harness::ExitCode => Capabilities { + // The neutral contract carries no host tool surface of its own. + plan_tools: PlanTools::Surveyed(&[]), events: CONVERGED_EVENTS, // Not a host: the channel is the exit status alone, which has no // third value to carry an escalation. Measured, not unsurveyed. @@ -12154,6 +12215,46 @@ deny contains "V-REFUSED-BY-THE-MODULE" if { (Harness::ExitCode, "Write"), ]; + #[test] + /// A new adapter must either name a fetched plan spelling or say who owes + /// the survey. CLOUD-472's column exists to keep those apart, so a row that + /// declares neither is the one thing it cannot express. + #[test] + fn every_harness_declares_a_plan_surface_or_names_who_owes_the_survey() { + for harness in Harness::ALL { + if let PlanTools::Unsurveyed(owner) = harness.capabilities().plan_tools { + assert!( + !owner.is_empty(), + "{}: unsurveyed with no owner — the gap has to be stated, \ + which is `#MUTANT-OWNER`'s bargain one layer over", + harness.as_str() + ); + } + } + } + + /// SURVEYED-AND-NONE IS AN ANSWER; UNSURVEYED IS NOT. The whole reason the + /// column has two variants is that collapsing them reproduces the trap + /// `operation_of`'s own comment records — an absence of DATA reading as an + /// absence of CAPABILITY. Asserted over the committed table so a later edit + /// cannot quietly turn one into the other. + #[test] + fn an_unsurveyed_plan_surface_is_never_reported_as_having_none() { + assert_eq!( + Harness::ExitCode.capabilities().plan_tools, + PlanTools::Surveyed(&[]), + "the neutral contract carries no host tool surface, which is a measured none" + ); + assert!( + matches!( + Harness::Cursor.capabilities().plan_tools, + PlanTools::Unsurveyed(_) + ), + "Cursor has a Todos feature and no vendor-documented spelling was fetched, \ + so it is unsurveyed rather than none" + ); + } + #[test] fn every_harness_classifies_its_own_write_spelling_as_write() { for harness in Harness::ALL { diff --git a/crates/batten/tests/it/doctor.rs b/crates/batten/tests/it/doctor.rs index 7abb694e3..7c5efdf32 100644 --- a/crates/batten/tests/it/doctor.rs +++ b/crates/batten/tests/it/doctor.rs @@ -63,7 +63,7 @@ fn a_healthy_repository_exits_zero() { assert_eq!(output.status.code(), Some(0)); assert_eq!( stdout(&output), - "config ok\ngit-repo ok\ncommand-programs ok\nhook-handlers ok\ndoctor: 4 check(s), 0 failed\n" + "config ok\ngit-repo ok\ncommand-programs ok\nhook-handlers ok\nplan-surface ok\ndoctor: 5 check(s), 0 failed\n" ); } @@ -115,11 +115,17 @@ fn every_check_is_reported_not_just_the_first_failure() { let text = stdout(&output); assert!(text.contains("config failed"), "got: {text}"); assert!(text.contains("git-repo failed"), "got: {text}"); - // Four checks now; still two failures, because a checkout with no config + // Five checks now; still two failures, because a checkout with no config // declares no handlers and `hook-handlers` passes vacuously over an empty // table. That is the honest answer — there is nothing there to be wrong — // and it is why the count moved while the failure count did not. - assert!(text.contains("doctor: 4 check(s), 2 failed"), "got: {text}"); + // + // `plan-surface` passes for a different reason worth keeping distinct: it + // reads the COMMITTED harness table rather than this checkout, so it says + // the same thing in every scratch repository. What it can fail on is a + // harness declaring neither a fetched spelling nor the row that owes the + // survey (CLOUD-472), which is a defect in the crate and not in a tree. + assert!(text.contains("doctor: 5 check(s), 2 failed"), "got: {text}"); } // --- doctor never renders a policy verdict ----------------------------------- @@ -246,7 +252,13 @@ fn json_is_valid_and_carries_every_check() { let names: Vec<&str> = checks.iter().filter_map(|c| c["name"].as_str()).collect(); assert_eq!( names, - vec!["config", "git-repo", "command-programs", "hook-handlers"] + vec![ + "config", + "git-repo", + "command-programs", + "hook-handlers", + "plan-surface" + ] ); } From 1e3e8df4fb781755a01d8d7eea81c5837b009b7d Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 00:13:06 +0000 Subject: [PATCH 8/8] feat(ready): a test obligation's mutation names a slug, not a sentence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `REQUIRED_CLAIMS` has forced `tests` since CLOUD-453 and every entry has carried a `mutation` since CLOUD-418 — the obligation as a field, where an entry that cannot name the change which would kill it cannot be written. It was PROSE, so it was joinable to nothing. A slug is: `batten mutate` resolves it, applies the expression, runs the named case, and a SURVIVOR is the finding. That is the difference between "pressure tested" as an assertion and as an exit code. SHAPE HERE, RESOLUTION AT `verify`. The case does not exist at refinement time, so resolving the slug now would refuse every honest row before its code was written — the false-failure trap CLOUD-472's own §3 names. Whitespace is the whole discriminator, because `mutate`'s three-field row format already forbids it in a slug. The shared fixture carried the exact defect the check exists for — a sentence where a resolvable token belongs — so it is corrected at source and every case inherits the right shape. Both directions are asserted: prose refused, and the unmodified fixture clean, because a refusal whose remedy is unreachable is a wall. Also fixes a duplicated `#[test]` this branch introduced, which the test tier could not see: it is a warning, and only clippy's `-D warnings` promotes it. 3775/3775 green. Refs: CLOUD-472 --- crates/batten/src/hook.rs | 1 - crates/batten/src/ready.rs | 23 ++++++++++++++++++++++ crates/batten/tests/it/ready.rs | 35 ++++++++++++++++++++++++++++++++- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 0547cafe9..f951d6379 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -12215,7 +12215,6 @@ deny contains "V-REFUSED-BY-THE-MODULE" if { (Harness::ExitCode, "Write"), ]; - #[test] /// A new adapter must either name a fetched plan spelling or say who owes /// the survey. CLOUD-472's column exists to keep those apart, so a row that /// declares neither is the one thing it cannot express. diff --git a/crates/batten/src/ready.rs b/crates/batten/src/ready.rs index 0defea78e..c3c988713 100644 --- a/crates/batten/src/ready.rs +++ b/crates/batten/src/ready.rs @@ -1212,6 +1212,29 @@ fn check_claimed_tests(claims: &serde_json::Value, line: usize, report: &mut Rep }); } } + // `mutation` NAMES A DECLARED `#MUTANT` SLUG (CLOUD-472). The field + // landed under CLOUD-418 as prose describing the mutation that would + // kill the case — which is a better claim than nothing and is still not + // joinable to anything. A slug is: `batten mutate` resolves it, applies + // the expression, runs the named case, and a SURVIVOR is the finding. So + // "pressure tested" stops being an assertion and becomes an exit code. + // + // SHAPE HERE, RESOLUTION AT `verify`. At refinement time the case does + // not exist yet — refusing an unresolvable slug here is the false-failure + // trap this row's own §3 names — so this checks only that the field is a + // TOKEN rather than a sentence. A slug carries no whitespace, which is + // the whole discriminator and is what `mutate`'s own three-field row + // format already requires of it. + let prose = entry + .get("mutation") + .and_then(serde_json::Value::as_str) + .is_some_and(|text| text.split_whitespace().count() > 1); + if prose { + report.findings.push(Finding { + line, + rule: "test-claim-mutation-not-a-slug".to_owned(), + }); + } } } diff --git a/crates/batten/tests/it/ready.rs b/crates/batten/tests/it/ready.rs index 4bf893315..90f5e0de6 100644 --- a/crates/batten/tests/it/ready.rs +++ b/crates/batten/tests/it/ready.rs @@ -267,9 +267,14 @@ fn complete_claims() -> serde_json::Value { "gate": { "task": "verify", "exits": [0, 2] }, "commit_type": "feat", "blockers": [], + // A SLUG, NOT PROSE (CLOUD-472). This fixture carried + // "drop the required-key check" — a sentence, which is what the field + // meant under CLOUD-418 and which nothing can resolve. `batten mutate` + // resolves a slug, so the obligation becomes checkable rather than + // asserted, and every case below inherits the corrected shape. "tests": [{ "file": "crates/batten/tests/it/ready.rs", - "mutation": "drop the required-key check", + "mutation": "required-key-unread", }], }) } @@ -462,6 +467,34 @@ fn an_empty_value_is_an_omission_wearing_a_declarations_shape() { ); } +/// CLOUD-472. `mutation` landed under CLOUD-418 as PROSE describing the change +/// that would kill the case — a better claim than nothing, and still joinable to +/// nothing. A slug is joinable: `batten mutate` resolves it, applies the +/// expression, runs the named case, and a survivor is the finding. +/// +/// Shape only, at this tier and at this moment: the case does not exist at +/// refinement time, so resolving the slug here would refuse every honest row +/// before its code was written. Whitespace is the whole discriminator, because +/// `mutate`'s own three-field row format already forbids it in a slug. +#[test] +fn a_mutation_written_as_prose_rather_than_a_slug_is_refused() { + let dir = with_tasks("ready-claims-mutation-prose"); + let mut object = complete_claims(); + object["tests"][0]["mutation"] = serde_json::json!("drop the required-key check"); + let output = lint(&dir, &claims_payload(&object, &[])); + assert_eq!(code(&output), 2, "{}", stderr(&output)); + assert!( + stderr(&output).contains("test-claim-mutation-not-a-slug"), + "the refusal must name the class: {}", + stderr(&output) + ); + + // THE REMEDY IS REACHABLE, which is what keeps this from being a wall: the + // unmodified fixture already carries a slug and passes. + let clean = lint(&dir, &claims_payload(&complete_claims(), &[])); + assert_eq!(code(&clean), 0, "{}", stderr(&clean)); +} + #[test] fn a_gate_that_names_no_task_is_refused() { // The half that makes the mechanism unwritable as prose, which is the row's