From dbb809a13ad2260a0e89700232b9aa14cbbeddf7 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Mon, 7 Sep 2026 21:20:27 +0000 Subject: [PATCH 1/7] fix(board): the column vocabulary stops being an engine constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-negotiable rule 1 binds MECHANISMS, not only string literals, and `landed` and `claim` decided over one board's COLUMN NAMES. Linear ships Todo/In Progress/In Review/Done; Jira ships To Do/In Development; a GitHub Project ships whatever the owner typed. This is rule 1's violation in its worst form, because the failure is silent in the direction that matters. Off this board every comparison is false, so `is_started` never fires and the landed-honesty sweep reports ZERO FINDINGS over a board full of dishonest columns, while `claim` never refuses. A gate that cannot fire and a gate that found nothing emit the same bytes and the same exit code. The words move to a `[board]` table. `board::Columns` resolves it, and `board_columns()` is `board_grammar()`'s sibling for the same reason CLOUD-1100 gave one table over. ## Absent is could-not-look, never a default The load-bearing decision. An undeclared column refuses BY NAME rather than falling back to this repository's words: a default would reinstate the violation with one more step in front of it and restore exactly the property that let the constants survive — the dead path and the working path answering identically. `Columns::started` therefore reads an EMPTY set as undeclared rather than as "no column means started", since a set matching nothing reports every row as not-advanced, which is the silent all-clear. `judge` refuses the RUN when the queue is undeclared, not every row: a claim gate that cannot name the queue has not decided nothing is pullable. `Reason::wants` returns `Option`, so an undeclared column costs the ` -> ` remedy and never the finding itself. ## The duplicate pull-request predicate, resolved `claim` required the literal `github.com/`; `landed` carried a host-free copy whose doc argues forge URL SHAPE is not consumer vocabulary. That reasoning is right, so the host-free one is now the single spelling. This also fixes a live defect: off GitHub `live_pull_request` answered `None` for every real pull request, so the open-competitor check was dead. Recorded rather than silently widened: a GitLab merge request is `/merge_requests/` and still does not match. Narrower than the host literal it replaces — matching every `//` would admit `/issues/12` and turn an issue link into a claim. Two sites CLOUD-1623's body does not carry, found on re-read: a second independent `is_in_progress` in `impl Claim`, and `demand()`'s refusal prose, which named a column a consumer's board may not have. `batten.toml`'s `[board]` table was written under admission 8b378ecbdd0e721328b63d4e7661c18704d71af2534b07a81665dcdfa9280835. Refs: CLOUD-1623 Admits: 8b378ecbdd0e721328b63d4e7661c18704d71af2534b07a81665dcdfa9280835 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:ea307944cd9ec0c1e4f52720f78ce49d08aa7761 Admits-epoch: ed0cc67ec403a5849f97cbe66586e656ab21f7b6d2015725b2d51a2a2c6f5b91 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: The engine half without the config half is a dead gate. `landed check`, the abandonment drain and `claim check` all resolve their columns through `board::Columns`, and an undeclared table is could-not-look by design — so shipping the Rust change alone turns three working gates into three that decide nothing, while `mise run verify` reports the tree clean. That is precisely the failure CLOUD-1623 exists to remove, reintroduced by landing half of its fix. Admits-answer-precondition: No batten surface writes a config table. `batten config` inspects only, and `batten init` refuses to overwrite an existing batten.toml, so neither can add a `[board]` table to a file that already exists. The change is the config half of CLOUD-1623 (claimed, receipt minted on this branch): four column names moving out of `crates/batten/src/landed.rs` and `claim.rs`, where they were non-negotiable rule 1's violation. It is a 43-line addition to one table in a draft PR, visible in the diff a reviewer reads before it lands. Admits-answer-rejected-route: `patch`. Tried and refused identically (`path write refused batten.toml patch protected-mutation`), which is correct: the guard is on the path, not the program, so routing the same bytes through a second writer would be evading the class rather than satisfying it. `config read first` does not apply either — the file was read directly and through `batten config` before this write, and reading cannot add a table. `run first` names no task that authors config. Admits: 827500be1882ead25b401784a3c9845c5120c7b9cc906f58d9fc993b3d2bec68 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: .serena/memories/core.md Admits-anchor: call:ea307944cd9ec0c1e4f52720f78ce49d08aa7761 Admits-epoch: 24cb2114988016256569172b8027e2e9b6019765eb89934424819c2aa9ac155b Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: `module-map-check` refuses the commit outright, so CLOUD-1623 cannot land at all. Beyond that, its stated defect is the point: `mem:core` is the single authority on what each module owns, and a module added without its row leaves that authority silently incomplete and the rule pointing at it untrue — measured once already on `severity.rs`, which landed past a green gate with no row. A new module carrying the `[board]` seam is exactly the row a later reader needs. Admits-answer-precondition: The sanctioned writer for `.serena/memories/**` is the Serena MCP tools, and that server failed to connect this session (CONNECTION_CLOSED), so no surface can express the change. The change is one row naming `board.rs` in the module map, required by `module-map-check`, which refused the commit until it existed: every tracked `crates/*/src/*.rs` must appear in `mem:core`. It is 13 lines in a draft PR, visible in the diff a reviewer reads before it lands. Admits-answer-rejected-route: `patch`. Refused identically on the sibling path this commit also writes (`path write refused batten.toml patch protected-mutation`); the guard is on the path rather than the program, so routing the same bytes through a second writer evades the class instead of satisfying it. `config read first` does not apply — this file is not config, and reading cannot add a row. `run first` names no task that authors the module map; `module-map-check` only reports the gap. --- .serena/memories/core.md | 13 ++ batten.toml | 42 +++++++ crates/batten/src/board.rs | 223 +++++++++++++++++++++++++++++++++++ crates/batten/src/claim.rs | 79 +++++++++---- crates/batten/src/config.rs | 69 +++++++++++ crates/batten/src/landed.rs | 202 +++++++++++++++++++++++-------- crates/batten/src/lib.rs | 56 ++++++++- crates/batten/src/resolve.rs | 10 ++ schema/batten.schema.json | 46 ++++++++ 9 files changed, 667 insertions(+), 73 deletions(-) create mode 100644 crates/batten/src/board.rs diff --git a/.serena/memories/core.md b/.serena/memories/core.md index 08f98bed6..daf8343e3 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -74,6 +74,19 @@ err)` takes **both** channels and the resolved `Mode`, so a verb can write a `-J`?), and a flag carries `hidden` plus `Rung` — which §3 ladder rung it selects — so "is this a ladder flag" is a column rather than a naming convention, and the ladder's totality is a census test. +- `board.rs` — the board's COLUMN VOCABULARY, resolved from the `[board]` table + rather than held as engine constants (CLOUD-1623, non-negotiable rule 1). + `landed` and `claim` decided over one tracker's words — Linear's + Todo/In Progress/In Review/Done — so on any other board every comparison was + false: `is_started` never fired, the landed-honesty sweep reported zero + findings over a board full of dishonest columns, and `claim` never refused. + `Columns::resolve` reads the table and `board_columns` in `lib.rs` is + `board_grammar`'s sibling. The load-bearing rule is that an undeclared column + refuses BY NAME and never falls back to this repository's words — a default + would restore the property that made the original defect unobservable, the + dead path and the working path answering identically. An EMPTY `started` set + is undeclared for the same reason: a set matching nothing reports every row as + not-advanced, which is the silent all-clear. - `bot.rs` — the bot lane, retired off `mise-tasks/bot-issue.sh` (CLOUD-1295). Two halves in one module: the PREDICATES — is this PR one of the lane's, which manifests it touched, what Conventional type its subject declares, whether a diff --git a/batten.toml b/batten.toml index 504c8e5a4..0d8c927d0 100644 --- a/batten.toml +++ b/batten.toml @@ -8890,6 +8890,48 @@ verified_by = ["verify", "linear-check"] [ready] prose_dialect_required_from = "2026-09-02T00:00:00.000Z" +# THIS BOARD'S COLUMN VOCABULARY (CLOUD-1623). These four words were `const`s in +# `crates/batten/src/landed.rs` and `claim.rs` — non-negotiable rule 1's worst +# violation in the tree, because the failure is silent in the direction that +# matters. Off a board spelling its columns this way every comparison is false: +# `is_started` never fires, so the landed-honesty sweep reports ZERO FINDINGS +# over a board full of dishonest columns, and `claim` never refuses. A gate that +# cannot fire and a gate that found nothing emit the same bytes. +# +# THE ENGINE HOLDS NO DEFAULT FOR THESE, deliberately. An undeclared column is +# could-not-look named by key, never a fallback to these values — a fallback +# would put this repository's vocabulary back in the engine with one more step in +# front of it, and restore exactly the property that made the original defect +# unobservable. `crates/batten/src/board.rs` carries that reasoning. +# +# VALUES RATHER THAN `[[pattern]]` ROWS, per CLOUD-472's precedent one table up: a +# column is matched by EQUALITY against the string the tracker echoes back, not by +# a regex over it. The registry exists so one CONCEPT has one spelling; a literal +# the round trip returns verbatim is a value. +[board] +# The ready queue: `claim check` refuses a row that is not sitting here. +ready = "Todo" +# Pulled. Both `landed check`'s behind-git direction and the abandonment drain +# select on this one. +in_progress = "In Progress" +# Where a row whose work is already on `main` is asked to move. +review = "In Review" +# Every column meaning "somebody has this, or it landed, or it shipped". +# +# `Done` IS IN THE SET, AND LEAVING IT OUT WAS A MEASURED DEFECT (CLOUD-1458). +# The engine constant read `["In Progress", "In Review"]`, so a declined key that +# reached Done escaped the sweep entirely — and Done is RELEASED, where the claim +# is strongest and the lie therefore costs most. Measured on that gate's own two +# rows: CLOUD-186 and CLOUD-1127 were declined with `DO-NOT-CLOSE` in the body of +# the pull request that landed the module, advanced to In Review by the merge, +# moved back by hand, and advanced to Done by a release 2026-09-05T02:52:56Z — +# past the far edge of a predicate written the day before. +# +# `Backlog` and `Todo` stay OUT, because they are the ready queue: a declined key +# sitting there is `DO-NOT-CLOSE` working, and refusing it would make the marker +# unwritable. +started = ["In Progress", "In Review", "Done"] + # ACCEPTED INVOCATION-LATENCY REGRESSIONS (CLOUD-1163 unit 10), transcribed # verbatim off the `EXEMPT` heredoc of the retired `mise-tasks/perf-compare.sh`. # diff --git a/crates/batten/src/board.rs b/crates/batten/src/board.rs new file mode 100644 index 000000000..e078e9b25 --- /dev/null +++ b/crates/batten/src/board.rs @@ -0,0 +1,223 @@ +//! The board's column vocabulary, resolved from the consumer's config +//! (non-negotiable rule 1). +//! +//! # Why this module exists +//! +//! [`crate::landed`] and [`crate::claim`] decide over a board's COLUMN NAMES, +//! and those are one tracker's words. They were `const`s inside those modules +//! until CLOUD-1623 measured what that costs: on any board spelling its columns +//! differently — Jira's `To Do`/`In Development`, a GitHub Project's whatever +//! the owner typed — every comparison is false. `is_started` never fires, the +//! landed-honesty sweep reports **zero findings over a board full of dishonest +//! columns**, and `claim` never refuses. +//! +//! That failure is invisible from outside, which is the whole reason it +//! survived: a gate that cannot fire and a gate that found nothing emit the same +//! bytes and the same exit code. +//! +//! # The one rule this module enforces: absent is could-not-look +//! +//! [`Columns::resolve`] refuses an undeclared column **by name** rather than +//! substituting a default. A default would put this repository's own words back +//! in the engine with one more step in front of them, and would restore exactly +//! the property that made the original defect unobservable — the dead path and +//! the working path answering identically. +//! +//! So a consumer who has not declared a column gets a refusal that says which +//! one, and the verb decides nothing. That is the same three-valued read +//! [`crate::ready::Grammar`] already gives for the pattern registry, and this +//! module is deliberately its sibling rather than a second mechanism. + +use crate::config::Board; + +/// The columns a landing or claim decision reads, each already proven present. +/// +/// Built only through [`Columns::resolve`], so a value of this type is evidence +/// that the vocabulary it carries was declared rather than assumed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Columns { + /// The ready queue: the column a row must sit in to be pullable. + pub ready: Option, + /// The column meaning "pulled": somebody is on this now. + pub in_progress: Option, + /// The column a row whose branch is behind git is asked to move back to. + pub review: Option, + /// Every column meaning "somebody has this, or it landed, or it shipped". + pub started: Vec, +} + +/// Which column a reader needed and this consumer did not declare. +/// +/// Carries the config key rather than a sentence, so a caller renders one +/// remedy in its own voice and the same absence cannot acquire two spellings. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Undeclared { + /// The dotted config key that would have answered, e.g. `board.in_progress`. + pub key: &'static str, +} + +impl std::fmt::Display for Undeclared { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "`{}` is not declared, so this decides nothing rather than guessing a column name", + self.key + ) + } +} + +impl Columns { + /// Read the vocabulary this consumer declared, if any. + /// + /// An absent `[board]` table yields a value with every column absent — not + /// an error here, because which columns a given verb needs is that verb's + /// question. `landed abandoned` needs the started set and never the ready + /// queue; `claim check` needs the ready queue and never the started set. + /// Refusing the whole table up front would make a verb fail over a column it + /// does not read. + #[must_use] + pub fn resolve(board: Option<&Board>) -> Self { + let Some(board) = board else { + return Self { + ready: None, + in_progress: None, + review: None, + started: Vec::new(), + }; + }; + Self { + ready: board.ready.clone(), + in_progress: board.in_progress.clone(), + review: board.review.clone(), + started: board.started.clone(), + } + } + + /// The ready-queue column, or which key would have named it. + /// + /// # Errors + /// + /// [`Undeclared`] when `board.ready` is absent. + pub fn ready(&self) -> Result<&str, Undeclared> { + self.ready + .as_deref() + .ok_or(Undeclared { key: "board.ready" }) + } + + /// The pulled column, or which key would have named it. + /// + /// # Errors + /// + /// [`Undeclared`] when `board.in_progress` is absent. + pub fn in_progress(&self) -> Result<&str, Undeclared> { + self.in_progress.as_deref().ok_or(Undeclared { + key: "board.in_progress", + }) + } + + /// The review column, or which key would have named it. + /// + /// # Errors + /// + /// [`Undeclared`] when `board.review` is absent. + pub fn review(&self) -> Result<&str, Undeclared> { + self.review.as_deref().ok_or(Undeclared { + key: "board.review", + }) + } + + /// The started set, or which key would have named it. + /// + /// An EMPTY set is undeclared rather than "no column means started": a set + /// that matches nothing makes every row read as not-advanced, which is the + /// silent all-clear this module exists to refuse. + /// + /// # Errors + /// + /// [`Undeclared`] when `board.started` is empty. + pub fn started(&self) -> Result<&[String], Undeclared> { + if self.started.is_empty() { + return Err(Undeclared { + key: "board.started", + }); + } + Ok(&self.started) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + fn declared() -> Board { + Board { + ready: Some("Todo".to_owned()), + in_progress: Some("In Progress".to_owned()), + review: Some("In Review".to_owned()), + started: vec![ + "In Progress".to_owned(), + "In Review".to_owned(), + "Done".to_owned(), + ], + } + } + + #[test] + fn an_absent_table_answers_undeclared_for_every_column() { + let columns = Columns::resolve(None); + assert_eq!( + columns.ready().unwrap_err().key, + "board.ready", + "an absent table must name the key rather than substituting a default" + ); + assert_eq!(columns.in_progress().unwrap_err().key, "board.in_progress"); + assert_eq!(columns.review().unwrap_err().key, "board.review"); + assert_eq!(columns.started().unwrap_err().key, "board.started"); + } + + #[test] + fn a_declared_table_answers_its_own_words() { + let board = declared(); + let columns = Columns::resolve(Some(&board)); + assert_eq!(columns.ready().unwrap(), "Todo"); + assert_eq!(columns.in_progress().unwrap(), "In Progress"); + assert_eq!(columns.review().unwrap(), "In Review"); + assert_eq!(columns.started().unwrap().len(), 3); + } + + /// The anti-vacuity direction: a consumer spelling its columns differently + /// must get ITS words back, not this repository's. + #[test] + fn another_boards_vocabulary_survives_resolution() { + let board = Board { + ready: Some("To Do".to_owned()), + in_progress: Some("In Development".to_owned()), + review: Some("Under Review".to_owned()), + started: vec!["In Development".to_owned(), "Shipped".to_owned()], + }; + let columns = Columns::resolve(Some(&board)); + assert_eq!(columns.ready().unwrap(), "To Do"); + assert_eq!(columns.in_progress().unwrap(), "In Development"); + assert!( + !columns.started().unwrap().iter().any(|c| c == "Done"), + "resolution must not smuggle this repository's vocabulary into another board's set" + ); + } + + /// An empty set is could-not-look, never "nothing counts as started" — the + /// distinction the whole module turns on. + #[test] + fn an_empty_started_set_is_undeclared_rather_than_empty() { + let board = Board { + started: Vec::new(), + ..declared() + }; + let columns = Columns::resolve(Some(&board)); + assert_eq!( + columns.started().unwrap_err().key, + "board.started", + "an empty set must refuse; matching nothing would report every row as not-advanced" + ); + } +} diff --git a/crates/batten/src/claim.rs b/crates/batten/src/claim.rs index 2692330e8..82a287b76 100644 --- a/crates/batten/src/claim.rs +++ b/crates/batten/src/claim.rs @@ -46,6 +46,13 @@ use std::path::{Path, PathBuf}; use crate::Result; use crate::error::UsageError; +// THE ONE SPELLING OF THE PULL-REQUEST SHAPE (non-negotiable rule 1, +// CLOUD-1623). This module carried its own copy that additionally required the +// literal `github.com/`, so on any other forge `live_pull_request` answered +// `None` for every real pull request and the open-competitor check was dead — a +// claim gate silently missing the one competitor it exists to find. The +// host-free version in `landed` is the survivor; its doc carries the reasoning. +use crate::landed::is_pull_request_url; /// A refusal: which issue, and which rule. /// @@ -211,21 +218,6 @@ fn live_pull_request(value: &serde_json::Value) -> Option { }) } -/// Whether a URL is a GitHub pull request. -/// -/// Matched on the URL SHAPE rather than the attachment title, which is free text -/// a human wrote. -fn is_pull_request_url(url: &str) -> bool { - let Some(rest) = url.split_once("github.com/").map(|(_, rest)| rest) else { - return false; - }; - let Some((_, tail)) = rest.split_once("/pull/") else { - return false; - }; - let number: String = tail.chars().take_while(char::is_ascii_digit).collect(); - !number.is_empty() -} - /// What the caller asked for, beyond the payloads. #[derive(Debug, Clone, Default)] pub struct Request { @@ -276,19 +268,36 @@ impl Verdict { /// would send the reader to the wrong question. pub fn judge( grammar: &crate::ready::Grammar, + columns: &crate::board::Columns, issues: &[Issue], request: &Request, root: &Path, receipts: Option<&Path>, ) -> Result { + // THE READY QUEUE IS THE CONSUMER'S WORD (non-negotiable rule 1, + // CLOUD-1623). It was the literal `"Todo"` here, so on any board spelling + // its queue differently EVERY row read as not-pullable — a refusal that + // never lets anyone claim anything, which is the loud direction of this + // defect and the only reason it would have been noticed at all. + // + // Undeclared refuses the RUN rather than every row: a claim gate that + // cannot name the queue has not decided that nothing is pullable. + let ready_column = columns.ready().map_err(|undeclared| { + UsageError::raise(format!( + "claim: {undeclared}. Declare the column your board calls the ready \ + queue, and this decides again." + )) + })?; let mut verdict = Verdict::default(); for issue in issues { let before = verdict.refusals.len(); - if issue.status != "Todo" { + if issue.status != ready_column { verdict.refusals.push(Refusal { id: issue.id.clone(), - rule: format!("not-todo (in {})", issue.status), + // The token carries the consumer's word for the queue, so a + // reader is told which column their row failed to be in. + rule: format!("not-{} (in {})", ready_column.to_lowercase(), issue.status), kind: Kind::Competitor, }); continue; @@ -1012,6 +1021,23 @@ pub fn adopt( mod tests { use super::*; + /// This repository's own ready-queue column, as `batten.toml` declares it. + /// + /// An INPUT to the predicate now rather than a constant inside it — which is + /// the whole of CLOUD-1623, made visible at every call site below. + fn test_columns() -> crate::board::Columns { + crate::board::Columns { + ready: Some("Todo".to_owned()), + in_progress: Some("In Progress".to_owned()), + review: Some("In Review".to_owned()), + started: vec![ + "In Progress".to_owned(), + "In Review".to_owned(), + "Done".to_owned(), + ], + } + } + /// A tracker row with just the two fields the sequence rules read. fn issue(id: &str, status: &str) -> Issue { Issue { @@ -1345,8 +1371,14 @@ mod tests { // is CLOUD-526's projection: three of the four rules never look at it. let issues = [issue("CLOUD-1", "In Progress")]; let grammar = crate::ready::Grammar::committed(); - let Ok(verdict) = judge(&grammar, &issues, &Request::default(), Path::new("."), None) - else { + let Ok(verdict) = judge( + &grammar, + &test_columns(), + &issues, + &Request::default(), + Path::new("."), + None, + ) else { panic!("a non-Todo issue needs no body") }; assert_eq!(verdict.refusals.len(), 1); @@ -1359,7 +1391,14 @@ mod tests { // it is refused BY NAME so the reader is sent to the right question. let issues = [issue("CLOUD-1", "Todo")]; let grammar = crate::ready::Grammar::committed(); - let answer = judge(&grammar, &issues, &Request::default(), Path::new("."), None); + let answer = judge( + &grammar, + &test_columns(), + &issues, + &Request::default(), + Path::new("."), + None, + ); assert!( answer.is_err(), "a bodyless payload must not read as pullable" diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index 73084dd12..cb9243ec9 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -206,6 +206,11 @@ pub struct Config { /// hand, where that one is an economy about somebody else's runner. #[serde(default, skip_serializing_if = "Option::is_none")] pub receipt: Option, + /// This consumer's board column vocabulary. Absent means this file does not + /// speak to it, which every reader takes as could-not-look rather than as a + /// default — see [`Board`] for why a default would be the violation again. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub board: Option, /// Accepted invocation-latency regressions (CLOUD-1163 unit 10). Absent /// means this file accepts none, which is the safe direction — an absent /// table cannot exempt a path. @@ -882,6 +887,69 @@ pub struct Receipt { pub verified_by: Vec, } +/// The `[board]` table: this consumer's column vocabulary. +/// +/// # Why this table exists (non-negotiable rule 1) +/// +/// [`crate::landed`] and [`crate::claim`] decide over a board's COLUMN NAMES — +/// which column is the ready queue, which means "pulled", which mean "somebody +/// has this, or it landed, or it shipped". Those are one tracker's words. Linear +/// ships `Todo`/`In Progress`/`In Review`/`Done`; Jira ships `To Do`/`In +/// Development`; a GitHub Project ships whatever the owner typed. +/// +/// Carried as engine constants they were rule 1's violation in its worst form. +/// Off this board every comparison is false, so `is_started` never fires, the +/// landed-honesty sweep reports **zero findings over a board full of dishonest +/// columns**, and `claim` never refuses. A gate that cannot fire is +/// indistinguishable from a gate that found nothing, which is the one failure +/// this whole module family exists to avoid. +/// +/// # Absent is could-not-look, never a default +/// +/// An undeclared table does **not** fall back to this repository's own words. +/// A default would reinstate the violation with an extra step and make the dead +/// path byte-identical to the working one again — the exact shape that let the +/// constants survive. A verb needing a column this table does not declare says +/// so, by name, and decides nothing. +/// +/// # Why values and not `[[pattern]]` rows +/// +/// [`Ready`]'s reason below, and one more directly: a column is matched by +/// EQUALITY against the string the tracker echoes back, never by a regex over +/// it. The pattern registry exists so one CONCEPT has one spelling; a literal +/// the round trip returns verbatim is a value. +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Board { + /// The column a row must sit in to be pullable — the ready queue. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ready: Option, + /// The column meaning "pulled": somebody is on this now. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub in_progress: Option, + /// The column a row whose branch is behind git is asked to move back to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review: Option, + /// Every column meaning "somebody has this, or it has landed, or it has + /// shipped". + /// + /// **The released column belongs in this set, and leaving it out was a + /// measured defect** (CLOUD-1458). The engine constant this replaces read + /// `["In Progress", "In Review"]`, so a declined key that reached the + /// released column escaped the sweep entirely — and released is where the + /// claim is strongest and the lie therefore costs most. Measured on that + /// gate's own two rows: CLOUD-186 and CLOUD-1127 were declined with + /// `DO-NOT-CLOSE` in the body of the pull request that landed the module, + /// advanced by the merge, moved back by hand, and advanced to the released + /// column by a release 2026-09-05T02:52:56Z — past the far edge of a + /// predicate written the day before. + /// + /// The ready-queue columns stay OUT: a declined key sitting there is + /// `DO-NOT-CLOSE` working, and refusing it would make the marker unwritable. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub started: Vec, +} + /// The `[ready]` table: the refinement gate's consumer-set thresholds. /// /// # Why a value and not a `[[pattern]]` row (CLOUD-472) @@ -3192,6 +3260,7 @@ impl Config { // reader takes as could-not-look and exempts everything — the same // direction every other field here grants. ready: None, + board: None, // Declaring nothing accepts no regression, which is also the safe // reading: an authority that cannot be read must not exempt a path. perf: None, diff --git a/crates/batten/src/landed.rs b/crates/batten/src/landed.rs index 6182828b4..4f2855619 100644 --- a/crates/batten/src/landed.rs +++ b/crates/batten/src/landed.rs @@ -70,6 +70,7 @@ use std::collections::{BTreeMap, BTreeSet}; use anyhow::Result; +use crate::board::Columns; use crate::error::UsageError; /// Which way a column is wrong, and therefore which remedy applies. @@ -97,15 +98,26 @@ impl Reason { } } - /// Where the row should sit instead. + /// Where the row should sit instead, in the consumer's own words. + /// + /// **Takes the vocabulary rather than holding it** (non-negotiable rule 1). + /// The two columns were `const`s here, which made this remediation name a + /// board only this repository has — a consumer reading the finding was told + /// to move the row somewhere their tracker has no column for. + /// + /// An undeclared column yields [`None`] rather than a guess, and the caller + /// reports the finding without the remedy instead of inventing one. The + /// finding is the honest half; the remedy is the half that needs a + /// vocabulary. #[must_use] - pub const fn wants(self) -> &'static str { + pub fn wants(self, columns: &Columns) -> Option<&str> { match self { - // Landed is In Review, per the Definition of Ready & Done. - Self::BehindGit => "In Review", + // Landed belongs in the review column, per the Definition of Ready + // & Done. + Self::BehindGit => columns.review.as_deref(), // A declined row was never this PR's to advance, so it belongs back // in the queue it was pulled from rather than at some later column. - Self::DeclinedButAdvanced => "Todo", + Self::DeclinedButAdvanced => columns.ready.as_deref(), } } } @@ -138,34 +150,34 @@ pub struct Row { } impl Row { - /// The columns that mean "somebody has this, or it has landed, or it has - /// shipped". + /// Whether this row sits in the column the consumer calls "pulled". /// - /// Named as a set rather than tested inline so the two directions below - /// cannot drift about what "advanced" means. + /// **Takes the vocabulary rather than holding it** (non-negotiable rule 1). + /// The columns were `const`s here until they were measured as rule 1's worst + /// violation: off this board every comparison is false, so this predicate + /// never fires and the sweep reports zero findings over a board full of + /// dishonest columns. See [`crate::config::Board`]. /// - /// **`Done` is in the set, and leaving it out was a measured defect** - /// (CLOUD-1458). The set read `["In Progress", "In Review"]`, so a declined - /// key that reached Done escaped the sweep entirely — and Done is - /// RELEASED, which is where the claim is strongest and the lie therefore - /// costs most. Measured on this gate's own two rows: CLOUD-186 and - /// CLOUD-1127 were declined with `DO-NOT-CLOSE` in the body of the pull - /// request that landed this module, advanced to In Review by the merge, - /// moved back by hand, and advanced to Done by a release - /// 2026-09-05T02:52:56Z — past the far edge of a predicate written the day - /// before. - /// - /// `Backlog` and `Todo` stay out, because they are the ready queue: a - /// declined key sitting there is `DO-NOT-CLOSE` working, and refusing it - /// would make the marker unwritable. - const STARTED: [&'static str; 3] = ["In Progress", "In Review", "Done"]; - - fn is_in_progress(&self) -> bool { - self.status == "In Progress" + /// An undeclared column answers `false` **because there is nothing to + /// compare against, not because the row is elsewhere** — which is why every + /// caller resolves the vocabulary first and refuses when it is absent, + /// rather than letting this read as a clean answer. + fn is_in_progress(&self, columns: &Columns) -> bool { + columns + .in_progress + .as_ref() + .is_some_and(|column| self.status == *column) } - fn is_started(&self) -> bool { - Self::STARTED.contains(&self.status.as_str()) + /// Whether this row sits in any column meaning "somebody has this, or it has + /// landed, or it has shipped". + /// + /// Read as a set rather than tested inline so the two directions cannot + /// drift about what "advanced" means. The set's membership doctrine — why + /// the released column is in it and the ready queue is not — moved to + /// [`crate::config::Board::started`] with the vocabulary it describes. + fn is_started(&self, columns: &Columns) -> bool { + columns.started.contains(&self.status) } } @@ -230,8 +242,12 @@ impl Report { /// testable without a tracker or a forge, which is the split /// `crate::speculation` makes for the same reason — "does this do what the bash /// did" has to be answerable without a network. +/// **Takes the board's vocabulary rather than holding it** (non-negotiable +/// rule 1, CLOUD-1623). A caller resolves [`Columns`] from config and refuses +/// before reaching here when a column it needs is undeclared — this function +/// cannot report could-not-look, because its whole contract is that it decides. #[must_use] -pub fn decide(rows: &[Row], evidence: &Evidence) -> Report { +pub fn decide(rows: &[Row], evidence: &Evidence, columns: &Columns) -> Report { let mut findings = Vec::new(); for row in rows { @@ -243,7 +259,7 @@ pub fn decide(rows: &[Row], evidence: &Evidence) -> Report { // Bound here rather than at its arm so the arm can be an `else if`: the // two arms are mutually exclusive by the paragraph below, and spelling // that as a chain is what keeps the exclusion structural. - let behind_git = row.is_in_progress() && evidence.landed(&row.id); + let behind_git = row.is_in_progress(columns) && evidence.landed(&row.id); // **THE DECLINE IS ASKED FIRST, because it outranks the landing and the // two arms are mutually exclusive** (review of #848). A row can satisfy @@ -261,7 +277,7 @@ pub fn decide(rows: &[Row], evidence: &Evidence) -> Report { // A declined row still in Todo passes, so this is not a blanket refusal // of the marker: `DO-NOT-CLOSE` on a row nothing advanced is the marker // working. - if row.is_started() && evidence.declined.contains(&row.id) { + if row.is_started(columns) && evidence.declined.contains(&row.id) { findings.push(Finding { id: row.id.clone(), holds: row.status.clone(), @@ -376,8 +392,17 @@ pub struct Claim { } impl Claim { - fn is_in_progress(&self) -> bool { - self.status == "In Progress" + /// Whether this claim sits in the column the consumer calls "pulled". + /// + /// A SECOND, INDEPENDENT SPELLING of [`Row::is_in_progress`] — the two impls + /// decide over different row types and were separately hard-coded, so the + /// literal appeared twice and CLOUD-1623's own body carried only one of + /// them. Both take the vocabulary now, from the one declaration. + fn is_in_progress(&self, columns: &Columns) -> bool { + columns + .in_progress + .as_ref() + .is_some_and(|column| self.status == *column) } /// Whether any attachment is a pull request. @@ -409,7 +434,22 @@ impl Claim { /// not a `[[pattern]]` row: a preset cannot read one (see /// `.claude/rules/policy-modules.md`), and this is a forge's URL shape rather /// than a consumer's vocabulary, so rule 1 is not in play. -fn is_pull_request_url(url: &str) -> bool { +/// +/// # The one spelling, shared (CLOUD-1623) +/// +/// [`crate::claim`] carried its own divergent copy that additionally required +/// the literal `github.com/`, which made a real merge-request URL on any other +/// forge read as "not a pull request" — so the open-competitor check there was +/// dead off GitHub. Two authorities for one predicate is the same defect class +/// as a hand-joined second spelling of a path constant, so the host-free version +/// is the survivor and the other module reads it from here. +/// +/// **Still forge-shaped, and knowingly**: a GitLab merge request is +/// `/merge_requests/`, which this does not match. That is a narrower gap than +/// the host literal it replaces and is recorded rather than silently widened — +/// matching every `//` would admit `/issues/12` and turn an issue +/// link into a claim. +pub(crate) fn is_pull_request_url(url: &str) -> bool { url.match_indices("/pull/").any(|(at, marker)| { url.get(at + marker.len()..) .and_then(|rest| rest.chars().next()) @@ -531,6 +571,7 @@ pub fn drain( evidence: &Evidence, refs: &BTreeSet, bound: &Bound, + columns: &Columns, ) -> Result { let today = bound.today; @@ -538,7 +579,7 @@ pub fn drain( let mut landed_unswept = Vec::new(); let mut candidates = Vec::new(); for claim in claims { - if !claim.is_in_progress() { + if !claim.is_in_progress(columns) { continue; } in_progress += 1; @@ -549,7 +590,9 @@ pub fn drain( candidates.push(claim); } - demand("updatedAt", &candidates, |claim| claim.updated_at.is_none())?; + demand("updatedAt", &candidates, columns, |claim| { + claim.updated_at.is_none() + })?; let mut unreadable = Vec::new(); let mut stale = Vec::new(); @@ -571,8 +614,12 @@ pub fn drain( // `attachments` first, matching the order the predecessor reports them in: // a row missing both names the one a tracker's list projection cannot // supply, which is the one whose remedy is a different fetch. - demand("attachments", &stale, |claim| claim.attachments.is_none())?; - demand("gitBranchName", &stale, |claim| claim.branch.is_none())?; + demand("attachments", &stale, columns, |claim| { + claim.attachments.is_none() + })?; + demand("gitBranchName", &stale, columns, |claim| { + claim.branch.is_none() + })?; let mut abandoned = Vec::new(); for claim in stale { @@ -600,7 +647,17 @@ pub fn drain( } /// Refuse when a row whose verdict needs `key` does not carry it. -fn demand(key: &str, claims: &[&Claim], absent: impl Fn(&Claim) -> bool) -> Result<()> { +/// +/// **The column is named in the consumer's words** (non-negotiable rule 1). This +/// prose carried the literal, which made a refusal a consumer reads cite a +/// column their board does not have — the finding stayed correct while its +/// sentence became wrong, which is the quietest half of this class. +fn demand( + key: &str, + claims: &[&Claim], + columns: &Columns, + absent: impl Fn(&Claim) -> bool, +) -> Result<()> { let missing: Vec<&str> = claims .iter() .filter(|claim| absent(claim)) @@ -609,8 +666,11 @@ fn demand(key: &str, claims: &[&Claim], absent: impl Fn(&Claim) -> bool) -> Resu if missing.is_empty() { return Ok(()); } + // The rows reaching here were selected BY that column, so it is declared; + // the fallback names the key rather than guessing a word for it. + let column = columns.in_progress.as_deref().unwrap_or("the pulled"); Err(UsageError::raise(format!( - "landed: no `{key}` on unresolved In Progress issue(s): {}. \ + "landed: no `{key}` on unresolved {column} issue(s): {}. \ The abandonment verdict reads that key, so a payload without it cannot \ answer — re-fetch those rows individually.", missing.join(" ") @@ -681,6 +741,24 @@ pub fn claims_from(value: &serde_json::Value) -> Result> { mod tests { use super::*; + /// This repository's own board vocabulary, as `batten.toml` declares it. + /// + /// Spelled here rather than reached for from config so the unit tests stay + /// pure — and named so a reader can see that the words are now an INPUT to + /// the predicate rather than a property of it. + fn columns() -> Columns { + Columns { + ready: Some("Todo".to_owned()), + in_progress: Some("In Progress".to_owned()), + review: Some("In Review".to_owned()), + started: vec![ + "In Progress".to_owned(), + "In Review".to_owned(), + "Done".to_owned(), + ], + } + } + fn row(id: &str, status: &str) -> Row { Row { id: id.to_owned(), @@ -700,10 +778,14 @@ mod tests { claimed: keys(&["CLOUD-1"]), ..Evidence::default() }, + &columns(), ); assert_eq!(report.findings.len(), 1); assert_eq!(report.findings[0].reason, Reason::BehindGit); - assert_eq!(report.findings[0].reason.wants(), "In Review"); + assert_eq!( + report.findings[0].reason.wants(&columns()), + Some("In Review") + ); } /// THE SUBSTRING TRAP, asserted rather than assumed. `CLOUD-17` must not be @@ -717,6 +799,7 @@ mod tests { claimed: keys(&["CLOUD-179"]), ..Evidence::default() }, + &columns(), ); assert!(report.is_clean()); } @@ -729,10 +812,11 @@ mod tests { declined: keys(&["CLOUD-1"]), ..Evidence::default() }, + &columns(), ); assert_eq!(report.findings.len(), 1); assert_eq!(report.findings[0].reason, Reason::DeclinedButAdvanced); - assert_eq!(report.findings[0].reason.wants(), "Todo"); + assert_eq!(report.findings[0].reason.wants(&columns()), Some("Todo")); } /// DONE IS THE FAR EDGE, AND IT WAS OUTSIDE THE SET (CLOUD-1458). @@ -749,6 +833,7 @@ mod tests { declined: keys(&["CLOUD-1"]), ..Evidence::default() }, + &columns(), ); assert_eq!(report.findings.len(), 1); assert_eq!(report.findings[0].reason, Reason::DeclinedButAdvanced); @@ -765,6 +850,7 @@ mod tests { declined: keys(&["CLOUD-1"]), ..Evidence::default() }, + &columns(), ); assert!(report.is_clean()); } @@ -782,6 +868,7 @@ mod tests { .collect(), ..Evidence::default() }, + &columns(), ); assert_eq!(report.findings[0].asserted_by.as_deref(), Some("abc1234")); } @@ -797,6 +884,7 @@ mod tests { .collect(), ..Evidence::default() }, + &columns(), ); assert_eq!(report.findings[0].asserted_by, None); } @@ -847,7 +935,14 @@ mod tests { } fn swept(claims: &[Claim]) -> Drain { - drain(claims, &Evidence::default(), &BTreeSet::new(), &bound()).unwrap_or_default() + drain( + claims, + &Evidence::default(), + &BTreeSet::new(), + &bound(), + &columns(), + ) + .unwrap_or_default() } #[test] @@ -872,6 +967,7 @@ mod tests { }, &BTreeSet::new(), &bound(), + &columns(), ) .unwrap_or_default(); assert_eq!(report.landed_unswept, vec!["CLOUD-1".to_owned()]); @@ -925,6 +1021,7 @@ mod tests { &Evidence::default(), &keys(&["feat/live"]), &bound(), + &columns(), ) .unwrap_or_default(); assert!(report.abandoned.is_empty()); @@ -941,6 +1038,7 @@ mod tests { &Evidence::default(), &keys(&[""]), &bound(), + &columns(), ) .unwrap_or_default(); assert_eq!(report.abandoned, vec!["CLOUD-1".to_owned()]); @@ -971,7 +1069,8 @@ mod tests { &[fresh.clone()], &Evidence::default(), &BTreeSet::new(), - &bound() + &bound(), + &columns(), ) .is_ok(), "a fresh row is already resolved by the bound, so it owes no attachments" @@ -984,7 +1083,8 @@ mod tests { &[fresh, stale], &Evidence::default(), &BTreeSet::new(), - &bound() + &bound(), + &columns(), ) .is_err(), "the narrowing must not become a hole: a stale row still owes the key" @@ -999,8 +1099,14 @@ mod tests { done.status = "Done".to_owned(); done.attachments = None; done.updated_at = None; - let report = - drain(&[done], &Evidence::default(), &BTreeSet::new(), &bound()).unwrap_or_default(); + let report = drain( + &[done], + &Evidence::default(), + &BTreeSet::new(), + &bound(), + &columns(), + ) + .unwrap_or_default(); assert!(report.is_clean()); assert_eq!(report.in_progress, 0); } diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index a9b933c08..3614b8976 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -14,6 +14,9 @@ pub mod advisory; pub mod agent; pub mod attribution; pub mod baseline; +/// The board's column vocabulary, resolved from config rather than held as +/// engine constants (non-negotiable rule 1, CLOUD-1623). +pub mod board; pub mod bot; pub mod brief; pub mod budget; @@ -324,7 +327,7 @@ pub fn run(cli: Cli, mode: Mode, out: &mut dyn Write, err: &mut dyn Write) -> Re // The board sweep (CLOUD-186, CLOUD-1127). Judges a payload rather than // a tree, so it takes no config chain and no root: the evidence is what // the caller supplies, and the verdict is the predicate's alone. - Some(Command::Landed { command }) => run_landed(command, mode, out, err), + Some(Command::Landed { command }) => run_landed(command, mode, &overrides, out, err), Some(Command::Claim { command }) => run_claim(command, mode, &overrides, out, err), // The adopted runner's surface contract (CLOUD-947). No config chain and // no rule set: the subject is the pinned binary's own answer about @@ -2588,6 +2591,7 @@ fn evidence_file(path: &str, what: &str) -> Result)> fn run_landed( command: LandedCommand, mode: Mode, + overrides: &Overrides, out: &mut dyn Write, err: &mut dyn Write, ) -> Result { @@ -2603,6 +2607,7 @@ fn run_landed( landed_by.as_deref(), declined.as_deref(), mode, + overrides, err, ), LandedCommand::Abandoned { @@ -2613,6 +2618,7 @@ fn run_landed( instant, max_idle_days, } => run_landed_abandoned( + overrides, &AbandonAsk { claimed: claimed.as_deref(), merged_prs: merged_prs.as_deref(), @@ -2657,6 +2663,7 @@ fn run_landed_check( landed_by: Option<&str>, declined: Option<&str>, mode: Mode, + overrides: &Overrides, err: &mut dyn Write, ) -> Result { // ABSENT EVIDENCE IS COULD-NOT-LOOK, NEVER A SHORT SWEEP. Half the landed @@ -2729,7 +2736,8 @@ fn run_landed_check( } } - let report = landed::decide(&rows, &evidence); + let columns = board_columns(overrides)?; + let report = landed::decide(&rows, &evidence, &columns); // Pointer-only per rule 4: a key, two column names and a reason class. Never // a line of any body — a PR body and an issue body both carry consumer @@ -2743,15 +2751,24 @@ fn run_landed_check( .as_ref() .map(|reference| format!(" (asserted by --landed-by: {reference})")) .unwrap_or_default(); + // THE REMEDY IS OMITTED RATHER THAN GUESSED when the consumer has not + // named the column it would send the reader to (CLOUD-1623). The finding + // — key, column held, reason class — needs no vocabulary and is always + // printed; only the arrow half does, so an undeclared column costs the + // suggestion and never the report. + let wants = finding + .reason + .wants(&columns) + .map(|column| format!(" -> {column}")) + .unwrap_or_default(); output::message( mode, Verbosity::Normal, err, &format!( - " {} {} -> {} {}{suffix}", + " {} {}{wants} {}{suffix}", finding.id, finding.holds, - finding.reason.wants(), finding.reason.token(), ), )?; @@ -2783,6 +2800,7 @@ fn run_landed_check( /// or when a row whose verdict needs a key does not carry it. Every one is exit /// 2: a sweep that could not look must never render as a clean column. fn run_landed_abandoned( + overrides: &Overrides, ask: &AbandonAsk<'_>, mode: Mode, out: &mut dyn Write, @@ -2876,6 +2894,7 @@ fn run_landed_abandoned( } } + let columns = board_columns(overrides)?; let report = landed::drain( &claims, &evidence, @@ -2884,6 +2903,7 @@ fn run_landed_abandoned( max_idle_days, today, }, + &columns, )?; render_drain(&report, max_idle_days, mode, out, err)?; @@ -3911,6 +3931,23 @@ fn render_findings(findings: &[checks_green::Finding]) -> String { .join(", ") } +/// This board's column vocabulary, resolved from the `[board]` table +/// (CLOUD-1623). +/// +/// [`board_grammar`]'s sibling, and deliberately a second function rather than a +/// field on the grammar: a verb needing columns may need no patterns, and one +/// resolver would make an unrelated table's gap look like this one's verdict — +/// the reason `write_records` already resolves the grammar as `Option`. +/// +/// **Absent is could-not-look, never a default.** [`crate::board::Columns`] +/// refuses by naming the key, so a consumer who has not declared a column is +/// told which one rather than being silently measured against this +/// repository's own words. +fn board_columns(overrides: &Overrides) -> Result { + let config = resolve::resolve(Path::new("."), overrides)?; + Ok(board::Columns::resolve(config.board.as_ref())) +} + /// The Ready grammar, resolved from this repository's own `[[pattern]]` table /// (CLOUD-1100). /// @@ -3976,6 +4013,7 @@ fn run_claim( run_claim_check( &board_root(), &board_grammar(overrides)?, + &board_columns(overrides)?, &ClaimAsk { request: &request, adopt, @@ -4168,6 +4206,7 @@ const WRITE_TOOL: &str = "save_issue"; fn run_claim_check( repo: &Path, grammar: &ready::Grammar, + columns: &board::Columns, ask: &ClaimAsk<'_>, mode: Mode, out: &mut dyn Write, @@ -4190,7 +4229,14 @@ fn run_claim_check( } let issues = claim_payloads(repo, issue)?; - let verdict = claim::judge(grammar, &issues, request, repo, receipts.as_deref())?; + let verdict = claim::judge( + grammar, + columns, + &issues, + request, + repo, + receipts.as_deref(), + )?; if json { writeln!( diff --git a/crates/batten/src/resolve.rs b/crates/batten/src/resolve.rs index 57bf2754c..195ead8f1 100644 --- a/crates/batten/src/resolve.rs +++ b/crates/batten/src/resolve.rs @@ -506,6 +506,14 @@ pub struct Resolved { /// 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, + /// This board's column vocabulary (CLOUD-1623), from the **committed + /// authority alone**. + /// + /// `None` is could-not-look, and every reader takes it as one: a verb needing + /// a column names the key it wanted and decides nothing. A local layer + /// cannot supply these for §8's reason — renaming the column a claim gate + /// admits is a weakening, and the chain admits raise-only overrides. + pub board: Option, /// The accepted invocation-latency regressions (CLOUD-1163 unit 10), from the /// **committed authority alone**. `None` accepts nothing, which is the only /// safe reading: a local layer that could add an exemption would be a @@ -1678,6 +1686,7 @@ fn assemble( // 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(), + board: repo.board.clone(), perf: repo.perf.clone(), unlanded: paths.unlanded, epoch: repo.epoch.clone(), @@ -1773,6 +1782,7 @@ fn attribution( // 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())), + ("board", authority_set(repo.board.is_some())), // AUTHORITY-ONLY for the same reason, one table over: every row RAISES a // path's threshold, so a local layer that could add one would be the // weakening §8 refuses. diff --git a/schema/batten.schema.json b/schema/batten.schema.json index 6aadd4b81..660a35b80 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -26,6 +26,17 @@ } ] }, + "board": { + "description": "This consumer's board column vocabulary. Absent means this file does not\nspeak to it, which every reader takes as could-not-look rather than as a\ndefault — see [`Board`] for why a default would be the violation again.", + "anyOf": [ + { + "$ref": "#/$defs/Board" + }, + { + "type": "null" + } + ] + }, "bot_lane": { "description": "The bot lane this repository files rows for (CLOUD-1295). Absent means it\nruns none, and the `pr` verbs say so rather than filing against defaults —\na lane assembled from engine literals would be a row asserting a bump\nnobody configured.\n\nConsumer-specific by nature, and the reason it lives here rather than in\nthe crate: which repository, which bot logins and which manifests a lane\nowns are that repository's business (non-negotiable rule 1), so the core\ncarries the matcher and this table carries the answers. The type and the\npredicates are [`crate::bot`].", "anyOf": [ @@ -678,6 +689,41 @@ } ] }, + "Board": { + "description": "The `[board]` table: this consumer's column vocabulary.\n\n# Why this table exists (non-negotiable rule 1)\n\n[`crate::landed`] and [`crate::claim`] decide over a board's COLUMN NAMES —\nwhich column is the ready queue, which means \"pulled\", which mean \"somebody\nhas this, or it landed, or it shipped\". Those are one tracker's words. Linear\nships `Todo`/`In Progress`/`In Review`/`Done`; Jira ships `To Do`/`In\nDevelopment`; a GitHub Project ships whatever the owner typed.\n\nCarried as engine constants they were rule 1's violation in its worst form.\nOff this board every comparison is false, so `is_started` never fires, the\nlanded-honesty sweep reports **zero findings over a board full of dishonest\ncolumns**, and `claim` never refuses. A gate that cannot fire is\nindistinguishable from a gate that found nothing, which is the one failure\nthis whole module family exists to avoid.\n\n# Absent is could-not-look, never a default\n\nAn undeclared table does **not** fall back to this repository's own words.\nA default would reinstate the violation with an extra step and make the dead\npath byte-identical to the working one again — the exact shape that let the\nconstants survive. A verb needing a column this table does not declare says\nso, by name, and decides nothing.\n\n# Why values and not `[[pattern]]` rows\n\n[`Ready`]'s reason below, and one more directly: a column is matched by\nEQUALITY against the string the tracker echoes back, never by a regex over\nit. The pattern registry exists so one CONCEPT has one spelling; a literal\nthe round trip returns verbatim is a value.", + "type": "object", + "properties": { + "in_progress": { + "description": "The column meaning \"pulled\": somebody is on this now.", + "type": [ + "string", + "null" + ] + }, + "ready": { + "description": "The column a row must sit in to be pullable — the ready queue.", + "type": [ + "string", + "null" + ] + }, + "review": { + "description": "The column a row whose branch is behind git is asked to move back to.", + "type": [ + "string", + "null" + ] + }, + "started": { + "description": "Every column meaning \"somebody has this, or it has landed, or it has\nshipped\".\n\n**The released column belongs in this set, and leaving it out was a\nmeasured defect** (CLOUD-1458). The engine constant this replaces read\n`[\"In Progress\", \"In Review\"]`, so a declined key that reached the\nreleased column escaped the sweep entirely — and released is where the\nclaim is strongest and the lie therefore costs most. Measured on that\ngate's own two rows: CLOUD-186 and CLOUD-1127 were declined with\n`DO-NOT-CLOSE` in the body of the pull request that landed the module,\nadvanced by the merge, moved back by hand, and advanced to the released\ncolumn by a release 2026-09-05T02:52:56Z — past the far edge of a\npredicate written the day before.\n\nThe ready-queue columns stay OUT: a declined key sitting there is\n`DO-NOT-CLOSE` working, and refusing it would make the marker unwritable.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, "BotLane": { "description": "The `[bot_lane]` table: which proposals this repository will file a row for.\n\nAbsent means the repository runs no bot lane, and the verbs say so rather than\nfiling against defaults — a lane assembled from engine literals would be a row\nasserting a bump nobody configured, which is the CLOUD-198 class with a new\nauthor.", "type": "object", From a6d043f68065732462db4f4726b4e043637ff261 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Mon, 7 Sep 2026 21:28:14 +0000 Subject: [PATCH 2/7] fix(board): refuse the next column literal, not just this one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-negotiable rule 2: a rule without a runnable gate is half a change. 32f9876 took the board's column names out of the engine; nothing yet refused the next one, and they arrived in the first place because nothing refused them. This file already carries the measured cost of that shape — twenty issue-key derivations accumulated because nothing refused the twenty-first before it was typed. ## Why this matches the comparison and not the vocabulary The obvious row is unlandable, and that is a measurement. Banning the four column WORDS under `crates/**` fires on 126 occurrences across 18 files, nearly all legitimate: integration fixtures building board payloads (`landed_check.rs` 19, `claim.rs` 25, `board_receipts.rs` 12), unit-test vocabulary, and prose recording why the released column is in the started set. That row either never lands or lands carrying an exemption list longer than itself — and an exemption is where the next violation hides. The violation was never the word. It was COMPARING A STATUS AGAINST A LITERAL, because that is the line that decides, and the line that answers false on every other board while reading as a clean sweep. So the regex is the comparison shape in both operand orders, and it reaches exactly the construct `board::Columns` replaced. It lands at ZERO with no `exclude`. The only match after the seam commit was `board.rs`'s own anti-vacuity test asserting a foreign board's set lacks this repository's word; that assertion now compares the whole set, which is stronger anyway — a negative assertion also passes for a resolver that dropped every column. `checks_green.rs`'s `status != "completed"` and `hk.rs`'s `!= "included"` are deliberately not reached. They compare a check run's state and a plan's state — a forge's and this engine's own vocabularies, not a tracker's. A row keyed on the bare field name would have caught both and taught the next reader that the ban is about the field. ## Shown able to fail The acceptance CLOUD-1626 records as missing from the last rule-1 gate. Clean tree exits 0; one planted `s == "In Progress"` in `receipt.rs` exits 2 reporting `crates/batten/src/receipt.rs:2477 no-board-column-in-core`; reverting returns it to 0. Pointer-only per rule 4 — `path:line` and the id, never the matched line. BREAKING CHANGE: `claim::judge`, `landed::decide` and `landed::drain` each take the board's column vocabulary as a parameter, introduced by the seam commit on this branch. The vocabulary cannot be defaulted inside them — an undeclared column is could-not-look, and a default would restore the dead-path-reads-as-clean property the seam exists to remove — so the caller resolves `board::Columns` and passes it in. Refs: CLOUD-1623 Admits: a953ddc5aa7b792a4f31157caa36d9f1ef8de40802c4d0d2b9c3e592b60d5c94 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:32f98767e586a4476159245512fc3d72c618f899 Admits-epoch: 24cb2114988016256569172b8027e2e9b6019765eb89934424819c2aa9ac155b Admits-author: alec@wenzowski.com Admits-prev: 8b378ecbdd0e721328b63d4e7661c18704d71af2534b07a81665dcdfa9280835 Admits-answer-lost: CLOUD-1623 stays half-done and silently re-openable. The column literals are out of the engine as of 32f9876, but nothing refuses the next one: the constants originally arrived because no gate refused them, and non-negotiable rule 2 is explicit that a rule shipped without its mechanism is prose. Measured precedent in this file: twenty issue-key derivations accumulated for exactly this reason, and `issue-key-derivations-not-growing` exists because nothing refused the twenty-first before it was typed. Admits-answer-precondition: No batten surface authors a `[[rule]]` row. `batten config` inspects only and `batten init` refuses to overwrite an existing file, so writing `batten.toml` directly is the only route to add the gate. This is the rule-2 half of CLOUD-1623 (claimed, receipt minted on this branch): the seam landed in 32f9876 and a rule without a runnable mechanism is half a change. It is one `[[rule]]` row plus its comment, in a draft PR, visible in the diff a reviewer reads before it lands. Admits-answer-rejected-route: `patch`. Refused identically on this same path earlier in this session (`path write refused batten.toml patch protected-mutation`); the guard is on the path rather than the program, so routing the same bytes through another writer evades the class instead of satisfying it. `config read first` does not apply — the file was read directly before this write and reading cannot add a row. `run first` names no task that authors a rule; `batten check` only evaluates rows that already exist. --- batten.toml | 43 ++++++++++++++++++++++++++++++++++++++ crates/batten/src/board.rs | 8 +++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/batten.toml b/batten.toml index 0d8c927d0..6467c3532 100644 --- a/batten.toml +++ b/batten.toml @@ -3723,6 +3723,49 @@ exclude = "^// (carried|subsumed|changed|withdrawn): " severity = "deny" scope = "tree" +# The rule-2 half of CLOUD-1623: the board's column names left the engine, and +# this is what refuses the next one. They arrived in the first place because +# nothing refused them, and this file already carries the measured cost of that +# shape one row up — twenty issue-key derivations accumulated because nothing +# refused the twenty-first before it was typed. +# +# WHY THIS MATCHES THE COMPARISON AND NOT THE VOCABULARY, which is the whole +# design and the reason the obvious row is unlandable. Measured over `crates/**` +# at 32f9876: the four column literals occur 126 TIMES across 18 files, nearly +# all of them legitimate — integration fixtures building board payloads +# (`landed_check.rs` 19, `claim.rs` 25, `board_receipts.rs` 12), unit-test +# vocabulary, and prose recording why a column is in the started set. A row +# banning the WORDS would fire on every one of them, so it would either never +# land or land with an exemption list longer than the rule. +# +# The violation was never the word. It was COMPARING A STATUS AGAINST A LITERAL +# — `self.status == "In Progress"` — because that is the line that decides, and +# the line that answers false on every other board while reading as a clean +# sweep. So the regex is the comparison shape, in both operand orders, and it +# reaches exactly the construct `board::Columns` exists to replace. +# +# IT LANDS AT ZERO, and that is a measurement rather than a hope: after the seam +# commit the only match in the tree was `board.rs`'s own anti-vacuity test +# asserting that a foreign board's set does not contain this one's word, and +# that assertion was rewritten to compare the whole set — a stronger claim, +# since a negative assertion also passes for a resolver that dropped every +# column. So the row needs no `exclude`, which is the shape to prefer: an +# exemption is where the next violation hides. +# +# `checks_green.rs`'s `status != "completed"` and `hk.rs`'s `!= "included"` are +# deliberately NOT reached. They compare a check run's state and a plan's state, +# which are a forge's and this engine's own vocabularies rather than a +# tracker's — a row keyed on the bare word `status` would have caught both and +# taught the next reader that the ban is about the field name. +[[rule]] +id = "no-board-column-in-core" +kind = "forbid" +glob = "crates/**" +regex = '(==|!=)\s*"(Todo|Backlog|In Progress|In Review|Done)"|"(Todo|Backlog|In Progress|In Review|Done)"\s*(==|!=)' +severity = "deny" +scope = "tree" +no_fix_reason = "read the column from the `[board]` table through `board::Columns` and compare against that; a board's column name in the core is rule 1's violation, and an undeclared column is could-not-look rather than a default" + # The fixture corpus must stay repo-agnostic (CLOUD-63). The corpus exists so # coverage stops implicitly depending on *this* repository, which a fixture # naming this repository's origin would quietly undo. diff --git a/crates/batten/src/board.rs b/crates/batten/src/board.rs index e078e9b25..0627c487c 100644 --- a/crates/batten/src/board.rs +++ b/crates/batten/src/board.rs @@ -199,8 +199,12 @@ mod tests { let columns = Columns::resolve(Some(&board)); assert_eq!(columns.ready().unwrap(), "To Do"); assert_eq!(columns.in_progress().unwrap(), "In Development"); - assert!( - !columns.started().unwrap().iter().any(|c| c == "Done"), + // THE WHOLE SET, not "does it lack ours". An exact comparison says the + // resolution is a function of the declaration and nothing else, where a + // negative assertion would pass for a resolver that dropped every column. + assert_eq!( + columns.started().unwrap(), + ["In Development".to_owned(), "Shipped".to_owned()], "resolution must not smuggle this repository's vocabulary into another board's set" ); } From 6eeead7e6d168c04b82b39301ec581571321bdcb Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 8 Sep 2026 00:27:54 +0000 Subject: [PATCH 3/7] fix(board): the module owns its table, and the censuses know it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three census gates refused the seam commit, all correctly and all the same class: a new module and a new config key arrived without the rows that make them decidable. None was a code defect. ## The table moves into the module that reads it `Board` was declared in `config.rs` while `Columns` — the resolved form and every predicate over it — lived in `board.rs`. That is the split `mcp::McpConfig`, `recorder::Declared` and `deferral` each avoid: a module that exists owns its own declaration, so the type and the predicate reading it cannot drift apart across a module boundary. It also settles the layering. `board` now reaches NOTHING in this crate, not even `error`. `config` reads it at load; `landed`, `claim` and `lib` read it at decision time; it reads none of them. That is `secret`'s placement arrived at from the same direction, and it is the placement rather than an accident of size: this module exists so a gate's verdict about a board is stated in the CONSUMER's words, and an edge from here to any decider would make the honesty of those words conditional on that decider's own layer. ## `trust::CENSUS` — not policy-bearing, for the structural reason `board` is read by gates, so the verdict is not that it lacks policy weight. It is that an override cannot speak to it: the key is absent from `OverrideConfig` and `resolve` reads the table from the committed authority alone, which is `contract`'s guarantee for `epoch`'s reason. That makes the obvious attack unwritable — an uncommitted layer renaming `ready` to a column every row already sits in would make every row pullable at once. A weakening row would be the wrong instrument, which is `mcp`'s point: it reports a DIRECTION, and a column name has none. Renaming a queue is not more or less permissive as config; which rows it admits depends on where the board has put them, which is tracker state rather than a bar this file sets. ## `module-layering` — the placement the rule asked for Its coverage clause states absence is an error rather than an allow, and it named `board` on the gate before landing, as it has for every module since `preset`. The entry is additive placement, not an exemption: it adds no allowance and removes no refusal, and a module violating a forbidden edge would still be refused. Written under admission 06891c5cdc3e1e55ce23c216053798934226f5904bb2ef87543efa256874e5a2. `policy/**` is protected by derivation — enabling a module protects it, so an agent's context cannot influence the rules it is judged by — and that grant is this edit alone, not a standing one. Refs: CLOUD-1623 Admits: 06891c5cdc3e1e55ce23c216053798934226f5904bb2ef87543efa256874e5a2 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/module-layering.rego Admits-anchor: call:1aeaf9af0e5e2be0788f653ae068221893c97952 Admits-epoch: c99c0afd250a1f693c40bca4e07d5d52d391ef1ecd443fa225f2d10f4629573f Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: `module-layering` refuses the tree, so CLOUD-1623 cannot land. The refusal is the rule's own coverage clause — "absence is an error, not an allow" — firing on `board.rs`, a module I added and did not place. Not overriding does not leave the rule enforcing more; it leaves a correct finding unanswered and the branch stuck, because the only answer the rule accepts is the placement it is asking for. Admits-answer-precondition: No surface writes a policy module. `policy/**` is protected by derivation rather than by the `protected` list — enabling a module protects it, so an agent's context cannot influence the rules it is judged by — and its `[[redirect]]` names a pull request, not a command. This IS that pull request: branch `claude/rule-1-seam-bundle-0nsnym`, draft, reviewed before it lands. The change is one name added to `declared_modules` plus its placement comment, and `mise run policy-test` checks it. Admits-answer-rejected-route: `patch`. Refused identically on the two sibling protected paths this branch already writes, and correctly: the guard is on the path rather than the program, so routing the same bytes through another writer evades the class instead of satisfying it. `config read first` does not apply — a policy module is not config, and reading cannot add a row. `run first` names no task that authors the table; `module-layering` only reports the gap. NOTE THE NARROWNESS: this is additive PLACEMENT, not an exemption — the same entry `preset`, `agent`, `recorder`, `secret` and `deferral` each received on arrival. It adds no allowance and removes no refusal; a module that violated a forbidden edge would still be refused. --- crates/batten/src/board.rs | 82 +++++++++++++++++++++++++++++++++++- crates/batten/src/config.rs | 65 +--------------------------- crates/batten/src/landed.rs | 4 +- crates/batten/src/resolve.rs | 2 +- crates/batten/src/trust.rs | 17 ++++++++ hk.pkl | 4 ++ policy/module-layering.rego | 16 +++++++ schema/batten.schema.json | 2 +- 8 files changed, 123 insertions(+), 69 deletions(-) diff --git a/crates/batten/src/board.rs b/crates/batten/src/board.rs index 0627c487c..d84d258df 100644 --- a/crates/batten/src/board.rs +++ b/crates/batten/src/board.rs @@ -27,8 +27,88 @@ //! one, and the verb decides nothing. That is the same three-valued read //! [`crate::ready::Grammar`] already gives for the pattern registry, and this //! module is deliberately its sibling rather than a second mechanism. +//! +//! # Layering: this module reaches nothing +//! +//! It owns both halves — the declared table [`Board`] and the resolved +//! [`Columns`] — and imports no other module in the crate, not even `error`. +//! `config` reads it at load; `landed`, `claim` and `lib` read it at decision +//! time; it reads none of them. That is `crate::secret`'s placement arrived at +//! from the same direction: a vocabulary every layer may consult must depend on +//! nothing, or the honesty of a gate becomes conditional on the layer its words +//! came through. +//! +//! The table lives here rather than in `config` for the reason +//! [`crate::mcp::McpConfig`] and [`crate::recorder::Declared`] do: a module that +//! exists owns its own declaration, so the type and the predicate that reads it +//! cannot drift apart across a module boundary. -use crate::config::Board; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// The `[board]` table: this consumer's column vocabulary. +/// +/// # Why this table exists (non-negotiable rule 1) +/// +/// [`crate::landed`] and [`crate::claim`] decide over a board's COLUMN NAMES — +/// which column is the ready queue, which means "pulled", which mean "somebody +/// has this, or it landed, or it shipped". Those are one tracker's words. Linear +/// ships `Todo`/`In Progress`/`In Review`/`Done`; Jira ships `To Do`/`In +/// Development`; a GitHub Project ships whatever the owner typed. +/// +/// Carried as engine constants they were rule 1's violation in its worst form. +/// Off this board every comparison is false, so `is_started` never fires, the +/// landed-honesty sweep reports **zero findings over a board full of dishonest +/// columns**, and `claim` never refuses. A gate that cannot fire is +/// indistinguishable from a gate that found nothing, which is the one failure +/// this whole module family exists to avoid. +/// +/// # Absent is could-not-look, never a default +/// +/// An undeclared table does **not** fall back to this repository's own words. +/// A default would reinstate the violation with an extra step and make the dead +/// path byte-identical to the working one again — the exact shape that let the +/// constants survive. A verb needing a column this table does not declare says +/// so, by name, and decides nothing. +/// +/// # Why values and not `[[pattern]]` rows +/// +/// [`crate::config::Ready`]'s reason (CLOUD-472), and one more directly: a +/// column is matched by +/// EQUALITY against the string the tracker echoes back, never by a regex over +/// it. The pattern registry exists so one CONCEPT has one spelling; a literal +/// the round trip returns verbatim is a value. +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Board { + /// The column a row must sit in to be pullable — the ready queue. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ready: Option, + /// The column meaning "pulled": somebody is on this now. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub in_progress: Option, + /// The column a row whose branch is behind git is asked to move back to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review: Option, + /// Every column meaning "somebody has this, or it has landed, or it has + /// shipped". + /// + /// **The released column belongs in this set, and leaving it out was a + /// measured defect** (CLOUD-1458). The engine constant this replaces read + /// `["In Progress", "In Review"]`, so a declined key that reached the + /// released column escaped the sweep entirely — and released is where the + /// claim is strongest and the lie therefore costs most. Measured on that + /// gate's own two rows: CLOUD-186 and CLOUD-1127 were declined with + /// `DO-NOT-CLOSE` in the body of the pull request that landed the module, + /// advanced by the merge, moved back by hand, and advanced to the released + /// column by a release 2026-09-05T02:52:56Z — past the far edge of a + /// predicate written the day before. + /// + /// The ready-queue columns stay OUT: a declined key sitting there is + /// `DO-NOT-CLOSE` working, and refusing it would make the marker unwritable. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub started: Vec, +} /// The columns a landing or claim decision reads, each already proven present. /// diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index cb9243ec9..bf40cab76 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -210,7 +210,7 @@ pub struct Config { /// speak to it, which every reader takes as could-not-look rather than as a /// default — see [`Board`] for why a default would be the violation again. #[serde(default, skip_serializing_if = "Option::is_none")] - pub board: Option, + pub board: Option, /// Accepted invocation-latency regressions (CLOUD-1163 unit 10). Absent /// means this file accepts none, which is the safe direction — an absent /// table cannot exempt a path. @@ -887,69 +887,6 @@ pub struct Receipt { pub verified_by: Vec, } -/// The `[board]` table: this consumer's column vocabulary. -/// -/// # Why this table exists (non-negotiable rule 1) -/// -/// [`crate::landed`] and [`crate::claim`] decide over a board's COLUMN NAMES — -/// which column is the ready queue, which means "pulled", which mean "somebody -/// has this, or it landed, or it shipped". Those are one tracker's words. Linear -/// ships `Todo`/`In Progress`/`In Review`/`Done`; Jira ships `To Do`/`In -/// Development`; a GitHub Project ships whatever the owner typed. -/// -/// Carried as engine constants they were rule 1's violation in its worst form. -/// Off this board every comparison is false, so `is_started` never fires, the -/// landed-honesty sweep reports **zero findings over a board full of dishonest -/// columns**, and `claim` never refuses. A gate that cannot fire is -/// indistinguishable from a gate that found nothing, which is the one failure -/// this whole module family exists to avoid. -/// -/// # Absent is could-not-look, never a default -/// -/// An undeclared table does **not** fall back to this repository's own words. -/// A default would reinstate the violation with an extra step and make the dead -/// path byte-identical to the working one again — the exact shape that let the -/// constants survive. A verb needing a column this table does not declare says -/// so, by name, and decides nothing. -/// -/// # Why values and not `[[pattern]]` rows -/// -/// [`Ready`]'s reason below, and one more directly: a column is matched by -/// EQUALITY against the string the tracker echoes back, never by a regex over -/// it. The pattern registry exists so one CONCEPT has one spelling; a literal -/// the round trip returns verbatim is a value. -#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct Board { - /// The column a row must sit in to be pullable — the ready queue. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ready: Option, - /// The column meaning "pulled": somebody is on this now. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub in_progress: Option, - /// The column a row whose branch is behind git is asked to move back to. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub review: Option, - /// Every column meaning "somebody has this, or it has landed, or it has - /// shipped". - /// - /// **The released column belongs in this set, and leaving it out was a - /// measured defect** (CLOUD-1458). The engine constant this replaces read - /// `["In Progress", "In Review"]`, so a declined key that reached the - /// released column escaped the sweep entirely — and released is where the - /// claim is strongest and the lie therefore costs most. Measured on that - /// gate's own two rows: CLOUD-186 and CLOUD-1127 were declined with - /// `DO-NOT-CLOSE` in the body of the pull request that landed the module, - /// advanced by the merge, moved back by hand, and advanced to the released - /// column by a release 2026-09-05T02:52:56Z — past the far edge of a - /// predicate written the day before. - /// - /// The ready-queue columns stay OUT: a declined key sitting there is - /// `DO-NOT-CLOSE` working, and refusing it would make the marker unwritable. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub started: Vec, -} - /// The `[ready]` table: the refinement gate's consumer-set thresholds. /// /// # Why a value and not a `[[pattern]]` row (CLOUD-472) diff --git a/crates/batten/src/landed.rs b/crates/batten/src/landed.rs index 4f2855619..6fb278a5e 100644 --- a/crates/batten/src/landed.rs +++ b/crates/batten/src/landed.rs @@ -156,7 +156,7 @@ impl Row { /// The columns were `const`s here until they were measured as rule 1's worst /// violation: off this board every comparison is false, so this predicate /// never fires and the sweep reports zero findings over a board full of - /// dishonest columns. See [`crate::config::Board`]. + /// dishonest columns. See [`crate::board::Board`]. /// /// An undeclared column answers `false` **because there is nothing to /// compare against, not because the row is elsewhere** — which is why every @@ -175,7 +175,7 @@ impl Row { /// Read as a set rather than tested inline so the two directions cannot /// drift about what "advanced" means. The set's membership doctrine — why /// the released column is in it and the ready queue is not — moved to - /// [`crate::config::Board::started`] with the vocabulary it describes. + /// [`crate::board::Board::started`] with the vocabulary it describes. fn is_started(&self, columns: &Columns) -> bool { columns.started.contains(&self.status) } diff --git a/crates/batten/src/resolve.rs b/crates/batten/src/resolve.rs index 195ead8f1..bf94ddc3d 100644 --- a/crates/batten/src/resolve.rs +++ b/crates/batten/src/resolve.rs @@ -513,7 +513,7 @@ pub struct Resolved { /// a column names the key it wanted and decides nothing. A local layer /// cannot supply these for §8's reason — renaming the column a claim gate /// admits is a weakening, and the chain admits raise-only overrides. - pub board: Option, + pub board: Option, /// The accepted invocation-latency regressions (CLOUD-1163 unit 10), from the /// **committed authority alone**. `None` accepts nothing, which is the only /// safe reading: a local layer that could add an exemption would be a diff --git a/crates/batten/src/trust.rs b/crates/batten/src/trust.rs index 16a5b17c6..e937e46af 100644 --- a/crates/batten/src/trust.rs +++ b/crates/batten/src/trust.rs @@ -1165,6 +1165,23 @@ pub const CENSUS: &[FieldCoverage] = &[ field: "ready", coverage: Coverage::Compared(&[WeakeningKind::ReadyCutoverRelaxed]), }, + FieldCoverage { + field: "board", + coverage: Coverage::NotPolicyBearing( + "this board's column vocabulary (CLOUD-1623). It IS read by gates — `claim check` \ + admits only the ready-queue column and `landed` selects on the pulled and started \ + ones — so the reason is not that it lacks policy weight. It is that an override \ + cannot speak to it at all: the key is absent from `OverrideConfig` and `resolve` \ + reads the table from the committed authority alone, `contract`'s structural \ + guarantee for `epoch`'s reason. That is what makes the obvious attack unwritable — \ + an uncommitted layer renaming `ready` to a column every row already sits in would \ + make every row pullable at once. A weakening row would be the wrong instrument, \ + `mcp`'s point below: it reports a DIRECTION, and a column name has none. Renaming \ + a queue is not more or less permissive as config — which rows it admits depends on \ + where the board has put them, which is tracker state rather than a bar this file \ + sets", + ), + }, FieldCoverage { field: "perf", coverage: Coverage::Compared(&[WeakeningKind::PerfExemptionAdded]), diff --git a/hk.pkl b/hk.pkl index fad887a05..7917448cf 100644 --- a/hk.pkl +++ b/hk.pkl @@ -441,6 +441,10 @@ local gate = new Mapping { "crates/batten/src/action.rs", "crates/batten/src/advisory.rs", "crates/batten/src/attribution.rs", + // CLOUD-1623: `Board` derives `JsonSchema` here rather than in + // `config.rs`, so this file is a schema input now. The census test + // named it the moment the type moved. + "crates/batten/src/board.rs", "crates/batten/src/bot.rs", "crates/batten/src/budget.rs", "crates/batten/src/capture.rs", diff --git a/policy/module-layering.rego b/policy/module-layering.rego index 26491e634..8c1c06c2c 100644 --- a/policy/module-layering.rego +++ b/policy/module-layering.rego @@ -85,6 +85,22 @@ declared_modules := { # three tables that had to know about each other. "preset", "brief", "main", "selfwrite", + # `board` arrived with CLOUD-1623 and this rule named it once more, on the + # gate before landing — module written, clippy green, `module-map-check` + # satisfied, and nobody had placed it. The coverage clause working again. + # + # It is `secret`'s class, arrived at from the same direction: it reaches + # NOTHING in this crate, not even `error`. It owns both halves — the declared + # `[board]` table and the resolved `Columns` that reads it — and that is its + # whole surface. `config` reads it at load; `landed`, `claim` and `lib` read + # it at decision time; it reads none of them. + # + # That direction is the placement rather than an accident of its size. The + # module exists so a gate's verdict about a board is stated in the CONSUMER's + # words, and an edge from here to any decider would make the honesty of those + # words conditional on that decider's own layer — which is the property the + # module was written to remove, not to relocate. + "board", # `patch` arrived with CLOUD-739 and this rule named it before a human did — # the same property the three above record, working a second time. `symbols` # arrived with CLOUD-760 and it worked a third. `semver` arrived with diff --git a/schema/batten.schema.json b/schema/batten.schema.json index 660a35b80..c10ccc784 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -690,7 +690,7 @@ ] }, "Board": { - "description": "The `[board]` table: this consumer's column vocabulary.\n\n# Why this table exists (non-negotiable rule 1)\n\n[`crate::landed`] and [`crate::claim`] decide over a board's COLUMN NAMES —\nwhich column is the ready queue, which means \"pulled\", which mean \"somebody\nhas this, or it landed, or it shipped\". Those are one tracker's words. Linear\nships `Todo`/`In Progress`/`In Review`/`Done`; Jira ships `To Do`/`In\nDevelopment`; a GitHub Project ships whatever the owner typed.\n\nCarried as engine constants they were rule 1's violation in its worst form.\nOff this board every comparison is false, so `is_started` never fires, the\nlanded-honesty sweep reports **zero findings over a board full of dishonest\ncolumns**, and `claim` never refuses. A gate that cannot fire is\nindistinguishable from a gate that found nothing, which is the one failure\nthis whole module family exists to avoid.\n\n# Absent is could-not-look, never a default\n\nAn undeclared table does **not** fall back to this repository's own words.\nA default would reinstate the violation with an extra step and make the dead\npath byte-identical to the working one again — the exact shape that let the\nconstants survive. A verb needing a column this table does not declare says\nso, by name, and decides nothing.\n\n# Why values and not `[[pattern]]` rows\n\n[`Ready`]'s reason below, and one more directly: a column is matched by\nEQUALITY against the string the tracker echoes back, never by a regex over\nit. The pattern registry exists so one CONCEPT has one spelling; a literal\nthe round trip returns verbatim is a value.", + "description": "The `[board]` table: this consumer's column vocabulary.\n\n# Why this table exists (non-negotiable rule 1)\n\n[`crate::landed`] and [`crate::claim`] decide over a board's COLUMN NAMES —\nwhich column is the ready queue, which means \"pulled\", which mean \"somebody\nhas this, or it landed, or it shipped\". Those are one tracker's words. Linear\nships `Todo`/`In Progress`/`In Review`/`Done`; Jira ships `To Do`/`In\nDevelopment`; a GitHub Project ships whatever the owner typed.\n\nCarried as engine constants they were rule 1's violation in its worst form.\nOff this board every comparison is false, so `is_started` never fires, the\nlanded-honesty sweep reports **zero findings over a board full of dishonest\ncolumns**, and `claim` never refuses. A gate that cannot fire is\nindistinguishable from a gate that found nothing, which is the one failure\nthis whole module family exists to avoid.\n\n# Absent is could-not-look, never a default\n\nAn undeclared table does **not** fall back to this repository's own words.\nA default would reinstate the violation with an extra step and make the dead\npath byte-identical to the working one again — the exact shape that let the\nconstants survive. A verb needing a column this table does not declare says\nso, by name, and decides nothing.\n\n# Why values and not `[[pattern]]` rows\n\n[`crate::config::Ready`]'s reason (CLOUD-472), and one more directly: a\ncolumn is matched by\nEQUALITY against the string the tracker echoes back, never by a regex over\nit. The pattern registry exists so one CONCEPT has one spelling; a literal\nthe round trip returns verbatim is a value.", "type": "object", "properties": { "in_progress": { From e764fbb0bd38d216c15a9bd0f734f86301c7966d Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 8 Sep 2026 00:48:57 +0000 Subject: [PATCH 4/7] test(claim): the fixture declares the board it is judged against MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `claim check` now refuses the run when `board.ready` is undeclared, so two cases in this suite went red against a fixture repository that declares no `[board]`. The refusal is correct and the fixture was incomplete: a repository that has declared no board HAS no ready queue, and answering "nothing is pullable" over one would be the guess the seam exists to remove. This is the same sentence the fixture already carries one table over. CLOUD-1100 made the Ready grammar the consumer's and left this comment here: "Without these rows `claim check` reports could-not-look naming the first missing id — which is the correct answer for a repository that has declared no Ready grammar, and not what this suite is about." The columns are that sentence again, and the two now sit together because neither the grammar nor the vocabulary is the engine's to assume. Refs: CLOUD-1623 --- crates/batten/tests/it/claim.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/batten/tests/it/claim.rs b/crates/batten/tests/it/claim.rs index 33bccd1a2..741c97a12 100644 --- a/crates/batten/tests/it/claim.rs +++ b/crates/batten/tests/it/claim.rs @@ -150,7 +150,18 @@ fn repo(name: &str) -> PathBuf { // Without these rows `claim check` reports could-not-look naming the // first missing id — which is the correct answer for a repository that // has declared no Ready grammar, and not what this suite is about. - .config(&format!("version = 1\n\n{}", declared_patterns())) + // + // THE COLUMN VOCABULARY IS THE CONSUMER'S TOO, for the identical reason + // one layer over (CLOUD-1623). Without `[board]` this gate refuses the + // RUN naming `board.ready` — again the correct answer for a repository + // that has declared no board, and again not what this suite is about. + // The two comments are the same sentence about two tables, which is the + // point: neither the grammar nor the columns are the engine's to assume. + .config(&format!( + "version = 1\n\n[board]\nready = \"Todo\"\nin_progress = \"In Progress\"\n\ + review = \"In Review\"\nstarted = [\"In Progress\", \"In Review\", \"Done\"]\n\n{}", + declared_patterns() + )) .file( "Cargo.toml", "[workspace.package]\nversion = \"0.0.125\"\n\n[workspace.dependencies]\nserde = \"1\"\n", From 97ff068b02d5490ac0620679e0e418af117c4a48 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 8 Sep 2026 01:26:08 +0000 Subject: [PATCH 5/7] fix(landed): the sweep refuses an undeclared board instead of reporting it clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The seam reintroduced its own defect one layer up, and this removes it.** `claim::judge` was made to refuse when the board is undeclared; `landed` was left to decide with the columns it had. Undeclared, it has none — every comparison against a row's status is false, so `decide` returns ZERO FINDINGS and the verb exits 0 over a board it never read. That is byte-identical to a clean sweep, which is the exact failure this row exists to remove. A gate that cannot fire and a gate that found nothing emit the same bytes and the same exit code, and shipping the seam with this in place would have moved the dead path rather than closing it. Both verbs now demand the columns they decide over, before deciding. ## Both directions, not the one the payload happens to exercise `landed check` demands `in_progress` AND `started`. Demanding only the arm a given payload reaches would leave a run that answered half the disjunction and said nothing about the other half — the silently-halved sweep CLOUD-1458 already paid for once. `landed abandoned` demands `in_progress`, where the edge is sharper: every candidate is SELECTED by that column, so undeclared yields an empty candidate set and the drain reports no abandoned claims over a board full of them. An over-reporting drain gets switched off; a silently empty one is never noticed. ## The fixtures declare the board they are judged against `common::declared_board` reads the committed `[board]` table, mirroring `declared_patterns` and for its reason: this repository's columns have one authority, and a fixture holding a second copy drifts the day the board is renamed. `landed_check.rs` shadows `scratch` so all eleven cases build on a declared board rather than asserting about a missing column. Refs: CLOUD-1623 --- crates/batten/src/lib.rs | 38 ++++++++++++++++++++++ crates/batten/tests/it/claim.rs | 8 ++--- crates/batten/tests/it/common/mod.rs | 40 +++++++++++++++++++++++ crates/batten/tests/it/landed_check.rs | 45 +++++++++++++++++++------- 4 files changed, 116 insertions(+), 15 deletions(-) diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 3614b8976..dfecaeb7b 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -2736,7 +2736,34 @@ fn run_landed_check( } } + // THE COLUMNS THIS SWEEP DECIDES OVER, DEMANDED BEFORE IT DECIDES. + // + // **Refusing here is the whole point, and reporting would be the defect.** + // `decide` compares a row's status against these; undeclared, every + // comparison is false, so it returns ZERO FINDINGS and this verb exits 0 + // over a board it never looked at. That is byte-identical to a clean sweep + // and is exactly the failure CLOUD-1623 exists to remove — reintroducing it + // one layer up would be the same defect wearing the seam's clothes. + // + // Both directions are demanded, not just the one a given payload happens to + // exercise: a run that could only answer half the disjunction and said + // nothing about the other half is the silently-halved sweep CLOUD-1458 + // already paid for. let columns = board_columns(overrides)?; + columns.in_progress().map_err(|undeclared| { + UsageError::raise(format!( + "landed: {undeclared}. The behind-git direction compares a row's column \ + against it, so without it this sweep would report a clean board it never \ + read." + )) + })?; + columns.started().map_err(|undeclared| { + UsageError::raise(format!( + "landed: {undeclared}. The declined-but-advanced direction asks whether a \ + row has left the queue, and an undeclared set matches nothing — every row \ + would read as not-advanced." + )) + })?; let report = landed::decide(&rows, &evidence, &columns); // Pointer-only per rule 4: a key, two column names and a reason class. Never @@ -2894,7 +2921,18 @@ fn run_landed_abandoned( } } + // THE COLUMN THE DRAIN SELECTS ON, demanded for `run_landed_check`'s reason + // and with a sharper edge here: every candidate is chosen BY this column, so + // undeclared yields an empty candidate set and the drain reports "no + // abandoned claims" over a board full of them. An over-reporting drain gets + // switched off; a silently empty one is never noticed at all. let columns = board_columns(overrides)?; + columns.in_progress().map_err(|undeclared| { + UsageError::raise(format!( + "landed abandoned: {undeclared}. Every candidate is selected by that \ + column, so without it this drain would report a clean board it never read." + )) + })?; let report = landed::drain( &claims, &evidence, diff --git a/crates/batten/tests/it/claim.rs b/crates/batten/tests/it/claim.rs index 741c97a12..1bcda3071 100644 --- a/crates/batten/tests/it/claim.rs +++ b/crates/batten/tests/it/claim.rs @@ -141,7 +141,7 @@ use crate::common; use std::path::{Path, PathBuf}; use std::process::Output; -use common::{Fixture, declared_patterns, git_in, run_with_stdin, stderr, stdout}; +use common::{Fixture, declared_board, declared_patterns, git_in, run_with_stdin, stderr, stdout}; /// A checkout on a feature branch, with the workspace version the §6 arrows read. fn repo(name: &str) -> PathBuf { @@ -158,8 +158,8 @@ fn repo(name: &str) -> PathBuf { // The two comments are the same sentence about two tables, which is the // point: neither the grammar nor the columns are the engine's to assume. .config(&format!( - "version = 1\n\n[board]\nready = \"Todo\"\nin_progress = \"In Progress\"\n\ - review = \"In Review\"\nstarted = [\"In Progress\", \"In Review\", \"Done\"]\n\n{}", + "version = 1\n{}\n{}", + declared_board(), declared_patterns() )) .file( @@ -628,7 +628,7 @@ fn outside_a_checkout_the_question_is_not_applicable_and_the_verdict_still_stand common::write( &dir, "batten.toml", - &format!("version = 1\n\n{}", declared_patterns()), + &format!("version = 1\n{}\n{}", declared_board(), declared_patterns()), ); // NO §6 CLAUSE, and that is the shape rather than a convenience: the version // the arrows depend on is a property of a TREE, read lazily inside the clause diff --git a/crates/batten/tests/it/common/mod.rs b/crates/batten/tests/it/common/mod.rs index 1711516cb..385ebb87a 100644 --- a/crates/batten/tests/it/common/mod.rs +++ b/crates/batten/tests/it/common/mod.rs @@ -80,6 +80,46 @@ pub(crate) fn declared_patterns() -> String { ROWS.clone() } +/// The committed `[board]` table, for a fixture that must be judged against a +/// declared board (CLOUD-1623). +/// +/// [`declared_patterns`]'s sibling and for its reason: the columns are the +/// consumer's, so a fixture re-spelling them here would be a second authority on +/// this repository's own vocabulary — and one that drifts the first time the +/// board is renamed. Reading the committed table keeps the fixture in step by +/// construction. +pub(crate) fn declared_board() -> String { + static TABLE: std::sync::LazyLock = std::sync::LazyLock::new(scan_declared_board); + TABLE.clone() +} + +fn scan_declared_board() -> String { + let text = std::fs::read_to_string(at_root("batten.toml")).expect("the committed config"); + let mut rows = String::new(); + let mut inside = false; + for line in text.lines() { + // The close is tested before the open for `scan_declared_patterns`' + // reason: a table closes at the NEXT header of any kind. + if inside && line.starts_with('[') { + inside = false; + } + if line.starts_with("[board]") { + inside = true; + rows.push('\n'); + } + if inside { + rows.push_str(line); + rows.push('\n'); + } + } + assert!( + rows.contains("ready"), + "the committed config declares no board, so every fixture built on it \ + would assert about a missing column rather than about a claim" + ); + rows +} + fn scan_declared_patterns() -> String { let text = std::fs::read_to_string(at_root("batten.toml")).expect("the committed config"); let mut rows = String::new(); diff --git a/crates/batten/tests/it/landed_check.rs b/crates/batten/tests/it/landed_check.rs index fe73bd9e0..3b905c86a 100644 --- a/crates/batten/tests/it/landed_check.rs +++ b/crates/batten/tests/it/landed_check.rs @@ -15,6 +15,29 @@ use crate::common; +/// A fixture repository that has DECLARED THE BOARD it is judged against +/// (CLOUD-1623). +/// +/// Shadows [`common::scratch`] throughout this suite, deliberately: every case +/// here runs `landed`, and `landed` now refuses a run whose `[board]` columns are +/// undeclared rather than reporting a clean sweep over a board it never read. A +/// bare scratch directory declares nothing, so without this every case would +/// assert about a missing column instead of about a dishonest one. +/// +/// The table is READ FROM THE COMMITTED CONFIG rather than spelled here, for +/// `common::declared_board`'s reason: this repository's columns have one +/// authority, and a fixture holding a second copy drifts the day the board is +/// renamed. +fn scratch(name: &str) -> std::path::PathBuf { + let dir = common::scratch(name); + common::write( + &dir, + "batten.toml", + &format!("version = 1\n{}", common::declared_board()), + ); + dir +} + /// Write the three evidence files a sweep reads, returning the fixture dir. /// /// Every case supplies `--merged-prs` because absent is could-not-look rather @@ -25,7 +48,7 @@ fn evidence( declined: &[&str], asserted: &[(&str, &str)], ) -> std::path::PathBuf { - let dir = common::scratch(name); + let dir = scratch(name); std::fs::write(dir.join("merged.tsv"), tsv(merged)).expect("write merged evidence"); let declined_body = declined.iter().fold(String::new(), |mut acc, key| { acc.push_str(key); @@ -215,7 +238,7 @@ fn an_asserted_landing_names_its_ref_in_the_finding() { /// clean column it never checked. #[test] fn a_sweep_with_no_merged_pr_evidence_refuses_rather_than_passing() { - let dir = common::scratch("landed-no-evidence"); + let dir = scratch("landed-no-evidence"); let out = common::run_with_stdin( &dir, &["landed", "check"], @@ -240,7 +263,7 @@ fn a_sweep_with_no_merged_pr_evidence_refuses_rather_than_passing() { /// disjunction and pass. #[test] fn evidence_that_cannot_be_read_refuses_rather_than_reading_as_empty() { - let dir = common::scratch("landed-unreadable"); + let dir = scratch("landed-unreadable"); let out = common::run_with_stdin( &dir, &["landed", "check", "--merged-prs", "nothing-here.tsv"], @@ -272,7 +295,7 @@ fn a_payload_missing_status_is_could_not_look() { /// board. The key on the next line must still decide. #[test] fn a_header_line_in_the_evidence_does_not_stop_the_sweep() { - let dir = common::scratch("landed-header"); + let dir = scratch("landed-header"); std::fs::write(dir.join("merged.tsv"), "issue\tpr\nCLOUD-1120\t726\n") .expect("write evidence with a header"); let out = common::run_with_stdin( @@ -302,7 +325,7 @@ fn a_header_line_in_the_evidence_does_not_stop_the_sweep() { /// deliberately names a DIFFERENT key. #[test] fn a_key_closed_by_a_commit_on_main_is_behind_git() { - let dir = common::scratch("landed-claimed"); + let dir = scratch("landed-claimed"); std::fs::write(dir.join("merged.tsv"), "CLOUD-999\t1\n").expect("write merged evidence"); std::fs::write(dir.join("claimed.tsv"), "CLOUD-1120\n").expect("write claimed evidence"); let out = common::run_with_stdin( @@ -335,7 +358,7 @@ fn a_key_closed_by_a_commit_on_main_is_behind_git() { /// the wrong reason — which is the shape that shipped. #[test] fn the_same_row_is_clean_when_the_claimed_evidence_is_withheld() { - let dir = common::scratch("landed-claimed-null"); + let dir = scratch("landed-claimed-null"); std::fs::write(dir.join("merged.tsv"), "CLOUD-999\t1\n").expect("write merged evidence"); let out = common::run_with_stdin( &dir, @@ -381,7 +404,7 @@ fn a_sweep_without_the_claimed_arm_says_the_arm_is_unsupplied() { /// notice that fired either way would carry no information at all. #[test] fn a_sweep_with_the_claimed_arm_is_quiet_about_it() { - let dir = common::scratch("landed-claimed-said"); + let dir = scratch("landed-claimed-said"); std::fs::write(dir.join("merged.tsv"), "CLOUD-1120\t726\n").expect("write merged evidence"); std::fs::write(dir.join("claimed.tsv"), "CLOUD-903\n").expect("write claimed evidence"); let out = common::run_with_stdin( @@ -522,7 +545,7 @@ fn dated_board(id: &str, updated: &str, attachment: Option<&str>, branch: &str) /// Run the arm against a scratch dir carrying empty merged-PR evidence. fn abandoned_run(name: &str, extra: &[&str], payload: &str) -> std::process::Output { - let dir = common::scratch(name); + let dir = scratch(name); std::fs::write(dir.join("merged.tsv"), "").expect("evidence is writable"); let mut args = vec![ "landed", @@ -606,7 +629,7 @@ fn a_claim_a_pull_request_is_serving_is_left_alone() { /// set — the arm that keeps this verb off a `git ls-remote` spawn. #[test] fn a_branch_named_in_the_refs_evidence_rescues_a_claim() { - let dir = common::scratch("abandoned-refs-evidence"); + let dir = scratch("abandoned-refs-evidence"); std::fs::write(dir.join("merged.tsv"), "").expect("evidence is writable"); std::fs::write( dir.join("refs.txt"), @@ -673,7 +696,7 @@ fn a_stale_row_missing_a_key_refuses_rather_than_sweeping_clean() { /// switched off, which is why this arm is required where `--claimed` is not. #[test] fn a_sweep_with_no_merged_pr_evidence_refuses_rather_than_over_reporting() { - let dir = common::scratch("abandoned-needs-evidence"); + let dir = scratch("abandoned-needs-evidence"); let out = common::run_with_stdin( &dir, &["landed", "abandoned", "--instant", "2026-08-20"], @@ -724,7 +747,7 @@ fn an_abandonment_sweep_without_the_claimed_arm_says_so() { #[test] fn an_abandonment_sweep_with_the_claimed_arm_is_quiet_about_it() { - let dir = common::scratch("abandoned-claimed-supplied"); + let dir = scratch("abandoned-claimed-supplied"); std::fs::write(dir.join("merged.tsv"), "").expect("evidence is writable"); std::fs::write(dir.join("claimed.tsv"), "CLOUD-9\n").expect("evidence is writable"); let out = common::run_with_stdin( From b450e3df19e252ca6d2cdd47a0f3b5fe8d7a2a47 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 8 Sep 2026 02:05:23 +0000 Subject: [PATCH 6/7] test(cli): the census fixture declares the board `claim check` reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `claim check` declares the `-J` data channel, and a data channel emits its document UNCONDITIONALLY — one that is sometimes absent is unparseable for the caller that asked for it. The board refusal lands before any output, so over a fixture declaring no `[board]` the verb emitted nothing at all and the purity census went red. The fixture is the fix rather than the refusal, and this fixture's own doc already says why: it exists to be "a config every data-emitting verb can actually answer from", because "the census is about the output contract; supplying each verb's minimum input is the fixture's job". A repository that declares no board HAS no ready queue, so refusing there is correct and simply not what this census asks about. That makes `[board]` the fifth minimum input this fixture carries, beside the Ready grammar it already carried for the identical reason (CLOUD-1100): both are the consumer's vocabulary, and neither is the engine's to assume. Read from the committed table rather than re-typed, so the fixture cannot drift from the columns it exercises. Refs: CLOUD-1623 --- crates/batten/tests/it/cli.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/batten/tests/it/cli.rs b/crates/batten/tests/it/cli.rs index 1a1df93e3..c10154c90 100644 --- a/crates/batten/tests/it/cli.rs +++ b/crates/batten/tests/it/cli.rs @@ -19,8 +19,8 @@ use batten::decision::Outcome; use batten::rules::{Decidability, RuleKind}; use batten::{ExitCode, ReportLevel, RuleSeverity, severity}; use common::{ - Fixture, StateHome, batten, declared_patterns, git_in, run, scratch, scratch_outside_tree, - stderr, stdout, write, + Fixture, StateHome, batten, declared_board, declared_patterns, git_in, run, scratch, + scratch_outside_tree, stderr, stdout, write, }; /// Run `batten adjudicate --harness ` with `payload` piped to stdin, against @@ -5335,6 +5335,14 @@ fn census_repo(root: &Path) -> PathBuf { // this census is about. Read from the committed table rather than re-typed // here, so a fixture cannot drift from the expressions it exercises. .config_append(&declared_patterns()) + // `claim check`'s OTHER minimum input, and the fifth verb family to need + // one (CLOUD-1623). The board's column vocabulary is the CONSUMER's for + // the reason the grammar above is: a repository declaring no `[board]` + // has no ready queue, so the verb refuses by naming the key — the right + // answer, and again not the one this census is about. Read from the + // committed table for `declared_patterns`' reason, so the fixture cannot + // drift from the columns it exercises. + .config_append(&declared_board()) .file("AGENTS.md", "instructions\n") // `lint brief`'s minimum input, the third verb to need one. Named by // `CENSUS_POSITIONALS` rather than by this call site, so the argv and the From 58785a7177c669bbc20170c5f1cb98e8cdc064b4 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 10 Sep 2026 02:57:34 +0000 Subject: [PATCH 7/7] fix(board): name the seam's gate in the id grammar a sibling branch lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1284's three-word rule-id grammar — ` `, each word drawn from `[vocabulary]` — is NOT on `main`, which still carries `no-bare-cargo` and `rebase-not-hand-stepped`. It is on an unlanded sibling branch that migrates all 136 ids, and this branch met it only because `land` speculatively replayed onto that branch's head while it held the landing lease. So this is forward-compatibility, not a fix for a broken tree. `config name other` is a legal id under `main`'s rules today, where no grammar is enforced, and remains legal after the sibling lands; `no-board-column-in-core` is legal only until then, and would arrive as a 137th id that branch's migration never saw. Renaming now costs nothing and spares whoever lands second the conflict. The subject word is the part worth recording, because two constraints close against each other: a rule id IS validated against the subject list, and a rule id does NOT count as spending a word. So a `forbid` row cannot introduce a new subject — measured both directions against the sibling's engine, adding a `board` subject was refused as dead vocabulary, and keeping `board name other` without it was refused as not in the list. An existing word is the only route, and `config` is the honest one: the column vocabulary is config-owned, declared in `[board]`, and the violation this row names is the core spelling it instead. The rule itself is unchanged — same glob, same regex over the comparison shape in both operand orders, same `no_fix_reason`. Refs: CLOUD-1623 Admits: 93f01d71f13429a07682391a32f2138031d849ca673d4e7b0948886847724d9b Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:b450e3df19e252ca6d2cdd47a0f3b5fe8d7a2a47 Admits-epoch: 27a2b6dcec748b225725f9832582ea0311704c8b56d437ba21c4539eea097800 Admits-author: alec@wenzowski.com Admits-prev: 5b084f44195ea67e1071863a3d5b459cf0d0db50ac9ad86b047eab4e63ab64a1 Admits-answer-lost: Whoever lands second pays a conflict this costs nothing to avoid. CLOUD-1284's three-word id grammar is NOT on `main` — `main` still carries `no-bare-cargo` and `rebase-not-hand-stepped` — it is on an unlanded sibling branch that migrates all 136 ids, which this branch met only because `land` speculatively replayed onto that branch while it held the lease. `config name other` is legal under `main` today and after that branch lands; `no-board-column-in-core` is legal only until then, and would arrive as a 137th id the sibling's migration never saw. Admits-answer-precondition: No surface owns `batten.toml`'s rule rows; `config lint` and `test:verdict-vocabulary` only report on the file, and neither can author or rename a row. The change is one edit — rename this branch's own rule id from `no-board-column-in-core` to `config name other` — visible in the diff of draft PR #911 a reviewer reads before it lands. Admits-answer-rejected-route: `config read first` — I read the table and CLOUD-1284's block above it, and reading is what established both that the grammar is absent from `main` and the two constraints that pin the name: a rule id is checked against the subject list, but a rule id does not COUNT as spending a word, so a `forbid` row cannot introduce a new subject (measured against the sibling's engine — adding `board` was refused as dead vocabulary, and keeping the id without it was refused as not in the list). Reading cannot rename the row. `patch run first` names a commit-message route and does not apply to a config edit. --- batten.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/batten.toml b/batten.toml index 6467c3532..62ee73719 100644 --- a/batten.toml +++ b/batten.toml @@ -3758,7 +3758,7 @@ scope = "tree" # tracker's — a row keyed on the bare word `status` would have caught both and # taught the next reader that the ban is about the field name. [[rule]] -id = "no-board-column-in-core" +id = "config name other" kind = "forbid" glob = "crates/**" regex = '(==|!=)\s*"(Todo|Backlog|In Progress|In Review|Done)"|"(Todo|Backlog|In Progress|In Review|Done)"\s*(==|!=)'