diff --git a/.serena/memories/core.md b/.serena/memories/core.md index 60c3b7c25..09038206d 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -82,6 +82,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 a844d20d5..125a0e8de 100644 --- a/batten.toml +++ b/batten.toml @@ -287,6 +287,16 @@ mutation = "change it in a pull request — a registered module is protected bec # could-not-look, which is honest and useless. Declared, it answers `1`. [transcript] path = ".claude/.transcript.jsonl" +# Which host wrote it, selecting the grammar to read it with (CLOUD-1624). The +# fourth fact about one host on this table, and the one that was missing: the +# engine used to read every transcript through this host's shape whatever wrote +# it, so on any other host it decoded to nothing and four gates reported clean +# over a session they had not read. +# +# OMITTED WOULD BE COULD-NOT-LOOK, never a default — so this line is what keeps +# those four deciding here rather than abstaining, and dropping it is a declared +# weakening (`transcript-harness-removed`) rather than a silent quieting. +harness = "claude-code" tasks = "~/.claude/tasks/{session}" # --------------------------------------------------------------------------- @@ -3723,6 +3733,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. @@ -5557,6 +5610,14 @@ id = "task table other" kind = "policy" scope = "tree" preset = "mise" +# ONLY THIS ROW OF THE TWO, and that is the point of a per-module declaration +# (CLOUD-1625). At `tree` the `mise` preset compiles +# `action-version-matches-the-pin`, which matches a step's `uses:` coordinate — +# GitHub Actions. At `mediated_call` the same preset compiles +# `task-over-executable`, which decides task argv and reads no provider at all, +# so `mise-preset` above needs nothing. A manifest-level declaration could not +# have told those two apart. +provider = "github-actions" sources = [ "batten.toml", ".github/workflows/*.yml", @@ -5594,6 +5655,12 @@ id = "job spelling wrong" kind = "policy" scope = "tree" preset = "ci-hygiene" +# Both of this preset's modules read GitHub Actions expression language and key +# on documents carrying a `jobs:` mapping (CLOUD-1625). Declaring the provider is +# what lets them load: without it the engine refuses, because on any other +# provider that document set is empty and the modules would report a clean tree +# they never read. +provider = "github-actions" sources = [".github/workflows/*.yml", ".github/workflows/*.yaml"] # # `line_sources` AS WELL, for exactly one clause. The swallowed-interpolation @@ -7987,11 +8054,28 @@ delete_branch_on_merge = true root = "target" keep = 2 +# THE 2026-09-12 WARM RESCALE (CLOUD-1622's bundle). The rule-1 seam bundle added +# `assertion_gates.rs` and took the live count to 255, eleven past a basis of 244 +# against a tolerance of 10 — this gate working, exactly as the 2026-08-30 entry +# describes it working. +# +# WARM SCALES BY THE STEM MODEL, as every move above it did: 11140 at 244 is +# 45.66 per stem, and 45.66 x 255 is 11642. Derived rather than independently +# measured, and said so for the reason the basis block insists on: an honest warm +# number needs a minimal post-prune tree, and a reader who needs it exact should +# take it rather than trust this line. +# +# WORTH LEAVING BEHIND, because it is a second reading the same lap produced: the +# LEARNED floor this container observed on 2026-09-11 was 15195 MB, well above +# either declared figure. That is the learned mechanism doing its job — it lives in +# $GIT_DIR/batten-prune/laps.json and already governs — so the declared number is +# not raised to meet it. A declared floor that chases the worst observed lap is the +# floor-nobody-can-satisfy this file warns about, two blocks down. [prune.warm] -mb = 11140 -worst_mb = 11140 +mb = 11642 +worst_mb = 11642 multiplier = 1 -measured = "2026-09-08" +measured = "2026-09-12" [prune.cold] mb = 21455 @@ -8449,6 +8533,21 @@ glob = "crates/batten/tests/**/*.rs" count = 255 tolerance = 10 +# COLD'S BASIS MOVES AND COLD'S FLOOR DOES NOT, and that is the entry above +# `[prune.cold]` being obeyed rather than ignored (2026-09-12). It ends: "the next +# move that raises cold should take the exact measurement rather than scale again", +# because the derived figure already exceeded what this container can offer and +# "a floor nothing can satisfy refuses every lap and gets switched off". Scaling +# to 87.93 x 255 = 22422 would do precisely that. +# +# SO THIS IS RECORDED AS A KNOWN UNDER-BUDGET, not as a number that is fine. Cold +# now budgets for 244 stems against a basis of 255, which is the direction this +# file says fails silently. It is bounded: cold is judged only once the escalation +# has dropped the cache, which is reached only after warm is breached, and warm +# moved with the basis. The exact measurement — a build from an empty `target` — +# is what this owes, and it cannot be taken from inside a lap that needs the cache +# it would destroy. Whoever takes it moves `mb`, `worst_mb` and `measured` +# together. [prune.cold.basis] glob = "crates/batten/tests/**/*.rs" count = 255 @@ -8672,8 +8771,44 @@ body_template = ".github/bot-lane-row.md" # `[[provision.env]]` no-proxy list above. Pointed at a route the proxy carries, # the control arm fails and the engine reports could-not-look rather than # inventing a verdict. +# +# `names` IS THIS REPOSITORY'S FACT AND WAS AN ENGINE LITERAL FOR ONE COMMIT +# (CLOUD-1615). `crates/batten` carried `["GITHUB_PERSONAL_ACCESS_TOKEN", +# "BATTEN_GITHUB_TOKEN"]`, which is rule 1: a consumer on any other forge, or on +# a host injecting under a third name, finds neither variable set, so every +# candidate is absent, the verdict is `Unusable`, and every removal above is +# skipped FOREVER. That failure is invisible, because skipping a removal is also +# the right answer for a genuinely bad credential. +# +# THE SAME TWO NAMES THE ROWS ABOVE PREFER, and no others. `GITHUB_TOKEN` and +# `GH_TOKEN` are deliberately absent even though tools read them: this container +# injects a `proxy-`-prefixed marker under both, so probing them would measure +# the host's substituted credential rather than one we hold — the exact +# conflation this whole mechanism exists to undo. They stay in `from_first_set` +# as last resorts and stay out of here as evidence. [credential] probe_url = "https://api.github.com/rate_limit" +names = ["GITHUB_PERSONAL_ACCESS_TOKEN", "BATTEN_GITHUB_TOKEN"] + +# Which variables the REST tier may authenticate with (CLOUD-1622). +# +# A SEPARATE LIST FROM `[credential] names` DIRECTLY ABOVE, and the difference is +# the whole reason this is its own table rather than a reuse of that one. That +# list names credentials WE hold, and deliberately excludes the forge's +# conventional spellings: this host injects a substitutable placeholder under +# them, so probing those would measure the host's credential rather than ours. +# This list is the opposite question — what a REST read may PRESENT — and the +# host-injected job token is exactly what belongs in it. Folding the two would +# have stopped every REST read using that token. +# +# These two spellings are what `crates/batten` carried as literals until this +# change, so declaring them here keeps this repository's behaviour byte for byte +# while the engine stops assuming any forge at all. `GH_TOKEN` first, which is the +# forge CLI's own precedence: a session that set one for that tool does not have +# to set a second. Naming none would be could-not-look, never "no credential +# needed" — there is deliberately no fallback in the engine to fall back to. +[forge] +credential_names = ["GH_TOKEN", "GITHUB_TOKEN"] # --- the scripts the launcher re-arms every spawn (CLOUD-1704) ----------------- # @@ -8961,6 +9096,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/bot.rs b/crates/batten/src/bot.rs index 7f38536b8..a51801429 100644 --- a/crates/batten/src/bot.rs +++ b/crates/batten/src/bot.rs @@ -34,6 +34,8 @@ //! the matcher. A grep of `crates/batten` for a bot's name or a manifest path //! returns nothing, which is the same standing `[attribution]` has. //! +//! verified-by: batten-check — the rule-1 `forbid` rows over `crates/**` in this repository's own `batten.toml`, which run on every gate invocation. +//! //! # Pointer-only per non-negotiable rule 4 //! //! Every refusal names a PR number, an issue key, a login or a path. Never a diff diff --git a/crates/batten/src/bypass.rs b/crates/batten/src/bypass.rs index 36b52a88b..7e7b7b322 100644 --- a/crates/batten/src/bypass.rs +++ b/crates/batten/src/bypass.rs @@ -340,6 +340,7 @@ pub fn no_fix_reason() -> String { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; + use crate::hook::RecordShape; use crate::transcript::parse; /// A call, its refusal, and its retry, spelled as the host writes them. @@ -368,7 +369,7 @@ mod tests { } fn scan_body(body: &str) -> Vec { - scan(&parse(body, "fixture").expect("fixture parses")) + scan(&parse(body, "fixture", RecordShape::Jsonl).expect("fixture parses")) } #[test] diff --git a/crates/batten/src/claim.rs b/crates/batten/src/claim.rs index 113e68ac2..b1a0eb316 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/completion.rs b/crates/batten/src/completion.rs index 59933a0da..4b92174d8 100644 --- a/crates/batten/src/completion.rs +++ b/crates/batten/src/completion.rs @@ -335,6 +335,7 @@ pub fn no_fix_reason(target: &str) -> String { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; + use crate::hook::RecordShape; use crate::transcript::parse; const USER: &str = r#"{"message":{"role":"user","content":"do it"}}"#; @@ -344,7 +345,7 @@ mod tests { r#"{"attachment":{"type":"hook_success","hookEvent":"Stop","exitCode":0}}"#; fn signal_of(body: &str) -> Option { - signal(&parse(body, "fixture").expect("fixture parses")) + signal(&parse(body, "fixture", RecordShape::Jsonl).expect("fixture parses")) } #[test] diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index d79022811..9ec3b3fb3 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. @@ -328,8 +333,14 @@ pub struct Config { /// /// Consumer-specific by nature, and this table is where non-negotiable rule 1 /// is paid: `get_issue`, `issue-read`, `id` and `updatedAt` name a tracker, - /// its tools and its schema, so a grep of `crates/batten` for any of them - /// returns nothing and every one of them lives here. + /// its tools and its schema, and every one of them is DECLARED here rather + /// than chosen by the engine. + /// + /// This paragraph used to assert that a scan of the crate for those names + /// found none. It was false — they appear throughout `crates/batten/src`, + /// including in prose explaining the rule — and nothing re-ran it, which is + /// the class `assertion_gates.rs` now refuses. What rule 1 actually requires + /// is that the engine not CHOOSE them, and that is what this table is for. #[serde(default, rename = "mint", skip_serializing_if = "Vec::is_empty")] pub mints: Vec, /// Records written from the tool result that earned them (CLOUD-1051). @@ -341,8 +352,9 @@ pub struct Config { /// /// Consumer-owned for the same reason `[[mint]]` is, and more so: the column /// names, the verdict tokens and the programs are all a tracker's vocabulary, - /// so a grep of `crates/batten` for any of them returns nothing and every one - /// of them lives here. + /// and every one of them is declared here rather than chosen by the engine. + /// (The scan this paragraph used to report having run is gone with + /// [`Config::mints`]' — same false claim, same reason.) #[serde(default, rename = "recorder", skip_serializing_if = "Vec::is_empty")] pub recorders: Vec, /// The programs a `[[recorder]]` may run, by id. @@ -409,8 +421,9 @@ pub struct Config { /// /// Consumer-owned for [`Config::mints`]' reason and more sharply: the server /// id, the method names, the field sets and the reduction chosen per method - /// are all a tracker's vocabulary, so a grep of `crates/batten` for any of - /// them returns nothing and every one of them lives here. The crate knows + /// are all a tracker's vocabulary, and every one of them is declared here + /// rather than chosen by the engine. (The scan this paragraph used to report + /// having run is gone with [`Config::mints`]' — same false claim.) The crate knows /// only *dispatch a declared method; reduce by a declared projection*. The /// type, the transport and the reductions are [`crate::mcp`]. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -611,8 +624,10 @@ pub struct Config { /// Consumer-specific by nature, and the reason it lives here: the engine /// carries the matcher, this file carries the vendor literals. That extends /// non-negotiable rule 1 from consumers to vendors — a grep of `crates/` for - /// the configured patterns returns nothing. The type and the predicate are - /// [`crate::attribution`]. + /// the configured patterns returns nothing (verified-by: batten-check — the + /// rule-1 `forbid` rows over `crates/**` in this repository's own + /// `batten.toml`, which run on every gate invocation). The type and the + /// predicate are [`crate::attribution`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub attribution: Option, /// How a session credential is PROVED usable before anything is stripped on @@ -627,6 +642,27 @@ pub struct Config { /// could-not-look rather than as healthy: no removal is authorised. #[serde(default, skip_serializing_if = "Option::is_none")] pub credential: Option, + /// Which forge this repository is hosted on, in the two places the engine + /// otherwise assumed one (CLOUD-1622). + /// + /// **A SEPARATE TABLE FROM `[credential]` ABOVE, and that separation is + /// measured rather than tidy.** `[credential] names` answers "which variables + /// hold a credential WE hold", and its own doc is explicit that the forge's + /// conventional names are deliberately excluded — a host may inject a + /// substitutable placeholder under them, so probing those would measure the + /// host's credential rather than ours. [`Forge::credential_names`] answers the + /// opposite question: which variables hold a token to AUTHENTICATE WITH, + /// host-injected job tokens emphatically included. Folding the two would have + /// stopped every REST read using the CI-provided token — a live regression, + /// and the reason the row's suggestion to reuse the existing key does not + /// survive contact with what that key declares. + /// + /// Absent is could-not-look rather than a default. The engine's own + /// spellings are gone rather than kept as a fallback, because a fallback is + /// how the seam stayed invisible: it worked here, on this forge, and returned + /// a safe-looking nothing everywhere else. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub forge: Option, /// The commit-subject convention this repository holds itself to /// (CLOUD-701). Absent means no convention is declared and the gate is not /// active — which the gate reports as exit 1, never as a clean pass over @@ -971,7 +1007,8 @@ pub struct Trust { /// meaningless in the next. The core therefore carries only the default (this /// file), and every consumer's own list lives in that consumer's own config, so /// a grep of `crates/batten` for any consumer's identifiers returns nothing -/// (non-negotiable rule 1). +/// (non-negotiable rule 1; verified-by: batten-check — the rule-1 `forbid` rows over `crates/**` in this repository's own `batten.toml`, which run on every gate invocation). + #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Epoch { @@ -997,7 +1034,8 @@ pub struct Epoch { /// Declared as config for the reason [`Epoch`] gives: which files carry a /// repository's contract is that repository's business, so a grep of /// `crates/batten` for any consumer's identifiers returns nothing -/// (non-negotiable rule 1). +/// (non-negotiable rule 1; verified-by: batten-check — the rule-1 `forbid` rows over `crates/**` in this repository's own `batten.toml`, which run on every gate invocation). + #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Contract { @@ -1028,6 +1066,16 @@ pub struct Contract { pub fn parse(text: &str, source: &str) -> Result { let config = parse_ungated(text, source)?; check_min_version(&config, source)?; + // THE ONE WRITER of the forge declaration (CLOUD-1622). Every load path — + // `load`, `load_authority`, `load_site` — funnels through here, so recording + // it once at the gate is what keeps the REST tier's credential reader a single + // authority instead of a parameter threaded up fifteen call chains. `declare` + // ignores a later call, so a second parse cannot move the credential a request + // in flight would use; an unparsed or absent config never reaches this line, + // and the REST tier then reads could-not-look rather than a default. + if let Some(forge) = config.forge.clone() { + crate::rest::declare(forge); + } Ok(config) } @@ -2221,7 +2269,14 @@ fn binary_is_behind_the_config(source: &str, text: &str) -> bool { let Some(root) = Path::new(source).parent() else { return false; }; - let at = root.join("schema").join("batten.schema.json"); + // [`SCHEMA_PATH`], never a second spelling of it. It was hand-joined here + // for one commit, which is the two-authorities shape: the constant exists + // precisely because more than one reader needs the location, and a reader + // that spells its own is the one that keeps working after the constant + // moves. It is batten's own convention rather than a consumer identifier — + // the same class as [`CONFIG_FILE`] — so the fix is the constant, not + // config. + let at = root.join(SCHEMA_PATH); let (Ok(committed), Ok(derived)) = (fs::read_to_string(&at), schema()) else { return false; }; @@ -3314,6 +3369,7 @@ impl Config { pub fn declaring_nothing() -> Self { Config { credential: None, + forge: None, unresolvable: Vec::new(), version: SUPPORTED_VERSION, deferrals: Vec::new(), @@ -3336,6 +3392,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, @@ -3579,6 +3636,9 @@ fn default_rules() -> Vec { module: None, bundle: None, preset: None, + // No preset, so no provider to qualify: the default rule is a `forbid` + // over conflict markers and reads no CI language at all (CLOUD-1625). + provider: None, documents: Vec::new(), // Unconditional (CLOUD-125): the zero-config layer has no repository // shape to declare a precondition against, and a default rule that diff --git a/crates/batten/src/contract.rs b/crates/batten/src/contract.rs index efa08d9c3..aa601a989 100644 --- a/crates/batten/src/contract.rs +++ b/crates/batten/src/contract.rs @@ -48,7 +48,8 @@ //! Which files carry a repository's contract is that repository's business — an //! agent guide, a rules directory, a hook config, a task tree — so the paths live //! in `batten.toml`'s `[contract]` table and a grep of `crates/batten` for any -//! consumer's identifiers returns nothing (non-negotiable rule 1). +//! consumer's identifiers returns nothing (non-negotiable rule 1; +//! verified-by: batten-check — the rule-1 `forbid` rows over `crates/**` in this repository's own `batten.toml`, which run on every gate invocation). //! //! **It is not `[epoch] tracked`, and the reason is a finding rather than a //! preference.** CLOUD-461 names that table as the source of truth, and the diff --git a/crates/batten/src/doctor.rs b/crates/batten/src/doctor.rs index ee92c05d0..10d0c2ecc 100644 --- a/crates/batten/src/doctor.rs +++ b/crates/batten/src/doctor.rs @@ -818,21 +818,38 @@ pub fn diagnose_commit_gate(dir: &Path) -> Check { /// The reason ids are `Capability`'s own labels rather than new spellings, so a /// reader who has seen one in a `-J` document sees the same token here. fn transcript_reason(root: &Path) -> Option { - let configured = resolve::resolve(root, &crate::Overrides::default()) + let declared = resolve::resolve(root, &crate::Overrides::default()) .ok()? - .transcript? - .path?; + .transcript?; + let configured = declared.path.clone()?; Some( - match crate::transcript::resolve(root, Some(configured.as_str())) { + match crate::transcript::resolve(root, Some(configured.as_str()), declared.harness) { // POINTER-ONLY: the capability carries a `