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..62ee73719 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 = "config name other" +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. @@ -8890,6 +8933,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..d84d258df --- /dev/null +++ b/crates/batten/src/board.rs @@ -0,0 +1,307 @@ +//! 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. +//! +//! # 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 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. +/// +/// 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"); + // 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" + ); + } + + /// 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..bf40cab76 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. @@ -3192,6 +3197,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..6fb278a5e 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::board::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::board::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..dfecaeb7b 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,35 @@ fn run_landed_check( } } - let report = landed::decide(&rows, &evidence); + // 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 // a line of any body — a PR body and an issue body both carry consumer @@ -2743,15 +2778,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 +2827,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 +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, @@ -2884,6 +2941,7 @@ fn run_landed_abandoned( max_idle_days, today, }, + &columns, )?; render_drain(&report, max_idle_days, mode, out, err)?; @@ -3911,6 +3969,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 +4051,7 @@ fn run_claim( run_claim_check( &board_root(), &board_grammar(overrides)?, + &board_columns(overrides)?, &ClaimAsk { request: &request, adopt, @@ -4168,6 +4244,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 +4267,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..bf94ddc3d 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/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/crates/batten/tests/it/claim.rs b/crates/batten/tests/it/claim.rs index 33bccd1a2..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 { @@ -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{}", + declared_board(), + declared_patterns() + )) .file( "Cargo.toml", "[workspace.package]\nversion = \"0.0.125\"\n\n[workspace.dependencies]\nserde = \"1\"\n", @@ -617,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/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 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( 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 6aadd4b81..c10ccc784 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[`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": { + "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",