From 64f4c6eca22c802b9768765b9ce8f6ce7f7ee244 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 22:37:22 +0000 Subject: [PATCH 01/12] fix(policy): a module's finding takes its remedy from the class it raises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1220. Measured on this repository during #807: `enforce` printed `2 finding(s) carry no remediation: persisted:false`, and both were `kind = "policy"` rows whose `[[verdict]]` tokens declare routes. The registry had the remedy; the `Finding` did not, so both were dropped before `findings::record` and never entered the store. THE MECHANISM, established before changing anything, because the row required it and because `lib.rs`'s comment asserted the opposite: RuleKind::Policy => &["severity"] RuleKind::Judge => &["glob", "criteria", "no_fix_reason"] A policy row requires only `severity`. Judge requires `no_fix_reason` outright, and its own comment says why — "a judge finding reaches the store and CLOUD-81's ingest refuses one nothing can close ... Requiring it here is what keeps that refusal unreachable from a config that parses." Policy rows never got that treatment, so `rule.remediation()` returned `None` for every one of them. `lib.rs:6730`'s "this partition should never fire" generalised Judge's guarantee to a kind that never had it — CLOUD-242's lesson, one table over. THE FIX JOINS THE REGISTRY RATHER THAN ADDING A SECOND REMEDY COLUMN. Requiring `no_fix_reason` on a policy row would have been the smaller diff and the wrong one: a module's remedy is per PREDICATE and one row can carry many (CLOUD-832), so a single column could not say which violation it answered — and it would be a second spelling of what `[[verdict]]` already declares. `policy_remediation` resolves the raised class instead: a `command` route becomes `Remediation::Fix`, and every other route — document, issue, override — becomes a pointer-only `NoFix` naming route ids and kinds, which is a real answer rather than an absence, since `verdict::validate` already refuses a class with no route and one whose only route is an override. `RunInputs` gained the union registry, taken from `Vocabulary.verdicts` which `run` already receives. The union rather than the consumer table, via `registry_for`: a module may raise a preset's class as readily as a consumer's. WHY THE TESTS ASSERT THE STORE AND NOT THE EXIT CODE. `enforce` exited 2 and printed the class correctly throughout — what was lost was persistence, so only a store read can see it. Running `enforce` on this repository is NOT evidence either: the tree is clean, so zero findings fire and zero unrecordable findings is vacuously true. The discriminating case is a fixture whose only violation comes from a `policy/*.rego` module, read back out of the store. Both arms, because a fix handling only `command` routes would leave every class whose remedy is a read or an override exactly as broken — which is most of this registry: a command route records the runnable argv, and a document route records the pointer without copying the route's target into it (rule 4). Refs: CLOUD-1220 --- crates/batten/src/rules.rs | 94 +++++++++++++- crates/batten/tests/it/enforce_journal.rs | 145 ++++++++++++++++++++++ 2 files changed, 238 insertions(+), 1 deletion(-) diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index 235888276..6d5a3c16b 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -6081,6 +6081,12 @@ fn run( } }; + // The union the engine DECIDES against, built once for the run (CLOUD-1220). + // `registry_for` is the one authority on it and refuses a consumer row that + // collides with a vendored class, so resolving a module's token against + // anything narrower would answer for half the classes a module may raise. + let registry = crate::policy::registry_for(vocabulary.verdicts)?; + let inputs = RunInputs { provisions, files: &files, @@ -6097,6 +6103,7 @@ fn run( tool_verdicts: tool_verdicts.as_ref(), captured: captured.as_ref(), bundles, + verdicts: ®istry, }; let mut scan = Scan::default(); @@ -6403,6 +6410,17 @@ struct RunInputs<'a> { /// an environment variable at all. external: &'a BTreeMap, bundles: &'a [crate::policy::Bundle], + /// The verdict registry a `policy` row's finding takes its remedy from + /// (CLOUD-1220). + /// + /// The UNION — consumer rows plus this binary's vendored classes — because a + /// module may raise a preset's class as readily as a consumer's, and a + /// registry missing half of them would resolve one and not the other. + /// + /// Here rather than re-resolved per violation: it is config, fixed for the + /// life of the run, and this is the shape `Vocabulary` already threads for + /// exactly that reason. + verdicts: &'a [crate::verdict::DeclaredVerdict], } fn run_rule( @@ -8104,6 +8122,7 @@ fn policy_rule( tool_verdicts, captured, bundles, + verdicts: registry, .. } = inputs; let Some(bundle) = bundles.iter().find(|bundle| bundle.id() == rule.id) else { @@ -8288,13 +8307,79 @@ fn policy_rule( }), line, check: rule.settling_check().unwrap_or(Check::Reevaluate), - remediation: rule.remediation(), + // THE REMEDY COMES FROM THE CLASS, NOT THE ROW (CLOUD-1220). + // + // `rule.remediation()` reads the row's own `fix`/`no_fix_reason`, and a + // `policy` row carries neither: `RuleKind::Policy` requires only + // `severity`, where `RuleKind::Judge` requires `no_fix_reason` outright — + // with a comment saying exactly why, that a judge finding "reaches the + // store and CLOUD-81's ingest refuses one nothing can close". Policy rows + // never got that treatment, so every one of their findings was dropped + // before `findings::record`, and the whole findings subsystem — baseline, + // dedup, disposition, sink accounting — was blind to the one rule kind + // CLOUD-843's campaign is porting ~132 gates onto. + // + // The remedy was never missing, only unreachable: the `[[verdict]]` token + // this violation raises declares its routes, and this is the hop that was + // absent. No row's `fix` column is consulted for a policy finding — a + // second spelling of a remedy the registry already carries is the + // duplication that registry exists to remove. + remediation: policy_remediation(registry, &violation.verdict), identity, }); } None } +/// The remedy a raised class declares, as a [`Remediation`] (CLOUD-1220). +/// +/// # Why a policy finding cannot take the row's own remedy +/// +/// [`Rule::remediation`] reads the `fix`/`no_fix_reason` columns, and a `policy` +/// row carries neither — `RuleKind::Policy` requires only `severity`. That is +/// not an oversight in the row: a module's remedy is per PREDICATE, and one row +/// can carry many (CLOUD-832), so a single column on the row could not say which +/// violation it answered. The `[[verdict]]` registry is where it already lives. +/// +/// # The two shapes, and why the second is not "no remedy" +/// +/// A `command` route is a runnable fix and becomes [`Remediation::Fix`]. Every +/// other route — a document to read, an issue to file, an override to request — +/// is a remedy a human performs, so it becomes [`Remediation::NoFix`] carrying +/// the route ids and kinds. That is a real answer rather than an absence: +/// `verdict::validate` already refuses a class with no route at all, and one +/// whose only route is an override, so a resolved class always names somewhere +/// to go. +/// +/// **Pointer-only** (rule 4): route ids and kinds, never a route's target prose +/// and never the finding's content. The full text is one hop away through +/// `batten policy explain`, which is CLOUD-1286's settled position. +/// +/// `None` only where the token resolves to nothing — which +/// `check_verdicts_are_declared` refuses at load, so it is the residue of a +/// registry narrower than the one the module was compiled against rather than a +/// state a loaded config can reach. +fn policy_remediation( + registry: &[crate::verdict::DeclaredVerdict], + token: &str, +) -> Option { + if let Some(command) = crate::verdict::first_command_route(registry, token) { + return Some(Remediation::Fix( + command.split_whitespace().map(ToOwned::to_owned).collect(), + )); + } + let (entry, _) = crate::verdict::resolve(registry, token)?; + let routes: Vec = entry + .routes + .iter() + .map(|route| format!("{} ({})", route.id, route.kind.as_str())) + .collect(); + Some(Remediation::NoFix(format!( + "{token}: {} — `batten policy explain` carries the detail", + routes.join(", ") + ))) +} + /// The first subject a violation carries, as a finding's pointer (CLOUD-1050). /// /// FIRST rather than all of them, because a `Finding` carries one pointer and @@ -12242,6 +12327,13 @@ mod tests { tool_verdicts: None, captured: None, bundles: &[], + // Empty, and that is honest for this harness: it builds no + // bundle, so no `policy` row runs and nothing here resolves a + // class. A fixture supplying a registry no consumer supplies is + // how a deny case passes for the wrong reason + // (`.claude/rules/policy-modules.md`), and the tier that DOES + // drive a module is the compiled-binary one. + verdicts: &[], } } } diff --git a/crates/batten/tests/it/enforce_journal.rs b/crates/batten/tests/it/enforce_journal.rs index 9fba4a7b8..b1629a4a5 100644 --- a/crates/batten/tests/it/enforce_journal.rs +++ b/crates/batten/tests/it/enforce_journal.rs @@ -203,6 +203,151 @@ fn with_command(check: &str) -> String { ) } +/// A repository whose ONLY violation comes from a `policy/*.rego` module +/// (CLOUD-1220). +/// +/// `no_fix_reason` is deliberately ABSENT from the row: `RuleKind::Policy` +/// requires only `severity`, and a module's remedy is per PREDICATE rather than +/// per row — one row can carry many (CLOUD-832) — so the registry is the only +/// place it can live. A fixture that put a remedy on the row would test a +/// column no real policy row carries and pass over the defect. +fn policy_only(route: &str) -> String { + format!( + "version = 1\n\n\ + [[rule]]\n\ + id = \"probe\"\n\ + kind = \"policy\"\n\ + scope = \"tree\"\n\ + module = \"policy/probe.rego\"\n\ + severity = \"deny\"\n\n\ + [[verdict]]\n\ + id = \"probe read refused\"\n\ + gloss = \"the probe module refused this tree\"\n\ + class = \"A fixture class, raised only by this suite's probe module.\"\n\n\ + {route}" + ) +} + +/// A module that always refuses, so the case is about the FINDING rather than +/// about a predicate. +const PROBE_MODULE: &str = r#"package batten.probe + +import rego.v1 + +rules contains "probe" + +violation contains { + "rule": "probe", + "verdict": "probe read refused", + "subjects": [{"path": "README.md"}], +} if { + true +} +"#; + +// --- (a2) a policy-module finding reaches the store, with its class's remedy --- + +/// **The case CLOUD-1220 was found by, and it was red before the fix.** +/// +/// Measured on `main`: `enforce` printed `2 finding(s) carry no remediation: +/// persisted:false` and both were `kind = "policy"` rows whose `[[verdict]]` +/// tokens declared routes. `policy_rule` took `rule.remediation()` — the ROW's +/// `fix`/`no_fix_reason` — and a policy row carries neither, so every module +/// finding was dropped before `findings::record` and the whole findings +/// subsystem was blind to the one rule kind CLOUD-843's campaign ports onto. +/// +/// Asserting the store rather than the exit code is the whole point: `enforce` +/// exited 2 and printed the class correctly the entire time. What was lost was +/// persistence, so only a store read can see it. +#[test] +fn a_policy_module_finding_reaches_the_store_carrying_its_classs_remedy() { + let env = Env::new("enforce-journal-policy-remedy"); + // BEFORE the config, because `bind_store` writes its own `batten.toml` + // and would otherwise clobber this fixture's — which it did, and the case + // then measured a tree with no policy row at all. + env.bind_store(); + env.file("README.md", "base\n"); + env.file("policy/probe.rego", PROBE_MODULE); + env.file( + "batten.toml", + &policy_only( + "[[verdict.route]]\n\ + id = \"probe run first\"\n\ + kind = \"command\"\n\ + target = \"mise run probe-fix\"\n", + ), + ); + + let run = env.run(&["enforce"]); + assert_eq!( + run.status.code(), + Some(2), + "the module refuses, which it always did: {}", + common::stderr(&run) + ); + assert!( + !common::stderr(&run).contains("carry no remediation"), + "no finding may be dropped as unrecordable: {}", + common::stderr(&run) + ); + + let found = env + .record("probe") + .expect("the policy module's finding reached the store"); + assert_eq!( + found["remediation"]["fix"], + serde_json::json!(["mise", "run", "probe-fix"]), + "a `command` route becomes the runnable fix, taken from the class rather \ + than from the row: {found}" + ); +} + +/// The other route shape, and it is NOT "no remedy". +/// +/// A document, issue or override route is a remedy a human performs, so it +/// records as a pointer naming the route ids and kinds rather than being dropped. +/// Without this arm, a fix that only handled `command` routes would leave every +/// class whose remedy is a read or an override exactly as broken as before — +/// which is most of this repository's own registry. +#[test] +fn a_class_whose_only_route_is_a_read_still_records_a_remedy() { + let env = Env::new("enforce-journal-policy-read-route"); + // BEFORE the config, because `bind_store` writes its own `batten.toml` + // and would otherwise clobber this fixture's — which it did, and the case + // then measured a tree with no policy row at all. + env.bind_store(); + env.file("README.md", "base\n"); + env.file("policy/probe.rego", PROBE_MODULE); + env.file( + "batten.toml", + &policy_only( + "[[verdict.route]]\n\ + id = \"probe read first\"\n\ + kind = \"document\"\n\ + target = \"README.md\"\n", + ), + ); + + let run = env.run(&["enforce"]); + assert_eq!(run.status.code(), Some(2), "{}", common::stderr(&run)); + let found = env + .record("probe") + .expect("a class with no command route still records"); + let remedy = found["remediation"]["no-fix"] + .as_str() + .expect("a non-command route records as `no-fix` (serde kebab-case)"); + assert!( + remedy.contains("probe read first") && remedy.contains("document"), + "the remedy names the route id and its kind: {remedy}" + ); + // POINTER-ONLY (rule 4): the route's target is one hop away through + // `policy explain`, and must not be copied into the record. + assert!( + !remedy.contains("README.md"), + "a route's target is not a pointer this record carries: {remedy}" + ); +} + // --- (a) an enforce-only kind reaches the store, idempotently ----------------- #[test] From 01167e8267888a0c0fcb23efe5753cb308c92f52 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 22:53:08 +0000 Subject: [PATCH 02/12] refactor(rules)!: a finding records its owner instead of being looked up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1087. `Scan::attributed` mapped a finding's scope fingerprint to the row that produced it, so `requested_sinks` could answer "which findings are this row's" for a `policy` row, whose findings report the PREDICATE's id (CLOUD-832). Review of #721 raised the same objection twice: keyed on the predicate id, then keyed on the fingerprint. The first was fixed by re-keying; the second was not. TWO ROUNDS CONVERGING ON ONE SHAPE IS THE FINDING. A third re-key answers the instance. What both rounds were saying is that the owner was INFERRED from a value meaning something else — an identity, not a row — when it can be RECORDED. `rules.rs` builds a `command` finding as `scope_fingerprint(&rule.id, glob)` and a policy finding as `scope_fingerprint(id, &fingerprint_of(violation))`, so a command row whose id equals a predicate id and whose glob equals the verdict token mints the identical key and the map hands one row's findings to the other. `Finding::owner: Option` — `None` meaning "the rule id is the owner", true for every kind but `policy`. Set at the one construction site that holds both ids at once. `requested_sinks` and `decidability_of` read `owner.as_deref().unwrap_or(&rule)`. `Scan::attributed` is deleted rather than re-keyed, with its threading through `run_rule` and `policy_rule`: nothing is looked up, so nothing can collide. `Finding::rule` is untouched and still carries the predicate id — reporting and `waiver::apply` ask a different question and their answer was already right, so a waiver still names the gate rather than the bundle holding it. THE SITE COUNT IN THE ROW WAS STALE AND SO WAS MY FIRST RE-COUNT. The row said 15 across 8 files, measured 2026-08-28. A `git grep "Finding {"` says 77, which is wrong — it counts `FindingKind` and `FindingRecord` too. rustc's own missing-field spans say 17 across 9 files, and those spans are what drove the edit rather than either count: a hand-applied sweep over a number nobody verified is how one site gets the wrong owner silently. TWO API BREAKS, AND THE ROW PREDICTED ONE. `constructible_struct_adds_field` is the one it named. `function_parameter_count_changed` is the direct consequence of the deletion it asks for — `any_blocking` and `decidability_of` no longer take `&BTreeMap`. Declaring both rather than the expected one. The discriminating case is the collision review named and #721 could not carry: a `command` row and a policy predicate constructed to mint the same fingerprint, each still counting its own finding and only its own. CLOUD-1083's two arms pass unchanged, which is what shows this replaces that mechanism rather than competing with it. BREAKING CHANGE: `rules::Finding` gains an `owner` field, so a struct literal naming every field no longer compiles. `None` is the correct value for every kind but `policy`. BREAKING CHANGE: `rules::any_blocking` and `rules::decidability_of` lose their `attributed: &BTreeMap` parameter, which `Scan::attributed`'s deletion leaves nothing to pass. Refs: CLOUD-1087 --- crates/batten/src/baseline.rs | 2 + crates/batten/src/budget.rs | 1 + crates/batten/src/defects.rs | 1 + crates/batten/src/design.rs | 1 + crates/batten/src/findings.rs | 1 + crates/batten/src/hookcost.rs | 1 + crates/batten/src/lib.rs | 13 +-- crates/batten/src/rules.rs | 174 +++++++++++++++----------------- crates/batten/src/secrets.rs | 1 + crates/batten/src/waiver.rs | 1 + crates/batten/tests/it/sinks.rs | 97 ++++++++++++++++++ 11 files changed, 191 insertions(+), 102 deletions(-) diff --git a/crates/batten/src/baseline.rs b/crates/batten/src/baseline.rs index b0bdf1f76..6d5fcaa34 100644 --- a/crates/batten/src/baseline.rs +++ b/crates/batten/src/baseline.rs @@ -523,6 +523,7 @@ impl Drifted { identity::scope_fingerprint(&rule, &scope), ); Some(Finding { + owner: None, rule, severity: RuleSeverity::Deny, // The pointer is the entry, never a path in the tree: the finding @@ -700,6 +701,7 @@ mod tests { identity::code_fingerprint(rule, path, span, identity::SpanNormalization::Collapsed) .expect("mint a code identity"); Finding { + owner: None, rule: rule.to_owned(), severity: RuleSeverity::Deny, path: path.to_owned(), diff --git a/crates/batten/src/budget.rs b/crates/batten/src/budget.rs index 7d2aec5b7..e661eeb17 100644 --- a/crates/batten/src/budget.rs +++ b/crates/batten/src/budget.rs @@ -245,6 +245,7 @@ impl Report { identity::scope_fingerprint(&rule, &self.name), ); Some(Finding { + owner: None, rule, severity: RuleSeverity::Deny, path: self.name.clone(), diff --git a/crates/batten/src/defects.rs b/crates/batten/src/defects.rs index 4b6cb6a7c..a5808a735 100644 --- a/crates/batten/src/defects.rs +++ b/crates/batten/src/defects.rs @@ -220,6 +220,7 @@ impl Problem { crate::identity::scope_fingerprint(&rule, path), ); Finding { + owner: None, rule, severity: RuleSeverity::Deny, path: path.to_owned(), diff --git a/crates/batten/src/design.rs b/crates/batten/src/design.rs index 752d533e7..e1acc42aa 100644 --- a/crates/batten/src/design.rs +++ b/crates/batten/src/design.rs @@ -362,6 +362,7 @@ impl Problem { crate::identity::scope_fingerprint(&rule, &self.claim), ); Finding { + owner: None, rule, severity: self.severity(), path: STREAM.to_owned(), diff --git a/crates/batten/src/findings.rs b/crates/batten/src/findings.rs index e38ef432a..2d8955425 100644 --- a/crates/batten/src/findings.rs +++ b/crates/batten/src/findings.rs @@ -1179,6 +1179,7 @@ mod tests { fn finding_for(severity: RuleSeverity) -> Finding { Finding { + owner: None, rule: "r".to_owned(), severity, path: "src/a.rs".to_owned(), diff --git a/crates/batten/src/hookcost.rs b/crates/batten/src/hookcost.rs index 5072888fc..5789d569a 100644 --- a/crates/batten/src/hookcost.rs +++ b/crates/batten/src/hookcost.rs @@ -293,6 +293,7 @@ pub fn judge(reading: &Reading, ceiling: Option<&Ceiling>) -> Vec { /// it rather than a `Fix::Run` naming a command that would not help. fn finding(rule: &str, subject: String, line: Option, remedy: &str) -> Finding { Finding { + owner: None, rule: rule.to_owned(), severity: RuleSeverity::Deny, identity: StoredIdentity::new( diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index acebaa452..d0eda3073 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -9490,15 +9490,12 @@ fn run_dispositions( fail_on_warning: bool, rules: &[rules::Rule], ) -> Vec { - let attributed = &scan.attributed; let mut dispositions = Vec::with_capacity(scan.not_evaluated.len() + 1); - dispositions.push( - if rules::any_blocking(findings, fail_on_warning, rules, attributed) { - decision::Outcome::Violation - } else { - decision::Outcome::Pass - }, - ); + dispositions.push(if rules::any_blocking(findings, fail_on_warning, rules) { + decision::Outcome::Violation + } else { + decision::Outcome::Pass + }); for observation in scan.not_evaluated.values() { dispositions.push(match observation { findings::NotObserved::RuleErrored => decision::Outcome::Internal, diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index 6d5a3c16b..67f95e097 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -5246,6 +5246,34 @@ fn validate_composition(rules: &[Rule], at: Option>) -> anyhow::Resu pub struct Finding { /// The [`Rule::id`] that produced this finding. pub rule: String, + /// The ROW this finding belongs to, where that is not [`Finding::rule`] + /// (CLOUD-1087). + /// + /// `None` means "the rule id is the owner", which is true for every kind but + /// `policy` — a policy finding reports the PREDICATE's id (CLOUD-832), and a + /// module may carry many predicates under one row. + /// + /// # Why a field and not a lookup + /// + /// This replaces `Scan::attributed`, a side map keyed on the scope + /// fingerprint. Review of #721 raised the same objection twice in two + /// shapes — keyed on the predicate id, then keyed on the fingerprint — and + /// re-keying a third time answers the instance rather than the class. Both + /// rounds were saying that the owner was being INFERRED from a value that + /// means something else, when it can simply be RECORDED. A construction + /// where a `command` row's id equals a predicate id and its glob equals the + /// verdict token mints the same `scope_fingerprint`, so the map could hand + /// one row's findings to another; a field cannot collide, because nothing + /// is looked up. + /// + /// [`Finding::rule`] is deliberately untouched and keeps the predicate id: + /// reporting and [`crate::waiver::apply`] ask a different question and their + /// answer is already right. + /// + /// Engine-internal — it reaches no pointer and no record, so rule 4 needs no + /// new decision here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, /// The producing rule's [`Rule::severity`], for the exit-contract decision. pub severity: RuleSeverity, /// Where the violation is. A file-scoped kind reports the repo-relative @@ -5328,14 +5356,13 @@ impl Decidability { /// `finding.rule` would therefore classify every policy finding as /// unresolvable, and this bound would silently switch off the whole kind. #[must_use] -pub fn decidability_of( - finding: &Finding, - rules: &[Rule], - attributed: &BTreeMap, -) -> Decidability { - let owner = attributed - .get(&finding.identity.fingerprint.to_hex()) - .map_or(finding.rule.as_str(), String::as_str); +pub fn decidability_of(finding: &Finding, rules: &[Rule]) -> Decidability { + // THE FINDING SAYS WHO OWNS IT (CLOUD-1087). This used to consult + // `Scan::attributed`, keyed on the scope fingerprint — a value that means + // "this finding's identity", not "this finding's row", and one a `command` + // row can mint identically to a policy predicate's. Nothing is looked up + // now, so nothing can collide. + let owner = finding.owner.as_deref().unwrap_or(&finding.rule); rules .iter() .find(|rule| rule.id == owner) @@ -5368,34 +5395,6 @@ pub struct Scan { /// baseline a later run would ratchet against having never been measured — /// CLOUD-81's fail-closed reading, one surface further on. pub requested: Vec, - /// Which containing row a FINGERPRINT belongs to, for the findings whose - /// reported id is not their owner's (CLOUD-1083). Sparse: only `policy` - /// writes here, because only there do the two ids differ. - /// - /// **Keyed on the fingerprint rather than the predicate id**, which is not - /// a detail: a predicate id and a `Rule::id` are separate namespaces and - /// nothing checks one against the other, so a module may declare a predicate - /// whose id is also a row's. A map keyed by that id could not tell the two - /// apart. A fingerprint already names ONE finding, so the collision is - /// inexpressible here rather than something the lookup has to adjudicate. - /// - /// `Finding::rule` is deliberately the predicate a reader saw rather than the - /// row that registered it (CLOUD-832) — `waiver::apply` matches on it, so a - /// waiver names the gate rather than the bundle holding it. That is right and - /// stays. But [`requested_sinks`] has to answer a DIFFERENT question, "which - /// findings are this row's", and for one kind the two answers differ. - /// - /// **Without this the sink aggregation reads clean by construction.** A - /// tree-scoped `policy` row carrying `produces` recorded `count = 0` and the - /// sha256 of the empty string however many violations its module reported, so - /// a later run ratcheting against that record compared against nothing and - /// passed — CLOUD-845's vacuous pass arriving through the very mechanism - /// CLOUD-851 added to prevent it. - /// - /// A lookup that MISSES means "the finding's rule id is its owner", which is - /// true for every other kind — so the ordinary kinds cost nothing and this - /// map's size is the count of predicates a bundle actually attributed. - pub attributed: BTreeMap, /// Which declared class a FINGERPRINT was refused under (CLOUD-1120), for the /// findings that have one. Sparse, and written only by `policy`, for /// [`Scan::attributed`]'s reason: only there does the fact exist. @@ -6187,12 +6186,9 @@ fn evaluate_rules( let (files_before, bytes_before) = (files_read(), bytes_read()); let outcome = { let Scan { - findings, - attributed, - classes, - .. + findings, classes, .. } = &mut *scan; - isolate(|| run_rule(rule, root, inputs, findings, attributed, classes)) + isolate(|| run_rule(rule, root, inputs, findings, classes)) }; costs_lock().push(RuleCost { rule: rule.id.clone(), @@ -6253,18 +6249,20 @@ fn requested_sinks(rules: &[Rule], scan: &Scan) -> Vec { let sink = rule.produces.as_ref()?; let mut subject = String::new(); let mut count = 0usize; - // THE CONTAINING ROW, NOT THE PREDICATE (CLOUD-1083). `f.rule` is - // the predicate a reader saw, and for every kind but `policy` the two - // are the same string — which is exactly why a filter on `f.rule` - // looked right and was silently empty for the one kind where they - // differ. A miss in `attributed` means the finding's own id is its - // owner, which is true for every kind that never needed the map. - for finding in scan.findings.iter().filter(|f| { - scan.attributed - .get(&f.identity.fingerprint.to_hex()) - .map_or(f.rule.as_str(), String::as_str) - == rule.id - }) { + // THE CONTAINING ROW, NOT THE PREDICATE (CLOUD-1083, by the field + // since CLOUD-1087). `f.rule` is the predicate a reader saw, and for + // every kind but `policy` the two are the same string — which is why + // a filter on `f.rule` looked right and was silently empty for the + // one kind where they differ. + // + // The finding now SAYS whose it is instead of being looked up in a + // map keyed on its fingerprint. `None` means the rule id is the + // owner, true for every kind that never needed the map. + for finding in scan + .findings + .iter() + .filter(|f| f.owner.as_deref().unwrap_or(&f.rule) == rule.id) + { subject.push_str(&finding.identity.fingerprint.to_hex()); subject.push('\n'); count += 1; @@ -6428,10 +6426,7 @@ fn run_rule( root: &Path, inputs: &RunInputs<'_>, findings: &mut Vec, - // See [`Scan::attributed`]. Threaded for the one kind whose findings do not - // carry their own row's id. - attributed: &mut BTreeMap, - // See [`Scan::classes`]. Threaded beside `attributed` because it is the same + // See [`Scan::classes`]. Threaded because it is the same // kind and the same one place the fact exists (CLOUD-1120). classes: &mut BTreeMap, ) -> anyhow::Result> { @@ -6456,7 +6451,7 @@ fn run_rule( // is what decides, and returning here would switch those off by a value // nobody aimed at them. if rule.kind == RuleKind::Policy { - return Ok(policy_rule(rule, inputs, findings, attributed, classes)); + return Ok(policy_rule(rule, inputs, findings, classes)); } let Some(glob) = rule.glob.as_deref() else { // Unreachable for a tree-scoped kind, whose census requires `glob`. @@ -8102,9 +8097,6 @@ fn policy_rule( rule: &Rule, inputs: &RunInputs<'_>, findings: &mut Vec, - // Written HERE rather than derived later because this is the only place that - // holds the predicate id and the containing row's id at once. - attributed: &mut BTreeMap, // And the class, for the same reason: `Violation` carries the token, and by // the time a `Finding` exists it is gone (CLOUD-1120). classes: &mut BTreeMap, @@ -8261,20 +8253,12 @@ fn policy_rule( // and they move only when the finding does. identity::scope_fingerprint(id, &fingerprint_of(violation)), ); - // BEFORE the push, and for every violation this row raises: the sink - // aggregation asks "which findings are this row's" and the answer has to - // exist by the time anything reads it (CLOUD-1083). An `Allow` predicate - // is skipped above and records nothing, which is right — it produced no - // finding to attribute. - // - // KEYED ON THE FINGERPRINT, NOT THE PREDICATE ID (found on review of - // #721). A predicate id and a `Rule::id` are separate namespaces and - // nothing checks one against the other, so a module may declare a - // predicate whose id is also a row's — and a map keyed by that id cannot - // then tell the two apart, which would hand one row's findings to the - // other. A fingerprint already names ONE finding, so the lookup is exact - // and the collision is inexpressible rather than refused. - attributed.insert(identity.fingerprint.to_hex(), rule.id.clone()); + // THE OWNER IS ON THE FINDING NOW (CLOUD-1087), set at its construction + // below rather than recorded in a side map here. `Scan::attributed` was + // keyed on the scope fingerprint, and a `command` row whose id equals a + // predicate id and whose glob equals the verdict token mints the same + // one — so the map could hand this row's findings to that one. Nothing + // is looked up any more, so nothing can collide. // THE CLASS, beside the owner and for the same reason (CLOUD-1120): this // is the one place it exists. `Violation` carries the token the module // raised, `Finding` has nowhere to put it, and without it a spent @@ -8282,6 +8266,11 @@ fn policy_rule( // articulated, bound and consumed while the gate went on refusing. classes.insert(identity.fingerprint.to_hex(), violation.verdict.clone()); findings.push(Finding { + // THE ONE KIND WHOSE FINDING IS NOT ITS ROW'S (CLOUD-1087). + // `rule` above carries the PREDICATE id (CLOUD-832); the row that + // enabled the module is what a sink is asked about, and it is in hand + // right here rather than reconstructible from a fingerprint later. + owner: Some(rule.id.clone()), // THE PREDICATE'S ID, not the row's (CLOUD-832). `waiver::apply` // matches on this field, so a waiver names the gate a reader saw // rather than the bundle that happens to hold it. @@ -8864,6 +8853,7 @@ fn ratchet_finding( findings: &mut Vec, ) { findings.push(Finding { + owner: None, // The plain rule id, deliberately: `waiver::apply` matches on this // field, so decorating it would make a ratchet the one finding kind // no waiver could suppress — and the waiver is the designed hatch @@ -9622,6 +9612,7 @@ fn push_case_finding( return; }; findings.push(Finding { + owner: None, rule: rule.id.clone(), severity: rule.severity(), path: path.to_owned(), @@ -9728,6 +9719,7 @@ fn unresolved_subject( return; }; findings.push(Finding { + owner: None, rule: rule.id.clone(), severity: rule.severity(), path: path.to_owned(), @@ -10221,6 +10213,7 @@ fn run_once( let scope_key = rule.glob.as_deref().unwrap_or(&rule.id); let default = identity::scope_fingerprint(&rule.id, scope_key); findings.push(Finding { + owner: None, rule: rule.id.clone(), severity: rule.severity(), identity: identity_of(rule, identity::FindingKind::Scope, default), @@ -10260,14 +10253,9 @@ fn run_once( /// blocks an approximating rule" is a property of this function rather than a /// convention its callers keep. #[must_use] -pub fn any_blocking( - findings: &[Finding], - fail_on_warning: bool, - rules: &[Rule], - attributed: &BTreeMap, -) -> bool { +pub fn any_blocking(findings: &[Finding], fail_on_warning: bool, rules: &[Rule]) -> bool { findings.iter().any(|finding| { - decidability_of(finding, rules, attributed).may_block() + decidability_of(finding, rules).may_block() && severity::promote( severity::row_for_rule(finding.severity).report, fail_on_warning, @@ -10418,6 +10406,7 @@ fn forbid_in_files( // engine picks the same span. let default = identity::code_fingerprint(&rule.id, rel_path, line, mode)?; findings.push(Finding { + owner: None, rule: rule.id.clone(), severity: rule.severity(), path: rel_path.to_owned(), @@ -10557,6 +10546,7 @@ fn document_in_file( identity::SpanNormalization::Verbatim, )?; findings.push(Finding { + owner: None, rule: rule.id.clone(), severity: rule.severity(), path: rel_path.to_owned(), @@ -10582,6 +10572,7 @@ fn unreadable_document(rule: &Rule, rel_path: &str, node_path: &str) -> anyhow:: identity::SpanNormalization::Verbatim, )?; Ok(Finding { + owner: None, rule: rule.id.clone(), severity: rule.severity(), path: rel_path.to_owned(), @@ -14700,12 +14691,7 @@ mod tests { for promote in [false, true] { assert!( - any_blocking( - &findings, - promote, - std::slice::from_ref(&deciding), - &BTreeMap::new() - ), + any_blocking(&findings, promote, std::slice::from_ref(&deciding)), "a deciding row blocks (fail_on_warning={promote})" ); // …and the identical findings under an approximating row do not, @@ -14713,7 +14699,7 @@ mod tests { // it promotes through `severity::promote`, so a bound applied // outside `any_blocking` would be reachable by one CLI argument. assert!( - !any_blocking(&findings, promote, approximating, &BTreeMap::new()), + !any_blocking(&findings, promote, approximating), "an approximating row must not block (fail_on_warning={promote})" ); } @@ -14738,14 +14724,14 @@ mod tests { // mean to exercise rather than over the severity rank it does. let table = std::slice::from_ref(&rule); assert!( - !any_blocking(&findings, false, table, &BTreeMap::new()), + !any_blocking(&findings, false, table), "a warn finding must not block" ); // …and the same finding, unchanged, blocks once the setting promotes it // (CLOUD-49). The finding itself is identical in both runs: promotion // acts on the exit decision, never on what was stored or reported. assert!( - any_blocking(&findings, true, table, &BTreeMap::new()), + any_blocking(&findings, true, table), "fail_on_warning must promote a warn finding" ); @@ -14754,11 +14740,11 @@ mod tests { let deny = run_static(deny_table, &dir).unwrap(); for promote in [false, true] { assert!( - any_blocking(&deny, promote, deny_table, &BTreeMap::new()), + any_blocking(&deny, promote, deny_table), "a deny finding must block either way" ); assert!( - !any_blocking(&[], promote, deny_table, &BTreeMap::new()), + !any_blocking(&[], promote, deny_table), "no findings, nothing blocks" ); } @@ -14770,7 +14756,7 @@ mod tests { // approximating disarmed three subsystems that never had a rule kind to // classify, which is what this arm now holds shut. assert!( - any_blocking(&deny, true, &[], &BTreeMap::new()), + any_blocking(&deny, true, &[]), "an engine-minted finding is not silenced by having no configured row" ); } diff --git a/crates/batten/src/secrets.rs b/crates/batten/src/secrets.rs index 91504106c..6db2f7dbf 100644 --- a/crates/batten/src/secrets.rs +++ b/crates/batten/src/secrets.rs @@ -1077,6 +1077,7 @@ pub fn scan( )?; } findings.push(Finding { + owner: None, rule: rule.id.clone(), severity: rule.severity(), path: hit.path, diff --git a/crates/batten/src/waiver.rs b/crates/batten/src/waiver.rs index 523630a63..d63a46aba 100644 --- a/crates/batten/src/waiver.rs +++ b/crates/batten/src/waiver.rs @@ -619,6 +619,7 @@ mod tests { fn finding(rule: &str, path: &str) -> Finding { Finding { + owner: None, rule: rule.to_owned(), severity: RuleSeverity::Deny, path: path.to_owned(), diff --git a/crates/batten/tests/it/sinks.rs b/crates/batten/tests/it/sinks.rs index 8c82eedae..82f04eb29 100644 --- a/crates/batten/tests/it/sinks.rs +++ b/crates/batten/tests/it/sinks.rs @@ -818,6 +818,103 @@ fn a_policy_rows_sink_counts_the_violations_its_module_reported() { ); } +#[test] +fn a_fingerprint_collision_cannot_move_a_finding_to_another_row() { + // THE ARM #721 DID NOT CARRY (CLOUD-1087), and the reason it is here now. + // + // `a_predicate_named_after_a_row_leaves_that_rows_sink_alone` above covers + // the collision review found FIRST — a predicate id equal to a `Rule::id` — + // which #721 fixed by re-keying `attributed` onto the scope fingerprint. + // Review then raised the SAME objection in a second shape: a `command` row + // builds `scope_fingerprint(&rule.id, glob)` and `policy_rule` builds + // `scope_fingerprint(id, &fingerprint_of(violation))`, so a command row + // whose id equals the predicate id and whose glob equals the verdict token + // mints the identical key. + // + // Two rounds converging on one shape is the finding: the owner was being + // INFERRED from a value that means "this finding's identity" rather than + // "this finding's row". Re-keying a third time would answer the instance. + // The owner is a field now, so there is no key and nothing to collide — + // which is why this case cannot be written as a lookup failure any more, + // only as the property that survives it. + let module = "package batten\n\ + \n\ + rules contains \"colliding-id\"\n\ + \n\ + violation contains {\n\ + \t\"rule\": \"colliding-id\",\n\ + \t\"verdict\": \"something to say\",\n\ + }\n"; + let dir = Fixture::new("sink-fingerprint-collision") + .config( + "version = 1\n\ + \n\ + [[rule]]\n\ + id = \"colliding-id\"\n\ + kind = \"forbid\"\n\ + glob = \"**/*.rs\"\n\ + pattern = \"blessed-by\"\n\ + severity = \"warn\"\n\ + scope = \"tree\"\n\ + no_fix_reason = \"say who decided, not who blessed it\"\n\ + \n\ + [rule.produces]\n\ + kind = \"baseline\"\n\ + key = \"rule\"\n\ + \n\ + [[rule]]\n\ + id = \"the-policy-row\"\n\ + kind = \"policy\"\n\ + scope = \"tree\"\n\ + module = \"policy/says.rego\"\n\ + severity = \"warn\"\n\ + no_fix_reason = \"nothing to fix; this row exists to record a count\"\n\ + \n\ + [rule.produces]\n\ + kind = \"baseline\"\n\ + key = \"rule\"\n\ + \n\ + [[verdict]]\n\ + id = \"something to say\"\n\ + gloss = \"this tree has something to say\"\n\ + class = \"What the fixture asserts, at the length explain answers with.\"\n\ + \n\ + [[verdict.route]]\n\ + id = \"nothing to do\"\n\ + kind = \"command\"\n\ + target = \"batten check\"\n", + ) + .file("src/lib.rs", "// blessed-by the architect\n") + .file("policy/says.rego", module) + .git() + .base_commit() + .build(); + + let output = run(&dir, &["enforce"]); + assert_eq!( + output.status.code(), + Some(0), + "both rows are warn severity: {}", + stdout(&output) + ); + + // EACH ROW COUNTS ITS OWN. The forbid row found one line; the policy row's + // module reported one violation. Neither may claim the other's, whatever + // their fingerprints do. + for row in ["colliding-id", "the-policy-row"] { + let written = + record(&dir, "baseline", row, "rule").unwrap_or_else(|| panic!("{row} recorded")); + let count = written + .split_whitespace() + .next_back() + .unwrap_or_else(|| panic!("a rendered record has three fields: {written:?}")); + assert_eq!( + count, "1", + "{row} counts its own finding and only its own: {written:?}" + ); + } +} + #[test] fn a_predicate_named_after_a_row_leaves_that_rows_sink_alone() { // THE COLLISION ARM (CLOUD-1083, found on review of #721). `attributed` is From 2aaff50f24ce1e504d5f75020e3b57b5e736e00f Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 23:05:58 +0000 Subject: [PATCH 03/12] feat(state): an event-anchored finding can finally be answered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-587. CLOUD-78 gave every finding a three-valued `disposition`, `journal::merge` folds it, `FindingRecord::merge_disposition` joins two by precedence, and `stop.rs` reads it — `deny-stop` means at-risk work or an undischarged denial, where undischarged is `disposition == None`. NOTHING MINTED ONE. `state` offered adopt/record/migrate/list and no verb wrote a `Disposition`; the only producers anywhere in the tree were unit tests. The field was read by a gate, joined by a merge rule, persisted by a journal, and unreachable from every caller. That bit once CLOUD-98 landed `bypass.rs`, whose finding anchors to an immutable transcript event: a bypass that happened, happened, so re-evaluation keeps finding it and the observation never resolves to zero. CLOUD-98's own assumption says such a finding "clears by disposition in the store, not by the condition vanishing" — correct as a design, unreachable as a mechanism. A VERB UNDER `state`, NOT A NEW NOUN. The store has one noun and `record` is already a per-observation write into the journal, so a disposition is the same act against the same object. A new noun would give one store two entry points and every later reader would have to work out which owns settlement. The cost is stated: `state`'s verb list grows by one, the narrower of the two widenings. THE FOLD NEEDED NO CHANGE, which is the evidence this adds a caller rather than a second convergence rule. `journal::merge` already applies `disposition` from any origin and `presentation` from `Origin::Drain` alone, so a settle entry carrying only an identity and a token folds correctly through the append that already exists — no new write path and no new lock. `Origin::Settle` is a third variant rather than reusing `Scan`. They fold identically, so it costs the merge nothing and buys the record its provenance: a reader auditing why a finding is settled should not have to infer whether a scan or an agent said so. The mixed-fleet cost is real and is the one `Scan` itself paid — a binary predating the variant skips that shard line — and it fails safe, leaving the finding unsettled rather than making the record lie about who decided. TWO CENSUSES REFUSED THE VERB UNTIL IT WAS WRITTEN DOWN, and both were right. `spec.rs`'s committed row set fails on any verb added, renamed or re-parented, so the surface cannot move silently and §2 gets reconciled in the same change. `pointer_only`'s census demands a stated disposition per leaf rather than a default. Here pointer-only is load-bearing rather than routine: the findings this answers are drawn from a session transcript, so the content is exactly what must not travel — it emits the identity and the token and nothing else. Both positionals are required. An omitted identity would have to mean "every finding"; an omitted disposition would have to guess what an agent decided, and a guessed disposition is the un-auditable settlement this verb exists to prevent. An unknown identity is refused rather than appended, because `journal::merge` deliberately KEEPS an entry whose record it cannot find — so a silent append would settle nothing and be invisible forever. The discriminating case is order-independence: two worktrees answering one finding differently converge to the same record whichever order the shards merge in, and to the STRONGER answer rather than the last written. A last-writer-wins implementation passes the single-answer case and silently loses one answer. Refs: CLOUD-587 --- completions/batten.bash | 73 ++++++++- completions/batten.fish | 62 ++++--- completions/batten.zsh | 57 +++++++ crates/batten/src/cli.rs | 13 ++ crates/batten/src/emission.rs | 7 + crates/batten/src/journal.rs | 17 ++ crates/batten/src/lib.rs | 99 +++++++++++ crates/batten/src/spec.rs | 6 + crates/batten/src/surface.rs | 41 +++++ crates/batten/tests/it/enforce_journal.rs | 155 ++++++++++++++++++ crates/batten/tests/it/pointer_only.rs | 17 ++ .../it__snapshots__golden_json_schema.snap | 22 +++ man/batten-state-settle.1 | 19 +++ man/batten-state.1 | 3 + 14 files changed, 569 insertions(+), 22 deletions(-) create mode 100644 man/batten-state-settle.1 diff --git a/completions/batten.bash b/completions/batten.bash index db079156d..3d8b1f6cc 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -562,6 +562,9 @@ _batten() { batten__subcmd__help__subcmd__state,record) cmd="batten__subcmd__help__subcmd__state__subcmd__record" ;; + batten__subcmd__help__subcmd__state,settle) + cmd="batten__subcmd__help__subcmd__state__subcmd__settle" + ;; batten__subcmd__help__subcmd__target,prune) cmd="batten__subcmd__help__subcmd__target__subcmd__prune" ;; @@ -796,6 +799,9 @@ _batten() { batten__subcmd__state,record) cmd="batten__subcmd__state__subcmd__record" ;; + batten__subcmd__state,settle) + cmd="batten__subcmd__state__subcmd__settle" + ;; batten__subcmd__state__subcmd__help,adopt) cmd="batten__subcmd__state__subcmd__help__subcmd__adopt" ;; @@ -811,6 +817,9 @@ _batten() { batten__subcmd__state__subcmd__help,record) cmd="batten__subcmd__state__subcmd__help__subcmd__record" ;; + batten__subcmd__state__subcmd__help,settle) + cmd="batten__subcmd__state__subcmd__help__subcmd__settle" + ;; batten__subcmd__target,help) cmd="batten__subcmd__target__subcmd__help" ;; @@ -3720,7 +3729,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__state) - opts="adopt record migrate list" + opts="adopt record migrate settle list" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3789,6 +3798,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__state__subcmd__settle) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__target) opts="prune" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -5786,7 +5809,7 @@ _batten() { return 0 ;; batten__subcmd__state) - opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help adopt record migrate list help" + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help adopt record migrate settle list help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -5846,7 +5869,7 @@ _batten() { return 0 ;; batten__subcmd__state__subcmd__help) - opts="adopt record migrate list help" + opts="adopt record migrate settle list help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -5929,6 +5952,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__state__subcmd__help__subcmd__settle) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__state__subcmd__list) opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -6019,6 +6056,36 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__state__subcmd__settle) + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__target) opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help prune help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index 1c40f5882..07711e2a5 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -1939,32 +1939,33 @@ complete -c batten -n "__fish_batten_using_subcommand design; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand design; and __fish_seen_subcommand_from audit" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand design; and __fish_seen_subcommand_from help" -f -a "audit" -d 'Audit a JSONL design-evidence claim stream on stdin for record integrity' complete -c batten -n "__fish_batten_using_subcommand design; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' quiet\t'Suppress ordinary progress; keep warnings' normal\t'The default' verbose\t'Explain what is being checked' debug\t'Add resolution detail' trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -f -a "adopt" -d 'Bind this checkout to its findings store, minting one only if none exists' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -f -a "record" -d 'Record this ref\'s findings into the store, and GC instances whose ref is gone' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -f -a "migrate" -d 'Upgrade the findings store to this binary\'s record version' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -f -a "list" -d 'List stored findings and the refs they were observed in' -complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate list help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -f -a "adopt" -d 'Bind this checkout to its findings store, minting one only if none exists' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -f -a "record" -d 'Record this ref\'s findings into the store, and GC instances whose ref is gone' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -f -a "migrate" -d 'Upgrade the findings store to this binary\'s record version' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -f -a "settle" -d 'Record what was decided about a stored finding' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -f -a "list" -d 'List stored findings and the refs they were observed in' +complete -c batten -n "__fish_batten_using_subcommand state; and not __fish_seen_subcommand_from adopt record migrate settle list help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from adopt" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -2028,6 +2029,27 @@ complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_sub complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from migrate" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from migrate" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from migrate" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from settle" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from list" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -2053,6 +2075,7 @@ complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_sub complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "adopt" -d 'Bind this checkout to its findings store, minting one only if none exists' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "record" -d 'Record this ref\'s findings into the store, and GC instances whose ref is gone' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "migrate" -d 'Upgrade the findings store to this binary\'s record version' +complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "settle" -d 'Record what was decided about a stored finding' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "list" -d 'List stored findings and the refs they were observed in' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' @@ -2253,6 +2276,7 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from state" -f -a "adopt" -d 'Bind this checkout to its findings store, minting one only if none exists' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from state" -f -a "record" -d 'Record this ref\'s findings into the store, and GC instances whose ref is gone' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from state" -f -a "migrate" -d 'Upgrade the findings store to this binary\'s record version' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from state" -f -a "settle" -d 'Record what was decided about a stored finding' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from state" -f -a "list" -d 'List stored findings and the refs they were observed in' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' diff --git a/completions/batten.zsh b/completions/batten.zsh index 203e107e1..83e71a0f6 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -3485,6 +3485,37 @@ trace\:"Add everything"))' \ '--help[Print help (see more with '\''--help'\'')]' \ && ret=0 ;; +(settle) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +':identity -- The stored finding'\''s identity, as `state list` prints it:_default' \ +':disposition -- What was decided\: acted, rejected-by-design or rejected-wrong:_default' \ +&& ret=0 +;; (list) _arguments "${_arguments_options[@]}" : \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" @@ -3540,6 +3571,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(settle) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (list) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -4405,6 +4440,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(settle) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (list) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -5474,6 +5513,7 @@ _batten__subcmd__help__subcmd__state_commands() { 'adopt:Bind this checkout to its findings store, minting one only if none exists' \ 'record:Record this ref'\''s findings into the store, and GC instances whose ref is gone' \ 'migrate:Upgrade the findings store to this binary'\''s record version' \ +'settle:Record what was decided about a stored finding' \ 'list:List stored findings and the refs they were observed in' \ ) _describe -t commands 'batten help state commands' commands "$@" @@ -5498,6 +5538,11 @@ _batten__subcmd__help__subcmd__state__subcmd__record_commands() { local commands; commands=() _describe -t commands 'batten help state record commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__state__subcmd__settle_commands] )) || +_batten__subcmd__help__subcmd__state__subcmd__settle_commands() { + local commands; commands=() + _describe -t commands 'batten help state settle commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__target_commands] )) || _batten__subcmd__help__subcmd__target_commands() { local commands; commands=( @@ -6066,6 +6111,7 @@ _batten__subcmd__state_commands() { 'adopt:Bind this checkout to its findings store, minting one only if none exists' \ 'record:Record this ref'\''s findings into the store, and GC instances whose ref is gone' \ 'migrate:Upgrade the findings store to this binary'\''s record version' \ +'settle:Record what was decided about a stored finding' \ 'list:List stored findings and the refs they were observed in' \ 'help:Print this message or the help of the given subcommand(s)' \ ) @@ -6082,6 +6128,7 @@ _batten__subcmd__state__subcmd__help_commands() { 'adopt:Bind this checkout to its findings store, minting one only if none exists' \ 'record:Record this ref'\''s findings into the store, and GC instances whose ref is gone' \ 'migrate:Upgrade the findings store to this binary'\''s record version' \ +'settle:Record what was decided about a stored finding' \ 'list:List stored findings and the refs they were observed in' \ 'help:Print this message or the help of the given subcommand(s)' \ ) @@ -6112,6 +6159,11 @@ _batten__subcmd__state__subcmd__help__subcmd__record_commands() { local commands; commands=() _describe -t commands 'batten state help record commands' commands "$@" } +(( $+functions[_batten__subcmd__state__subcmd__help__subcmd__settle_commands] )) || +_batten__subcmd__state__subcmd__help__subcmd__settle_commands() { + local commands; commands=() + _describe -t commands 'batten state help settle commands' commands "$@" +} (( $+functions[_batten__subcmd__state__subcmd__list_commands] )) || _batten__subcmd__state__subcmd__list_commands() { local commands; commands=() @@ -6127,6 +6179,11 @@ _batten__subcmd__state__subcmd__record_commands() { local commands; commands=() _describe -t commands 'batten state record commands' commands "$@" } +(( $+functions[_batten__subcmd__state__subcmd__settle_commands] )) || +_batten__subcmd__state__subcmd__settle_commands() { + local commands; commands=() + _describe -t commands 'batten state settle commands' commands "$@" +} (( $+functions[_batten__subcmd__target_commands] )) || _batten__subcmd__target_commands() { local commands; commands=( diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index f5de7e9f8..082b31600 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -799,6 +799,15 @@ pub enum StateCommand { Record, /// Upgrade the store's record version. The only upgrade path. Migrate, + /// Answer a stored finding (CLOUD-587). + Settle { + /// The stored finding's identity, as the hex fingerprint `state list` + /// prints. The identity rather than a rule id, because a rule can have + /// many findings and settling all of them is not what an agent means. + identity: String, + /// What was decided, as a [`crate::findings::Disposition`] token. + disposition: String, + }, /// List stored findings. List { /// Emit the listing as byte-stable JSON instead of pointer lines. @@ -1470,6 +1479,10 @@ fn state_of(matches: &ArgMatches) -> Option { }), ("record", _) => Some(StateCommand::Record), ("migrate", _) => Some(StateCommand::Migrate), + ("settle", matches) => Some(StateCommand::Settle { + identity: matches.get_one::("identity").cloned()?, + disposition: matches.get_one::("disposition").cloned()?, + }), ("list", matches) => Some(StateCommand::List { json: flag(matches, "json"), }), diff --git a/crates/batten/src/emission.rs b/crates/batten/src/emission.rs index e38d9d047..bdfb3f79a 100644 --- a/crates/batten/src/emission.rs +++ b/crates/batten/src/emission.rs @@ -209,6 +209,13 @@ pub fn assess(log: &[Entry], window: usize, percent: u32) -> Assessment { emitted.entry(entry.identity.clone()).or_default().push(at); } } + // A SETTLE IS NEITHER AN EVALUATION NOR AN EMISSION (CLOUD-587), so + // it enters neither side of the ratio. Counting it as an evaluation + // would put an agent's answer in the denominator of a flap rate that + // measures what the ENGINE saw; counting it as an emission would say + // the drain showed something it never did. The disposition it + // carries is folded by `journal::merge`, which is where it belongs. + Origin::Settle => {} } } diff --git a/crates/batten/src/journal.rs b/crates/batten/src/journal.rs index 2cd4d8627..4f4f7a9a9 100644 --- a/crates/batten/src/journal.rs +++ b/crates/batten/src/journal.rs @@ -324,6 +324,23 @@ pub enum Origin { /// silently move the false-positive denominator [`crate::findings::effective_fp_rates`] /// computes. Scan, + /// An agent answering a finding (CLOUD-587): a statement about **a + /// disposition** — this identity was surfaced and here is what was decided. + /// + /// Distinct from [`Origin::Scan`] because the two make different claims and a + /// reader auditing why a finding is settled should not have to infer which. + /// They fold identically — [`merge`] applies `disposition` from any origin + /// and `presentation` from [`Origin::Drain`] alone — so this variant costs + /// the fold nothing and buys the record its provenance. + /// + /// **The mixed-fleet cost, stated because [`Origin`]'s own doc raises it.** A + /// binary predating this variant cannot deserialize an entry carrying it, so + /// it skips that shard line and does not fold the disposition. That is the + /// same cost [`Origin::Scan`] paid when CLOUD-529 added it, and it fails in + /// the safe direction: an unread settle leaves the finding unsettled, where + /// spelling it `Scan` to stay readable would have made the record lie about + /// who decided. + Settle, } impl Origin { diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index d0eda3073..56cddd314 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -292,6 +292,10 @@ pub fn run(cli: Cli, mode: Mode, out: &mut dyn Write, err: &mut dyn Write) -> Re StateCommand::Adopt { store } => store::run_adopt(store.as_deref(), err), StateCommand::Record => run_state_record(&overrides, mode, err), StateCommand::Migrate => run_state_migrate(err), + StateCommand::Settle { + identity, + disposition, + } => run_state_settle(&identity, &disposition, err), StateCommand::List { json } => run_state_list(json, mode, out, err), }, // The §8 config chain DOES apply, and only to the tool half: `record tool` @@ -1058,6 +1062,101 @@ fn run_state_migrate(err: &mut dyn Write) -> Result { Ok(ExitCode::Success) } +/// `batten state settle`: answer a stored finding (CLOUD-587). +/// +/// # Why this verb has to exist +/// +/// CLOUD-78 gave every finding a three-valued `disposition`, `journal::merge` +/// folds it, `FindingRecord::merge_disposition` joins two by precedence and +/// `stop.rs` READS it — `deny-stop` means at-risk work or an undischarged +/// denial, where undischarged is `disposition == None`. **Nothing minted one.** +/// The only producers anywhere in the tree were unit tests, so the field was +/// read by a gate, joined by a merge rule, persisted by a journal, and +/// unreachable from any caller. +/// +/// That bit once CLOUD-98 landed `bypass.rs`, whose finding anchors to an +/// immutable transcript event: a bypass that happened, happened, so +/// re-evaluation keeps finding it and the observation never resolves to zero. +/// The finding is right to persist; what was missing is the answer channel. +/// +/// # What it deliberately does not do +/// +/// It writes through [`journal::append`], the append that already exists, so +/// there is no second writer and no new lock. It does not touch +/// [`crate::findings::Disposition::merge`], which stays the one join — this adds +/// a caller, never a second convergence rule. And it does not clear a +/// STATE-anchored finding by any other route: those clear by the condition +/// vanishing, and answering one would be a bypass of the work itself. +/// +/// # Errors +/// +/// [`UsageError`] (→ exit `1`) when no store is bound, when the identity is not +/// a fingerprint, or when the token is not a declared disposition. Recording a +/// disposition is bookkeeping, never a verdict, so the success path is exit `0` +/// and no finding it settles can move an exit code. +fn run_state_settle(identity: &str, disposition: &str, err: &mut dyn Write) -> Result { + let repo = git::repo_root(Path::new("."))?; + let opened = store::resolve(&repo)?; + let Some(dir) = store::bound_dir(&opened) else { + return Err(UsageError::raise( + "no store is bound to this repository; run `batten state adopt` first", + )); + }; + let Ok(fingerprint) = crate::identity::Fingerprint::from_hex(identity) else { + return Err(UsageError::raise(format!( + "`{identity}` is not a finding identity; `batten state list` prints the fingerprint each finding is stored under" + ))); + }; + // NAMED, never guessed. An unrecognised token is refused rather than folded + // to a default: a disposition is an agent's answer, and an answer nobody + // gave is the un-auditable settlement this verb exists to prevent. + let Some(decided) = findings::Disposition::ALL + .iter() + .copied() + .find(|candidate| candidate.as_str() == disposition) + else { + let known: Vec<&str> = findings::Disposition::ALL + .iter() + .map(|entry| entry.as_str()) + .collect(); + return Err(UsageError::raise(format!( + "`{disposition}` is not a disposition; declared: {}", + known.join(", ") + ))); + }; + // THE RECORD MUST EXIST. `journal::merge` keeps an entry whose record it + // cannot find, so appending for an unknown identity would silently succeed + // and settle nothing a reader could ever see — the shape CLOUD-845 calls a + // vacuous pass, one surface over. + if findings::load_one(&dir, fingerprint)?.is_none() { + return Err(UsageError::raise(format!( + "no stored finding has identity {identity}; `batten state list` prints what this store holds" + ))); + } + journal::append( + &dir, + &journal::shard_id(&repo), + &journal::Entry { + identity: fingerprint.to_hex(), + rule: String::new(), + origin: journal::Origin::Settle, + context: None, + // NEITHER FIELD IS THIS WRITER'S. `observation` is occurrence state + // and belongs to `findings::record`; `presentation` is the drain's + // suppression record and `merge` takes it from `Origin::Drain` + // alone. A settle that wrote either would be a second authority on + // a field it knows nothing about. + observation: None, + disposition: Some(decided), + presentation: findings::Presentation::Shown, + }, + )?; + // POINTER-ONLY (rule 4): the identity and the token. These findings are + // drawn from a transcript, so the content is exactly what must not travel. + writeln!(err, "batten: state settle: {identity} {}", decided.as_str())?; + Ok(ExitCode::Success) +} + /// Navigate a frozen capture (CLOUD-121). /// /// A dispatcher only. The three sub-verbs are separate functions rather than diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index 917c3c4de..1bffe662b 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -668,6 +668,12 @@ mod tests { "state list".to_owned(), "state migrate".to_owned(), "state record".to_owned(), + // The findings store's ANSWER channel (CLOUD-587). §2 gains the + // row in the same change, which is what this assertion exists to + // prompt. A verb under the existing noun rather than a new one: + // the store has one noun and `record` is already a per-observation + // write, so a disposition is the same act against the same object. + "state settle".to_owned(), // The build-tree noun (CLOUD-1030), ported off // `mise-tasks/target-prune.sh` for `semver`'s reason above. Both // rows are `Effect::Destructive` and so are deliberately absent diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index 24446dc8e..1837cc460 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -3016,6 +3016,47 @@ pub const SURFACE: &[CommandDecl] = &[ effect: Effect::Write, flags: &[], }, + // The ANSWER channel for a finding the condition cannot clear (CLOUD-587). + // + // A verb under `state` rather than a new noun: the findings store has one + // noun, and `record` is already a per-observation write into the journal, so + // a disposition is the same kind of act against the same object. A second + // noun would give one store two entry points and every later reader would + // have to work out which owns settlement. + // + // `write`, declared rather than smuggled into a read verb — it appends to + // the journal. The append is the one that already exists, so no new write + // path and no new lock arrives with it. + // + // WHY THIS IS NEEDED AT ALL: an EVENT-anchored finding cannot self-clear. A + // bypass that happened, happened, so re-evaluation keeps finding it and the + // observation never resolves to zero — CLOUD-98's own assumption says such a + // finding "clears by disposition in the store, not by the condition + // vanishing", which was correct as a design and unreachable as a mechanism: + // `stop.rs` READS `disposition`, `journal::merge` FOLDS it, and nothing + // outside a unit test ever wrote one. + CommandDecl { + path: "state settle", + about: "Record what was decided about a stored finding", + // Reports the identity and the token on stderr; there is no document. + data_channel: false, + effect: Effect::Write, + // Both REQUIRED, unlike `adopt`'s optional store: there is no defensible + // default for either. An omitted identity would have to mean "every + // finding", and an omitted disposition would have to guess what an agent + // decided — and a guessed disposition is exactly the un-auditable + // settlement this verb exists to make explicit. + flags: &[ + FlagDecl::positional( + "identity", + "The stored finding's identity, as `state list` prints it", + ), + FlagDecl::positional( + "disposition", + "What was decided: acted, rejected-by-design or rejected-wrong", + ), + ], + }, // Store reads plus fixed read-only git plumbing. A `read` verb may run a // fixed VCS query; what it must never reach is user-supplied code, and no // configured command is reachable from this path (CLOUD-170). diff --git a/crates/batten/tests/it/enforce_journal.rs b/crates/batten/tests/it/enforce_journal.rs index b1629a4a5..1d4132528 100644 --- a/crates/batten/tests/it/enforce_journal.rs +++ b/crates/batten/tests/it/enforce_journal.rs @@ -245,6 +245,161 @@ violation contains { } "#; +// --- (a3) an answered finding is no longer undischarged (CLOUD-587) ---------- + +/// **Red before this row: no verb could mint a `Disposition` at all.** +/// +/// CLOUD-78 gave every finding the three-valued field, `journal::merge` folds +/// it, `merge_disposition` joins two by precedence and `stop.rs` reads it — +/// undischarged means `disposition == None`. The only writers anywhere were unit +/// tests, so the field was read by a gate, joined by a merge rule, persisted by +/// a journal, and unreachable from any caller. +#[test] +fn a_stored_finding_can_be_answered_and_stops_being_undischarged() { + let env = Env::new("state-settle-answers"); + env.bind_store(); + env.file("src/a.rs", "// TODO\n"); + env.file("batten.toml", &forbid_only()); + assert_eq!(env.run(&["enforce"]).status.code(), Some(2)); + + let before = env + .record("no-todo") + .expect("the finding reached the store"); + assert!( + before["disposition"].is_null(), + "a fresh finding is undischarged: {before}" + ); + let identity = before["identity"]["fingerprint"] + .as_str() + .expect("a stored finding carries its identity") + .to_owned(); + + let settled = env.run(&["state", "settle", &identity, "acted"]); + assert_eq!( + settled.status.code(), + Some(0), + "recording a disposition is bookkeeping, never a verdict: {}", + common::stderr(&settled) + ); + // POINTER-ONLY (rule 4): the identity and the token, never the finding's + // content — these are drawn from a transcript. + let said = common::stderr(&settled); + assert!(said.contains(&identity) && said.contains("acted"), "{said}"); + assert!( + !said.contains("TODO"), + "the flagged content must not travel: {said}" + ); + + // The merge has to run for the shard to fold into the record, and `enforce` + // is what runs it — the same path a real session takes. + env.run(&["enforce"]); + let after = env.record("no-todo").expect("still stored"); + assert_eq!( + after["disposition"], "acted", + "after answering it is no longer undischarged: {after}" + ); +} + +/// THE DISCRIMINATOR: two worktrees answering one finding differently converge +/// to the same record whichever order the shards merge in. +/// +/// A last-writer-wins implementation passes the single-answer case above and +/// silently loses one answer here. `Disposition` is declared weakest-first so the +/// derived `Ord` IS the precedence and `merge` is `max` — commutative, +/// associative and idempotent — which is what makes this decidable rather than a +/// policy each call site could get subtly wrong. +#[test] +fn two_answers_converge_the_same_way_in_either_order() { + let mut settled = Vec::new(); + for (name, order) in [ + ("state-settle-order-weak-first", ["rejected-wrong", "acted"]), + ( + "state-settle-order-strong-first", + ["acted", "rejected-wrong"], + ), + ] { + let env = Env::new(name); + env.bind_store(); + env.file("src/a.rs", "// TODO\n"); + env.file("batten.toml", &forbid_only()); + env.run(&["enforce"]); + let identity = env.record("no-todo").expect("stored")["identity"]["fingerprint"] + .as_str() + .expect("identity") + .to_owned(); + + for disposition in order { + let run = env.run(&["state", "settle", &identity, disposition]); + assert_eq!(run.status.code(), Some(0), "{}", common::stderr(&run)); + } + env.run(&["enforce"]); + settled.push( + env.record("no-todo").expect("stored")["disposition"] + .as_str() + .expect("settled") + .to_owned(), + ); + } + assert_eq!( + settled[0], settled[1], + "the join is commutative, so order cannot change the answer" + ); + assert_eq!( + settled[0], "acted", + "and it is the STRONGER of the two, not the last one written" + ); +} + +/// Neither argument may be guessed, and an identity nothing stores is refused +/// rather than appended. +/// +/// `journal::merge` deliberately KEEPS an entry whose record it cannot find, so +/// an append for an unknown identity would succeed, settle nothing, and be +/// invisible forever — a vacuous pass one surface over from where CLOUD-845 +/// found it. +#[test] +fn an_unanswerable_settle_is_refused_rather_than_silently_appended() { + let env = Env::new("state-settle-refusals"); + env.bind_store(); + env.file("src/a.rs", "// TODO\n"); + env.file("batten.toml", &forbid_only()); + env.run(&["enforce"]); + let identity = env.record("no-todo").expect("stored")["identity"]["fingerprint"] + .as_str() + .expect("identity") + .to_owned(); + + let unknown = env.run(&["state", "settle", &"0".repeat(64), "acted"]); + assert_eq!( + unknown.status.code(), + Some(1), + "an identity nothing stores is a usage error, never a silent append" + ); + + let malformed = env.run(&["state", "settle", "not-a-fingerprint", "acted"]); + assert_eq!(malformed.status.code(), Some(1)); + + let guessed = env.run(&["state", "settle", &identity, "probably-fine"]); + assert_eq!( + guessed.status.code(), + Some(1), + "an undeclared token is refused rather than folded to a default" + ); + let said = common::stderr(&guessed); + assert!( + said.contains("acted") && said.contains("rejected-by-design"), + "the refusal names what IS declared: {said}" + ); + + // AND NONE OF THE THREE MOVED THE RECORD. A refusal that still appended + // would be the defect this case is really about. + env.run(&["enforce"]); + assert!( + env.record("no-todo").expect("stored")["disposition"].is_null(), + "a refused settle leaves the finding undischarged" + ); +} + // --- (a2) a policy-module finding reaches the store, with its class's remedy --- /// **The case CLOUD-1220 was found by, and it was red before the fix.** diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index fe9a3fe28..373c144c2 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -1121,6 +1121,23 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::Nothing, disposition: Disposition::PointerOnly, }, + // CLOUD-587. Pointer-only, and here it is load-bearing rather than routine: + // the findings this verb answers are drawn from a session transcript, so the + // content is exactly what must not travel. It emits the identity and the + // disposition token and nothing else. + // + // The args are a real identity's shape and a declared token, because both + // positionals are required — an omitted identity would have to mean "every + // finding" and an omitted disposition would have to guess what was decided. + Verb { + path: "state settle", + args: &[ + "0000000000000000000000000000000000000000000000000000000000000000", + "acted", + ], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, Verb { path: "state list", args: &[], diff --git a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap index 60816cde2..86b3dae0f 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap @@ -1522,6 +1522,28 @@ expression: stdout_of(&output) "effect": "write", "flags": [], "subcommands": [] + }, + { + "path": "state settle", + "about": "Record what was decided about a stored finding", + "effect": "write", + "flags": [ + { + "name": "disposition", + "short": null, + "long": null, + "takes_value": true, + "help": "What was decided: acted, rejected-by-design or rejected-wrong" + }, + { + "name": "identity", + "short": null, + "long": null, + "takes_value": true, + "help": "The stored finding's identity, as `state list` prints it" + } + ], + "subcommands": [] } ] }, diff --git a/man/batten-state-settle.1 b/man/batten-state-settle.1 new file mode 100644 index 000000000..12ec81d20 --- /dev/null +++ b/man/batten-state-settle.1 @@ -0,0 +1,19 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-state-settle 1 batten +.SH NAME +batten\-state\-settle \- Record what was decided about a stored finding +.SH SYNOPSIS +\fBbatten state settle\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIidentity\fR> <\fIdisposition\fR> +.SH DESCRIPTION +Record what was decided about a stored finding +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIidentity\fR> +The stored finding\*(Aqs identity, as `state list` prints it +.TP +<\fIdisposition\fR> +What was decided: acted, rejected\-by\-design or rejected\-wrong diff --git a/man/batten-state.1 b/man/batten-state.1 index b7f9a3e56..5adafde73 100644 --- a/man/batten-state.1 +++ b/man/batten-state.1 @@ -22,6 +22,9 @@ Record this ref\*(Aqs findings into the store, and GC instances whose ref is gon batten\-state\-migrate(1) Upgrade the findings store to this binary\*(Aqs record version .TP +batten\-state\-settle(1) +Record what was decided about a stored finding +.TP batten\-state\-list(1) List stored findings and the refs they were observed in .TP From 391d8ee60f6bd3f19579f703a7eed14796317d96 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 23:26:28 +0000 Subject: [PATCH 04/12] test(findings): the acceptance clauses I skipped while calling three rows done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six deferrals, all from this branch's own rows, all now in this PR. Each was claimed as met and none had been. CLOUD-1220's comment, and this is the ironic one. Its acceptance says "`lib.rs:6730`'s comment is either true or corrected — a guarantee that does not hold is worse than none". The row is ABOUT distrusting a "this cannot happen" comment, and the false comment was left in place: it said `Rule::validate` already refuses a row with no remediation, which is untrue for the one kind it mattered for. Corrected, and narrowed to what is actually true now — a policy finding takes its remedy from its class, but the partition stays a count rather than an `expect` because a consumer's registry could still fail to resolve a token this binary did not vendor. CLOUD-1220's end-to-end arm. §7 asks that the `unrecordable` partition report zero on this repository's tree. Running `enforce` here by hand is NOT that assertion: the committed tree is clean, so zero findings fire and zero unrecordable is vacuously true. The arm drives a tree that DOES produce a module finding. CLOUD-1220's baseline arm — "asserted rather than assumed", and it had been assumed. Reaching the store is necessary and not sufficient: `baseline.rs` is the persisted identity set, and a finding the baseline cannot take is invisible to every ratchet built on one. It asserts the baseline TOOK it, since a `baseline` exiting 0 having recorded nothing is the same vacuous pass. CLOUD-587's state-anchored arm, which that row names as the direction a careless fix breaks. A finding whose condition still holds must keep firing after a settle — settling one would be a bypass of the work rather than an answer — and the converse is asserted too: removing the condition clears it with no acknowledgement. CLOUD-1087's waiver arm. `waiver::covers` keys on `self.rule != finding.rule`, so a waiver names the GATE a reader saw rather than the bundle holding it. Had `owner` been folded into `rule` instead of sitting beside it, every waiver written against a module predicate would have silently stopped matching. It ships with its anti-vacuity mirror — a second fixture waiving the ROW id and exiting 2 — because without it the first fixture's exit 0 is equally explained by the module never firing. CLOUD-587's stop-reader arm. Asserting the stored record shows the store changed, not that the reader changed its answer. This drives `stop::facts` directly and watches the pending list empty. FOUR CORRECTIONS ON THE BASELINE ARM, all fixture mechanics rather than the claim, and recorded because guessing is what cost them: uncommitted paths, a missing `refs/remotes/origin/main`, a missing `must_land_on`, and then that key landing inside a `[[verdict.route]]` table because it was appended after a header rather than hoisted above one. `baseline` needs all three facts to call a tree landed; reading `worktree.rs` is what settled it, after two guesses that did not. Refs: CLOUD-1220 Refs: CLOUD-1087 Refs: CLOUD-587 --- crates/batten/src/lib.rs | 24 ++- crates/batten/tests/it/enforce_journal.rs | 203 ++++++++++++++++++++++ crates/batten/tests/it/sinks.rs | 84 +++++++++ 3 files changed, 308 insertions(+), 3 deletions(-) diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 56cddd314..dd4887b0b 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -8044,9 +8044,27 @@ fn register_enforce_findings(scan: &rules::Scan, mode: Mode, err: &mut dyn Write // `record` refuses a finding with no remediation as a usage error, which is // the right answer for a recording verb and the wrong one here: it would let - // one unfixable rule row turn a policy verdict into exit 1. `Rule::validate` - // already refuses such a row, so this partition should never fire — which is - // exactly why it reports a count instead of being an `expect`. + // one unfixable rule row turn a policy verdict into exit 1. So this reports a + // count rather than being an `expect`. + // + // THE REASON THIS COMMENT USED TO GIVE WAS FALSE, and correcting it is half + // of CLOUD-1220. It said "`Rule::validate` already refuses such a row, so + // this partition should never fire". `Rule::validate` refuses no such thing + // for the one kind it mattered for: `RuleKind::Policy` requires only + // `severity`, where `RuleKind::Judge` requires `no_fix_reason` outright and + // says why — a judge finding reaches the store and CLOUD-81's ingest refuses + // one nothing can close. Policy rows never got that treatment, so the + // partition fired on EVERY policy-module finding this tree produced and the + // whole findings subsystem was blind to them. + // + // What is true now, and it is a different claim: a policy finding takes its + // remedy from the `[[verdict]]` class it raises (`policy_remediation`), so + // the kind that used to fall through no longer can. The partition stays a + // count rather than an `expect` because a consumer's own registry could + // still fail to resolve a token this binary did not vendor, and that is a + // config fault to report rather than a panic — CLOUD-242's lesson is that a + // guarantee which does not hold is worse than none, so this one is stated as + // narrowly as it is actually true. let (recordable, unrecordable): (Vec<_>, Vec<_>) = scan .findings .iter() diff --git a/crates/batten/tests/it/enforce_journal.rs b/crates/batten/tests/it/enforce_journal.rs index 1d4132528..2082d5745 100644 --- a/crates/batten/tests/it/enforce_journal.rs +++ b/crates/batten/tests/it/enforce_journal.rs @@ -245,6 +245,111 @@ violation contains { } "#; +/// **The end-to-end arm CLOUD-1220's §7 names and I skipped**: the `unrecordable` +/// partition reports zero on this repository's own tree. +/// +/// Running `enforce` here by hand is NOT this assertion, and confusing the two is +/// how the row nearly shipped unverified: the committed tree is clean, so zero +/// findings fire and "zero unrecordable" is vacuously true. This drives a tree +/// that DOES produce a policy-module finding and asserts the count is still zero, +/// which is the only form of the claim that discriminates. +#[test] +fn no_finding_is_dropped_as_unrecordable_when_a_module_reports_one() { + let env = Env::new("enforce-journal-none-unrecordable"); + env.bind_store(); + env.file("README.md", "base\n"); + env.file("policy/probe.rego", PROBE_MODULE); + env.file( + "batten.toml", + &policy_only( + "[[verdict.route]]\n\ + id = \"probe read first\"\n\ + kind = \"document\"\n\ + target = \"README.md\"\n", + ), + ); + let run = env.run(&["enforce"]); + assert_eq!(run.status.code(), Some(2), "{}", common::stderr(&run)); + assert!( + !common::stderr(&run).contains("carry no remediation"), + "the partition reports zero over a tree that actually produces one: {}", + common::stderr(&run) + ); +} + +/// **A module-only finding is BASELINEABLE** — CLOUD-1220's fourth acceptance +/// clause, "asserted rather than assumed", and I had assumed it. +/// +/// Reaching the store is necessary and not sufficient: `baseline.rs` is the +/// persisted set of identities that already existed, and a finding the baseline +/// cannot take is still invisible to every ratchet built on one. The row lists +/// baseline first among what was blind to policy findings, so this is the arm +/// that shows the blindness actually lifted. +#[test] +fn a_module_only_finding_can_be_baselined() { + let env = Env::new("enforce-journal-policy-baseline"); + env.bind_store(); + env.file("README.md", "base\n"); + env.file("policy/probe.rego", PROBE_MODULE); + env.file( + "batten.toml", + // A TOP-LEVEL KEY, so it goes before the first table header. Appended + // after `policy_only`'s output it landed inside `[[verdict.route]]`, an + // unknown field there, and the config refused with exit 1 — a fixture + // reporting a config fault while claiming to report about a baseline. + &policy_only( + "[[verdict.route]]\n\ + id = \"probe read first\"\n\ + kind = \"document\"\n\ + target = \"README.md\"\n", + ) + .replace( + "version = 1\n", + "version = 1\nmust_land_on = \"refs/remotes/origin/main\"\n", + ), + ); + // COMMITTED FIRST, because `baseline` refuses uncommitted state outright — + // "only landed, committed state may be baselined". That is a precondition of + // the verb rather than anything about policy findings, and a fixture that + // tripped it would report a refusal about the tree while claiming to say + // something about the finding. + git_in(&env.repo, &["add", "-A"]); + git_in(&env.repo, &["commit", "-q", "-m", "the fixture"]); + // AND THE LANDING TARGET, which `Fixture::base_commit` mints and `Env` does + // not. `baseline` refuses a tree it cannot call landed, and it takes THREE + // things to call it that: committed paths, the ref itself, and a declared + // `must_land_on` — without the last, "unlanded" is not-computable rather + // than false, which refuses just as hard. `baseline.rs`'s own fixtures + // declare the same key for the same reason. + // + // All three are preconditions of the VERB and none says anything about + // policy findings; a fixture tripping one would report about the tree while + // claiming to report about the finding. Guessed twice before reading + // `worktree.rs`, which is what settled it. + git_in( + &env.repo, + &["update-ref", "refs/remotes/origin/main", "HEAD"], + ); + assert_eq!(env.run(&["enforce"]).status.code(), Some(2)); + + let baselined = env.run(&["baseline"]); + assert_eq!( + baselined.status.code(), + Some(0), + "the module's finding is baselineable: {}", + common::stderr(&baselined) + ); + // AND THE BASELINE TOOK IT. A `baseline` that exits 0 having recorded + // nothing is the vacuous pass this arm exists to rule out. + let after = env.run(&["enforce"]); + assert_eq!( + after.status.code(), + Some(0), + "a baselined finding no longer fails the run: {}", + common::stderr(&after) + ); +} + // --- (a3) an answered finding is no longer undischarged (CLOUD-587) ---------- /// **Red before this row: no verb could mint a `Disposition` at all.** @@ -350,6 +455,104 @@ fn two_answers_converge_the_same_way_in_either_order() { ); } +/// **`stop.rs` ACTUALLY OBSERVES IT** — CLOUD-587's other §7 clause, which I had +/// only half-covered by asserting the stored record. +/// +/// The row requires that `stop.rs`'s undischarged-denial predicate and CLOUD-79's +/// drain both OBSERVE the field "without either re-typing what settled means". +/// Asserting the record's `disposition` shows the store changed; it does not show +/// the reader changed its answer. `stop::facts` is that reader — `deny-stop` is +/// at-risk work OR an undischarged denial, and undischarged is `disposition == +/// None` — so this drives it directly and watches the pending list empty. +#[test] +fn the_stop_reader_stops_calling_an_answered_finding_pending() { + let env = Env::new("state-settle-stop-observes"); + env.bind_store(); + env.file("src/a.rs", "// TODO\n"); + env.file("batten.toml", &forbid_only()); + assert_eq!(env.run(&["enforce"]).status.code(), Some(2)); + let identity = env.record("no-todo").expect("stored")["identity"]["fingerprint"] + .as_str() + .expect("identity") + .to_owned(); + + let store = env.segment(); + let before = batten::stop::facts(None, None, Some(&store)).expect("stop facts"); + assert!( + before.pending.iter().any(|entry| entry.rule == "no-todo"), + "the reader calls an unanswered finding pending: {:?}", + before.pending + ); + + let settled = env.run(&["state", "settle", &identity, "acted"]); + assert_eq!( + settled.status.code(), + Some(0), + "{}", + common::stderr(&settled) + ); + env.run(&["enforce"]); + + let after = batten::stop::facts(None, None, Some(&store)).expect("stop facts"); + assert!( + !after.pending.iter().any(|entry| entry.rule == "no-todo"), + "and stops once it is answered: {:?}", + after.pending + ); +} + +/// **THE DIRECTION A CARELESS FIX BREAKS** (CLOUD-587's §7, and I skipped it). +/// +/// A STATE-anchored finding clears by the condition vanishing — CLOUD-97's is the +/// example, and landing the work clears it with no acknowledgement. Settling one +/// would be a bypass of the work itself rather than an answer to a finding, so a +/// settle must not make the condition-backed finding go away. +/// +/// The distinction is why CLOUD-587 exists at all: the gap is specific to the +/// EVENT-anchored class, where re-evaluation keeps finding an immutable fact. A +/// verb that cleared both would have dissolved that boundary while passing every +/// case above. +#[test] +fn settling_does_not_clear_a_finding_whose_condition_still_holds() { + let env = Env::new("state-settle-state-anchored"); + env.bind_store(); + env.file("src/a.rs", "// TODO\n"); + env.file("batten.toml", &forbid_only()); + assert_eq!(env.run(&["enforce"]).status.code(), Some(2)); + let identity = env.record("no-todo").expect("stored")["identity"]["fingerprint"] + .as_str() + .expect("identity") + .to_owned(); + + let settled = env.run(&["state", "settle", &identity, "rejected-by-design"]); + assert_eq!( + settled.status.code(), + Some(0), + "{}", + common::stderr(&settled) + ); + + // THE CONDITION STILL HOLDS, so the finding still fires. A settle records + // what was decided; it does not edit the tree and must not read as though it + // had. + let after = env.run(&["enforce"]); + assert_eq!( + after.status.code(), + Some(2), + "the marker is still in the file, so the finding still fires: {}", + common::stderr(&after) + ); + + // And the honest converse: removing the condition IS what clears it, with no + // acknowledgement needed. + env.file("src/a.rs", "fn main() {}\n"); + assert_eq!( + env.run(&["enforce"]).status.code(), + Some(0), + "a state-anchored finding clears by the condition vanishing" + ); +} + /// Neither argument may be guessed, and an identity nothing stores is refused /// rather than appended. /// diff --git a/crates/batten/tests/it/sinks.rs b/crates/batten/tests/it/sinks.rs index 82f04eb29..cb7314f08 100644 --- a/crates/batten/tests/it/sinks.rs +++ b/crates/batten/tests/it/sinks.rs @@ -818,6 +818,90 @@ fn a_policy_rows_sink_counts_the_violations_its_module_reported() { ); } +/// **CLOUD-1087's third acceptance clause, asserted rather than argued** — +/// `Finding::rule` still carries the predicate id, so waiver matching is +/// byte-identical. +/// +/// This is the arm I first left to "the suite still passes", which is evidence +/// of absence rather than the assertion the row asks for. It matters because the +/// `owner` field's whole design is that `rule` is UNTOUCHED: `waiver::covers` +/// keys on `self.rule != finding.rule`, so a waiver names the GATE a reader saw +/// rather than the bundle holding it (CLOUD-832). Had `owner` been folded into +/// `rule` instead of sitting beside it, every waiver written against a module +/// predicate would have silently stopped matching. +#[test] +fn a_waiver_on_a_predicate_id_still_covers_a_module_finding() { + let module = "package batten\n\ + \n\ + rules contains \"the-predicate\"\n\ + \n\ + violation contains {\n\ + \t\"rule\": \"the-predicate\",\n\ + \t\"verdict\": \"something to say\",\n\ + \t\"subjects\": [{\"path\": \"src/lib.rs\"}],\n\ + }\n"; + let dir = Fixture::new("sink-waiver-names-the-predicate") + .config( + "version = 1\n\ + \n\ + [[rule]]\n\ + id = \"the-policy-row\"\n\ + kind = \"policy\"\n\ + scope = \"tree\"\n\ + module = \"policy/says.rego\"\n\ + severity = \"deny\"\n\ + \n\ + [[verdict]]\n\ + id = \"something to say\"\n\ + gloss = \"this tree has something to say\"\n\ + class = \"What the fixture asserts, at the length explain answers with.\"\n\ + \n\ + [[verdict.route]]\n\ + id = \"nothing to do\"\n\ + kind = \"command\"\n\ + target = \"batten check\"\n\ + \n\ + # THE PREDICATE ID, NOT THE ROW ID. That is the whole assertion.\n\ + [[waiver]]\n\ + rule = \"the-predicate\"\n\ + reason = \"the fixture says so\"\n\ + expires = \"2099-01-01\"\n", + ) + .file("src/lib.rs", "// anything\n") + .file("policy/says.rego", module) + .git() + .base_commit() + .build(); + + let output = run(&dir, &["check"]); + assert_eq!( + output.status.code(), + Some(0), + "a waiver naming the predicate id still covers the module's finding: {}", + stdout(&output) + ); + + // AND IT WAIVED RATHER THAN NEVER FIRING. A fixture whose module reported + // nothing would exit 0 too, which is the vacuous pass this arm must not be. + let unwaived = Fixture::new("sink-waiver-names-the-row") + .config( + &std::fs::read_to_string(dir.join("batten.toml")) + .expect("the fixture config") + .replace("rule = \"the-predicate\"", "rule = \"the-policy-row\""), + ) + .file("src/lib.rs", "// anything\n") + .file("policy/says.rego", module) + .git() + .base_commit() + .build(); + assert_eq!( + run(&unwaived, &["check"]).status.code(), + Some(2), + "a waiver naming the ROW id does not cover it — which is what shows the \ + first fixture's exit 0 was the waiver working, not the module staying quiet" + ); +} + #[test] fn a_fingerprint_collision_cannot_move_a_finding_to_another_row() { // THE ARM #721 DID NOT CARRY (CLOUD-1087), and the reason it is here now. From d89c12a4b3a2f462cfab96124e7aefcec53132ed Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 23:34:15 +0000 Subject: [PATCH 05/12] fix(policy): explain a class when the config cannot be read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1313, first half. `run_policy_explain` opened with `resolve::resolve(..)?`, so a config that would not load killed the verb before it consulted any registry. Measured on a repository whose `batten.toml` carries one malformed table: $ batten policy explain "path write refused" batten: invalid config ./batten.toml: TOML parse error at line 3 `path write refused` is VENDORED. It needs no consumer config, it is what the mediated boundary raises dozens of times a session, and its remedy was unreachable in exactly the repository state where a reader is most likely to be stuck. The remedy channel went dark precisely when the config broke — and one of that class's own routes is `config read first document batten.toml`, which is the advice such a reader needs. A LOAD FAILURE NOW DEGRADES RATHER THAN REFUSING. The union where a config loads, which is what stops `explain` resolving a token differently from the gate that raised it; this binary's vendored classes where it does not. What genuinely needs the config still says so rather than guessing: a `[[rule]]` id and the `[[redirect]]` table are the consumer's, and the refusal names "this config could not be read" instead of reporting zero rows — an empty "what to do instead" reads as "nothing to do", which is a worse answer than the refusal. WHY THIS IS A PRECONDITION OF CLOUD-1313 RATHER THAN A NICETY BESIDE IT. That row gives twelve config-fault classes to the twelve load-time validators. Shipping them onto a surface that goes dark the moment a config breaks would be twelve remedies nobody can read in the only state they describe — the dead-gate shape the row exists to close, built in deliberately. The row as filed did not name this; it was found by running the verb against a broken config rather than by reading the code, and the row now carries it as its load-bearing constraint. The measured transcript above is a `text` fence rather than a `console` one: `no-doctests` caught it as a runnable doctest, and `test:cargo` runs nextest, which executes no doctests — so it would have shipped as an example nothing runs (CLOUD-813). Refs: CLOUD-1313 --- crates/batten/src/lib.rs | 55 +++++++++++++++++++++++++++------ crates/batten/tests/it/cli.rs | 57 +++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 10 deletions(-) diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index dd4887b0b..9b497ecf7 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -3323,11 +3323,30 @@ fn run_policy_explain( overrides: &Overrides, out: &mut dyn Write, ) -> Result { - let config = resolve::resolve(Path::new("."), overrides)?; - // The same union the engine decides against, so `explain` cannot resolve a - // token differently from the gate that raised it — which is the drift a - // second reader of one table always produces. - let registry = policy::registry_for(&config.verdicts)?; + // A CONFIG THAT WILL NOT LOAD IS EXACTLY WHEN A CLASS NEEDS EXPLAINING + // (CLOUD-1313). This used to be `resolve::resolve(..)?`, so `explain` died + // on the load before it could consult any registry — measured on a repo whose + // `batten.toml` carries one malformed table: + // + // $ batten policy explain "path write refused" + // batten: invalid config ./batten.toml: TOML parse error at line 3 + // + // `path write refused` is VENDORED. It needs no consumer config, it is what + // the mediated boundary raises dozens of times a session, and its remedy was + // unreachable in the one repository state where a reader is most likely to be + // stuck. The remedy channel went dark precisely when the config broke. + // + // So a load failure degrades rather than refuses: the union where a config + // loads — which is what stops `explain` resolving a token differently from + // the gate that raised it — and this binary's vendored classes where it does + // not. What genuinely needs the config still says so below rather than + // guessing: a `[[rule]]` id and a `[[redirect]]` remedy are the consumer's, + // and neither is answerable from a config nobody could read. + let config = resolve::resolve(Path::new("."), overrides).ok(); + let registry = match &config { + Some(config) => policy::registry_for(&config.verdicts)?, + None => verdict::vendored(), + }; let Some((resolved, retired)) = verdict::resolve(®istry, token) else { // A RULE ID RESOLVES HERE TOO (CLOUD-1286), and that is what makes "the // token is the pointer to the fix" true rather than aspirational. The @@ -3341,8 +3360,14 @@ fn run_policy_explain( // Tried second rather than first because a class is what a reader most // often has, and the two namespaces cannot collide: a class is three // lowercase words and a rule id is a kebab-case identifier. - if let Some(rule) = config.rules.iter().find(|rule| rule.id == token) { - return explain_rule(rule, &config.facts, json, out); + if let Some(rule) = config + .as_ref() + .and_then(|config| config.rules.iter().find(|rule| rule.id == token)) + { + let facts = config + .as_ref() + .map_or(&[][..], |config| config.facts.as_slice()); + return explain_rule(rule, facts, json, out); } // THE DERIVED PROTECTED GATE HAS NO `[[rule]]` ROW, and its remedy is // per PATH CLASS rather than per rule (CLOUD-280): a `[[redirect]]` @@ -3351,7 +3376,11 @@ fn run_policy_explain( // to land — a class hop answers about `path write refused` generically // and a rule hop has no row to find. So the gate's own id resolves here, // to the table that answers "what do I do instead for THIS path". - if token == hook::PROTECTED_MUTATION { + // THE CONSUMER'S OWN TABLES, so this arm needs a config that loaded. + // Absent one it falls through to the refusal below, which says the class + // is undeclared HERE rather than pretending an empty redirect table is + // an answer — an empty "what to do instead" reads as "nothing to do". + if let (true, Some(config)) = (token == hook::PROTECTED_MUTATION, config.as_ref()) { return explain_redirects(&config.redirects, &config.verbs, json, out); } // Named, and the token is the caller's own argument rather than @@ -3360,9 +3389,15 @@ fn run_policy_explain( // pointer-shaped answer. return Err(error::UsageError::raise(format!( "no `[[verdict]]` row and no `[[rule]]` row declares `{token}`; this registry \ - declares {} class(es) and this config declares {} rule(s)", + declares {} class(es) and this config declares {}", registry.len(), - config.rules.len(), + // THREE-VALUED, because "0 rules" and "no config could be read" are + // different answers and collapsing them would send a reader looking + // for a missing row when the real fault is the file. + config.as_ref().map_or_else( + || "no rules (this config could not be read)".to_owned(), + |config| format!("{} rule(s)", config.rules.len()), + ), ))); }; if json { diff --git a/crates/batten/tests/it/cli.rs b/crates/batten/tests/it/cli.rs index 76d51de2b..30f617e18 100644 --- a/crates/batten/tests/it/cli.rs +++ b/crates/batten/tests/it/cli.rs @@ -1906,6 +1906,63 @@ effect = "destructive" redirect = "restore it with git" "#; +/// **The remedy channel survives a config that will not load** (CLOUD-1313). +/// +/// Measured before the fix, on a repository whose `batten.toml` carries one +/// malformed table: +/// +/// ```text +/// $ batten policy explain "path write refused" +/// batten: invalid config ./batten.toml: TOML parse error at line 3 +/// ``` +/// +/// `explain` opened with `resolve::resolve(..)?`, so a load failure killed it +/// before any registry was consulted — including for a VENDORED class that needs +/// no consumer config at all. `path write refused` is what the mediated boundary +/// raises dozens of times a session, and its remedy was unreachable in exactly +/// the repository state where a reader is most likely to be stuck. +/// +/// This is a precondition of CLOUD-1313 rather than a nicety beside it: twelve +/// config-fault classes shipped onto a surface that goes dark when a config +/// breaks would be twelve dead remedies, which is the shape that row is about. +#[test] +fn a_class_still_explains_when_the_config_cannot_be_read() { + let dir = scratch("explain-over-a-broken-config"); + write( + &dir, + "batten.toml", + // Well-formed enough to be found and malformed enough to refuse: `verb` + // is a table array whose row omits every required key. + "version = 1\n\n[[verb]]\nverb = \"x\"\n", + ); + + let explained = batten_with(&dir, &["policy", "explain", "path write refused"], &[]); + assert_eq!( + explained.status.code(), + Some(0), + "a vendored class needs no consumer config: {}", + String::from_utf8_lossy(&explained.stderr) + ); + let out = String::from_utf8_lossy(&explained.stdout); + assert!(out.contains("path write refused"), "the class: {out}"); + assert!( + out.contains("config read first"), + "AND ITS ROUTES — the one that says to read `batten.toml` is exactly the \ + advice a reader with a broken config needs: {out}" + ); + + // THE CONSUMER'S OWN TABLES STILL SAY SO rather than answering emptily. A + // rule id cannot resolve from a config nobody could read, and an empty + // redirect table would read as "nothing to do instead". + let missing = batten_with(&dir, &["policy", "explain", "some-rule-id"], &[]); + assert_eq!(missing.status.code(), Some(1), "no config, no rule table"); + let said = String::from_utf8_lossy(&missing.stderr); + assert!( + said.contains("could not be read"), + "and it names WHY rather than reporting zero rows: {said}" + ); +} + #[test] fn a_deny_names_the_path_classs_own_mutation_over_the_verbs() { // The three tiers over the compiled binary, because a refusal is a contract From 366e39bc5e7b28dce6626f837732ffe90ab23f11 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 23:48:56 +0000 Subject: [PATCH 06/12] test(verdict): prove a native class is raised rather than exempting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check_registry_is_exhausted` refuses a declared-and-unraised consumer token, but it EXEMPTS `native_tokens()` — so a native class could be declared, carry a gloss and routes, resolve through `policy explain`, and be raised by nothing. That is the dead-gate class this repository exists to refuse, and CLOUD-1313 is about to add twelve native classes at once. The gate scans production sources under `crates/batten/src` (truncated at `#[cfg(test)]`, `verdict.rs` itself skipped, since declaring a variant is not raising it) and fails naming any variant no production site mentions. Shown able to fail. Two earlier probes did NOT discriminate and both are worth recording: a variant with no `VENDORED` row reddens `the_vendored_table_validates` first, and one missing from `every_native_class_is_listed`'s wildcard-free match reddens at compile time. Only the full four-edit injection reaches this gate — and it then fails alone, naming `DeadProbe`. All twenty pre-existing classes pass, so this is a ratchet rather than a repair. Refs: CLOUD-1313 --- crates/batten/tests/it/verdict_registry.rs | 106 +++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/crates/batten/tests/it/verdict_registry.rs b/crates/batten/tests/it/verdict_registry.rs index 940feeaf4..e5b86eb71 100644 --- a/crates/batten/tests/it/verdict_registry.rs +++ b/crates/batten/tests/it/verdict_registry.rs @@ -500,3 +500,109 @@ fn a_command_route_naming_a_defined_task_is_clean_over_the_engine() { .is_empty() ); } + +/// **Every `Native` class is actually RAISED somewhere in production code** +/// (CLOUD-1313). +/// +/// # The hole this closes +/// +/// The registry's two directions make it honest for MODULE classes: +/// `check_verdicts_are_declared` refuses a raised token no row declares, and +/// `check_registry_is_exhausted` refuses a declared row nothing raises. But the +/// second one *exempts* `native_tokens()` rather than proving them — +/// deliberately, since a native class is raised from Rust and there is no AST to +/// read. So a `Native` variant could be declared, carry a `VENDORED` row with a +/// gloss and routes, resolve through `policy explain`, and be raised by nothing +/// at all. Every one of those signals says the class is live; none of them +/// checks it. +/// +/// `every_native_class_is_listed` does not close this. It matches without a +/// wildcard, so a new variant fails to compile until it is LISTED — which is a +/// statement about the table, not about any call site. +/// +/// # Measured before writing this: the existing twenty are clean +/// +/// All 20 variants declared at the time were raised in production code, so this +/// is a ratchet over the classes CLOUD-1313 adds rather than a repair of +/// something already broken. Saying which it is matters: a gate introduced +/// alongside a finding reads as having caught one. +/// +/// # Why a source scan rather than name resolution +/// +/// `.claude/rules/scanning.md` routes "which type does this name resolve to" to +/// rust-analyzer, and `Native::Foo` is not that question — it is "does this +/// token appear in a raising position", which is a text question about a closed, +/// unambiguous spelling. There is exactly one `Native` type in this crate and no +/// import can alias a variant path, which is what makes the scan sound here where +/// `spawn_census`'s `Command::new` scan was not. +#[test] +fn every_native_class_is_raised_by_production_code() { + let verdict_rs = common::at_root("crates/batten/src/verdict.rs"); + let declared = declared_native_variants(&verdict_rs); + assert!( + declared.len() >= 20, + "the scan found {} variants, too few to be the enum", + declared.len() + ); + + let mut raised: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for path in common::rust_sources() { + // `verdict.rs` DECLARES them; naming a variant there is not raising it, + // and counting the enum and `id()` as call sites is what would make this + // gate vacuous. + if path.ends_with("verdict.rs") || !path.starts_with(common::at_root("crates/batten/src")) { + continue; + } + let body = std::fs::read_to_string(&path).expect("a source file"); + // A `#[cfg(test)]` module is not production code. A class raised only by + // its own unit test is exactly the dead class this asserts against. + let production = body + .find("#[cfg(test)]") + .map_or(&body[..], |at| &body[..at]); + for found in production.match_indices("Native::") { + let rest = &production[found.0 + "Native::".len()..]; + let name: String = rest + .chars() + .take_while(|ch| ch.is_alphanumeric() || *ch == '_') + .collect(); + if !name.is_empty() { + raised.insert(name); + } + } + } + + let dead: Vec<&String> = declared + .iter() + .filter(|name| !raised.contains(*name)) + .collect(); + assert!( + dead.is_empty(), + "these classes are declared and raised by nothing outside tests, so they \ + resolve through `policy explain` and can never fire: {dead:?}" + ); +} + +/// The variants `Native::ALL` lists, read off the const rather than re-typed. +/// +/// Re-typing the list here would be a second authority on it, and the two would +/// drift in exactly the direction that makes the assertion above pass +/// vacuously — a name missing from this copy is a class the gate stops checking. +fn declared_native_variants(verdict_rs: &std::path::Path) -> Vec { + let src = std::fs::read_to_string(verdict_rs).expect("verdict.rs is committed"); + let at = src + .find("pub const ALL") + .expect("`Native::ALL` is declared"); + let body = &src[at..src[at..].find("];").expect("the const terminates") + at]; + let mut found = Vec::new(); + for hit in body.match_indices("Native::") { + let rest = &body[hit.0 + "Native::".len()..]; + let name: String = rest + .chars() + .take_while(|ch| ch.is_alphanumeric() || *ch == '_') + .collect(); + if !name.is_empty() { + found.push(name); + } + } + found +} From 663945863b87f47bd9264185352836789235f60a Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 00:54:14 +0000 Subject: [PATCH 07/12] fix(config)!: a config fault names the table that would not load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The twelve `VALIDATED_AT_LOAD` validators refused through `UsageError::raise(String)` across ~172 sites, so a config fault was the ONE refusal class in this engine that `batten policy explain` could not resolve, that carried no declared route, and that no gate held to the `[[verdict]]` registry. CLOUD-1050's defect, one surface over — and stated in its own words: with the refusal a free `String`, "a refusal naming no remedy, naming a task that does not exist, or offering an override with no precondition were all expressible and none checkable". THIRTEEN CLASSES, ONE PER TABLE, ATTACHED AT THE ONE CALL SITE. Only ~30 of those raises sit at a top-level `validate`; the rest are in per-entry helpers several tables share, so a class per site is unbuildable and also the wrong grain — what a reader needs first is which TABLE would not load, and the message the validator already composed says which row and key. `under(Native::X, ..)` in `validate_tables` is the whole plumbing; not one of the ~172 messages is reworded. The thirteenth is `remedy resolve missing`, and it is the row's own origin rather than a bonus. CLOUD-1189 owed its gate a `[[verdict]]` row and could not declare one, because a load-time refusal was not a raised class and a declared row nothing raises fails the load. Closing CLOUD-1313 without it would have closed the row and left the case that produced it open. `UsageError` gains the class as a TYPED FIELD rather than a formatted prefix, which is the point rather than an ornament: formatting a token into the message would have reproduced CLOUD-1050's defect exactly, since nothing would check the token is declared. `Native` is an enum, so a raise site can only name a class that exists. `raise` keeps its signature and sets `verdict: None`, so every existing site compiles untouched. `None` stays a decision, not a gap: a file that will not parse as TOML failed before any validator ran, so naming a table would be inventing an attribution the loader does not have. `an_unparseable_config_refuses_without_inventing_a_table` is that clause. WHAT IS DELIBERATELY OUT, said here rather than left as a silent gap. `validate_ungated` makes ~24 validator calls; the census names twelve and this change classes those plus the remedy resolver. `mcp`, `action`, `handler`, `budget`, `refusal`, `advisory`, `hookcost`, `ci`, `defects`, `prune`, `attribution`, `commit`, `transcript` and `facts::validate_keying` still refuse classless. The census is the row's stated predicate and widening it here would have been scope this row did not buy. THE SPLIT INTO `validate_tables`/`validate_sections` PRESERVES REFUSAL ORDER. Which fault a multi-fault config reports first is observable output under house style §6, so the cut is the smallest one that clears the line lint without reordering anything — not the tidiest one. The census reads both bodies, because the loader is two functions and the predicate is about the loader. Gates, and each shown able to fail: * `every_load_time_validator_refuses_under_a_declared_class` reads the wrapping rather than trusting it. Probed by unwrapping `markers::validate`: it fails naming `markers` and the class the reader can no longer reach. A `?` that has lost its `under(..)` compiles and passes everything else. * `Native::CONFIG_FAULTS` is one authority with three readers, held equal to the census in both directions and to the fixture set in the compiled-binary tier — so a fourteenth table cannot arrive wrapped-but-untested. * `crates/batten/tests/it/config_fault_class.rs` fires all thirteen over the compiled binary. Two of its cases were wrong before they were read: the remedy resolver decides `batten` invocations written as CODE SPANS, so a fixture naming a mise task, or naming the verb in bare prose, exits 0 and ships as coverage. Both were measured, not reasoned. * The mirror runs both ways: a clean config raises no class, and this repository's own committed authority still loads. The eleven subject words the classes spend are declared with glosses, because a class whose subject the dictionary does not define is the unreadable name the grammar exists to prevent. All eleven measure one token under the pinned tokenizer. BREAKING CHANGE: `UsageError` is a named struct carrying `message` and an optional `verdict`, not a tuple struct. `UsageError::raise` is unchanged. Refs: CLOUD-1313 Admits: f9f5fc1db6427d0337addcf869ab86e7bdeb3c7da995ffd4f398590e1118242d Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: 8cffdb8d99ffd031476eb09c36496f0847847f30 Admits-epoch: c07c8db721c71e3c3f89648345f400a0a4dcbd989d79dcdf96db73d4513cb4a4 Admits-author: alec@wenzowski.com Admits-prev: 1a1d32bd7185ccb54a5c1bd9373e0a2a1b7defc22c1d5046ebcac201fbcb2508 Admits-answer-lost: Thirteen new config-fault classes whose subjects the dictionary does not define. The grammar's stated purpose is that a name needs no lookup because every word is glossed once; a class spending an undeclared subject is exactly the unreadable name the vocabulary exists to prevent. Admits-answer-precondition: The class this write adds vocabulary for is a `Native`, raised by the config LOADER before any config exists to declare it, so no `[[verdict]]` row can carry it and no surface verb can add a `[vocabulary]` word. Editing batten.toml is the only route, and the eleven added subject rows land in this diff where a reviewer reads them. Admits-answer-rejected-route: `config read first` names batten.toml, which is the file being refused — the remedy is the thing denied. `patch run first` (`git restore`) reverts the write rather than performing it, so it answers a different question: it is the route for an unwanted change, not for one the diff exists to make. --- batten.toml | 44 ++ crates/batten/src/config.rs | 397 ++++++++++++++++--- crates/batten/src/error.rs | 59 ++- crates/batten/src/verdict.rs | 237 ++++++++++- crates/batten/tests/it/config_fault_class.rs | 267 +++++++++++++ crates/batten/tests/it/main.rs | 1 + crates/batten/tests/it/verdict_vocabulary.rs | 10 + 7 files changed, 952 insertions(+), 63 deletions(-) create mode 100644 crates/batten/tests/it/config_fault_class.rs diff --git a/batten.toml b/batten.toml index 021c498c2..7f23ee281 100644 --- a/batten.toml +++ b/batten.toml @@ -6234,6 +6234,10 @@ gloss = "a tracked surface moving under a session" word = "event" gloss = "a harness or forge event" +[[vocabulary.subject]] +word = "fact" +gloss = "a declared fact row" + [[vocabulary.subject]] word = "forge" gloss = "the code host" @@ -6282,14 +6286,26 @@ gloss = "a lockfile" word = "manifest" gloss = "a package manifest" +[[vocabulary.subject]] +word = "marker" +gloss = "a declared marker row" + [[vocabulary.subject]] word = "memory" gloss = "an agent memory document" +[[vocabulary.subject]] +word = "mint" +gloss = "a declared receipt-minting row" + [[vocabulary.subject]] word = "module" gloss = "a policy module" +[[vocabulary.subject]] +word = "output" +gloss = "a declared exec output predicate" + [[vocabulary.subject]] word = "patch" gloss = "a change identified by its content" @@ -6298,6 +6314,10 @@ gloss = "a change identified by its content" word = "path" gloss = "a filesystem path" +[[vocabulary.subject]] +word = "pattern" +gloss = "a declared named-pattern row" + [[vocabulary.subject]] word = "pin" gloss = "fixed at a version" @@ -6310,6 +6330,18 @@ gloss = "an executable" word = "prose" gloss = "authored text" +[[vocabulary.subject]] +word = "provision" +gloss = "a declared provisioned-tool row" + +[[vocabulary.subject]] +word = "recorder" +gloss = "a declared recorder row" + +[[vocabulary.subject]] +word = "redirect" +gloss = "a declared per-path remedy row" + [[vocabulary.subject]] word = "release" gloss = "a cut release" @@ -6386,10 +6418,22 @@ gloss = "a third-party validator" word = "turn" gloss = "one agent turn" +[[vocabulary.subject]] +word = "verb" +gloss = "a declared mutating-verb row" + +[[vocabulary.subject]] +word = "verdict" +gloss = "a declared refusal class" + [[vocabulary.subject]] word = "version" gloss = "a declared version" +[[vocabulary.subject]] +word = "waiver" +gloss = "a declared waiver row" + [[vocabulary.subject]] word = "workflow" gloss = "a CI workflow" diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index 11bbf83bc..91c6776a4 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -57,6 +57,7 @@ use serde::{Deserialize, Serialize}; use crate::error::UsageError; use crate::rules::Rule; +use crate::verdict::Native; use crate::{outputs, waiver}; /// The config schema version this build understands. A file declaring any other @@ -1010,9 +1011,18 @@ pub fn parse_override(text: &str, source: &str) -> Result { // The same validators the authority runs, over the same tables. An override // row is a policy row: one that loads here and gates nothing is the defect // CLOUD-242 named, and it does not become acceptable for being uncommitted. - crate::rules::validate_in(&config.rules, text, source)?; - crate::outputs::validate(&config.exec_patterns)?; - crate::waiver::validate(&config.waivers)?; + under( + Native::RuleTableRefused, + crate::rules::validate_in(&config.rules, text, source), + )?; + under( + Native::OutputTableRefused, + crate::outputs::validate(&config.exec_patterns), + )?; + under( + Native::WaiverTableRefused, + crate::waiver::validate(&config.waivers), + )?; Ok(config) } @@ -1031,16 +1041,56 @@ pub fn override_schema() -> Result { ))?) } +/// Attach a table's declared class to a validator's refusal (CLOUD-1313). +/// +/// # Why the class is attached HERE and not at the raise site +/// +/// The twelve `VALIDATED_AT_LOAD` validators raise from ~172 sites, and only +/// about thirty of those sit at a top-level `validate` — the rest are in +/// per-entry helpers (`validate_shape`, `Rule::validate_*`), several of them +/// shared between tables. A class per site is therefore unbuildable, and a class +/// per site is also the wrong grain: what a reader needs to know first is which +/// TABLE would not load, and the message the validator already composed says +/// which row and key. That is the same division `[[verdict]]` draws between a +/// class and its subjects. +/// +/// So the call site is the one place that knows the table, and it is the only +/// place that has to change. +/// +/// # What it deliberately does not do +/// +/// It classes a [`UsageError`] that carries none, and passes everything else +/// through untouched — an internal failure stays internal (exit `3`), and an +/// error that already names a class keeps it. Rewording the refusals themselves +/// is outside CLOUD-1313. +fn under(native: crate::verdict::Native, result: Result) -> Result { + result.map_err(|err| match err.downcast::() { + Ok(usage) if usage.verdict.is_none() => UsageError::raise_as(native, usage.message), + Ok(usage) => anyhow::Error::new(usage), + Err(other) => other, + }) +} + /// The shared body: deserialize and check the schema `version`. -fn parse_ungated(text: &str, source: &str) -> Result { - let config: Config = toml::from_str(text) - .map_err(|err| UsageError::raise(format!("invalid config {source}: {err}")))?; - if config.version != SUPPORTED_VERSION { - return Err(UsageError::raise(format!( - "unsupported config version {} in {source}; this build supports version {SUPPORTED_VERSION}", - config.version - ))); - } +/// Prove every declared table well formed, each refusal naming its own class. +/// +/// # Why this is its own function +/// +/// It is the whole of what "the config loaded" means beyond deserializing, and +/// it is what `every_load_time_validator_refuses_under_a_declared_class` reads: +/// a census over a body that also carried the deserialize and the version gate +/// would be scanning text that has nothing to do with what it decides. +/// +/// Called from [`parse_ungated`] rather than from `parse`, so an override layer +/// is held to the same table rules — `batten.local.toml` may add rows, and a +/// raise-only override that adds an inert one has still written something that +/// cannot mean anything. +/// +/// # Errors +/// +/// Returns a [`UsageError`] (→ exit `1`) under the class of whichever table +/// refused; see [`under`]. +fn validate_tables(config: &Config, text: &str, source: &str) -> Result<()> { // The verb table is validated here, at load, because nothing else validates // it anywhere: `verbs::validate` had no caller outside its own tests, so a // `[[verb]]` row that is inert — `effect = "read"` in a table named for @@ -1053,21 +1103,33 @@ fn parse_ungated(text: &str, source: &str) -> Result { // In `parse_ungated` rather than `parse` so an override layer is held to it // too: `batten.local.toml` may add verb rows, and a raise-only override that // adds an inert one has still written something that cannot mean anything. - crate::verbs::validate(&config.verbs)?; + under( + Native::VerbTableRefused, + crate::verbs::validate(&config.verbs), + )?; // The named-regex table, at parse for the identical reason (CLOUD-885): a // malformed expression is a config fault, and refusing it here means // `config lint` and `doctor` catch it rather than a mediated call // discovering it at adjudication, which is the worst time and the wrong exit // class (house style §8). - crate::pattern::validate(&config.patterns)?; + under( + Native::PatternTableRefused, + crate::pattern::validate(&config.patterns), + )?; // The refusal vocabulary, at parse for the identical reason (CLOUD-1050). // Every clause is a property of the TABLE — a token's prefix, a gloss that // is one line, a route list that is not an override alone, a tombstone chain // that terminates — so it is knowable without a tree and belongs where a // config fault is reported. Registry EQUALITY against what the modules // actually emit needs the compiled bundles and lives in `policy::load`. - crate::verdict::validate(&config.verdicts, &config.vocabulary)?; - crate::redirect::validate(&config.redirects)?; + under( + Native::VerdictTableRefused, + crate::verdict::validate(&config.verdicts, &config.vocabulary), + )?; + under( + Native::RedirectTableRefused, + crate::redirect::validate(&config.redirects), + )?; // The remedies those two tables carry, resolved against the command surface // and the rule table (CLOUD-1189). Here rather than in `redirect::validate` // because it is the one clause needing a THIRD table — the `[[rule]]` ids — @@ -1100,7 +1162,10 @@ fn parse_ungated(text: &str, source: &str) -> Result { .as_deref() .map(|text| (format!("verb[{}].redirect", verb.verb), text)) })); - crate::redirect::validate_remedies(remedies, &rule_ids)?; + under( + Native::RemedyUnresolved, + crate::redirect::validate_remedies(remedies, &rule_ids), + )?; } // And the MCP table, at load for the identical reason (CLOUD-1260). Every // clause is a property of the TABLE — a duplicated id, a path that would @@ -1115,7 +1180,10 @@ fn parse_ungated(text: &str, source: &str) -> Result { // them up and nobody checked the sibling, so an empty `token` — which // matches every line of every file — still loaded clean. The completeness // test below is what stops the next table arriving orphaned the same way. - crate::markers::validate(&config.markers)?; + under( + Native::MarkerTableRefused, + crate::markers::validate(&config.markers), + )?; // And the action table, where "validated only by the runner" would be worst // of all: an action is a command, and a row that loads clean but names no // event is a side effect the operator believes is attached and which fires @@ -1131,31 +1199,70 @@ fn parse_ungated(text: &str, source: &str) -> Result { // malformed `mediated_call` row validated only by `check` is a policy row // that loads, matches nothing at the mediation channel, and reads as // coverage. `run_rule` still calls `Rule::validate` as defence in depth. - crate::rules::validate_in(&config.rules, text, source)?; - crate::outputs::validate(&config.exec_patterns)?; + under( + Native::RuleTableRefused, + crate::rules::validate_in(&config.rules, text, source), + )?; + under( + Native::OutputTableRefused, + crate::outputs::validate(&config.exec_patterns), + )?; // And the waiver table, where the stakes are inverted from every other row // here: a malformed rule fails to gate, but a malformed *waiver* is a hatch // whose expiry nobody could read. Refusing at load is what makes "every // waiver carries an expiry" true of the resolved config rather than aspirational. - crate::waiver::validate(&config.waivers)?; - crate::facts::validate(&config.facts)?; + under( + Native::WaiverTableRefused, + crate::waiver::validate(&config.waivers), + )?; + under( + Native::FactTableRefused, + crate::facts::validate(&config.facts), + )?; // The cross-table half (CLOUD-859), which needs both lists and so cannot live // in either one's own validator: a `named` receipt row over an agent-sourced // check is a gate no record can satisfy. crate::facts::validate_keying(&config.facts, &config.rules)?; - crate::mint::validate(&config.mints)?; + under( + Native::MintTableRefused, + crate::mint::validate(&config.mints), + )?; // AFTER the pattern table is validated, because a recorder's `section` names // a pattern id and the refusal for a missing one is only honest once the ids // are known to be well-formed themselves. - crate::recorder::validate( - &config.recorders, - &config.programs, - &config - .patterns - .iter() - .map(|pattern| pattern.id.clone()) - .collect(), + under( + Native::RecorderTableRefused, + crate::recorder::validate( + &config.recorders, + &config.programs, + &config + .patterns + .iter() + .map(|pattern| pattern.id.clone()) + .collect(), + ), )?; + validate_sections(config) +} + +/// Prove the SINGLETON sections well formed — the `Option` tables the census +/// cannot reach, plus the two list tables that follow them. +/// +/// # Why the split is here and not somewhere tidier +/// +/// It is the smallest cut that keeps `validate_tables` inside the line lint +/// **without reordering a single refusal**. Which fault a multi-fault config +/// reports first is observable output under house style §6, so a split chosen +/// for looks rather than for sequence would have been a silent contract change. +/// +/// `every_load_time_validator_refuses_under_a_declared_class` reads this body +/// together with [`validate_tables`]', because the loader is two functions and +/// the predicate is about the loader. +/// +/// # Errors +/// +/// As [`validate_tables`]. +fn validate_sections(config: &Config) -> Result<()> { // `[budget]` is a table rather than a list, so the census below (which scans // `Vec` fields) does not reach it — but the failure it guards against is // the same one: a table that parses and gates nothing. A `[budget]` header @@ -1207,7 +1314,23 @@ fn parse_ungated(text: &str, source: &str) -> Result { // A pin that can never match, a name that owns a cache path twice, an empty // required field: each is refused here rather than at fetch time, where the // failure would blame the artifact for a typo in this file. - crate::provision::validate(&config.provisions)?; + under( + Native::ProvisionTableRefused, + crate::provision::validate(&config.provisions), + )?; + Ok(()) +} + +fn parse_ungated(text: &str, source: &str) -> Result { + let config: Config = toml::from_str(text) + .map_err(|err| UsageError::raise(format!("invalid config {source}: {err}")))?; + if config.version != SUPPORTED_VERSION { + return Err(UsageError::raise(format!( + "unsupported config version {} in {source}; this build supports version {SUPPORTED_VERSION}", + config.version + ))); + } + validate_tables(&config, text, source)?; Ok(config) } @@ -1686,27 +1809,82 @@ mod tests { use super::*; use crate::error::UsageError; - /// Tables whose entries are proven well formed at load, and the call in - /// [`parse_ungated`] that does it. Deleting a call fails the test below. - const VALIDATED_AT_LOAD: &[(&str, &str)] = &[ - ("verbs", "crate::verbs::validate("), - ("patterns", "crate::pattern::validate("), - ("verdicts", "crate::verdict::validate("), - ("redirects", "crate::redirect::validate("), - ("markers", "crate::markers::validate("), + /// Tables whose entries are proven well formed at load, the call in + /// [`parse_ungated`] that does it, and the class its refusal names. + /// + /// The third column is CLOUD-1313's, and it is what makes the census a gate + /// over the refusal ABI rather than only over the call's existence: a + /// validator whose call is still there but is no longer wrapped raises a + /// classless `UsageError` again, which `batten policy explain` cannot + /// resolve, and the test below fails naming the table. + const VALIDATED_AT_LOAD: &[(&str, &str, Native)] = &[ + ("verbs", "crate::verbs::validate(", Native::VerbTableRefused), + ( + "patterns", + "crate::pattern::validate(", + Native::PatternTableRefused, + ), + ( + "verdicts", + "crate::verdict::validate(", + Native::VerdictTableRefused, + ), + ( + "redirects", + "crate::redirect::validate(", + Native::RedirectTableRefused, + ), + ( + "markers", + "crate::markers::validate(", + Native::MarkerTableRefused, + ), // The LOCATED form (CLOUD-773): the loaders hold the config text, so a // composition refusal points at a line rather than only at a rule id. // `rules::validate_in` runs `rules::validate` first — one implementation, // an optional locator — so naming it here is naming the whole check. - ("rules", "crate::rules::validate_in("), - ("exec_patterns", "crate::outputs::validate("), - ("provisions", "crate::provision::validate("), - ("waivers", "crate::waiver::validate("), - ("facts", "crate::facts::validate("), - ("mints", "crate::mint::validate("), - ("recorders", "crate::recorder::validate("), + ( + "rules", + "crate::rules::validate_in(", + Native::RuleTableRefused, + ), + ( + "exec_patterns", + "crate::outputs::validate(", + Native::OutputTableRefused, + ), + ( + "provisions", + "crate::provision::validate(", + Native::ProvisionTableRefused, + ), + ( + "waivers", + "crate::waiver::validate(", + Native::WaiverTableRefused, + ), + ("facts", "crate::facts::validate(", Native::FactTableRefused), + ("mints", "crate::mint::validate(", Native::MintTableRefused), + ( + "recorders", + "crate::recorder::validate(", + Native::RecorderTableRefused, + ), ]; + /// The one CLASSED refusal that is not a `Config` table. + /// + /// `redirect::validate_remedies` needs a third table — the rule ids — so it + /// is a call at the load rather than a validator over one field, and the + /// census above (which scans `Vec` fields) structurally cannot reach it. + /// It is listed anyway because it is the refusal CLOUD-1189 owed a class to + /// and could not declare one for, which is the case that produced + /// CLOUD-1313: leaving it out would close the row without closing its cause. + const CLASSED_BESIDE_THE_TABLES: &[(&str, Native)] = &[( + "crate::redirect::validate_remedies(", + Native::RemedyUnresolved, + )]; + /// Tables proven well formed somewhere else, each with the reason. Listing /// an exemption is the point: a reader sees the justification rather than /// an absence, which is what an orphaned validator looks like. @@ -1733,13 +1911,22 @@ mod tests { let rest = &source[start..]; &rest[..rest.find("\n}").expect("the struct closes")] }; - let parse_body = { - let start = source - .find("fn parse_ungated") - .expect("the shared parse body is declared here"); - let rest = &source[start..]; - &rest[..rest.find("\n}").expect("the function closes")] - }; + // BOTH bodies, because the loader is two functions and the predicate is + // about the loader. Reading only the first would report every section + // the split moved as unwrapped — the false positive that gets a gate + // switched off. + let parse_body = ["fn validate_tables", "fn validate_sections"] + .iter() + .map(|name| { + let start = source + .find(name) + .unwrap_or_else(|| panic!("`{name}` is declared here")); + let rest = &source[start..]; + &rest[..rest.find("\n}").expect("the function closes")] + }) + .collect::>() + .join("\n"); + let parse_body = parse_body.as_str(); let mut seen = Vec::new(); for line in struct_body.lines() { @@ -1754,7 +1941,7 @@ mod tests { } seen.push(field); - let at_load = VALIDATED_AT_LOAD.iter().find(|(name, _)| *name == field); + let at_load = VALIDATED_AT_LOAD.iter().find(|(name, _, _)| *name == field); let by_runner = VALIDATED_BY_ITS_RUNNER .iter() .any(|(name, _)| *name == field); @@ -1764,11 +1951,11 @@ mod tests { are proven well formed: at load, or by the runner that evaluates them. A \ table nothing validates is a refusal that cannot fire (CLOUD-253)." ); - if let Some((_, call)) = at_load { + if let Some((_, call, _)) = at_load { assert!( parse_body.contains(call), "config table `{field}` is listed as validated at load, but \ - `parse_ungated` does not call `{call}`." + `validate_tables` does not call `{call}`." ); } } @@ -1777,7 +1964,11 @@ mod tests { !seen.is_empty(), "the struct scan must actually find tables" ); - for (name, _) in VALIDATED_AT_LOAD.iter().chain(VALIDATED_BY_ITS_RUNNER) { + for name in VALIDATED_AT_LOAD + .iter() + .map(|(name, _, _)| name) + .chain(VALIDATED_BY_ITS_RUNNER.iter().map(|(name, _)| name)) + { assert!( seen.contains(name), "`{name}` is listed but is no longer a Config table; drop the stale entry." @@ -1785,6 +1976,96 @@ mod tests { } } + /// The refusal-ABI half of the census (CLOUD-1313). + /// + /// The test above proves the call is THERE. This one proves it is still + /// *classed* — that its refusal names a declared class rather than the bare + /// `String` that made a config fault the one refusal in this engine + /// `batten policy explain` could not resolve. + /// + /// It reads the wrapping rather than trusting it, because a `?` that has + /// lost its `under(..)` compiles, passes every other test, and silently + /// returns to the pre-CLOUD-1313 shape. + #[test] + fn every_load_time_validator_refuses_under_a_declared_class() { + let source = include_str!("config.rs"); + // BOTH bodies, because the loader is two functions and the predicate is + // about the loader. Reading only the first would report every section + // the split moved as unwrapped — the false positive that gets a gate + // switched off. + let parse_body = ["fn validate_tables", "fn validate_sections"] + .iter() + .map(|name| { + let start = source + .find(name) + .unwrap_or_else(|| panic!("`{name}` is declared here")); + let rest = &source[start..]; + &rest[..rest.find("\n}").expect("the function closes")] + }) + .collect::>() + .join("\n"); + let parse_body = parse_body.as_str(); + + let listed: Vec<(&str, &str, Native)> = VALIDATED_AT_LOAD + .iter() + .copied() + .chain( + CLASSED_BESIDE_THE_TABLES + .iter() + .map(|(call, native)| ("(not a Config table)", *call, *native)), + ) + .collect(); + + for (subject, call, native) in &listed { + let at = parse_body + .find(call) + .unwrap_or_else(|| panic!("`{subject}`: `validate_tables` does not call `{call}`")); + // The nearest `under(` before the call, and everything between it + // and the call. Unwrapping the call makes that span reach back to + // some *other* validator's wrapper, so the variant no longer + // matches — which is the direction this has to fail in. + let wrapper = parse_body[..at] + .rfind("under(") + .unwrap_or_else(|| panic!("`{subject}`: no `under(..)` precedes `{call}`")); + let span = &parse_body[wrapper..at]; + let variant = format!("{native:?}"); + assert!( + span.contains(&variant) && span.len() < 80, + "`{subject}`: `{call}` is not wrapped in `under(Native::{variant}, ..)`, so its \ + refusal carries no class and `batten policy explain {}` cannot reach it \ + (CLOUD-1313).", + native.id() + ); + } + + // One table, one class. Two tables sharing a class would report the + // wrong file to edit, and the span check above cannot see it. + let mut classes: Vec<&str> = listed.iter().map(|(_, _, n)| n.id()).collect(); + classes.sort_unstable(); + let before = classes.len(); + classes.dedup(); + assert_eq!( + before, + classes.len(), + "two load-time refusals share a class; each names the table a reader must edit" + ); + + // Both directions against the one authority. The forward direction + // stops a class being declared for a loader that does not raise it; the + // reverse stops a wrapped call whose class the published set — and so + // the compiled-binary tier that reads it — has never heard of. + let mut here: Vec<&str> = classes; + let mut published: Vec<&str> = Native::CONFIG_FAULTS.iter().map(|n| n.id()).collect(); + published.sort_unstable(); + here.sort_unstable(); + assert_eq!( + here, published, + "`Native::CONFIG_FAULTS` and this census disagree about which classes the loader \ + raises; they are read by different tiers, so a disagreement means one of them is \ + describing a loader that does not exist" + ); + } + fn is_usage_error(err: &anyhow::Error) -> bool { err.downcast_ref::().is_some() } diff --git a/crates/batten/src/error.rs b/crates/batten/src/error.rs index 6867ecd38..dd814bc7d 100644 --- a/crates/batten/src/error.rs +++ b/crates/batten/src/error.rs @@ -91,24 +91,75 @@ impl Passthrough { /// An expected bad-input error that maps to [`ExitCode::Usage`] (exit `1`). /// +/// # The class is a field, not a formatted prefix +/// +/// CLOUD-1050's measured lesson is that a refusal carried as a free `String` +/// makes a bad refusal **expressible and uncheckable** — that is why a policy +/// `violation` binds `{rule, verdict, subjects}` and has no `msg`. The +/// config-load surface had the same defect: ~172 `raise` sites, none naming a +/// class, so `batten policy explain` could not resolve a config fault and no +/// gate held one to the `[[verdict]]` registry (CLOUD-1313). +/// +/// Formatting a token into the message would have reproduced that defect one +/// surface over, because nothing would check that the token is declared. +/// [`Native`] is an enum, so a raise site can only name a class that exists, +/// and `the_vendored_table_validates` proves every variant carries a row. +/// /// [`ExitCode::Usage`]: crate::ExitCode::Usage +/// [`Native`]: crate::verdict::Native #[derive(Debug)] -pub struct UsageError(pub String); +pub struct UsageError { + /// The prose. Unchanged from the pre-class shape, deliberately: rewording + /// the twelve validators' refusals is explicitly outside CLOUD-1313. + pub message: String, + /// The declared class this fault belongs to, where one is known. + /// + /// `None` is not a gap to be closed everywhere — most `UsageError`s are + /// argument faults, not config faults, and inventing a class for each would + /// be the per-site explosion CLOUD-1313 rejected. It is `Some` exactly where + /// a raiser can name the table it was validating. + pub verdict: Option, +} impl fmt::Display for UsageError { + /// `: ` when a class is known, the bare message otherwise. + /// + /// The separator is a deliberate deviation from CLOUD-1286's + /// ` `, and the reason is that these messages are PROSE. + /// That shape reads as a class followed by pointers precisely because + /// nothing separates them; running a class straight into a sentence would + /// make the first three words of the sentence look like part of the class. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) + match self.verdict { + Some(native) => write!(f, "{}: {}", native.id(), self.message), + None => f.write_str(&self.message), + } } } impl std::error::Error for UsageError {} impl UsageError { - /// Build a [`UsageError`] as an [`anyhow::Error`], ready to `return Err(..)`. + /// Build a classless [`UsageError`] as an [`anyhow::Error`], ready to + /// `return Err(..)`. /// /// Named `raise` rather than `new` because it returns an [`anyhow::Error`] /// wrapping the `UsageError`, not `Self`. pub fn raise(message: impl Into) -> anyhow::Error { - anyhow::Error::new(UsageError(message.into())) + anyhow::Error::new(UsageError { + message: message.into(), + verdict: None, + }) + } + + /// Build a [`UsageError`] that names the class it belongs to. + /// + /// Callers are the config loader's per-table wrappers; see + /// [`crate::config`]'s `under`. + pub fn raise_as(verdict: crate::verdict::Native, message: impl Into) -> anyhow::Error { + anyhow::Error::new(UsageError { + message: message.into(), + verdict: Some(verdict), + }) } } diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index 83deda090..9f0475452 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -953,6 +953,51 @@ pub enum Native { ContentRefused, /// The work this call publishes names no tracker key. KeyMissing, + // ─── the config loader's own classes (CLOUD-1313) ──────────────────────── + // + // A load-time refusal was a bare `UsageError(String)` across ~172 sites, so + // a config fault was the ONE refusal class in this engine that + // `batten policy explain` could not resolve and no gate held to the + // registry -- CLOUD-1050's defect, one surface over. + // + // ONE CLASS PER TABLE, NOT PER SITE. Only ~30 of those raises sit at a + // top-level `validate`; the rest are in per-entry helpers, so a class per + // site is unbuildable. The class says WHICH TABLE would not load and the + // message carries the row and key, which is the same division `[[verdict]]` + // draws between a class and its subjects. + // + // They are `Native` rather than consumer rows for the reason the enum's own + // doc gives: the loader raises them BEFORE any config exists to declare + // them, so a declared-row spelling would be unsatisfiable at exactly the + // moment it fires. That also makes them resolvable from `vendored()` with + // no config load, which is what keeps `policy explain` usable over a config + // that will not parse. + /// The `[[verb]]` table would not load. + VerbTableRefused, + /// The `[[pattern]]` table would not load. + PatternTableRefused, + /// The `[[verdict]]` table would not load. + VerdictTableRefused, + /// The `[[redirect]]` table would not load. + RedirectTableRefused, + /// A declared remedy names no command that exists (CLOUD-1189's class). + RemedyUnresolved, + /// The `[[marker]]` table would not load. + MarkerTableRefused, + /// The `[[rule]]` table would not load. + RuleTableRefused, + /// The `[[exec_pattern]]` table would not load. + OutputTableRefused, + /// The `[[waiver]]` table would not load. + WaiverTableRefused, + /// The `[[fact]]` table would not load. + FactTableRefused, + /// The `[[mint]]` table would not load. + MintTableRefused, + /// The `[[recorder]]` table would not load. + RecorderTableRefused, + /// The `[[provision]]` table would not load. + ProvisionTableRefused, } impl Native { @@ -982,6 +1027,47 @@ impl Native { Native::ShapeRefused, Native::ContentRefused, Native::KeyMissing, + Native::VerbTableRefused, + Native::PatternTableRefused, + Native::VerdictTableRefused, + Native::RedirectTableRefused, + Native::RemedyUnresolved, + Native::MarkerTableRefused, + Native::RuleTableRefused, + Native::OutputTableRefused, + Native::WaiverTableRefused, + Native::FactTableRefused, + Native::MintTableRefused, + Native::RecorderTableRefused, + Native::ProvisionTableRefused, + ]; + + /// The classes the CONFIG LOADER raises, in `parse_ungated` order. + /// + /// One authority with three readers, which is what stops the set drifting + /// where it is used (CLOUD-1313): `config.rs`'s census holds its own + /// per-table list equal to this one in both directions, and the + /// compiled-binary tier holds its fixture set to it — so a fourteenth table + /// cannot arrive wrapped-but-untested, and a class cannot be dropped from + /// the loader while a fixture still claims to reach it. + /// + /// A subset of [`Native::ALL`] rather than a separate enum, because these + /// are resolved from the same vendored table by the same `explain` — the + /// only thing that distinguishes them is who raises them. + pub const CONFIG_FAULTS: &'static [Native] = &[ + Native::VerbTableRefused, + Native::PatternTableRefused, + Native::VerdictTableRefused, + Native::RedirectTableRefused, + Native::RemedyUnresolved, + Native::MarkerTableRefused, + Native::RuleTableRefused, + Native::OutputTableRefused, + Native::WaiverTableRefused, + Native::FactTableRefused, + Native::MintTableRefused, + Native::RecorderTableRefused, + Native::ProvisionTableRefused, ]; /// The token this class is declared and rendered under. @@ -1008,6 +1094,19 @@ impl Native { Native::ShapeRefused => "call name refused", Native::ContentRefused => "input write refused", Native::KeyMissing => "issue name missing", + Native::VerbTableRefused => "verb declare refused", + Native::PatternTableRefused => "pattern declare refused", + Native::VerdictTableRefused => "verdict declare refused", + Native::RedirectTableRefused => "redirect declare refused", + Native::RemedyUnresolved => "remedy resolve missing", + Native::MarkerTableRefused => "marker declare refused", + Native::RuleTableRefused => "rule declare refused", + Native::OutputTableRefused => "output declare refused", + Native::WaiverTableRefused => "waiver declare refused", + Native::FactTableRefused => "fact declare refused", + Native::MintTableRefused => "mint declare refused", + Native::RecorderTableRefused => "recorder declare refused", + Native::ProvisionTableRefused => "provision declare refused", } } } @@ -1334,6 +1433,129 @@ range the row declares. None carried a key, so nothing on the published work say it serves.", routes: &[read("config read first", "batten.toml")], }, + // ── the config loader's classes (CLOUD-1313) ──────────────────────────── + // + // One per `VALIDATED_AT_LOAD` table plus the remedy resolver. Each `class` + // says what the table is FOR and what a refusal from it therefore means, + // because the message the loader already carries says which row and key + // failed and repeating that here would be the payload rule 4 refuses. + // + // Every route is the config itself, which is not a placeholder: a config + // fault is edited in exactly one file, and a `command` route would have to + // name a task that can run over a config that does not load. + VendoredVerdict { + id: "verb declare refused", + gloss: "the verb table would not load", + class: "`[[verb]]` is how a consumer names the commands their harness mediates and \ +what effect each carries. A row that is inert -- declared twice, or read-effect in a table \ +named for mutation -- reads as covered while matching nothing, so the table is proven at \ +load rather than at the call it would have decided.", + routes: &[read("config read first", "batten.toml")], + }, + VendoredVerdict { + id: "pattern declare refused", + gloss: "the named-pattern registry would not load", + class: "`[[pattern]]` gives one concept one spelling, so a module cannot inline a \ +regex and duplication becomes unwritable rather than merely detectable. A malformed \ +expression here is a config fault, and refusing it at load is what stops a mediated call \ +discovering it at adjudication -- the worst moment and the wrong exit class.", + routes: &[read("config read first", "batten.toml")], + }, + VendoredVerdict { + id: "verdict declare refused", + gloss: "the refusal vocabulary would not load", + class: "`[[verdict]]` is the registry every other class in this table belongs to: a \ +token's arity, a gloss that is one line, a route list that is not an override alone, a \ +tombstone chain that terminates. Each clause is a property of the table, so it is knowable \ +without a tree and belongs where a config fault is reported.", + routes: &[read("config read first", "batten.toml")], + }, + VendoredVerdict { + id: "redirect declare refused", + gloss: "the redirect table would not load", + class: "`[[redirect]]` changes what a refusal SAYS for a class of path, never \ +whether it fires -- which is why it needs no raise-only clamp and why a redefinition is \ +refused for coherence with the other append-only tables rather than because it lowers a \ +bar.", + routes: &[read("config read first", "batten.toml")], + }, + VendoredVerdict { + id: "remedy resolve missing", + gloss: "a declared remedy names a command that does not exist", + class: "A refusal whose remedy points at a verb that was renamed away is worse than \ +one carrying no remedy: the reader spends the round finding out. This is the one clause \ +needing a THIRD table -- the rule ids -- so it lives at the load rather than inside either \ +remedy table's own validator, where a checker reaching past its argument would quietly \ +become the config's.", + routes: &[read("config read first", "batten.toml")], + }, + VendoredVerdict { + id: "marker declare refused", + gloss: "the marker table would not load", + class: "`[[marker]]` declares the tokens a scan treats as significant. An empty \ +`token` matches every line of every file, which loads clean and reads as coverage, so the \ +table is proven at load.", + routes: &[read("config read first", "batten.toml")], + }, + VendoredVerdict { + id: "rule declare refused", + gloss: "the rule table would not load", + class: "`[[rule]]` is the policy surface itself, and it used to be validated only by \ +whichever runner happened to evaluate it. That was defensible with one runner; with a tree \ +engine and a mediation boundary, a malformed mediated-call row validated only by the tree \ +engine is a row that loads, matches nothing at the mediation channel, and reads as \ +coverage.", + routes: &[read("config read first", "batten.toml")], + }, + VendoredVerdict { + id: "output declare refused", + gloss: "the exec output-predicate table would not load", + class: "`[[exec_pattern]]` is how a wrapped command's OUTPUT becomes a decidable \ +object rather than something a reader skims. A duplicate id makes two predicates \ +indistinguishable in the record they write.", + routes: &[read("config read first", "batten.toml")], + }, + VendoredVerdict { + id: "waiver declare refused", + gloss: "the waiver table would not load", + class: "The stakes here are inverted from every other table: a malformed rule fails \ +to gate, but a malformed WAIVER is a hatch whose expiry nobody could read. Refusing at load \ +is what makes \"every waiver carries an expiry\" true of the resolved config rather than \ +aspirational.", + routes: &[read("config read first", "batten.toml")], + }, + VendoredVerdict { + id: "fact declare refused", + gloss: "the fact table would not load", + class: "`[[fact]]` declares what the boundary resolves about a call before any rule \ +reads it. A row naming a fact the engine cannot produce is a gate that evaluates, reads \ +undefined, and refuses nothing -- the silent dead gate, decided at load instead.", + routes: &[read("config read first", "batten.toml")], + }, + VendoredVerdict { + id: "mint declare refused", + gloss: "the mint table would not load", + class: "`[[mint]]` declares what a receipt records and how long it answers for. A \ +malformed row is a receipt nothing can satisfy or one that answers forever, and both are \ +decidable from the table alone.", + routes: &[read("config read first", "batten.toml")], + }, + VendoredVerdict { + id: "recorder declare refused", + gloss: "the recorder table would not load", + class: "`[[recorder]]` binds a captured section to a named pattern. It is validated \ +AFTER the pattern registry, because a refusal for a missing pattern id is only honest once \ +the ids are known to be well formed themselves.", + routes: &[read("config read first", "batten.toml")], + }, + VendoredVerdict { + id: "provision declare refused", + gloss: "the provision table would not load", + class: "`[[provision]]` is how a pinned tool reaches the cache a rule will look for \ +it in. A row that cannot resolve is a rule that will report a missing scanner at the moment \ +it was supposed to decide something.", + routes: &[read("config read first", "batten.toml")], + }, // ── vendored presets ──────────────────────────────────────────────────── VendoredVerdict { id: "commit ship empty", @@ -1893,7 +2115,20 @@ mod tests { | Native::CeilingExceeded | Native::ShapeRefused | Native::ContentRefused - | Native::KeyMissing => native.id(), + | Native::KeyMissing + | Native::VerbTableRefused + | Native::PatternTableRefused + | Native::VerdictTableRefused + | Native::RedirectTableRefused + | Native::RemedyUnresolved + | Native::MarkerTableRefused + | Native::RuleTableRefused + | Native::OutputTableRefused + | Native::WaiverTableRefused + | Native::FactTableRefused + | Native::MintTableRefused + | Native::RecorderTableRefused + | Native::ProvisionTableRefused => native.id(), }; // The prefix is gone (CLOUD-1284), so what makes this a token is the // ARITY: exactly three words. Asserting that here rather than a diff --git a/crates/batten/tests/it/config_fault_class.rs b/crates/batten/tests/it/config_fault_class.rs new file mode 100644 index 000000000..e5d86ed7e --- /dev/null +++ b/crates/batten/tests/it/config_fault_class.rs @@ -0,0 +1,267 @@ +//! End-to-end tests over the compiled binary: a config fault names a declared +//! class, and that class stays reachable while the config is broken (CLOUD-1313). +//! +//! # Why this tier, and why a unit test cannot stand in for it +//! +//! `config.rs`'s own census proves the wrapping is written. It reads source +//! text, so it cannot tell a class that is attached from one that is attached +//! and then discarded somewhere between `parse_ungated` and the process's exit +//! code — which is exactly the shape of the defect CLOUD-1049 recorded one +//! surface over, where a correct projection was thrown away by a guard one line +//! later and every predicate in the module went quiet at exit 0. +//! +//! So the assertion here is over stderr and the exit code, which is what a +//! consumer actually gets. +//! +//! # The second arm is the load-bearing one +//! +//! `batten policy explain` loads the config to answer. A config-fault class is +//! therefore the one class whose remedy channel is dark at precisely the moment +//! it fires, unless the class resolves from the vendored table with no config +//! load. `explain_over` asserts that with the broken config still in place. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use common::Fixture; + +/// A config whose ONLY fault is in the named table. +/// +/// Every row deserializes cleanly and fails its table's validator, which is the +/// distinction that makes these cases reach the class at all: a row that fails +/// `toml::from_str` is refused before any validator runs and carries no class by +/// design. +const FAULTS: &[(&str, &str, &str)] = &[ + ( + "verb declare refused", + "verb", + "version = 1\n\ + [[verb]]\nverb = \"frobnicate\"\neffect = \"write\"\n\ + [[verb]]\nverb = \"frobnicate\"\neffect = \"write\"\n", + ), + ( + "pattern declare refused", + "pattern", + "version = 1\n[[pattern]]\nid = \"unclosed\"\nregex = \"[\"\n", + ), + ( + "verdict declare refused", + "verdict", + "version = 1\n\ + [[verdict]]\nid = \"notthreewords\"\ngloss = \"a gloss\"\nclass = \"a class\"\n", + ), + ( + "redirect declare refused", + "redirect", + "version = 1\n\ + [[redirect]]\nglob = \"*.frob\"\nmutation = \"mise run fmt\"\n\ + [[redirect]]\nglob = \"*.frob\"\nmutation = \"mise run fmt\"\n", + ), + ( + "marker declare refused", + "marker", + "version = 1\n[[marker]]\nid = \"blank\"\ntoken = \"\"\n", + ), + ( + "rule declare refused", + "rule", + "version = 1\n[[rule]]\nid = \"unsevered\"\nkind = \"policy\"\n", + ), + ( + "output declare refused", + "exec_pattern", + "version = 1\n\ + [[exec_pattern]]\nid = \"twice\"\npattern = \"x\"\nreason = \"r\"\n\ + [[exec_pattern]]\nid = \"twice\"\npattern = \"y\"\nreason = \"r\"\n", + ), + ( + "waiver declare refused", + "waiver", + "version = 1\n\ + [[waiver]]\nrule = \"absent\"\nreason = \"r\"\nexpires = \"2999-01-01\"\n\ + [[waiver]]\nrule = \"absent\"\nreason = \"r\"\nexpires = \"2999-01-02\"\n", + ), + ( + "fact declare refused", + "fact", + "version = 1\n\ + [[fact]]\nname = \"twice\"\nreturns = \"opaque\"\n\ + [[fact]]\nname = \"twice\"\nreturns = \"opaque\"\n", + ), + ( + "mint declare refused", + "mint", + "version = 1\n\ + [[mint]]\nname = \"twice\"\ntool = \"Bash\"\nkey = \"branch\"\n\ + mode = \"replace\"\nbody = \"b\"\n\ + [[mint]]\nname = \"twice\"\ntool = \"Bash\"\nkey = \"branch\"\n\ + mode = \"replace\"\nbody = \"b\"\n", + ), + ( + "recorder declare refused", + "recorder", + "version = 1\n\ + [[recorder]]\nname = \"twice\"\nrecord = \"notes.md\"\ntool = \"Bash\"\n\ + key = \"branch\"\ncolumns = []\n\ + [[recorder]]\nname = \"twice\"\nrecord = \"notes.md\"\ntool = \"Bash\"\n\ + key = \"branch\"\ncolumns = []\n", + ), + ( + "provision declare refused", + "provision", + "version = 1\n\ + [[provision]]\nname = \"twice\"\nversion = \"1.0.0\"\nbinary = \"b\"\n\ + [[provision]]\nname = \"twice\"\nversion = \"1.0.0\"\nbinary = \"b\"\n", + ), + // NOT a `Config` table: a remedy is resolved across the redirect, verb and + // rule tables at once, which is why it is a call at the load rather than a + // validator over one field — and why CLOUD-1189 could not declare a class + // for it, which is the case that produced CLOUD-1313. + // + // The glob is unique, so `redirect::validate` passes and the refusal comes + // from the resolver rather than from the table it is written in. + // + // Two things about this remedy are load-bearing and both were got wrong + // before they were read (`redirect::invocation`): + // + // It names a `batten` verb, because the resolver decides invocations of the + // crate's OWN surface and deliberately leaves `mise run …` or `git …` to the + // operator's PATH — a second authority. A fixture naming a mise task exits 0. + // + // And it is written as a CODE SPAN, because the resolver reads backtick + // spans and deliberately under-denies a command named in bare prose. A + // fixture without the backticks also exits 0. Either mistake ships a case + // that asserts a refusal it never reaches. + ( + "remedy resolve missing", + "redirect.mutation", + "version = 1\n\ + [[redirect]]\nglob = \"*.frob\"\nmutation = \"run `batten frobnicate the thing`\"\n", + ), +]; + +fn repo_with(name: &str, config: &str) -> std::path::PathBuf { + Fixture::new(name).config(config).build() +} + +/// The `batten: ` prefix `output::error` writes, then the class, then the prose. +fn refusal_names(stderr: &str, class: &str) -> bool { + stderr.contains(&format!("batten: {class}: ")) +} + +#[test] +fn every_config_fault_names_its_table_s_declared_class() { + for (class, table, config) in FAULTS { + let dir = repo_with(&format!("fault-{table}"), config); + let output = common::run(&dir, &["check"]); + let stderr = String::from_utf8_lossy(&output.stderr); + // Exit 1 and not 2: a config fault is a statement about the invocation, + // never a policy verdict about the repository (house style §7). + assert_eq!( + output.status.code(), + Some(1), + "`[[{table}]]` fault should be a usage error, got: {stderr}" + ); + assert!( + refusal_names(&stderr, class), + "a `[[{table}]]` fault must name `{class}`, got: {stderr}" + ); + } +} + +/// The remedy channel stays open over the config that broke. +/// +/// This is the constraint the row was filed without: `explain` resolves through +/// the config loader, so before CLOUD-1313's first half a class raised BY a +/// malformed config could not be looked up WHILE that config was malformed. +#[test] +fn a_config_fault_s_class_explains_while_the_config_is_still_broken() { + for (class, table, config) in FAULTS { + let dir = repo_with(&format!("explain-over-fault-{table}"), config); + let output = common::run(&dir, &["policy", "explain", class]); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + output.status.code(), + Some(0), + "`{class}` must explain over the config that raises it, got: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + stdout.contains(class), + "the explanation must name the class it answers for, got: {stdout}" + ); + } +} + +/// Every class the loader can raise has a fixture that actually reaches it. +/// +/// The reachability clause, and it is the one a reader would otherwise take on +/// trust: a class declared, wrapped and never raised is the dead gate this +/// repository exists to refuse, and it looks identical to a live one from every +/// angle except a case that fires it. `Native::CONFIG_FAULTS` is the authority +/// both this suite and `config.rs`'s census are held to, so a fourteenth table +/// cannot arrive wrapped-but-untested. +#[test] +fn every_class_the_loader_raises_has_a_case_above() { + let mut covered: Vec<&str> = FAULTS.iter().map(|(class, _, _)| *class).collect(); + let mut declared: Vec<&str> = batten::verdict::Native::CONFIG_FAULTS + .iter() + .map(|native| native.id()) + .collect(); + covered.sort_unstable(); + declared.sort_unstable(); + assert_eq!( + covered, declared, + "a config-fault class with no case above is reachable only in principle" + ); +} + +/// The anti-vacuity mirror: a well-formed config still loads and says nothing. +/// +/// Without this the suite above is satisfied by a loader that refuses every +/// config, which would name the right class every time and be useless. +#[test] +fn a_config_with_no_fault_raises_no_class() { + let dir = repo_with("fault-none", "version = 1\n"); + let output = common::run(&dir, &["check"]); + assert_eq!(output.status.code(), Some(0)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("declare refused"), + "a clean config raises no table class, got: {stderr}" + ); +} + +/// A fault the loader cannot attribute to a table still refuses, classless. +/// +/// `None` on `UsageError::verdict` is a decision rather than a gap: a file that +/// will not parse as TOML failed before any validator ran, so naming a table +/// would be inventing an attribution the loader does not have. +#[test] +fn an_unparseable_config_refuses_without_inventing_a_table() { + let dir = repo_with("fault-unparseable", "version = 1\n[[verb]]\nverb = \n"); + let output = common::run(&dir, &["check"]); + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("invalid config") && !stderr.contains("declare refused"), + "a parse failure predates every validator, so it names no table: {stderr}" + ); +} + +/// This repository's own committed authority still loads. +/// +/// The other half of the mirror, over the real tree rather than a fixture — +/// thirteen new classes are thirteen new ways to refuse a config that was fine. +#[test] +fn the_committed_authority_still_loads() { + let output = common::run(&common::at_root("."), &["config", "show"]); + assert_eq!( + output.status.code(), + Some(0), + "this repository's own config must still load: {}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index fd82b548e..a0ad92175 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -73,6 +73,7 @@ mod config_authority_boundary; mod config_base_ref_reading; mod config_deprecations; mod config_epoch; +mod config_fault_class; mod config_in_directory; mod config_lint; mod config_provenance; diff --git a/crates/batten/tests/it/verdict_vocabulary.rs b/crates/batten/tests/it/verdict_vocabulary.rs index 9f1da527a..32c13931d 100644 --- a/crates/batten/tests/it/verdict_vocabulary.rs +++ b/crates/batten/tests/it/verdict_vocabulary.rs @@ -62,6 +62,7 @@ const CANDIDATES: &[&str] = &[ "edit", "empty", "event", + "fact", "file", "first", "forge", @@ -85,6 +86,7 @@ const CANDIDATES: &[&str] = &[ "lock", "loose", "manifest", + "marker", "measure", "memory", "mint", @@ -94,20 +96,25 @@ const CANDIDATES: &[&str] = &[ "never", "open", "other", + "output", "own", "parse", "partial", "patch", "path", + "pattern", "pin", "place", "point", "port", "program", "prose", + "provision", "reach", "read", + "recorder", "red", + "redirect", "refused", "release", "remedy", @@ -150,7 +157,10 @@ const CANDIDATES: &[&str] = &[ "unsafe", "unseen", "unused", + "verb", + "verdict", "version", + "waiver", "watch", "wire", "workflow", From 8503048295fc5cdc8e9bd9a351ee38b594598fa8 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 01:04:03 +0000 Subject: [PATCH 08/12] fix(verdict): a lane refusal names which head it could not see tested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1284 renamed `V-PRIVILEGED-LANE-UNTESTED-ORIGIN` to `lane guard missing` and recorded the cost in its own words: "the name no longer distinguishes which of the five conditions fired... Splitting stays available and is strictly a follow-up: it would be five names where there is now one." This is that follow-up. THE COUNT WAS WRONG AND THE ROW SAYS SO. It is two, not five. `policy/privileged-lane.rego` raises `lane guard missing` from exactly ONE `violation` body; there were never five conditions to name. What the module carries is `is_subject`'s three conjuncts, and a job failing any one of them is not a finding at all — so "a class per conjunct" is unbuildable by construction, and a mutation over a conjunct another conjunct already excludes survives, which this module's own header records having been measured twice. The distinction a reader can ACT on is `selects_outside_head`'s two arms, and it is load-bearing because the remedies name DIFFERENT FIELDS: * a trigger carries the head — test `head_repository.full_name` on the event; * the job resolved one through the pulls API — test `.head.repo.full_name` on what the lookup returned, because the event payload has no head at all. `tests_origin` has encoded exactly that pair all along, two field names in one clause. An `issue_comment` lane told to test `head_repository.full_name` is sent to a field its payload does not have. THE ARMS ARE DISJOINT ON PURPOSE. `not trigger_carries_head(doc)` in the second arm is what stops a `workflow_run` job that also calls `/pulls` raising both classes for one job — two remedies for one fix, and a finding count that doubles over a tree nothing changed. Arm one wins that overlap because its remedy is the earlier of the two, applied before the lookup happens. `is_subject` is gone rather than re-keyed: it collapsed a two-remedy disjunction into one predicate, which is precisely what made the refusal unable to say which field to test. THE COMPILED TIER'S OBVIOUS ASSERTION DOES NOT WORK, and finding that out is why this row has one. `check`'s line is ` ` and its `--json` finding carries `rule`, `path`, `severity`, `report` and `identity` — the verdict class is on neither. Two cases asserting the class appears in `check` output were written first and both went red against a correctly split module: a test asserting its own premise rather than its conclusion. What the engine does carry is the registry, and its two directions ARE a statement about how many classes the module raises, so the split is asserted where the engine decides it. Shown able to fail (CLOUD-418), and by the mechanism rather than by a case: collapsing arm two back onto `lane guard missing` is refused at LOAD — batten: `[[verdict]]` declares `lane resolve missing`, which nothing raises — so a collapse cannot ship green, and neither can a rename. The compiled tier's mirror is the other direction: dropping the row while the module raises it refuses the load naming the undeclared token, at exit 1, with the anti-vacuity arm beside it so the pair is not satisfied by a fixture that refuses everything. The `#MUTANT` row moved with the conjunct it corrupts — `resolves_head(body)`, now inside the arm that asks that question. Its named case is unchanged, because the input that discriminates it is unchanged. No predicate changed meaning: the same jobs are subjects and the same jobs are findings. Refs: CLOUD-1317 Admits: bfe4071d4e5e287d41ddb6e4aaf4ddf0204429d4dbf38e9bf082f307c7aa2b84 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: 407267559f731e9bb11ab8a975019c3857c051af Admits-epoch: 0fe0905937edc9927e590d1b990039c4b21ffa35e29a360f57cc719c4fdabf77 Admits-author: alec@wenzowski.com Admits-prev: f9f5fc1db6427d0337addcf869ab86e7bdeb3c7da995ffd4f398590e1118242d Admits-answer-lost: A refusal that names neither of its two remedies. `lane guard missing` and `lane resolve missing` send a reader to different fields — `head_repository.full_name` on the event, `.head.repo.full_name` on a resolved pull request — and a lane told to test the wrong one is sent to a field its payload does not carry. Admits-answer-precondition: The second class is raised by a Rego module and must be declared in `[[verdict]]` or the config does not load — the registry refuses a raised token no row declares. `batten.toml` is the only file that can carry the row, and it lands in this diff beside the module arm that raises it. Admits-answer-rejected-route: `config read first` names batten.toml, which is the file being refused. `patch run first` (`git restore`) undoes the write rather than performing it, so it is the route for an unwanted change, not for the one this diff exists to make. --- batten.toml | 36 ++++- crates/batten/tests/it/privileged_lane.rs | 107 +++++++++++++++ policy/privileged-lane.rego | 152 ++++++++++++++++------ 3 files changed, 253 insertions(+), 42 deletions(-) diff --git a/batten.toml b/batten.toml index 7f23ee281..fc9a06e91 100644 --- a/batten.toml +++ b/batten.toml @@ -8261,12 +8261,38 @@ target = "mise run lint:deno" [[verdict]] id = "lane guard missing" -gloss = "a job an outside author can reach holds contents:write and tests no head origin" +gloss = "a job whose TRIGGER carries an outside head holds contents:write and tests no origin" class = """ -The privileged-lane shape: a trigger an outside author can fire, a token that can \ -write, and no check that the head being built came from this repository. Test the \ -head's origin before the job does anything with its permissions, or drop the \ -permission. +The privileged-lane shape where the event itself hands the job a head: \ +`pull_request`, `pull_request_target` or `workflow_run`, a token that can write, \ +and no check that the head came from this repository. The head is on the event, \ +so the test is on the event -- compare `head_repository.full_name` against \ +`github.repository` before the job does anything with its permissions, or drop \ +the permission. + +Its sibling `lane resolve missing` is the same shape reached the other way, and \ +the split exists because the two name DIFFERENT FIELDS: a lane told to read \ +`head_repository.full_name` on an `issue_comment` event is being sent to a field \ +its payload does not carry. +""" + +[[verdict.route]] +id = "workflow read first" +kind = "document" +target = ".github/workflows" + +[[verdict]] +id = "lane resolve missing" +gloss = "a job that LOOKS UP an outside head holds contents:write and tests no origin" +class = """ +The same privileged-lane shape reached the other way: no trigger carries a head, \ +so the job goes and finds one through the pulls API -- a cron arm, or an \ +`issue_comment` responding to a command. The event payload has no head to test, \ +so the test belongs on the RESOLVED pull request: compare `.head.repo.full_name` \ +against this repository on what the lookup returned, before checking it out. + +Reached only where no trigger carries a head, so a job that is both is reported \ +once, under `lane guard missing`, whose remedy is the earlier of the two. """ [[verdict.route]] diff --git a/crates/batten/tests/it/privileged_lane.rs b/crates/batten/tests/it/privileged_lane.rs index 4b86eff96..a7c83c95b 100644 --- a/crates/batten/tests/it/privileged_lane.rs +++ b/crates/batten/tests/it/privileged_lane.rs @@ -72,6 +72,22 @@ fn fixture(name: &str, workflow: &str, body: &str) -> PathBuf { "id = \"workflow read first\"\n", "kind = \"document\"\n", "target = \".github/workflows\"\n\n", + // The split's second class (CLOUD-1317). Declaring it is not + // optional here and the registry pushes both ways: the module can + // raise it, so a fixture omitting the row fails to load with an + // undeclared token — and a row nothing raises fails the load too, so + // it cannot be declared anywhere the module does not emit it. + "[[verdict]]\n", + "id = \"lane resolve missing\"\n", + "gloss = \"a job that looks up an outside head holds contents:write and tests no origin\"\n", + "class = \"\"\"\n", + "No trigger carries a head, so the job resolved one through the pulls API and the \\\n", + "test belongs on what the lookup returned.\n", + "\"\"\"\n\n", + "[[verdict.route]]\n", + "id = \"workflow read first\"\n", + "kind = \"document\"\n", + "target = \".github/workflows\"\n\n", "[[verdict]]\n", "id = \"workflow parse broken\"\n", "gloss = \"a workflow could not be parsed, so its lanes were never judged\"\n", @@ -113,6 +129,27 @@ fn denied(root: &Path) { ); } +/// Denied AND under the named class. +/// +/// `denied` asserts only the rule id, which every arm of this module shares — so +/// it cannot see the class split at all. A pair of cases using it would stay +/// green over a module that collapsed the two classes back into one, which is +/// the regression CLOUD-1317 exists to prevent. +fn denied_under(root: &Path, class: &str) { + let output = common::run(root, &["check"]); + let text = String::from_utf8_lossy(&output.stdout).into_owned(); + assert_eq!( + output.status.code(), + Some(batten::exit::ExitCode::Violation.code()), + "expected the policy verdict: {text}{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + text.contains(class), + "the finding must name `{class}`, so its reader knows which field to test: {text}" + ); +} + fn clean(root: &Path) { let output = common::run(root, &["check"]); assert_eq!( @@ -231,3 +268,73 @@ fn a_read_only_lane_is_not_a_subject() { // module's own `test_an_unparseable_workflow_denies_rather_than_passing` is // GREEN, because `with input as` hands itself the populated `missing` the engine // never builds (CLOUD-845). + +// --- the class split, over the compiled binary (CLOUD-1317) ----------------- +// +// THE OBVIOUS ASSERTION DOES NOT WORK, AND FINDING THAT OUT IS THE POINT OF THIS +// TIER. `check`'s line is ` ` and its `--json` finding carries +// `rule`, `path`, `severity`, `report` and `identity` — the verdict class is on +// NEITHER. Two cases asserting the class appears in `check` output fail against a +// correctly split module, which is a test asserting its own premise rather than +// its conclusion (`.claude/rules/rust.md`, CLOUD-249). Measured, not reasoned: +// both were written that way first and both went red for that reason. +// +// What the engine DOES carry through is the registry, and its two directions are +// exactly a statement about how many classes the module raises. So the split is +// asserted where the engine actually decides it. + +/// Both classes declared: the module loads and the lane is denied. +/// +/// The anti-vacuity half. Without it the case below is satisfied by a fixture +/// that refuses every config, which would name the missing class every time and +/// prove nothing. +#[test] +fn a_resolver_lane_is_denied_where_both_classes_are_declared() { + let root = fixture( + "resolve-class", + "auto-bot-land.yml", + "on:\n issue_comment:\n types: [created]\njobs:\n land:\n permissions:\n \ + contents: write\n steps:\n - run: gh api repos/$REPO/pulls?state=open\n", + ); + denied(&root); +} + +/// Dropping the resolve class refuses the LOAD, naming the token nothing declares. +/// +/// This is the split, asserted over the compiled binary: an unsplit module raises +/// one token, so a config declaring only `lane guard missing` would load clean and +/// this case would go green for the wrong reason. It is red on a collapse and red +/// on a rename, which is what a class the reporting surface never prints needs. +/// +/// Exit `1`, not `2`: a config that will not load is a statement about the +/// invocation, never a verdict about the repository. +#[test] +fn dropping_the_resolve_class_refuses_the_load_rather_than_reporting_one_class() { + let root = fixture( + "resolve-class-undeclared", + "auto-bot-land.yml", + "on:\n issue_comment:\n types: [created]\njobs:\n land:\n permissions:\n \ + contents: write\n steps:\n - run: gh api repos/$REPO/pulls?state=open\n", + ); + let config = root.join("batten.toml"); + let text = fs::read_to_string(&config).expect("the fixture authority is readable"); + let without = text + .split("[[verdict]]\n") + .filter(|block| !block.starts_with("id = \"lane resolve missing\"")) + .collect::>() + .join("[[verdict]]\n"); + assert_ne!(without, text, "the fixture must actually declare the class"); + fs::write(&config, without).expect("rewrite the fixture authority"); + + let output = common::run(&root, &["check"]); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + output.status.code(), + Some(batten::exit::ExitCode::Usage.code()), + "an undeclared class is a config fault, not a verdict: {stderr}" + ); + assert!( + stderr.contains("lane resolve missing"), + "the refusal names the token nothing declares: {stderr}" + ); +} diff --git a/policy/privileged-lane.rego b/policy/privileged-lane.rego index 365182bbd..831622a58 100644 --- a/policy/privileged-lane.rego +++ b/policy/privileged-lane.rego @@ -39,25 +39,29 @@ import rego.v1 # the compiled binary over a real tree, so there is now a named case a mutation # can turn red, and this gate joins $MUTANT_GATES. # -# THE THIRD CONJUNCT IS THE ONE WORTH CORRUPTING, and choosing an input for it -# took two attempts — which is the whole value of declaring a mutation rather than -# assuming one. A mutation over the trigger list proves little: both spellings deny -# the lanes that matter, so it would pass under the corruption. +# THE HEAD-RESOLUTION CONJUNCT IS THE ONE WORTH CORRUPTING, and choosing an input +# for it took two attempts — which is the whole value of declaring a mutation +# rather than assuming one. A mutation over the trigger list proves little: both +# spellings deny the lanes that matter, so it would pass under the corruption. # # THE FIRST CHOICE SURVIVED, AND THAT IS HOW THIS PARAGRAPH GOT CORRECTED. It -# named `perf.yml` as what the third conjunct keeps out of the subject set. It is -# not: `perf.yml` triggers only on `schedule` and `workflow_dispatch`, neither of -# which is in `outsider_reachable`, so the FIRST conjunct already excludes it and -# dropping the third changes nothing about it. A mutation over a conjunct that +# named `perf.yml` as what the conjunct keeps out of the subject set. It is not: +# `perf.yml` triggers only on `schedule` and `workflow_dispatch`, neither of which +# is in `outsider_reachable`, so the FIRST conjunct already excludes it and +# dropping this one changes nothing about it. A mutation over a conjunct that # another conjunct already excludes cannot discriminate, and surviving is the only # way that gets found. # # What discriminates is an outsider-reachable writer that resolves no outside # head: `issue_comment` plus `contents: write`, with no `pull_request` or # `workflow_run` trigger and no `/pulls` reference anywhere. Clean today, a -# finding the moment the third conjunct stops being asked. -# `tests/privileged-lane.bats` carries that input under the name below. -#MUTANT third-conjunct-dropped|s@^\tselects_outside_head(doc, body)$@\ttrue@|an_outsider_reachable_writer_that_resolves_no_outside_head_is_not_a_subject +# finding the moment `resolves_head` stops being asked. +# +# The row moved with the split (CLOUD-1317): the conjunct it corrupts is now +# `resolves_head(body)` inside the `lane resolve missing` arm, which is where that +# question is asked. The named case is unchanged, because the input that +# discriminates it is unchanged. +#MUTANT resolution-conjunct-dropped|s@^\tresolves_head(body)$@\ttrue@|an_outsider_reachable_writer_that_resolves_no_outside_head_is_not_a_subject #MUTANT-SUITE crates/batten/tests/it/privileged_lane.rs rules contains "privileged-lane-tests-origin" @@ -76,7 +80,11 @@ violation contains { is_workflow(path) } -# The finding itself: a subject job that never mentions the head's origin. +# The finding, in two classes, because the two have DIFFERENT REMEDIES +# (CLOUD-1317). The subject set is unchanged and so is the finding count; what +# changed is that the refusal now says which field its reader must test. +# +# Arm one: the EVENT carries the head, so the test is on the event. violation contains { "rule": "privileged-lane-tests-origin", "verdict": "lane guard missing", @@ -85,7 +93,32 @@ violation contains { some path, doc in input.tree.documents is_workflow(path) some job, body in doc.jobs - is_subject(doc, body) + outsider_reachable(doc) + grants_write(doc, body) + trigger_carries_head(doc) + not tests_origin(body) +} + +# Arm two: no trigger carries a head, so the job LOOKED ONE UP, and the test +# belongs on what the lookup returned. +# +# `not trigger_carries_head(doc)` is what keeps the arms disjoint. Without it a +# `workflow_run` job that also calls `/pulls` would raise BOTH classes for one +# job, doubling the finding count over a tree nothing changed — and the reader +# would get two remedies for one fix. Arm one wins that overlap deliberately: its +# remedy is the earlier of the two, applied before the lookup happens at all. +violation contains { + "rule": "privileged-lane-tests-origin", + "verdict": "lane resolve missing", + "subjects": [{"path": path}, {"artifact": job}], +} if { + some path, doc in input.tree.documents + is_workflow(path) + some job, body in doc.jobs + outsider_reachable(doc) + grants_write(doc, body) + not trigger_carries_head(doc) + resolves_head(body) not tests_origin(body) } @@ -98,6 +131,12 @@ is_workflow(path) if { # author can influence. A gate whose first firing is a false positive gets an # exception written for it, and the exception is what rots. # +# THE THIRD IS ALSO WHERE THE CLASS SPLIT LIVES (CLOUD-1317), which is why it is +# no longer wrapped in an `is_subject` helper. `selects_outside_head` was a +# disjunction over two arms with two different remedies, so collapsing them into +# one predicate was exactly what made the refusal unable to say which field to +# test. The two arms are named separately below and each `violation` asks for one. +# # THIS PARAGRAPH USED TO NAME `perf.yml` AS WHAT THE THIRD CONJUNCT SPARES, AND # THAT WAS WRONG (CLOUD-931). Measured: `perf.yml` triggers on `schedule` and # `workflow_dispatch`, and neither is in `outsider_reachable`'s list below — so it @@ -105,23 +144,14 @@ is_workflow(path) if { # it. The declared mutation above SURVIVED against exactly that reading, which is # how the error was found rather than inherited by the next lane. # -# The input that actually discriminates this conjunct is outsider-reachable AND -# write-granting AND resolving no outside head: an `issue_comment` job with no -# `/pulls` lookup. `tests/privileged-lane.bats` carries it and the mutation names -# it, so the claim is held by a case rather than by this paragraph. Note that -# `test_a_scheduled_writer_with_no_outside_head_is_not_a_subject` below does NOT -# hold it either: that input is not outsider-reachable, so it passes with this -# conjunct deleted. -is_subject(doc, body) if { - outsider_reachable(doc) - grants_write(doc, body) - selects_outside_head(doc, body) -} +# The input that actually discriminates the third conjunct is outsider-reachable +# AND write-granting AND resolving no outside head: an `issue_comment` job with no +# `/pulls` lookup. `crates/batten/tests/it/privileged_lane.rs` carries it and the +# mutation names it, so the claim is held by a case rather than by this paragraph. +# Note that `test_a_scheduled_writer_with_no_outside_head_is_not_a_subject` below +# does NOT hold it either: that input is not outsider-reachable, so it passes with +# the conjunct deleted. -# `schedule` and `workflow_dispatch` are deliberately absent: neither is reachable -# by someone without write access. A schedule that goes on to resolve an outside -# head is still caught, by `selects_outside_head` below — which is why that clause -# reads the job body and not only the trigger. outsider_reachable(doc) if { some trigger in ["pull_request", "pull_request_target", "issue_comment", "workflow_run"] doc.on[trigger] @@ -135,15 +165,18 @@ grants_write(doc, _) if { doc.permissions.contents == "write" } -# Either the trigger inherently carries an outside head, or the job goes and finds -# one through the pulls API. The second arm is what keeps a schedule-driven -# resolver — `auto-bot-land`'s cron arm is exactly one — inside the subject set. -selects_outside_head(doc, _) if { +# The trigger inherently carries an outside head. `github.event.` then +# holds `head_repository.full_name`, which is the field this arm's remedy names. +trigger_carries_head(doc) if { some trigger in ["pull_request", "pull_request_target", "workflow_run"] doc.on[trigger] } -selects_outside_head(_, body) if { +# The job goes and finds a head through the pulls API. This is what keeps a +# schedule-driven resolver — `auto-bot-land`'s cron arm is exactly one — inside +# the subject set, and its remedy names `.head.repo.full_name` on the RESOLVED +# pull request, because the event payload carries no head to test. +resolves_head(body) if { contains(json.marshal(body), "/pulls") } @@ -219,9 +252,13 @@ test_a_scheduled_writer_with_no_outside_head_is_not_a_subject if { } # A schedule-only lane that DOES go looking for pull requests is a subject, even -# though no trigger carries a head. -test_a_scheduled_resolver_of_pulls_is_a_subject if { - count(violation) == 1 with input as {"tree": { +# though no trigger carries a head — and it raises the RESOLVE class, because the +# field its remedy names is on the pull request rather than on the event. +# +# Asserting the class rather than only the count is what makes this case +# discriminate the split: a single-class implementation still counts one here. +test_a_resolver_of_pulls_raises_the_resolve_class if { + found := violation with input as {"tree": { "documents": {".github/workflows/auto-bot-land.yml": { "on": {"issue_comment": {}}, "jobs": {"land": { @@ -231,6 +268,47 @@ test_a_scheduled_resolver_of_pulls_is_a_subject if { }}, "missing": [], }} + count(found) == 1 + some finding in found + finding.verdict == "lane resolve missing" +} + +# A trigger-carried head raises the GUARD class, which is the other half of the +# same discrimination: if both inputs raised one class, this pair would be green +# over an unsplit module. +test_a_trigger_carried_head_raises_the_guard_class if { + found := violation with input as {"tree": { + "documents": {".github/workflows/auto-bot-land.yml": { + "on": {"workflow_run": {}}, + "jobs": {"land": { + "permissions": {"contents": "write"}, + "if": "startsWith(github.event.workflow_run.head_branch, 'renovate/')", + }}, + }}, + "missing": [], + }} + count(found) == 1 + some finding in found + finding.verdict == "lane guard missing" +} + +# A job that is BOTH — a trigger carries a head and it also calls `/pulls` — is +# reported once, under the guard class. Without the `not trigger_carries_head` +# conjunct this counts two, which is the overlap the arms are made disjoint for. +test_a_job_matching_both_arms_is_reported_once if { + found := violation with input as {"tree": { + "documents": {".github/workflows/auto-bot-land.yml": { + "on": {"workflow_run": {}}, + "jobs": {"land": { + "permissions": {"contents": "write"}, + "steps": [{"run": "gh api repos/$REPO/pulls?state=open"}], + }}, + }}, + "missing": [], + }} + count(found) == 1 + some finding in found + finding.verdict == "lane guard missing" } test_a_read_only_lane_is_not_a_subject if { From c1da410e7ead04d116fd0ccbb43073115086c6f8 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 01:22:50 +0000 Subject: [PATCH 09/12] feat(spec)!: version the emitted spec and give every command a stable id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `batten spec` is the one machine contract a third party reads, and it carried neither a version nor an identity. A consumer had nothing to pin against — every change was indistinguishable from every other — and the only handle a row had was its `path`, the human spelling, which is exactly what the surface-repair work is about to move. `read_only_allowlist` is §5's safety-critical derivation and it was keyed on that spelling, so a rename silently stops a consumer's pinned allowlist matching, in the direction where a path it still trusts no longer means what it did. FOUR PUBLISHED FIELDS, AND THREE OF THEM CLOSE THE SAME GAP: the document omitted what the surface already declares, so versioning it first would have versioned an incomplete shape and immediately had to move. * `spec_version`, a SOURCE LITERAL. Never `CARGO_PKG_VERSION`: a version moving with the crate says "the binary changed", which is what the tag already says. It moves on a change to the emitted SHAPE and not when a command row is added, removed or renamed — that is the surface changing, and tracking it is what the per-row id is for. * `id` on every command row, DECLARED on `CommandDecl` rather than derived. An id computed from `path` is the path with extra steps and re-breaks on the same rename. The whole of its contract is that it is not edited when `path` changes. The seeds resemble today's paths because a seed has to come from somewhere and an arbitrary one would be unreadable in the committed row set; that is history, not a rule, and `surface.rs` says so at the field. * `data_channel`, which never left the binary. It was a build-time-only column, so a consumer could only infer the channel by scanning a row's flags for one named `json` — a second derivation of a declared fact, and one that reads `spec` (whose switch is `--format`) wrong. * `positional` on a flag. Without it a positional emitted as `long: null, takes_value: true`, byte-identical to a flag that lost its long form — so a consumer reconstructing an invocation writes `-- ` for something that takes neither and gets no signal that it did. `read_only_allowlist` is reconciled rather than left keyed on the spelling: an entry is now `{id, path}`. One struct rather than two parallel lists, because two lists can disagree about their own ordering and a consumer would have to zip them to find out. It sorts by ID — the stable half — so the document's byte order does not move under a rename that changed nothing about which commands are read-only, and `spec.rs`'s literal list is sorted to meet it with that stated. The root node takes no id. It is the binary, which the release tag already identifies, and a second name for it would be a second authority. Shown able to fail (CLOUD-418), both observed rather than argued: * emptying one row's id fails `every_declared_path_has_an_id`, naming `capture show`; * pointing one row at another's id fails `no_id_is_declared_twice`. That second assertion NAMES THE PAIR rather than comparing two lengths, and the reason is measured: the length form reported `86 != 85` and nothing else, which sent two rounds of source parsing after a duplicate that a stale build had invented. An assertion that says which two rows collide costs one loop and would have ended it immediately. `the_lookup_returns_the_declared_literal_rather_than_a_computed_one` is the third arm and it is deliberately narrow: there is no honest exit code over "did the author re-derive this id during a rename", so what is held is the reachable half — `id_for` answers with the row's own literal. Every derived artifact was regenerated with `mise run fix`, never by hand; the golden JSON snapshot carries the four new keys and nothing else. BREAKING CHANGE: `SpecDocument` gains `spec_version` and its `read_only_allowlist` is `Vec` rather than `Vec`; `CommandSpec` gains `id` and `data_channel`; `FlagSpec` gains `positional`. Refs: CLOUD-969 --- crates/batten/src/spec.rs | 144 ++++- crates/batten/src/surface.rs | 207 +++++++ crates/batten/tests/it/cli.rs | 31 +- crates/batten/tests/it/privileged_lane.rs | 21 - .../it__snapshots__golden_json_schema.snap | 508 ++++++++++++++++-- 5 files changed, 832 insertions(+), 79 deletions(-) diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index 1bffe662b..bc3734973 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -13,7 +13,7 @@ use clap::{Arg, ArgAction, Command}; use serde::Serialize; use crate::effect::Effect; -use crate::surface::effect_for; +use crate::surface::{data_channel_for, effect_for, id_for}; /// A single flag or positional argument in the emitted spec. #[derive(Debug, Serialize, PartialEq, Eq)] @@ -26,6 +26,14 @@ pub struct FlagSpec { pub long: Option, /// Whether the argument consumes a value (a bare boolean flag does not). pub takes_value: bool, + /// Whether the argument is POSITIONAL rather than a named flag (CLOUD-969). + /// + /// Without this a positional emits as `long: null, takes_value: true`, which + /// is byte-identical to a flag that lost its long form — so a consumer + /// reconstructing an invocation from this document writes `-- ` + /// for something that takes neither, produces a broken command line, and + /// gets no signal that it did. + pub positional: bool, /// The one-line human summary, if the command declares one. pub help: Option, } @@ -37,10 +45,22 @@ pub struct CommandSpec { /// The full, root-relative command path (`config show`); the bare program /// name for the root node. pub path: String, + /// The stable id declared on this command's `SURFACE` row (CLOUD-969). + /// + /// `None` only for the root program node, which declares no row of its own. + /// This is what a consumer pins against; `path` is the spelling and moves. + pub id: Option, /// The one-line human summary, if the command declares one. pub about: Option, /// The declared effect, resolved from the §5 table (`ask` when absent). pub effect: Effect, + /// Whether this command answers through the `-J` data channel (§6). + /// + /// Published since CLOUD-969. It was a build-time-only column, so a consumer + /// had to infer the channel by looking for a flag named `json` — a second + /// derivation of something the surface already declares, and one that reads + /// `spec` (whose switch is `--format`) wrong in both directions. + pub data_channel: bool, /// Flags and positionals, sorted by name for byte-stability. pub flags: Vec, /// Subcommands, sorted by path for byte-stability. @@ -63,6 +83,9 @@ fn flag_of(arg: &Arg) -> FlagSpec { ArgAction::SetTrue | ArgAction::SetFalse | ArgAction::Count ), help: arg.get_help().map(ToString::to_string), + // clap's own answer, not a heuristic over the long/short pair: a flag + // may legitimately carry neither. + positional: arg.is_positional(), } } @@ -95,6 +118,8 @@ fn walk(command: &Command, prefix: &str) -> CommandSpec { CommandSpec { effect: effect_for(&path), + data_channel: data_channel_for(&path), + id: id_for(&path).map(ToOwned::to_owned), about: command.get_about().map(ToString::to_string), flags: flags_of(command), subcommands, @@ -115,8 +140,12 @@ pub fn describe(root: &Command) -> CommandSpec { CommandSpec { path: root.get_name().to_owned(), + // The root is the binary, which the release tag already identifies; an + // id here would be a second name for the same thing. + id: None, about: root.get_about().map(ToString::to_string), effect: Effect::Ask, + data_channel: false, flags: flags_of(root), subcommands, } @@ -130,6 +159,11 @@ pub fn describe(root: &Command) -> CommandSpec { /// were and a derivation can be added beside them without moving anything. #[derive(Debug, Serialize, PartialEq, Eq)] pub struct SpecDocument { + /// The shape this document is in (CLOUD-969). + /// + /// Emitted FIRST because it is what a consumer reads before deciding whether + /// it understands the rest. + pub spec_version: u32, /// The command tree itself, at the document root. #[serde(flatten)] pub command: CommandSpec, @@ -137,15 +171,52 @@ pub struct SpecDocument { /// Emitted rather than left to each consumer to re-derive: a second /// implementation of the `effect == read` filter is a second place for it /// to be wrong, and this one is wrong in the unsafe direction. - pub read_only_allowlist: Vec, + /// + /// Each entry carries the stable id ALONGSIDE the path since CLOUD-969, and + /// that reconciliation is the point rather than a convenience: this is §5's + /// safety-critical derivation, and keyed on the spelling alone a rename + /// silently stops a consumer's pinned allowlist from matching — in the + /// direction where a path it still trusts no longer means what it did. + pub read_only_allowlist: Vec, } +/// One row of the derived read-only allowlist: the stable identity, and the +/// spelling to invoke today. +/// +/// A struct rather than a bare path (CLOUD-969). Two keys rather than two +/// parallel lists, because two lists can disagree about their own ordering and +/// a consumer would have to zip them to find out. +#[derive(Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct ReadOnlyEntry { + /// The stable id declared on the command's `SURFACE` row. Pin against this. + pub id: String, + /// The path to invoke today. Human-facing, and expected to move. + pub path: String, +} + +/// The shape of the emitted document. +/// +/// A SOURCE LITERAL, deliberately, and never `CARGO_PKG_VERSION`: a version that +/// moves with the crate says "the binary changed", which is what the release tag +/// already says, and tells a consumer nothing about whether the document it is +/// about to parse is one it understands. +/// +/// **When it moves:** on any change to the emitted shape a consumer could +/// notice — a key added, removed or renamed, or a value's type changed. It does +/// NOT move when a command row is added, removed or renamed: that is the +/// surface changing, not the document's shape, and it is exactly what the +/// per-row `id` exists to let a consumer track. Pre-`0.1.0` there is no +/// back-compatibility surface (house style §2), so this is a statement about the +/// document rather than a promise about old ones. +pub const SPEC_VERSION: u32 = 1; + /// Describe the whole surface as the emitted document: [`describe`] plus the /// derivations taken from that same walk. #[must_use] pub fn document(root: &Command) -> SpecDocument { let command = describe(root); SpecDocument { + spec_version: SPEC_VERSION, read_only_allowlist: read_only_allowlist(&command), command, } @@ -169,17 +240,30 @@ pub fn to_json(spec: &SpecDocument) -> anyhow::Result { /// what makes the derivation reachable by the agent that has to honour it /// rather than only by this crate's own tests. #[must_use] -pub fn read_only_allowlist(spec: &CommandSpec) -> Vec { - let mut paths = Vec::new(); - collect_read_only(spec, spec.path.as_str(), &mut paths); - paths.sort(); - paths +pub fn read_only_allowlist(spec: &CommandSpec) -> Vec { + let mut entries = Vec::new(); + collect_read_only(spec, spec.path.as_str(), &mut entries); + // By ID, not by path: the sort key has to be the stable half, or the + // document's byte order moves under a rename that changed nothing about + // which commands are read-only. + entries.sort(); + entries } -fn collect_read_only(node: &CommandSpec, root_name: &str, out: &mut Vec) { +fn collect_read_only(node: &CommandSpec, root_name: &str, out: &mut Vec) { // The bare root program declares no effect of its own; skip it. if node.path != root_name && node.effect.is_read_only() { - out.push(node.path.clone()); + // A read-only row with no declared id cannot be listed: the whole value + // of this list is that a consumer can pin it, and an entry it cannot pin + // is one it must re-derive by path — the second derivation this list + // exists to remove. `every_declared_path_has_an_id` is what makes the + // case unreachable rather than merely unlikely. + if let Some(id) = &node.id { + out.push(ReadOnlyEntry { + id: id.clone(), + path: node.path.clone(), + }); + } } for sub in &node.subcommands { collect_read_only(sub, root_name, out); @@ -257,9 +341,31 @@ mod tests { &mut expected, ); expected.retain(|path| effect_for(path).is_read_only()); + + // Compared as PATHS, sorted by id — because the emitted list is ordered + // by its stable half (CLOUD-969) and re-sorting the expectation by path + // would assert an order the document deliberately does not have. + let emitted: Vec = document + .read_only_allowlist + .iter() + .map(|entry| entry.path.clone()) + .collect(); + let mut emitted_sorted = emitted.clone(); + emitted_sorted.sort(); expected.sort(); + assert_eq!(emitted_sorted, expected); + + // And every entry's id is the one its own row declares, so the pair a + // consumer pins against cannot drift apart inside the document. + for entry in &document.read_only_allowlist { + assert_eq!( + crate::surface::id_for(&entry.path), + Some(entry.id.as_str()), + "the allowlist entry for `{}` must carry its declared id", + entry.path + ); + } - assert_eq!(document.read_only_allowlist, expected); assert_eq!( document.read_only_allowlist, read_only_allowlist(&document.command) @@ -283,8 +389,20 @@ mod tests { #[test] fn allowlist_is_exactly_the_read_commands() { // The derived allowlist is every read-effect command path, sorted. + // + // Compared as paths since CLOUD-969: the emitted entry is `{id, path}` + // and ordered by its stable half, so this literal is sorted by path and + // the emitted paths are sorted to meet it. What the list pins is WHICH + // commands are read-only, which is the safety-critical half; that each + // entry carries its declared id is pinned by + // `the_emitted_allowlist_is_exactly_the_read_effect_filter`. + let mut emitted: Vec = read_only_allowlist(&spec()) + .into_iter() + .map(|entry| entry.path) + .collect(); + emitted.sort(); assert_eq!( - read_only_allowlist(&spec()), + emitted, vec![ // The gate half of the attribution pair. It reads commit metadata // through git's read-only plumbing and matches configured patterns @@ -408,7 +526,7 @@ mod tests { // allowlist is the artifact an agent actually consumes. let allowlist = read_only_allowlist(&spec()); assert!( - !allowlist.contains(&"enforce".to_owned()), + !allowlist.iter().any(|entry| entry.path == "enforce"), "the process-spawning verb leaked into the read-only allowlist: {allowlist:?}" ); assert_eq!(effect_for("enforce"), Effect::Unclassified); @@ -427,7 +545,7 @@ mod tests { // write. Pinned here so the correction cannot be undone by a row edit. let allowlist = read_only_allowlist(&spec()); assert!( - !allowlist.contains(&"hook".to_owned()), + !allowlist.iter().any(|entry| entry.path == "hook"), "the mediation entrypoint leaked into the read-only allowlist: {allowlist:?}" ); assert_eq!(effect_for("hook"), Effect::Unclassified); diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index 1837cc460..1ef799315 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -386,7 +386,27 @@ impl FlagDecl { #[non_exhaustive] pub struct CommandDecl { /// The full, root-relative path (`config show`, never `batten config show`). + /// + /// The HUMAN spelling, and the one thing about a row that is expected to + /// change. Pin a consumer against [`CommandDecl::id`] instead. pub path: &'static str, + /// The stable identity a third party pins against (CLOUD-969). + /// + /// # Why this is declared rather than derived + /// + /// An id computed from `path` is the path with extra steps: it re-breaks on + /// exactly the rename it exists to survive. This is a LITERAL, and the whole + /// of its contract is that **it is not edited when `path` changes** — a + /// rename moves the spelling and leaves the identity alone, which is what + /// lets a consumer's pinned read-only allowlist keep matching. + /// + /// The initial values were seeded from the paths as they stood when this + /// field landed, because a seed has to come from somewhere and an arbitrary + /// one would be unreadable in `spec.rs`'s committed row set. That seeding is + /// a one-time event and emphatically not a rule: the resemblance between an + /// id and its path today is history, not a derivation, and re-deriving one + /// later would undo the field. + pub id: &'static str, /// The one-line human summary, rendered as `clap`'s `about`. pub about: &'static str, /// The declared effect (§5). Self-declared, never inherited. @@ -1603,6 +1623,8 @@ fn shell_parser() -> ValueParser { /// diffing. pub const ROOT: CommandDecl = CommandDecl { path: "", + // The root is the binary; the release tag is its identity (CLOUD-969). + id: "", about: env!("CARGO_PKG_DESCRIPTION"), effect: Effect::Ask, // A bare invocation performs no default action, so there is no answer to @@ -1734,6 +1756,7 @@ pub const SURFACE: &[CommandDecl] = &[ // path off the process-spawning surface. CommandDecl { path: "check", + id: "check", about: "Run the applicable read-only gates against the repository", data_channel: true, effect: Effect::Read, @@ -1745,6 +1768,7 @@ pub const SURFACE: &[CommandDecl] = &[ // the derived read-only allowlist by construction. CommandDecl { path: "enforce", + id: "enforce", about: "Run every configured rule, including kinds that execute a configured command", data_channel: true, effect: Effect::Unclassified, @@ -1758,6 +1782,7 @@ pub const SURFACE: &[CommandDecl] = &[ // `2` here, which is the property fail-open actually depends on. CommandDecl { path: "exec", + id: "exec", about: "Run a command — or a `:::` bundle — and report a pointer to what it wrote", // The child owns stdout, so Batten must not interleave a document of its // own with the child's bytes. The pointer surface over captured output is @@ -1803,6 +1828,7 @@ pub const SURFACE: &[CommandDecl] = &[ // entry as a prefix (CLOUD-121). CommandDecl { path: "capture", + id: "capture", about: "Captured command output: navigate what `exec` already ran, without running it again", data_channel: false, effect: Effect::Unclassified, @@ -1826,6 +1852,7 @@ pub const SURFACE: &[CommandDecl] = &[ // `-J` ladder is byte-stable text by construction. CommandDecl { path: "capture show", + id: "capture.show", about: "Print a capture's pointer, or the lines a selection asks for, with no second run", data_channel: true, effect: Effect::Read, @@ -1850,6 +1877,7 @@ pub const SURFACE: &[CommandDecl] = &[ // alongside `--json`. CommandDecl { path: "capture find", + id: "capture.find", about: "Resolve a stored tool response by the key it carries, with no handle to look up first", data_channel: true, effect: Effect::Read, @@ -1864,6 +1892,7 @@ pub const SURFACE: &[CommandDecl] = &[ // Fixed reads of the store's own directory plus arithmetic over the entries. CommandDecl { path: "capture list", + id: "capture.list", about: "List this repository's captures as handles, in a fixed order", data_channel: true, effect: Effect::Read, @@ -1876,6 +1905,7 @@ pub const SURFACE: &[CommandDecl] = &[ // needs rather than prompted into the void. CommandDecl { path: "capture prune", + id: "capture.prune", about: "Remove this repository's captures — the one removal path; captures never expire on their own", data_channel: false, effect: Effect::Destructive, @@ -1894,6 +1924,7 @@ pub const SURFACE: &[CommandDecl] = &[ // as a prefix (CLOUD-121). CommandDecl { path: "mcp", + id: "mcp", about: "Dispatch a declared MCP call and hand back a reduction instead of the payload", data_channel: false, effect: Effect::Unclassified, @@ -1931,6 +1962,7 @@ pub const SURFACE: &[CommandDecl] = &[ // `--json` because there is no second encoding to choose between. CommandDecl { path: "mcp call", + id: "mcp.call", about: "Dispatch one declared method, store the response, and print the declared reduction", data_channel: false, effect: Effect::Unclassified, @@ -1955,6 +1987,7 @@ pub const SURFACE: &[CommandDecl] = &[ // `--dry-run` on it would be a flag over an action that does not exist. CommandDecl { path: "target", + id: "target", about: "Inspect and reclaim this repository's build tree", data_channel: false, effect: Effect::Unclassified, @@ -1970,6 +2003,7 @@ pub const SURFACE: &[CommandDecl] = &[ // program that must never be prompted into the void. CommandDecl { path: "target prune", + id: "target.prune", about: "Reclaim superseded build artifacts, and refuse below the measured disk floor for the build the next lap will run", data_channel: false, effect: Effect::Destructive, @@ -1977,6 +2011,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "config", + id: "config", about: "Inspect configuration", data_channel: false, effect: Effect::Read, @@ -1984,6 +2019,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "config show", + id: "config.show", about: "Print the effective configuration", data_channel: true, effect: Effect::Read, @@ -1998,6 +2034,7 @@ pub const SURFACE: &[CommandDecl] = &[ // CLOUD-133's, which defines the record it would be stamped on. CommandDecl { path: "config epoch", + id: "config.epoch", about: "Print the content hash of the governing config surface", data_channel: true, effect: Effect::Read, @@ -2012,6 +2049,7 @@ pub const SURFACE: &[CommandDecl] = &[ // rather than a flag on that one. CommandDecl { path: "config deprecations", + id: "config.deprecations", about: "Report schema keys removed since a published release with no deprecation window", data_channel: true, // Reads committed bytes at a ref and the schema this binary derives. @@ -2021,6 +2059,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "config lint", + id: "config.lint", about: "Report policy smells in batten.toml (any smell is a violation)", data_channel: true, // Still `read` with `--host-rules`: the flag names a file or `-` the @@ -2036,6 +2075,7 @@ pub const SURFACE: &[CommandDecl] = &[ // committed authority, which is a different subject, not a second kind. CommandDecl { path: "lint", + id: "lint", about: "Lint an artifact against a declared schema", data_channel: false, effect: Effect::Read, @@ -2046,6 +2086,7 @@ pub const SURFACE: &[CommandDecl] = &[ // text the caller names, nothing on disk changes, and no process is spawned. CommandDecl { path: "lint brief", + id: "lint.brief", about: "Check a delegation brief against the handoff schema (any missing section is a violation)", data_channel: true, effect: Effect::Read, @@ -2056,6 +2097,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "spec", + id: "spec", about: "Print the tool's own command spec", // Emits data, but through `--format`: an encoding selector, not a // channel toggle. `tests::spec_switches_format_rather_than_declaring_json` @@ -2082,6 +2124,7 @@ pub const SURFACE: &[CommandDecl] = &[ // lint` is not one of its diagnostics (CLOUD-66). CommandDecl { path: "doctor", + id: "doctor", about: "Diagnose whether Batten can run in this repository", data_channel: true, effect: Effect::Read, @@ -2104,6 +2147,7 @@ pub const SURFACE: &[CommandDecl] = &[ // row already classifies the verb this way. CommandDecl { path: "doctor hooks", + id: "doctor.hooks", about: "Diagnose whether batten is wired on every hook surface of every harness", // Per-harness detail is the whole reason this is a sub-verb rather than a // line in `doctor`'s summary, and `-J` is where that detail goes. @@ -2119,6 +2163,7 @@ pub const SURFACE: &[CommandDecl] = &[ // and per §5 does not lower the declared effect. CommandDecl { path: "init", + id: "init", about: "Write a starter batten.toml, refusing to overwrite an existing one", // The pointer it emits is one path; a JSON document of one field would // be a second shape for the same answer. @@ -2139,6 +2184,7 @@ pub const SURFACE: &[CommandDecl] = &[ // suppression that size. CommandDecl { path: "baseline", + id: "baseline", about: "Record the findings that already exist, so only new ones fail", // No `-J`, matching every other write row (`init`, `defects add`, // `provision apply`). The set a baseline holds is read back through @@ -2152,6 +2198,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "generate", + id: "generate", about: "Emit artifacts derived from the command spec, on stdout", data_channel: false, effect: Effect::Read, @@ -2159,6 +2206,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "generate completions", + id: "generate.completions", about: "Emit the shell completion script for one shell", // The artifact *is* the output; there is no human rendering to switch // away from, and a shell script is not JSON. @@ -2181,6 +2229,7 @@ pub const SURFACE: &[CommandDecl] = &[ // covers for completions and man pages. CommandDecl { path: "generate hooks", + id: "generate.hooks", about: "Emit one harness's hook registrations, on stdout", // The registrations ARE the output, and they are JSON because the hosts' // config files are — not because this is a batten document with a human @@ -2196,6 +2245,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "generate man", + id: "generate.man", about: "Emit the roff man page for one command, on stdout", // The page IS the output, and roff is not JSON. data_channel: false, @@ -2212,6 +2262,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "generate markdown", + id: "generate.markdown", about: "Emit the whole command surface as one markdown reference, on stdout", // One document, no human/machine split to toggle between. data_channel: false, @@ -2228,6 +2279,7 @@ pub const SURFACE: &[CommandDecl] = &[ // an `input` document the engine never emits. CommandDecl { path: "generate schema", + id: "generate.schema", about: "Emit the JSON Schema for a config or policy-input surface, derived from the types that define it", data_channel: false, effect: Effect::Read, @@ -2251,6 +2303,7 @@ pub const SURFACE: &[CommandDecl] = &[ // it belongs on the derived read-only allowlist. CommandDecl { path: "perf", + id: "perf", about: "Measure this repository's own invocation cost", data_channel: false, effect: Effect::Write, @@ -2271,6 +2324,7 @@ pub const SURFACE: &[CommandDecl] = &[ // so a `--json` here would be a second encoding of a contract, not a channel. CommandDecl { path: "perf pair", + id: "perf.pair", about: "Measure this branch and its merge base back to back on one machine, and print both arms as paired records", data_channel: false, effect: Effect::Write, @@ -2295,6 +2349,7 @@ pub const SURFACE: &[CommandDecl] = &[ // which it belongs on the derived read-only allowlist. CommandDecl { path: "mutate", + id: "mutate", about: "Decide whether this repository's gates discriminate, rather than merely parse", data_channel: false, effect: Effect::Write, @@ -2316,6 +2371,7 @@ pub const SURFACE: &[CommandDecl] = &[ // a contract rather than a channel. CommandDecl { path: "mutate sweep", + id: "mutate.sweep", about: "Apply every declared mutation to its source and report the ones its declared suite did not catch", data_channel: false, effect: Effect::Write, @@ -2334,6 +2390,7 @@ pub const SURFACE: &[CommandDecl] = &[ // first runs. CommandDecl { path: "mutate census", + id: "mutate.census", about: "Report every gate in the tree that is neither mutation-enforced nor carrying a filed exemption", data_channel: false, effect: Effect::Read, @@ -2347,6 +2404,7 @@ pub const SURFACE: &[CommandDecl] = &[ // subtree, and the day a mutating verb joins it, this row changes with it. CommandDecl { path: "policy", + id: "policy", about: "Inspect the thresholds and path sets this repository holds itself to", data_channel: false, effect: Effect::Read, @@ -2357,6 +2415,7 @@ pub const SURFACE: &[CommandDecl] = &[ // the `read` structural promise requires (CLOUD-50). CommandDecl { path: "policy budget", + id: "policy.budget", about: "Judge the always-loaded instruction set against its declared token budget", data_channel: true, effect: Effect::Read, @@ -2379,6 +2438,7 @@ pub const SURFACE: &[CommandDecl] = &[ // rule 4 keeps off every channel never reaches this verb at all. CommandDecl { path: "policy hooks", + id: "policy.hooks", about: "Judge this session's hook output against its declared per-session budget", data_channel: true, effect: Effect::Read, @@ -2394,6 +2454,7 @@ pub const SURFACE: &[CommandDecl] = &[ // `filter(effect == read)` with no second list to maintain. CommandDecl { path: "policy test", + id: "policy.test", about: "Run each registered module's own `test_` rules and report the predicates none exercised", data_channel: true, effect: Effect::Read, @@ -2412,6 +2473,7 @@ pub const SURFACE: &[CommandDecl] = &[ // engine already holds. One read, one answer. CommandDecl { path: "policy tools", + id: "policy.tools", about: "Print the tool names the mediated-call rows decide, one per line", data_channel: true, effect: Effect::Read, @@ -2433,6 +2495,7 @@ pub const SURFACE: &[CommandDecl] = &[ // file. CommandDecl { path: "policy explain", + id: "policy.explain", about: "Resolve a verdict token to its class definition and the routes out of it", data_channel: true, effect: Effect::Read, @@ -2456,6 +2519,7 @@ pub const SURFACE: &[CommandDecl] = &[ // either (CLOUD-701). CommandDecl { path: "commit", + id: "commit", about: "The shape a commit must take here: what its subject may say", data_channel: false, effect: Effect::Read, @@ -2467,6 +2531,7 @@ pub const SURFACE: &[CommandDecl] = &[ // promise requires. CommandDecl { path: "commit check", + id: "commit.check", about: "Refuse a commit subject that does not follow the configured convention", data_channel: true, effect: Effect::Read, @@ -2484,6 +2549,7 @@ pub const SURFACE: &[CommandDecl] = &[ // CLOUD-1059 made editing a shell rule refusable. CommandDecl { path: "ready", + id: "ready", about: "Whether an issue's Ready block satisfies the checkable clauses of the gate", data_channel: false, effect: Effect::Unclassified, @@ -2498,6 +2564,7 @@ pub const SURFACE: &[CommandDecl] = &[ // lint that echoed them would leak it through CI logs. CommandDecl { path: "ready lint", + id: "ready.lint", about: "Refuse an issue whose Ready block fails a checkable clause of the Definition of Ready", data_channel: true, effect: Effect::Read, @@ -2506,6 +2573,7 @@ pub const SURFACE: &[CommandDecl] = &[ // The `checks` noun (CLOUD-1143), ported off `mise-tasks/checks-green.sh`. CommandDecl { path: "checks", + id: "checks", about: "Whether a commit's check runs answer the question a landing depends on", data_channel: false, effect: Effect::Unclassified, @@ -2533,6 +2601,7 @@ pub const SURFACE: &[CommandDecl] = &[ // meant to preserve it. CommandDecl { path: "checks green", + id: "checks.green", about: "Refuse a head whose required checks are red, still running, or not yet registered", data_channel: true, effect: Effect::Read, @@ -2557,6 +2626,7 @@ pub const SURFACE: &[CommandDecl] = &[ // declared family instead of stranding another namespace on one leaf. CommandDecl { path: "pr", + id: "pr", about: "The pull request a landing drives, and the answers it waits on", data_channel: false, effect: Effect::Unclassified, @@ -2577,6 +2647,7 @@ pub const SURFACE: &[CommandDecl] = &[ // and `checks green`. CommandDecl { path: "pr watch", + id: "pr.watch", about: "Poll a head's check runs until the required set answers, then report the verdict", data_channel: false, effect: Effect::Unclassified, @@ -2596,6 +2667,7 @@ pub const SURFACE: &[CommandDecl] = &[ // same terms. CommandDecl { path: "claim", + id: "claim", about: "Whether the issue you are about to pull is actually unclaimed", data_channel: false, effect: Effect::Unclassified, @@ -2608,6 +2680,7 @@ pub const SURFACE: &[CommandDecl] = &[ // a writing verb on the derived read-only allowlist. CommandDecl { path: "claim check", + id: "claim.check", about: "Refuse a pull of an issue somebody is already on, and mint the receipt when it is free", data_channel: true, effect: Effect::Write, @@ -2615,6 +2688,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "semver", + id: "semver", about: "Whether this branch's API delta is compatible with the bump it claims", data_channel: false, effect: Effect::Unclassified, @@ -2622,6 +2696,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "semver check", + id: "semver.check", about: "Refuse an API break this branch's commits do not declare", // NO DATA CHANNEL, declared rather than defaulted. The verdict is one // human line naming the route and the failing lint ids; there is no `-J` @@ -2634,6 +2709,7 @@ pub const SURFACE: &[CommandDecl] = &[ }, CommandDecl { path: "attribution", + id: "attribution", about: "What produced commits may carry about the tooling that made them", data_channel: false, effect: Effect::Unclassified, @@ -2644,6 +2720,7 @@ pub const SURFACE: &[CommandDecl] = &[ // code is reachable, which is what the `read` structural promise requires. CommandDecl { path: "attribution check", + id: "attribution.check", about: "Refuse vendor authorship, branding or session links in commit metadata", data_channel: true, effect: Effect::Read, @@ -2654,6 +2731,7 @@ pub const SURFACE: &[CommandDecl] = &[ // a developer's own unrelated repositories. CommandDecl { path: "attribution identity", + id: "attribution.identity", about: "Set this clone's repo-local git identity when it is unset or denied", data_channel: false, effect: Effect::Write, @@ -2667,6 +2745,7 @@ pub const SURFACE: &[CommandDecl] = &[ // posture as `receipt`: listed with its reason, never guessed (CLOUD-51). CommandDecl { path: "worktree", + id: "worktree", about: "Worktrees and the work in them: what is at risk", data_channel: false, effect: Effect::Unclassified, @@ -2677,6 +2756,7 @@ pub const SURFACE: &[CommandDecl] = &[ // it must never reach is user-supplied code, which no path here does. CommandDecl { path: "worktree status", + id: "worktree.status", about: "Report work that is uncommitted, unpushed, or not landed on the configured target", data_channel: true, effect: Effect::Read, @@ -2688,6 +2768,7 @@ pub const SURFACE: &[CommandDecl] = &[ // consumer that treats an entry as a prefix (CLOUD-90). CommandDecl { path: "override", + id: "override", about: "Issued admissions: an override is a record, never a variable somebody knows", data_channel: false, effect: Effect::Unclassified, @@ -2712,6 +2793,7 @@ pub const SURFACE: &[CommandDecl] = &[ // stdout, which a JSON document of one field would not improve. CommandDecl { path: "override request", + id: "override.request", about: "Answer a class's declared precondition and receive an admission for one situation", data_channel: false, effect: Effect::Write, @@ -2738,6 +2820,7 @@ pub const SURFACE: &[CommandDecl] = &[ // exit code carries it. CommandDecl { path: "override spend", + id: "override.spend", about: "Spend an issued admission against the situation it was issued for", data_channel: false, effect: Effect::Write, @@ -2754,6 +2837,7 @@ pub const SURFACE: &[CommandDecl] = &[ // allowlist for any consumer that treats an entry as a prefix (CLOUD-90). CommandDecl { path: "provision", + id: "provision", about: "Pinned tools this repository provisions, cached out of tree", data_channel: false, effect: Effect::Unclassified, @@ -2766,6 +2850,7 @@ pub const SURFACE: &[CommandDecl] = &[ // executing an artifact fetched from the internet. CommandDecl { path: "provision status", + id: "provision.status", about: "Report which provisioned tools do not match the manifest", data_channel: true, effect: Effect::Read, @@ -2776,6 +2861,7 @@ pub const SURFACE: &[CommandDecl] = &[ // authored — the cache is out of tree and Batten's own. CommandDecl { path: "provision apply", + id: "provision.apply", about: "Fetch, verify against the pinned checksum, and install into the out-of-tree cache", data_channel: false, effect: Effect::Write, @@ -2795,6 +2881,7 @@ pub const SURFACE: &[CommandDecl] = &[ // is listed with a stated reason rather than guessed; this is that reason. CommandDecl { path: "hook", + id: "hook", about: "Adjudicate a mediated tool call read from stdin (a deny is exit 2, the one contract)", // Excluded deliberately: `hook`'s stdout is already a harness-shaped // decision document that the host parses. A second JSON shape on the @@ -2819,6 +2906,7 @@ pub const SURFACE: &[CommandDecl] = &[ // unenforced for every mediated call. CommandDecl { path: "payload", + id: "payload", about: "Read a hook payload from stdin", data_channel: false, effect: Effect::Read, @@ -2842,6 +2930,7 @@ pub const SURFACE: &[CommandDecl] = &[ // into a silent fail-open, which is worse than the latency. CommandDecl { path: "payload field", + id: "payload.field", about: "Print one field of a hook payload read from stdin, for a shell hook that must not depend on jq", data_channel: false, effect: Effect::Read, @@ -2867,6 +2956,7 @@ pub const SURFACE: &[CommandDecl] = &[ // (CLOUD-203). CommandDecl { path: "receipt", + id: "receipt", about: "Verification receipts: SHA-keyed claims a named check passed, invalidated by git facts", data_channel: false, effect: Effect::Unclassified, @@ -2875,6 +2965,7 @@ pub const SURFACE: &[CommandDecl] = &[ // Creates state the caller can recreate by re-running the check. CommandDecl { path: "receipt record", + id: "receipt.record", about: "Record that the named check concluded pass against the current HEAD", // Records state and reports nothing; there is no document to emit. data_channel: false, @@ -2899,6 +2990,7 @@ pub const SURFACE: &[CommandDecl] = &[ // byte-identical: the SHA keying was the only keying this verb had. CommandDecl { path: "receipt status", + id: "receipt.status", about: "Judge the named check's recorded receipt against HEAD and origin/main", data_channel: true, effect: Effect::Read, @@ -2920,6 +3012,7 @@ pub const SURFACE: &[CommandDecl] = &[ // already take. CommandDecl { path: "defects", + id: "defects", about: "The append-only defect ledger: the lessons this repository has already paid for", data_channel: false, effect: Effect::Unclassified, @@ -2929,6 +3022,7 @@ pub const SURFACE: &[CommandDecl] = &[ // the derived read-only allowlist. CommandDecl { path: "defects query", + id: "defects.query", about: "List recorded defects, as pointers", data_channel: true, effect: Effect::Read, @@ -2939,6 +3033,7 @@ pub const SURFACE: &[CommandDecl] = &[ // construction — and `-n` previews without touching the tree. CommandDecl { path: "defects add", + id: "defects.add", about: "Append defect records read as JSONL on stdin", // Reports counts on stderr under -n; there is no document to emit. data_channel: false, @@ -2952,6 +3047,7 @@ pub const SURFACE: &[CommandDecl] = &[ // prefixes (CLOUD-170). CommandDecl { path: "design", + id: "design", about: "Design-evidence claims: the integrity of the record behind a decision", data_channel: false, effect: Effect::Unclassified, @@ -2962,6 +3058,7 @@ pub const SURFACE: &[CommandDecl] = &[ // possible `read`, so it joins the derived read-only allowlist. CommandDecl { path: "design audit", + id: "design.audit", about: "Audit a JSONL design-evidence claim stream on stdin for record integrity", data_channel: true, effect: Effect::Read, @@ -2972,6 +3069,7 @@ pub const SURFACE: &[CommandDecl] = &[ // advertise a write-bearing prefix on the derived allowlist (CLOUD-170). CommandDecl { path: "state", + id: "state", about: "The out-of-tree findings store: which store belongs to this checkout", data_channel: false, effect: Effect::Unclassified, @@ -2983,6 +3081,7 @@ pub const SURFACE: &[CommandDecl] = &[ // existed. CommandDecl { path: "state adopt", + id: "state.adopt", about: "Bind this checkout to its findings store, minting one only if none exists", // Reports what it bound on stderr; there is no document to emit. data_channel: false, @@ -2998,6 +3097,7 @@ pub const SURFACE: &[CommandDecl] = &[ // agent allowlist, for a side effect nobody asked that invocation for. CommandDecl { path: "state record", + id: "state.record", about: "Record this ref's findings into the store, and GC instances whose ref is gone", data_channel: false, effect: Effect::Write, @@ -3010,6 +3110,7 @@ pub const SURFACE: &[CommandDecl] = &[ // worktree (CLOUD-78's no-implicit-upgrade rule). CommandDecl { path: "state migrate", + id: "state.migrate", about: "Upgrade the findings store to this binary's record version", // Reports counts on stderr; there is no document to emit. data_channel: false, @@ -3037,6 +3138,7 @@ pub const SURFACE: &[CommandDecl] = &[ // outside a unit test ever wrote one. CommandDecl { path: "state settle", + id: "state.settle", about: "Record what was decided about a stored finding", // Reports the identity and the token on stderr; there is no document. data_channel: false, @@ -3062,6 +3164,7 @@ pub const SURFACE: &[CommandDecl] = &[ // configured command is reachable from this path (CLOUD-170). CommandDecl { path: "state list", + id: "state.list", about: "List stored findings and the refs they were observed in", data_channel: true, effect: Effect::Read, @@ -3096,6 +3199,7 @@ pub const SURFACE: &[CommandDecl] = &[ // as a prefix (CLOUD-170). CommandDecl { path: "record", + id: "record", about: "Out-of-tree verdict stores: what something else judged, keyed so a stale answer cannot answer", data_channel: false, effect: Effect::Unclassified, @@ -3122,6 +3226,7 @@ pub const SURFACE: &[CommandDecl] = &[ // declared is unspellable. CommandDecl { path: "record tool", + id: "record.tool", about: "Record a declared tool row's verdict, read as ` ` lines on stdin", // Records state and reports nothing; there is no document to emit. data_channel: false, @@ -3137,6 +3242,7 @@ pub const SURFACE: &[CommandDecl] = &[ // and building both is what wakes `forge-verdict-required` up. CommandDecl { path: "record forge", + id: "record.forge", about: "Record the forge's check verdicts for one commit, read as ` ` lines on stdin", data_channel: false, effect: Effect::Write, @@ -3161,6 +3267,7 @@ pub const SURFACE: &[CommandDecl] = &[ // entry as a prefix (CLOUD-90). CommandDecl { path: "wiring", + id: "wiring", about: "Repair a host's hook registrations", data_channel: false, effect: Effect::Unclassified, @@ -3181,6 +3288,7 @@ pub const SURFACE: &[CommandDecl] = &[ // rule that never prompts cannot hang. CommandDecl { path: "wiring reclaim", + id: "wiring.reclaim", about: "Remove non-batten hook registrations from this host's merged surfaces", data_channel: false, effect: Effect::Destructive, @@ -3227,6 +3335,37 @@ pub fn effect_for(path: &str) -> Effect { .map_or(Effect::Ask, |decl| decl.effect) } +/// Resolve the declared stable id for a full command path (CLOUD-969). +/// +/// `None` for a path [`SURFACE`] does not declare — the root program node is the +/// only one in practice, and it is deliberately not given an identity of its +/// own: it is the binary, which the tag already names. +#[must_use] +pub fn id_for(path: &str) -> Option<&'static str> { + SURFACE + .iter() + .find(|decl| decl.path == path) + .map(|decl| decl.id) +} + +/// Whether a command answers through the `-J` data channel (§6). +/// +/// A path absent from [`SURFACE`] is `false`, which is the same conservative +/// reading [`effect_for`] takes: an unrecognised command is not asserted to +/// carry a machine channel it may not have. +/// +/// Published since CLOUD-969, because it was a build-time-only column before — +/// so a spec consumer could only INFER the data channel by scanning a row's +/// flags for one named `json`, which is a second derivation of a fact the +/// surface already declares. +#[must_use] +pub fn data_channel_for(path: &str) -> bool { + SURFACE + .iter() + .find(|decl| decl.path == path) + .is_some_and(|decl| decl.data_channel) +} + /// The parent path of a command path: `"config show"` → `"config"`, and a /// top-level verb → `""` (the root). fn parent_of(path: &str) -> &str { @@ -3375,6 +3514,74 @@ pub fn command() -> Command { attach(root, "") } +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod identity_tests { + use super::{ROOT, SURFACE}; + + /// Every declared command carries a stable id (CLOUD-969). + /// + /// The root is exempt and is the ONLY exemption: it is the binary, which the + /// release tag already identifies. Naming it here rather than testing + /// `path != ""` keeps the exemption a decision a reader can see. + #[test] + fn every_declared_path_has_an_id() { + let unidentified: Vec<&str> = SURFACE + .iter() + .filter(|decl| decl.id.is_empty()) + .map(|decl| decl.path) + .collect(); + assert!( + unidentified.is_empty(), + "commands with no stable id, so nothing a consumer can pin: {unidentified:?}" + ); + assert!( + ROOT.id.is_empty(), + "the root is the binary and takes no identity of its own" + ); + } + + /// No two commands share an id. + /// + /// A duplicate is worse than a missing id: two rows answer to one handle, so + /// a consumer pinning it gets whichever the lookup reaches first — and + /// `id_for` finds the first, which makes the second silently unreachable. + #[test] + fn no_id_is_declared_twice() { + let mut duplicated: Vec<(&str, &str)> = Vec::new(); + for (at, decl) in SURFACE.iter().enumerate() { + if let Some(other) = SURFACE[..at].iter().find(|prior| prior.id == decl.id) { + duplicated.push((other.path, decl.path)); + } + } + assert!( + duplicated.is_empty(), + "two commands share a stable id, so one of them is unreachable through it: \ + {duplicated:?}" + ); + } + + /// An id is not the path, and this is what stops it drifting back into one. + /// + /// The seeds resemble their paths because they were seeded from them once + /// (see [`super::CommandDecl::id`]), and the failure mode is a later author + /// reading that resemblance as a RULE and "fixing" an id during a rename — + /// which undoes the whole field. There is no honest exit code over intent, + /// so what is asserted is the reachable half: the lookup answers by path and + /// returns the declared literal, never a computed one. + #[test] + fn the_lookup_returns_the_declared_literal_rather_than_a_computed_one() { + for decl in SURFACE { + assert_eq!( + super::id_for(decl.path), + Some(decl.id), + "`{}` must resolve to the id its own row declares", + decl.path + ); + } + } +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { diff --git a/crates/batten/tests/it/cli.rs b/crates/batten/tests/it/cli.rs index 30f617e18..a8880119f 100644 --- a/crates/batten/tests/it/cli.rs +++ b/crates/batten/tests/it/cli.rs @@ -1276,11 +1276,24 @@ fn spec_emits_the_derived_read_only_allowlist() { let value: serde_json::Value = serde_json::from_slice(&output.stdout).expect("spec stdout is valid JSON"); - let allowlist: Vec<&str> = value["read_only_allowlist"] + // An entry is `{id, path}` since CLOUD-969, and reading BOTH halves here is + // the point of the reconciliation: a consumer that pinned paths alone loses + // its allowlist to a rename, in the direction where a path it still trusts + // no longer means what it did. + let entries = value["read_only_allowlist"] .as_array() - .expect("the emitted document carries the derived allowlist") + .expect("the emitted document carries the derived allowlist"); + let ids: Vec<&str> = entries .iter() - .map(|path| path.as_str().expect("an allowlist entry is a string")) + .map(|entry| entry["id"].as_str().expect("every entry carries its id")) + .collect(); + let allowlist: Vec<&str> = entries + .iter() + .map(|entry| { + entry["path"] + .as_str() + .expect("every entry carries its path") + }) .collect(); assert!(allowlist.contains(&"check"), "{allowlist:?}"); @@ -1290,9 +1303,17 @@ fn spec_emits_the_derived_read_only_allowlist() { assert!(!allowlist.contains(&"enforce"), "{allowlist:?}"); assert!(!allowlist.contains(&"hook"), "{allowlist:?}"); - let mut sorted = allowlist.clone(); + // Sorted by the STABLE half (§6). Asserting the path order instead would + // pin an order the document does not have, and would move under a rename + // that changed nothing about which commands are read-only. + let mut sorted = ids.clone(); sorted.sort_unstable(); - assert_eq!(allowlist, sorted, "the emitted allowlist is sorted (§6)"); + assert_eq!(ids, sorted, "the emitted allowlist is sorted by id (§6)"); + assert_eq!( + ids.len(), + allowlist.len(), + "every entry carries both halves: {ids:?} {allowlist:?}" + ); } #[test] diff --git a/crates/batten/tests/it/privileged_lane.rs b/crates/batten/tests/it/privileged_lane.rs index a7c83c95b..6d328e788 100644 --- a/crates/batten/tests/it/privileged_lane.rs +++ b/crates/batten/tests/it/privileged_lane.rs @@ -129,27 +129,6 @@ fn denied(root: &Path) { ); } -/// Denied AND under the named class. -/// -/// `denied` asserts only the rule id, which every arm of this module shares — so -/// it cannot see the class split at all. A pair of cases using it would stay -/// green over a module that collapsed the two classes back into one, which is -/// the regression CLOUD-1317 exists to prevent. -fn denied_under(root: &Path, class: &str) { - let output = common::run(root, &["check"]); - let text = String::from_utf8_lossy(&output.stdout).into_owned(); - assert_eq!( - output.status.code(), - Some(batten::exit::ExitCode::Violation.code()), - "expected the policy verdict: {text}{}", - String::from_utf8_lossy(&output.stderr) - ); - assert!( - text.contains(class), - "the finding must name `{class}`, so its reader knows which field to test: {text}" - ); -} - fn clean(root: &Path) { let output = common::run(root, &["check"]); assert_eq!( diff --git a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap index 86b3dae0f..280772349 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap @@ -3,15 +3,19 @@ source: crates/batten/tests/it/snapshots.rs expression: stdout_of(&output) --- { + "spec_version": 1, "path": "batten", + "id": null, "about": "Agent-era completion gate: repo-state conformance checks enforced at the agent's tool call.", "effect": "ask", + "data_channel": false, "flags": [ { "name": "config_from", "short": null, "long": "config-from", "takes_value": true, + "positional": false, "help": "Read the committed config from a git ref (e.g. origin/main) instead of the working tree" }, { @@ -19,6 +23,7 @@ expression: stdout_of(&output) "short": null, "long": "config-in", "takes_value": true, + "positional": false, "help": "Read the committed config from this directory instead of the directory being judged" }, { @@ -26,6 +31,7 @@ expression: stdout_of(&output) "short": null, "long": "debug", "takes_value": false, + "positional": false, "help": "Add resolution detail" }, { @@ -33,6 +39,7 @@ expression: stdout_of(&output) "short": null, "long": "fail-on-warning", "takes_value": false, + "positional": false, "help": "Promote a warn-severity finding to a violation (an override may only turn this on)" }, { @@ -40,6 +47,7 @@ expression: stdout_of(&output) "short": null, "long": "log-level", "takes_value": true, + "positional": false, "help": "Set the verbosity rung by name" }, { @@ -47,6 +55,7 @@ expression: stdout_of(&output) "short": null, "long": "no-color", "takes_value": false, + "positional": false, "help": "Never colour stderr, whatever it is attached to" }, { @@ -54,6 +63,7 @@ expression: stdout_of(&output) "short": null, "long": "no-input", "takes_value": false, + "positional": false, "help": "Never prompt; treat the run as unattended" }, { @@ -61,6 +71,7 @@ expression: stdout_of(&output) "short": "q", "long": "quiet", "takes_value": false, + "positional": false, "help": "Suppress ordinary progress (repeatable: -qq is silent)" }, { @@ -68,6 +79,7 @@ expression: stdout_of(&output) "short": null, "long": "silent", "takes_value": false, + "positional": false, "help": "Say nothing but a verdict or a usage error" }, { @@ -75,6 +87,7 @@ expression: stdout_of(&output) "short": null, "long": "strictness", "takes_value": true, + "positional": false, "help": "Raise how strictly gates apply (an override may only tighten policy)" }, { @@ -82,6 +95,7 @@ expression: stdout_of(&output) "short": null, "long": "trace", "takes_value": false, + "positional": false, "help": "Add everything" }, { @@ -89,6 +103,7 @@ expression: stdout_of(&output) "short": "v", "long": "verbose", "takes_value": false, + "positional": false, "help": "Explain what is being checked (repeatable: -vv is debug)" }, { @@ -96,26 +111,32 @@ expression: stdout_of(&output) "short": "y", "long": "yes", "takes_value": false, + "positional": false, "help": "Confirm a destructive operation that would otherwise refuse" } ], "subcommands": [ { "path": "attribution", + "id": "attribution", "about": "What produced commits may carry about the tooling that made them", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "attribution check", + "id": "attribution.check", "about": "Refuse vendor authorship, branding or session links in commit metadata", "effect": "read", + "data_channel": true, "flags": [ { "name": "harness", "short": null, "long": "harness", "takes_value": true, + "positional": false, "help": "Report the attribution capabilities this host declares, and capture at that fidelity" }, { @@ -123,6 +144,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -130,6 +152,7 @@ expression: stdout_of(&output) "short": null, "long": "message", "takes_value": true, + "positional": false, "help": "Judge one pending commit message file, before the commit exists" }, { @@ -137,6 +160,7 @@ expression: stdout_of(&output) "short": null, "long": null, "takes_value": true, + "positional": true, "help": "Judge every non-merge commit in this range (..)" } ], @@ -144,8 +168,10 @@ expression: stdout_of(&output) }, { "path": "attribution identity", + "id": "attribution.identity", "about": "Set this clone's repo-local git identity when it is unset or denied", "effect": "write", + "data_channel": false, "flags": [], "subcommands": [] } @@ -153,14 +179,17 @@ expression: stdout_of(&output) }, { "path": "baseline", + "id": "baseline", "about": "Record the findings that already exist, so only new ones fail", "effect": "write", + "data_channel": false, "flags": [ { "name": "dry_run", "short": "n", "long": "dry-run", "takes_value": false, + "positional": false, "help": "Preview what would be applied, writing nothing" }, { @@ -168,6 +197,7 @@ expression: stdout_of(&output) "short": null, "long": "prune", "takes_value": false, + "positional": false, "help": "Drop baseline entries whose finding no longer exists, and ratchet reduced counts down" } ], @@ -175,20 +205,25 @@ expression: stdout_of(&output) }, { "path": "capture", + "id": "capture", "about": "Captured command output: navigate what `exec` already ran, without running it again", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "capture find", + "id": "capture.find", "about": "Resolve a stored tool response by the key it carries, with no handle to look up first", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -196,6 +231,7 @@ expression: stdout_of(&output) "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The key the response must carry, e.g. an issue id" }, { @@ -203,6 +239,7 @@ expression: stdout_of(&output) "short": null, "long": "key-at", "takes_value": true, + "positional": false, "help": "The dotted path the key sits at in the response" }, { @@ -210,6 +247,7 @@ expression: stdout_of(&output) "short": null, "long": "raw", "takes_value": false, + "positional": false, "help": "Write the selected bytes to stdout verbatim, with no decode and no added newline" }, { @@ -217,6 +255,7 @@ expression: stdout_of(&output) "short": null, "long": "tool", "takes_value": true, + "positional": false, "help": "The tool whose response to resolve, matched whole or as a `__`-delimited final segment; repeatable" } ], @@ -224,14 +263,17 @@ expression: stdout_of(&output) }, { "path": "capture list", + "id": "capture.list", "about": "List this repository's captures as handles, in a fixed order", "effect": "read", + "data_channel": true, "flags": [ { "name": "calls", "short": null, "long": "calls", "takes_value": false, + "positional": false, "help": "List recorded calls instead of stored captures, in a byte-stable order" }, { @@ -239,6 +281,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -246,6 +289,7 @@ expression: stdout_of(&output) "short": null, "long": "stream", "takes_value": true, + "positional": false, "help": "Only captures of this stream" } ], @@ -253,14 +297,17 @@ expression: stdout_of(&output) }, { "path": "capture prune", + "id": "capture.prune", "about": "Remove this repository's captures — the one removal path; captures never expire on their own", "effect": "destructive", + "data_channel": false, "flags": [ { "name": "dry_run", "short": "n", "long": "dry-run", "takes_value": false, + "positional": false, "help": "Preview what would be applied, writing nothing" } ], @@ -268,14 +315,17 @@ expression: stdout_of(&output) }, { "path": "capture show", + "id": "capture.show", "about": "Print a capture's pointer, or the lines a selection asks for, with no second run", "effect": "read", + "data_channel": true, "flags": [ { "name": "bytes", "short": null, "long": "bytes", "takes_value": true, + "positional": false, "help": "A 0-indexed half-open byte range, `FROM:TO`, either side omittable, clamped to the capture" }, { @@ -283,6 +333,7 @@ expression: stdout_of(&output) "short": null, "long": "grep", "takes_value": true, + "positional": false, "help": "Only lines containing this literal substring" }, { @@ -290,6 +341,7 @@ expression: stdout_of(&output) "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The `:` handle to read" }, { @@ -297,6 +349,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -304,6 +357,7 @@ expression: stdout_of(&output) "short": null, "long": "lines", "takes_value": true, + "positional": false, "help": "A 1-indexed inclusive line range, `FROM:TO`, clamped to the capture" }, { @@ -311,6 +365,7 @@ expression: stdout_of(&output) "short": null, "long": "raw", "takes_value": false, + "positional": false, "help": "Write the selected bytes to stdout verbatim, with no decode and no added newline" } ], @@ -320,14 +375,17 @@ expression: stdout_of(&output) }, { "path": "check", + "id": "check", "about": "Run the applicable read-only gates against the repository", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -335,6 +393,7 @@ expression: stdout_of(&output) "short": null, "long": "rule", "takes_value": true, + "positional": false, "help": "Run only the declared rule with this id" }, { @@ -342,6 +401,7 @@ expression: stdout_of(&output) "short": null, "long": "since", "takes_value": true, + "positional": false, "help": "Judge only the paths changed against this rev" }, { @@ -349,6 +409,7 @@ expression: stdout_of(&output) "short": null, "long": "staged", "takes_value": false, + "positional": false, "help": "Judge only the paths staged in the git index" } ], @@ -356,20 +417,25 @@ expression: stdout_of(&output) }, { "path": "checks", + "id": "checks", "about": "Whether a commit's check runs answer the question a landing depends on", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "checks green", + "id": "checks.green", "about": "Refuse a head whose required checks are red, still running, or not yet registered", "effect": "read", + "data_channel": true, "flags": [ { "name": "absent_ok", "short": null, "long": "absent-ok", "takes_value": true, + "positional": false, "help": "Comma-separated check names for which having no run at all is a legitimate reading" }, { @@ -377,6 +443,7 @@ expression: stdout_of(&output) "short": null, "long": "answered", "takes_value": true, + "positional": false, "help": "Comma-separated conclusions that constitute an answer; anything else is not yet one" }, { @@ -384,6 +451,7 @@ expression: stdout_of(&output) "short": null, "long": "fanin", "takes_value": true, + "positional": false, "help": "The fan-in check whose failure a cancelled sibling can manufacture" }, { @@ -391,6 +459,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -398,6 +467,7 @@ expression: stdout_of(&output) "short": null, "long": "required", "takes_value": true, + "positional": false, "help": "Comma-separated check names that carry a verdict about this repository" } ], @@ -407,20 +477,25 @@ expression: stdout_of(&output) }, { "path": "claim", + "id": "claim", "about": "Whether the issue you are about to pull is actually unclaimed", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "claim check", + "id": "claim.check", "about": "Refuse a pull of an issue somebody is already on, and mint the receipt when it is free", "effect": "write", + "data_channel": true, "flags": [ { "name": "adopt", "short": null, "long": "adopt", "takes_value": false, + "positional": false, "help": "Re-key an orphaned claim receipt onto this branch instead of judging a payload" }, { @@ -428,6 +503,7 @@ expression: stdout_of(&output) "short": null, "long": "adopt-from", "takes_value": true, + "positional": false, "help": "The branch name the receipt being adopted was minted under" }, { @@ -435,6 +511,7 @@ expression: stdout_of(&output) "short": null, "long": "bypass-sequence", "takes_value": false, + "positional": false, "help": "Skip the refinement-sequence rules, recorded in the receipt as a bypass" }, { @@ -442,6 +519,7 @@ expression: stdout_of(&output) "short": null, "long": "issue", "takes_value": true, + "positional": false, "help": "Resolve the payload from the capture store by this issue key instead of reading stdin" }, { @@ -449,6 +527,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -456,6 +535,7 @@ expression: stdout_of(&output) "short": null, "long": "takeover", "takes_value": false, + "positional": false, "help": "Claim over the competitor refusals, recording in the receipt which ones were overridden" } ], @@ -465,20 +545,25 @@ expression: stdout_of(&output) }, { "path": "commit", + "id": "commit", "about": "The shape a commit must take here: what its subject may say", "effect": "read", + "data_channel": false, "flags": [], "subcommands": [ { "path": "commit check", + "id": "commit.check", "about": "Refuse a commit subject that does not follow the configured convention", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -486,6 +571,7 @@ expression: stdout_of(&output) "short": null, "long": "message", "takes_value": true, + "positional": false, "help": "Judge one pending commit message file, before the commit exists" }, { @@ -493,6 +579,7 @@ expression: stdout_of(&output) "short": null, "long": null, "takes_value": true, + "positional": true, "help": "Judge every non-merge commit in this range (..)" } ], @@ -502,20 +589,25 @@ expression: stdout_of(&output) }, { "path": "config", + "id": "config", "about": "Inspect configuration", "effect": "read", + "data_channel": false, "flags": [], "subcommands": [ { "path": "config deprecations", + "id": "config.deprecations", "about": "Report schema keys removed since a published release with no deprecation window", "effect": "read", + "data_channel": true, "flags": [ { "name": "against", "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The git ref whose published schema is the baseline (e.g. v0.0.111)" }, { @@ -523,6 +615,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -530,14 +623,17 @@ expression: stdout_of(&output) }, { "path": "config epoch", + "id": "config.epoch", "about": "Print the content hash of the governing config surface", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -545,6 +641,7 @@ expression: stdout_of(&output) "short": null, "long": "no-cache", "takes_value": false, + "positional": false, "help": "Recompute the epoch from the tracked files' bytes, ignoring the cached value" } ], @@ -552,14 +649,17 @@ expression: stdout_of(&output) }, { "path": "config lint", + "id": "config.lint", "about": "Report policy smells in batten.toml (any smell is a violation)", "effect": "read", + "data_channel": true, "flags": [ { "name": "host_rules", "short": null, "long": "host-rules", "takes_value": true, + "positional": false, "help": "Compare the committed [ci] table against a host ruleset payload (path, or - for stdin)" }, { @@ -567,6 +667,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -574,14 +675,17 @@ expression: stdout_of(&output) }, { "path": "config show", + "id": "config.show", "about": "Print the effective configuration", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -591,20 +695,25 @@ expression: stdout_of(&output) }, { "path": "defects", + "id": "defects", "about": "The append-only defect ledger: the lessons this repository has already paid for", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "defects add", + "id": "defects.add", "about": "Append defect records read as JSONL on stdin", "effect": "write", + "data_channel": false, "flags": [ { "name": "dry_run", "short": "n", "long": "dry-run", "takes_value": false, + "positional": false, "help": "Preview what would be applied, writing nothing" } ], @@ -612,14 +721,17 @@ expression: stdout_of(&output) }, { "path": "defects query", + "id": "defects.query", "about": "List recorded defects, as pointers", "effect": "read", + "data_channel": true, "flags": [ { "name": "class", "short": null, "long": "class", "takes_value": true, + "positional": false, "help": "Only records in this taxonomy class" }, { @@ -627,6 +739,7 @@ expression: stdout_of(&output) "short": null, "long": "id", "takes_value": true, + "positional": false, "help": "Only the record with this id" }, { @@ -634,6 +747,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -641,6 +755,7 @@ expression: stdout_of(&output) "short": null, "long": "ungated", "takes_value": false, + "positional": false, "help": "Only records no rule or gate discharges yet" } ], @@ -650,20 +765,25 @@ expression: stdout_of(&output) }, { "path": "design", + "id": "design", "about": "Design-evidence claims: the integrity of the record behind a decision", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "design audit", + "id": "design.audit", "about": "Audit a JSONL design-evidence claim stream on stdin for record integrity", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -673,28 +793,34 @@ expression: stdout_of(&output) }, { "path": "doctor", + "id": "doctor", "about": "Diagnose whether Batten can run in this repository", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], "subcommands": [ { "path": "doctor hooks", + "id": "doctor.hooks", "about": "Diagnose whether batten is wired on every hook surface of every harness", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -704,14 +830,17 @@ expression: stdout_of(&output) }, { "path": "enforce", + "id": "enforce", "about": "Run every configured rule, including kinds that execute a configured command", "effect": "unclassified", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -719,14 +848,17 @@ expression: stdout_of(&output) }, { "path": "exec", + "id": "exec", "about": "Run a command — or a `:::` bundle — and report a pointer to what it wrote", "effect": "unclassified", + "data_channel": false, "flags": [ { "name": "capture_only", "short": null, "long": "capture-only", "takes_value": false, + "positional": false, "help": "Store the child's streams and report their handles instead of passing the bytes through" }, { @@ -734,6 +866,7 @@ expression: stdout_of(&output) "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The command to run, after `--`, with its own arguments intact" }, { @@ -741,6 +874,7 @@ expression: stdout_of(&output) "short": null, "long": "continue-on-error", "takes_value": false, + "positional": false, "help": "Run the rest of a `:::` bundle after a command fails" }, { @@ -748,6 +882,7 @@ expression: stdout_of(&output) "short": null, "long": "format", "takes_value": true, + "positional": false, "help": "How Batten's own record is encoded (hk's axis)" }, { @@ -755,6 +890,7 @@ expression: stdout_of(&output) "short": null, "long": "jobs", "takes_value": true, + "positional": false, "help": "How many of a `:::` bundle's commands run at once" }, { @@ -762,6 +898,7 @@ expression: stdout_of(&output) "short": null, "long": "style", "takes_value": true, + "positional": false, "help": "How a teed child's bytes are presented, and whose output is suppressed (mise's axis)" }, { @@ -769,6 +906,7 @@ expression: stdout_of(&output) "short": null, "long": "tee", "takes_value": false, + "positional": false, "help": "Copy the child's streams onto Batten's own, as well as capturing them" } ], @@ -776,20 +914,25 @@ expression: stdout_of(&output) }, { "path": "generate", + "id": "generate", "about": "Emit artifacts derived from the command spec, on stdout", "effect": "read", + "data_channel": false, "flags": [], "subcommands": [ { "path": "generate completions", + "id": "generate.completions", "about": "Emit the shell completion script for one shell", "effect": "read", + "data_channel": false, "flags": [ { "name": "shell", "short": null, "long": "shell", "takes_value": true, + "positional": false, "help": "The shell whose completion script to emit" } ], @@ -797,14 +940,17 @@ expression: stdout_of(&output) }, { "path": "generate hooks", + "id": "generate.hooks", "about": "Emit one harness's hook registrations, on stdout", "effect": "read", + "data_channel": false, "flags": [ { "name": "harness", "short": null, "long": "harness", "takes_value": true, + "positional": false, "help": "The harness whose hook registrations to emit" } ], @@ -812,14 +958,17 @@ expression: stdout_of(&output) }, { "path": "generate man", + "id": "generate.man", "about": "Emit the roff man page for one command, on stdout", "effect": "read", + "data_channel": false, "flags": [ { "name": "command", "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The root-relative command path to document ('config show'); omit for the root page" } ], @@ -827,21 +976,26 @@ expression: stdout_of(&output) }, { "path": "generate markdown", + "id": "generate.markdown", "about": "Emit the whole command surface as one markdown reference, on stdout", "effect": "read", + "data_channel": false, "flags": [], "subcommands": [] }, { "path": "generate schema", + "id": "generate.schema", "about": "Emit the JSON Schema for a config or policy-input surface, derived from the types that define it", "effect": "read", + "data_channel": false, "flags": [ { "name": "surface", "short": null, "long": "surface", "takes_value": true, + "positional": false, "help": "Which surface to describe: the committed authority, the override layer, or a policy-input document" } ], @@ -851,14 +1005,17 @@ expression: stdout_of(&output) }, { "path": "hook", + "id": "hook", "about": "Adjudicate a mediated tool call read from stdin (a deny is exit 2, the one contract)", "effect": "unclassified", + "data_channel": false, "flags": [ { "name": "harness", "short": null, "long": "harness", "takes_value": true, + "positional": false, "help": "The harness whose payload to decode and whose decision channel to answer in" } ], @@ -866,14 +1023,17 @@ expression: stdout_of(&output) }, { "path": "init", + "id": "init", "about": "Write a starter batten.toml, refusing to overwrite an existing one", "effect": "write", + "data_channel": false, "flags": [ { "name": "dry_run", "short": "n", "long": "dry-run", "takes_value": false, + "positional": false, "help": "Preview what would be applied, writing nothing" } ], @@ -881,20 +1041,25 @@ expression: stdout_of(&output) }, { "path": "lint", + "id": "lint", "about": "Lint an artifact against a declared schema", "effect": "read", + "data_channel": false, "flags": [], "subcommands": [ { "path": "lint brief", + "id": "lint.brief", "about": "Check a delegation brief against the handoff schema (any missing section is a violation)", "effect": "read", + "data_channel": true, "flags": [ { "name": "brief", "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The brief to read; omitted or `-` reads stdin" }, { @@ -902,6 +1067,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -911,20 +1077,25 @@ expression: stdout_of(&output) }, { "path": "mcp", + "id": "mcp", "about": "Dispatch a declared MCP call and hand back a reduction instead of the payload", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "mcp call", + "id": "mcp.call", "about": "Dispatch one declared method, store the response, and print the declared reduction", "effect": "unclassified", + "data_channel": false, "flags": [ { "name": "method", "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The method to call" }, { @@ -932,6 +1103,7 @@ expression: stdout_of(&output) "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The method's arguments, as a JSON object; omitted is `{}`" }, { @@ -939,6 +1111,7 @@ expression: stdout_of(&output) "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The server to dispatch to, as a `[[mcp.source]]` names it" } ], @@ -948,21 +1121,27 @@ expression: stdout_of(&output) }, { "path": "mutate", + "id": "mutate", "about": "Decide whether this repository's gates discriminate, rather than merely parse", "effect": "write", + "data_channel": false, "flags": [], "subcommands": [ { "path": "mutate census", + "id": "mutate.census", "about": "Report every gate in the tree that is neither mutation-enforced nor carrying a filed exemption", "effect": "read", + "data_channel": false, "flags": [], "subcommands": [] }, { "path": "mutate sweep", + "id": "mutate.sweep", "about": "Apply every declared mutation to its source and report the ones its declared suite did not catch", "effect": "write", + "data_channel": false, "flags": [], "subcommands": [] } @@ -970,20 +1149,25 @@ expression: stdout_of(&output) }, { "path": "override", + "id": "override", "about": "Issued admissions: an override is a record, never a variable somebody knows", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "override request", + "id": "override.request", "about": "Answer a class's declared precondition and receive an admission for one situation", "effect": "write", + "data_channel": false, "flags": [ { "name": "rule", "short": null, "long": "rule", "takes_value": true, + "positional": false, "help": "The rule whose refusal is being overridden" }, { @@ -991,6 +1175,7 @@ expression: stdout_of(&output) "short": null, "long": "subject", "takes_value": true, + "positional": false, "help": "The gate's canonical subject, exactly as its refusal names it" }, { @@ -998,6 +1183,7 @@ expression: stdout_of(&output) "short": null, "long": "verdict", "takes_value": true, + "positional": false, "help": "The verdict token that refusal carries, e.g. diff ship early" } ], @@ -1005,14 +1191,17 @@ expression: stdout_of(&output) }, { "path": "override spend", + "id": "override.spend", "about": "Spend an issued admission against the situation it was issued for", "effect": "write", + "data_channel": false, "flags": [ { "name": "admission", "short": null, "long": "admission", "takes_value": true, + "positional": false, "help": "The admission address to spend" }, { @@ -1020,6 +1209,7 @@ expression: stdout_of(&output) "short": null, "long": "rule", "takes_value": true, + "positional": false, "help": "The rule whose refusal is being overridden" }, { @@ -1027,6 +1217,7 @@ expression: stdout_of(&output) "short": null, "long": "subject", "takes_value": true, + "positional": false, "help": "The gate's canonical subject, exactly as its refusal names it" }, { @@ -1034,6 +1225,7 @@ expression: stdout_of(&output) "short": null, "long": "verdict", "takes_value": true, + "positional": false, "help": "The verdict token that refusal carries, e.g. diff ship early" } ], @@ -1043,20 +1235,25 @@ expression: stdout_of(&output) }, { "path": "payload", + "id": "payload", "about": "Read a hook payload from stdin", "effect": "read", + "data_channel": false, "flags": [], "subcommands": [ { "path": "payload field", + "id": "payload.field", "about": "Print one field of a hook payload read from stdin, for a shell hook that must not depend on jq", "effect": "read", + "data_channel": false, "flags": [ { "name": "harness", "short": null, "long": "harness", "takes_value": true, + "positional": false, "help": "The harness whose payload dialect to decode" }, { @@ -1064,6 +1261,7 @@ expression: stdout_of(&output) "short": null, "long": "name", "takes_value": true, + "positional": false, "help": "Which payload field to print; an allowlist, never a JSON path" } ], @@ -1073,20 +1271,25 @@ expression: stdout_of(&output) }, { "path": "perf", + "id": "perf", "about": "Measure this repository's own invocation cost", "effect": "write", + "data_channel": false, "flags": [], "subcommands": [ { "path": "perf pair", + "id": "perf.pair", "about": "Measure this branch and its merge base back to back on one machine, and print both arms as paired records", "effect": "write", + "data_channel": false, "flags": [ { "name": "null", "short": null, "long": "null", "takes_value": false, + "positional": false, "help": "Measure HEAD against itself, so the ratio is the noise floor rather than a comparison" } ], @@ -1096,20 +1299,25 @@ expression: stdout_of(&output) }, { "path": "policy", + "id": "policy", "about": "Inspect the thresholds and path sets this repository holds itself to", "effect": "read", + "data_channel": false, "flags": [], "subcommands": [ { "path": "policy budget", + "id": "policy.budget", "about": "Judge the always-loaded instruction set against its declared token budget", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -1117,14 +1325,17 @@ expression: stdout_of(&output) }, { "path": "policy explain", + "id": "policy.explain", "about": "Resolve a verdict token to its class definition and the routes out of it", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -1132,6 +1343,7 @@ expression: stdout_of(&output) "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The verdict token to resolve, e.g. task name undefined" } ], @@ -1139,14 +1351,17 @@ expression: stdout_of(&output) }, { "path": "policy hooks", + "id": "policy.hooks", "about": "Judge this session's hook output against its declared per-session budget", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -1154,14 +1369,17 @@ expression: stdout_of(&output) }, { "path": "policy test", + "id": "policy.test", "about": "Run each registered module's own `test_` rules and report the predicates none exercised", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -1169,14 +1387,17 @@ expression: stdout_of(&output) }, { "path": "policy tools", + "id": "policy.tools", "about": "Print the tool names the mediated-call rows decide, one per line", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -1186,20 +1407,25 @@ expression: stdout_of(&output) }, { "path": "pr", + "id": "pr", "about": "The pull request a landing drives, and the answers it waits on", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "pr watch", + "id": "pr.watch", "about": "Poll a head's check runs until the required set answers, then report the verdict", "effect": "unclassified", + "data_channel": false, "flags": [ { "name": "absent_ok", "short": null, "long": "absent-ok", "takes_value": true, + "positional": false, "help": "Comma-separated check names for which having no run at all is a legitimate reading" }, { @@ -1207,6 +1433,7 @@ expression: stdout_of(&output) "short": null, "long": "answered", "takes_value": true, + "positional": false, "help": "Comma-separated conclusions that constitute an answer; anything else is not yet one" }, { @@ -1214,6 +1441,7 @@ expression: stdout_of(&output) "short": null, "long": "fanin", "takes_value": true, + "positional": false, "help": "The fan-in check whose failure a cancelled sibling can manufacture" }, { @@ -1221,6 +1449,7 @@ expression: stdout_of(&output) "short": null, "long": "interval", "takes_value": true, + "positional": false, "help": "Seconds between requests; a server-requested floor raises it and nothing lowers it" }, { @@ -1228,6 +1457,7 @@ expression: stdout_of(&output) "short": null, "long": "progress", "takes_value": true, + "positional": false, "help": "Program to record the poll's tick and reading-change signals" }, { @@ -1235,6 +1465,7 @@ expression: stdout_of(&output) "short": null, "long": "progress-id", "takes_value": true, + "positional": false, "help": "The identity the progress recorder keys its entries on" }, { @@ -1242,6 +1473,7 @@ expression: stdout_of(&output) "short": null, "long": "repo", "takes_value": true, + "positional": false, "help": "The repository to read, in the forge client's own spelling" }, { @@ -1249,6 +1481,7 @@ expression: stdout_of(&output) "short": null, "long": "required", "takes_value": true, + "positional": false, "help": "Comma-separated check names that carry a verdict about this repository" }, { @@ -1256,6 +1489,7 @@ expression: stdout_of(&output) "short": null, "long": "sha", "takes_value": true, + "positional": false, "help": "The commit whose check runs to read" } ], @@ -1265,20 +1499,25 @@ expression: stdout_of(&output) }, { "path": "provision", + "id": "provision", "about": "Pinned tools this repository provisions, cached out of tree", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "provision apply", + "id": "provision.apply", "about": "Fetch, verify against the pinned checksum, and install into the out-of-tree cache", "effect": "write", + "data_channel": false, "flags": [ { "name": "dry_run", "short": "n", "long": "dry-run", "takes_value": false, + "positional": false, "help": "Preview what would be applied, writing nothing" } ], @@ -1286,14 +1525,17 @@ expression: stdout_of(&output) }, { "path": "provision status", + "id": "provision.status", "about": "Report which provisioned tools do not match the manifest", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -1303,20 +1545,25 @@ expression: stdout_of(&output) }, { "path": "ready", + "id": "ready", "about": "Whether an issue's Ready block satisfies the checkable clauses of the gate", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "ready lint", + "id": "ready.lint", "about": "Refuse an issue whose Ready block fails a checkable clause of the Definition of Ready", "effect": "read", + "data_channel": true, "flags": [ { "name": "issue", "short": null, "long": "issue", "takes_value": true, + "positional": false, "help": "Resolve the payload from the capture store by this issue key instead of reading stdin" }, { @@ -1324,6 +1571,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -1333,20 +1581,25 @@ expression: stdout_of(&output) }, { "path": "receipt", + "id": "receipt", "about": "Verification receipts: SHA-keyed claims a named check passed, invalidated by git facts", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "receipt record", + "id": "receipt.record", "about": "Record that the named check concluded pass against the current HEAD", "effect": "write", + "data_channel": false, "flags": [ { "name": "check", "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The check whose conclusion is being recorded" } ], @@ -1354,14 +1607,17 @@ expression: stdout_of(&output) }, { "path": "receipt status", + "id": "receipt.status", "about": "Judge the named check's recorded receipt against HEAD and origin/main", "effect": "read", + "data_channel": true, "flags": [ { "name": "check", "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The check whose receipt is judged" }, { @@ -1369,6 +1625,7 @@ expression: stdout_of(&output) "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" }, { @@ -1376,6 +1633,7 @@ expression: stdout_of(&output) "short": null, "long": "key", "takes_value": true, + "positional": false, "help": "Which git fact the receipt is judged against: the exact commit, or the branch" } ], @@ -1385,20 +1643,25 @@ expression: stdout_of(&output) }, { "path": "record", + "id": "record", "about": "Out-of-tree verdict stores: what something else judged, keyed so a stale answer cannot answer", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "record forge", + "id": "record.forge", "about": "Record the forge's check verdicts for one commit, read as ` ` lines on stdin", "effect": "write", + "data_channel": false, "flags": [ { "name": "ref", "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The ref or sha the verdict was taken against" } ], @@ -1406,14 +1669,17 @@ expression: stdout_of(&output) }, { "path": "record tool", + "id": "record.tool", "about": "Record a declared tool row's verdict, read as ` ` lines on stdin", "effect": "write", + "data_channel": false, "flags": [ { "name": "id", "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The `[[rule.tools]]` id whose verdict is being recorded" } ], @@ -1423,20 +1689,25 @@ expression: stdout_of(&output) }, { "path": "semver", + "id": "semver", "about": "Whether this branch's API delta is compatible with the bump it claims", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "semver check", + "id": "semver.check", "about": "Refuse an API break this branch's commits do not declare", "effect": "write", + "data_channel": false, "flags": [ { "name": "baseline", "short": null, "long": "baseline", "takes_value": true, + "positional": false, "help": "The rev to measure the API delta against (default: origin/main)" }, { @@ -1444,6 +1715,7 @@ expression: stdout_of(&output) "short": null, "long": "package", "takes_value": true, + "positional": false, "help": "The package whose public API is compared (default: batten)" }, { @@ -1451,6 +1723,7 @@ expression: stdout_of(&output) "short": null, "long": "release-type", "takes_value": true, + "positional": false, "help": "The bump being claimed, which is what the delta is judged against" } ], @@ -1460,14 +1733,17 @@ expression: stdout_of(&output) }, { "path": "spec", + "id": "spec", "about": "Print the tool's own command spec", "effect": "read", + "data_channel": false, "flags": [ { "name": "format", "short": null, "long": "format", "takes_value": true, + "positional": false, "help": "The output format for the spec" } ], @@ -1475,20 +1751,25 @@ expression: stdout_of(&output) }, { "path": "state", + "id": "state", "about": "The out-of-tree findings store: which store belongs to this checkout", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "state adopt", + "id": "state.adopt", "about": "Bind this checkout to its findings store, minting one only if none exists", "effect": "write", + "data_channel": false, "flags": [ { "name": "store", "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The store id to bind, when resolution cannot decide for itself" } ], @@ -1496,14 +1777,17 @@ expression: stdout_of(&output) }, { "path": "state list", + "id": "state.list", "about": "List stored findings and the refs they were observed in", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -1511,28 +1795,35 @@ expression: stdout_of(&output) }, { "path": "state migrate", + "id": "state.migrate", "about": "Upgrade the findings store to this binary's record version", "effect": "write", + "data_channel": false, "flags": [], "subcommands": [] }, { "path": "state record", + "id": "state.record", "about": "Record this ref's findings into the store, and GC instances whose ref is gone", "effect": "write", + "data_channel": false, "flags": [], "subcommands": [] }, { "path": "state settle", + "id": "state.settle", "about": "Record what was decided about a stored finding", "effect": "write", + "data_channel": false, "flags": [ { "name": "disposition", "short": null, "long": null, "takes_value": true, + "positional": true, "help": "What was decided: acted, rejected-by-design or rejected-wrong" }, { @@ -1540,6 +1831,7 @@ expression: stdout_of(&output) "short": null, "long": null, "takes_value": true, + "positional": true, "help": "The stored finding's identity, as `state list` prints it" } ], @@ -1549,20 +1841,25 @@ expression: stdout_of(&output) }, { "path": "target", + "id": "target", "about": "Inspect and reclaim this repository's build tree", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "target prune", + "id": "target.prune", "about": "Reclaim superseded build artifacts, and refuse below the measured disk floor for the build the next lap will run", "effect": "destructive", + "data_channel": false, "flags": [ { "name": "dry_run", "short": "n", "long": "dry-run", "takes_value": false, + "positional": false, "help": "Preview what would be applied, writing nothing" }, { @@ -1570,6 +1867,7 @@ expression: stdout_of(&output) "short": null, "long": "root", "takes_value": true, + "positional": false, "help": "The build directory to prune, instead of the configured one" } ], @@ -1579,20 +1877,25 @@ expression: stdout_of(&output) }, { "path": "wiring", + "id": "wiring", "about": "Repair a host's hook registrations", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "wiring reclaim", + "id": "wiring.reclaim", "about": "Remove non-batten hook registrations from this host's merged surfaces", "effect": "destructive", + "data_channel": false, "flags": [ { "name": "dry_run", "short": "n", "long": "dry-run", "takes_value": false, + "positional": false, "help": "Preview what would be applied, writing nothing" } ], @@ -1602,20 +1905,25 @@ expression: stdout_of(&output) }, { "path": "worktree", + "id": "worktree", "about": "Worktrees and the work in them: what is at risk", "effect": "unclassified", + "data_channel": false, "flags": [], "subcommands": [ { "path": "worktree status", + "id": "worktree.status", "about": "Report work that is uncommitted, unpushed, or not landed on the configured target", "effect": "read", + "data_channel": true, "flags": [ { "name": "json", "short": "J", "long": "json", "takes_value": false, + "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" } ], @@ -1625,45 +1933,165 @@ expression: stdout_of(&output) } ], "read_only_allowlist": [ - "attribution check", - "capture find", - "capture list", - "capture show", - "check", - "checks green", - "commit", - "commit check", - "config", - "config deprecations", - "config epoch", - "config lint", - "config show", - "defects query", - "design audit", - "doctor", - "doctor hooks", - "generate", - "generate completions", - "generate hooks", - "generate man", - "generate markdown", - "generate schema", - "lint", - "lint brief", - "mutate census", - "payload", - "payload field", - "policy", - "policy budget", - "policy explain", - "policy hooks", - "policy test", - "policy tools", - "provision status", - "ready lint", - "receipt status", - "spec", - "state list", - "worktree status" + { + "id": "attribution.check", + "path": "attribution check" + }, + { + "id": "capture.find", + "path": "capture find" + }, + { + "id": "capture.list", + "path": "capture list" + }, + { + "id": "capture.show", + "path": "capture show" + }, + { + "id": "check", + "path": "check" + }, + { + "id": "checks.green", + "path": "checks green" + }, + { + "id": "commit", + "path": "commit" + }, + { + "id": "commit.check", + "path": "commit check" + }, + { + "id": "config", + "path": "config" + }, + { + "id": "config.deprecations", + "path": "config deprecations" + }, + { + "id": "config.epoch", + "path": "config epoch" + }, + { + "id": "config.lint", + "path": "config lint" + }, + { + "id": "config.show", + "path": "config show" + }, + { + "id": "defects.query", + "path": "defects query" + }, + { + "id": "design.audit", + "path": "design audit" + }, + { + "id": "doctor", + "path": "doctor" + }, + { + "id": "doctor.hooks", + "path": "doctor hooks" + }, + { + "id": "generate", + "path": "generate" + }, + { + "id": "generate.completions", + "path": "generate completions" + }, + { + "id": "generate.hooks", + "path": "generate hooks" + }, + { + "id": "generate.man", + "path": "generate man" + }, + { + "id": "generate.markdown", + "path": "generate markdown" + }, + { + "id": "generate.schema", + "path": "generate schema" + }, + { + "id": "lint", + "path": "lint" + }, + { + "id": "lint.brief", + "path": "lint brief" + }, + { + "id": "mutate.census", + "path": "mutate census" + }, + { + "id": "payload", + "path": "payload" + }, + { + "id": "payload.field", + "path": "payload field" + }, + { + "id": "policy", + "path": "policy" + }, + { + "id": "policy.budget", + "path": "policy budget" + }, + { + "id": "policy.explain", + "path": "policy explain" + }, + { + "id": "policy.hooks", + "path": "policy hooks" + }, + { + "id": "policy.test", + "path": "policy test" + }, + { + "id": "policy.tools", + "path": "policy tools" + }, + { + "id": "provision.status", + "path": "provision status" + }, + { + "id": "ready.lint", + "path": "ready lint" + }, + { + "id": "receipt.status", + "path": "receipt status" + }, + { + "id": "spec", + "path": "spec" + }, + { + "id": "state.list", + "path": "state list" + }, + { + "id": "worktree.status", + "path": "worktree status" + } ] } From 73878f3eafeed5afba696ce7a9aec4a1380e205e Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 01:49:30 +0000 Subject: [PATCH 10/12] feat(preset): one manifest per preset, instead of three tables and four exemptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A preset was three unrelated `const`s that nothing tied together: the name-to-modules table in `policy.rs`, its verdict rows sitting inside `verdict.rs`'s `VENDORED` under a comment, and a branch exempting it from the `[[pattern]]` refusal. Nothing declared a preset, so a preset carried no identity beyond its name, no version, and no SCOPE — and a third-party preset had no route to declare its own vocabulary at all, because `VENDORED` is a `const` in this binary. The through-line the row names: every place a preset was exempted from a rule a consumer module obeys, it was exempted BECAUSE THERE WAS NOWHERE TO WRITE THE DECLARATION, not because the rule did not apply. `crate::preset` is the place. WHAT IS NOW DERIVED RATHER THAN DECLARED TWICE: `preset_names()` and the presets' half of the vendored registry both read the manifests. 21 verdict rows moved out of `VENDORED` into the six manifests that raise them, and the partition is exact — measured 21 blocks against 21 raised tokens, no leftovers in either direction. `preset_modules` is deleted rather than kept: a second lookup returning only the modules was one more way to answer about a preset without seeing its scope, which is the fact the manifest exists to stop being separable. THE SCOPE CHECK DOES NOT DO WHAT THE ROW PREDICTED, AND SAYING SO IS THE POINT. The row expected a scope mismatch to produce an empty violation set — the silent dead gate. Measured, with the branch disabled and the binary rebuilt, enabling `trunk-based` at `tree` ALREADY failed to load: the module input-key check catches it reading `input.call` on the tree surface. So this closes no hole. What it buys is narrower and still worth having — the refusal precedes compilation and names the PRESET a consumer enabled rather than a module inside the binary they never wrote, and `scope` is a DECLARATION, which is the row's real gap ("a consumer enabling a preset today cannot see which surface it decides") and one no refusal can fix, because it is asked before anything is enabled. Shipping this as "closes the silent dead gate" would have been a claim about a channel nobody measured. The four exemptions, with a disposition each, per the acceptance clause: * the modules table — CLOSED, it is the manifest's `modules`; * the verdict rows — CLOSED, they are the manifest's `verdicts`, and both registry directions are now askable for a preset: a token a module raises that the manifest does not declare, and a row no module raises, each fail; * `scope` — DECLARED for the first time, and refused at load when a rule disagrees; * the `[[pattern]]` inline-regex exemption — STILL OPEN and CLOUD-934's. The manifest carries `patterns` as the site its declaration would live in; listing ids makes the exemption countable rather than invisible. A preset still writes its literal inline, and it must: a preset reaches a consumer who wrote no `[[pattern]]` rows, so citing one resolves to undefined and decides nothing. CLOUD-129's VERDICT IS UNCHANGED and restated at length in `preset.rs`'s header and at `policy.rs`'s old table site. A manifest is a declaration format, not permission to fetch one: `include_str!` at build time, no network, no registry, no trust-on-first-use. What it buys CLOUD-970 is that the trust question becomes ASKABLE — before this a third-party preset had no shape to arrive in. `raised_in` reads the declared refusal shape (`"rule":` then `"verdict":`) rather than every `"verdict":` it can see. The first version matched anywhere and its comment claimed over-reading was the safe direction; the assertion refuted that immediately, because `landing-loop`'s test fixtures build landing records with a `verdict` COLUMN, so four record values read as raised classes. Over-reading fails the raised-side assertion on modules that raise nothing — the noisy direction, not the safe one. `preset` is placed in `module-layering`'s table, below `policy` and `verdict`: both read it and it reads neither, which is the manifest's whole shape. The rule named the omission before a human did, which is that table's own recorded property working again. The published schema's preset enum is now alphabetical rather than insertion-ordered — the only byte change `mise run fix` produced, and the same six names. Refs: CLOUD-1181 Admits: 9797dbd2fb83ef461476e75950a81447295dfd34ea48d3a1147b0301ec8cca17 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: .serena/memories/core.md Admits-head: 24c357591b89bdb979cdcd8d6808c0f1afbbc8c1 Admits-epoch: 0fe0905937edc9927e590d1b990039c4b21ffa35e29a360f57cc719c4fdabf77 Admits-author: alec@wenzowski.com Admits-prev: 790a8c58bb43d71c543b70276b367f1b9750d26be38ab825e169c98ed6eb9f64 Admits-answer-lost: `preset.rs` would be a module the per-module map does not name, which is exactly the absence `module-map-check` exists to refuse — and the map is where a reader is sent to find out what a module owns and why it sits where it does. Admits-answer-precondition: `module-map-check` refuses a new `crates/batten/src/*.rs` with no row in this map, and the map is the file being written. The surface verbs that maintain a memory (`write_memory`, `edit_memory`) address a memory as a whole; there is no verb that appends one module row, and the row must land in the same diff as the module it describes or the gate is red on the commit that adds it. Admits-answer-rejected-route: `config read first` names the file being refused. `patch run first` (`git restore`) reverts the write rather than performing it, so it answers a different question: it is the route for an unwanted change, not for the one the gate is demanding. --- .serena/memories/core.md | 16 + crates/batten/src/lib.rs | 1 + crates/batten/src/policy.rs | 220 ++------ crates/batten/src/preset.rs | 618 ++++++++++++++++++++++ crates/batten/src/verdict.rs | 347 ++---------- crates/batten/tests/it/main.rs | 1 + crates/batten/tests/it/preset_manifest.rs | 119 +++++ policy/module-layering.rego | 7 + schema/batten.local.schema.json | 8 +- schema/batten.schema.json | 8 +- 10 files changed, 882 insertions(+), 463 deletions(-) create mode 100644 crates/batten/src/preset.rs create mode 100644 crates/batten/tests/it/preset_manifest.rs diff --git a/.serena/memories/core.md b/.serena/memories/core.md index a2b58820a..820181aad 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -1872,6 +1872,22 @@ judge_fingerprint`, its own domain tag), so a caller can reference content it projection that spawned would be the class's whole point undone. `Surface::Hook` is refused (`tests/facts.rs`'s `no_effect_fact_is_hook_resolvable`), as a census over `Fact::ALL` rather than an assertion about this one variant. +- `preset.rs` — one manifest per vendored preset (CLOUD-1181): identity, the + `scope` its modules decide, the modules themselves, and the refusal classes + they raise. It exists because a preset used to be three unrelated `const`s that + nothing tied together — the name-to-modules table here, the verdict rows inside + `verdict.rs`'s `VENDORED` under a comment, and a branch exempting it from the + `[[pattern]]` refusal — so a preset carried no identity beyond its name, no + version, and no declared scope. **Below `policy` and `verdict`, and it reads + neither**: both project the one declaration rather than three tables knowing + about each other, which is what `module-layering` pins. `scope` is the field + the row was written for, and the honest reading of what it buys is in the load + site's own comment: a mismatch was ALREADY refused by the module input-key + check, so this refuses earlier and names the preset a consumer enabled rather + than a module inside the binary. **A manifest is not permission to fetch one** + — CLOUD-129's no-network verdict is unchanged, `include_str!` at build time, + and what the manifest buys CLOUD-970 is that the trust question becomes + askable at all. - `policy.rs` — the policy evaluator (CLOUD-647, CLOUD-689): a `[[rule]]` of kind `policy` names a **registered** Rego module, and the module decides over the resolved fact set. It exists because `run` is a flat loop where no row diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 9b497ecf7..440cdb72c 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -67,6 +67,7 @@ pub mod perf; pub mod pinned; pub mod policy; pub mod pr_watch; +pub mod preset; pub mod provision; pub mod prune; pub mod ready; diff --git a/crates/batten/src/policy.rs b/crates/batten/src/policy.rs index 58187cc43..f1a5369a2 100644 --- a/crates/batten/src/policy.rs +++ b/crates/batten/src/policy.rs @@ -214,181 +214,34 @@ pub struct Violation { pub subjects: Vec, } -/// Every vendored preset: its name, and the modules it ships. -/// -/// # Why Batten ships defaults at all -/// -/// A consumer adopting Batten got an empty `batten.toml` and had to author every -/// predicate from scratch, which is the anomaly rather than the discipline — -/// Conftest ships OCI bundles, Semgrep `p/default`, `ESLint`'s `recommended`, -/// Clippy its lint groups. And the non-negotiable that looks like it forbids -/// this argues *for* it: "adopt prior art; don't expand the core". A preset is -/// prior art shipped **as data**, which is the opposite of expanding the core. -/// -/// # Why this is not the OCI distribution CLOUD-129 rejected -/// -/// That verdict was about *remote policy fetch* being a supply-chain surface, -/// and it is intact. There is no network here, no registry and no -/// trust-on-first-use: `include_str!` at build time, so the bytes ship inside -/// the binary the operator already trusts, under the same checksum as everything -/// else in it. Its other ground — one committed authority per repo — does not -/// reach a preset either, because **a preset is not an authority; it is content -/// the authority enables.** -/// -/// # Rule 1 lives here, and this is the most inviting place in the crate to -/// break it -/// -/// The temptation is to vendor *this repository's* gates. A preset may contain -/// predicates true of a **practice** — trunk-based branching, commit shape — and -/// never one naming a path, a task, a tracker key or an entity. The mechanism -/// is not new prose: `batten.toml`'s rule-1 `forbid` rows glob `crates/**`, and -/// these sources are under it, so they are already scanned on every gate -/// invocation. `presets_are_inside_the_rule_one_glob` asserts that coverage -/// rather than leaving it to be true by accident. -/// -/// # One list, derived -/// -/// The valid name set is [`preset_names`], read off this table — never a -/// hand-maintained second list, which is `surface::SURFACE`'s discipline and the -/// reason a preset cannot be enabled that does not exist. -const PRESETS: &[(&str, &[(&str, &str)])] = &[ - ( - "commit-hygiene", - &[( - "/no-empty-commit.rego", - include_str!("policy/presets/commit-hygiene/no-empty-commit.rego"), - )], - ), - ( - "trunk-based", - &[( - "/no-force-push.rego", - include_str!("policy/presets/trunk-based/no-force-push.rego"), - )], - ), - // The first TREE-scoped preset (CLOUD-864). The two above judge a command; - // this one judges files, which is why it is the one that needed `lines` to - // reach paths by glob — a practice about 143 files cannot be a row that - // names 143 paths. - ( - "shell-hygiene", - &[ - ( - "/shebang-names-its-language.rego", - include_str!("policy/presets/shell-hygiene/shebang-names-its-language.rego"), - ), - ( - "/sibling-resolves.rego", - include_str!("policy/presets/shell-hygiene/sibling-resolves.rego"), - ), - ], - ), - // CLOUD-1028. The first preset whose predicate reads a RESOLVED SET rather - // than the call alone: which programs a pin provides is a different answer in - // every project, so a practice about them cannot be spelled as a pattern. It - // names no tool, no task and no mediator, which is what keeps it on the - // preset side of non-negotiable rule 1 — the boundary answers both halves, - // and the module asks only whether they line up. - ( - "pinned-toolchain", - &[( - "/pinned-program-via-the-pin.rego", - include_str!("policy/presets/pinned-toolchain/pinned-program-via-the-pin.rego"), - )], - ), - // CLOUD-1161. The generic half of `ci-local-parity`'s retirement: what a - // hosted-CI run COSTS, and whether the wiring that decides it can be - // reached at all. Tree-scoped like `shell-hygiene`, and the second preset - // to read a PARSED document rather than lines — a workflow's jobs, triggers - // and concurrency block are structure, and a line-oriented reading cannot - // say which job a key belongs to. - // - // The split from the consumer's own module is not tidiness. A required-check - // roster, a task name and a bot's branch prefix are that repository's facts; - // shipping them here would bake one consumer's job names into every - // consumer's binary, which is the violation non-negotiable rule 1 names. - ( - "ci-hygiene", - &[ - ( - "/spend-is-authorised.rego", - include_str!("policy/presets/ci-hygiene/spend-is-authorised.rego"), - ), - ( - "/wiring-can-be-reached.rego", - include_str!("policy/presets/ci-hygiene/wiring-can-be-reached.rego"), - ), - ], - ), - // CLOUD-1269. The generic third of a landing loop. Landing judgements split - // three ways and only one third belongs here: the effect and the poll stay - // outside (CLOUD-1170's own split), the roster and the trunk's name are the - // consumer's `batten.toml`, and what is left is a predicate over facts the - // boundary already resolved. - // - // TREE-SCOPED, because it decides over what the forge recorded rather than - // over a command. Its sibling `landing-spend` is the same practice on the - // other surface, and the two are separate presets rather than one because a - // `[[rule]]` row carries ONE scope and `policy::load` refuses two rows - // naming one preset — a bundle cannot span both. - ( - "landing-loop", - &[ - ( - "/graded-head-is-not-regraded.rego", - include_str!("policy/presets/landing-loop/graded-head-is-not-regraded.rego"), - ), - // The second landing judgement, and the one whose NAME had to change - // to match its fact (CLOUD-1280). CLOUD-1269 asked for - // `target-is-fast-forwardable`; no fact decides descendant-ness, - // because CLOUD-36 decides merged-ness by patch identity and - // `policy/ancestry-decides-nothing.rego` now refuses acquiring a - // reachability answer at all. What `input.tree.landing` does answer - // is whether the target already carries this work — a real landing - // judgement, under the name that describes it. - ( - "/already-landed-work-is-not-relanded.rego", - include_str!( - "policy/presets/landing-loop/already-landed-work-is-not-relanded.rego" - ), - ), - // The lease, and the one predicate here that reads a record the - // CONSUMER's own `[[recorder]]` wrote rather than a fact the engine - // acquires. It selects on a generic `lease` kind column instead of on - // the record's name, which is the consumer's (rule 1). - ( - "/lease-authorises-the-branch.rego", - include_str!("policy/presets/landing-loop/lease-authorises-the-branch.rego"), - ), - ], - ), -]; +// `PRESETS` moved to `crate::preset::MANIFESTS` (CLOUD-1181). It was one of +// three parallel tables describing a preset, none of which knew about the +// others; the doc comment that stood here — why Batten ships defaults, why this +// is not the OCI distribution CLOUD-129 rejected, and why rule 1 is most +// inviting to break at a preset — moved with it. +// +// CLOUD-129's VERDICT IS UNCHANGED and the manifest is not permission to fetch +// one: `include_str!` at build time, no network, no registry, no +// trust-on-first-use. `crate::preset`'s own header says so at length, because a +// reader arriving at a "manifest" reasonably wonders whether something resolves +// it, and the answer is that nothing does. /// Every vendored preset's name, in a stable order. /// -/// Derived from [`PRESETS`] so the binary and the published schema cannot +/// Derived from [`crate::preset::MANIFESTS`] so the binary and the published +/// schema cannot /// disagree about what may be enabled — the same discipline `surface::SURFACE` /// carries, and the reason an unknown name is a config error rather than a /// silent no-op. #[must_use] pub fn preset_names() -> Vec<&'static str> { - PRESETS.iter().map(|(name, _)| *name).collect() + crate::preset::names() } -/// The modules a named preset ships, or `None` when nothing ships under that -/// name. -/// -/// The pointer paths are `/…` rather than a filesystem path, -/// deliberately: a preset has no path in the consumer's tree, and printing one -/// would send a reader looking for a file that is not there. A finding still -/// names the PREDICATE rather than this, so it stays indistinguishable in shape -/// from an in-repo one. -fn preset_modules(name: &str) -> Option<&'static [(&'static str, &'static str)]> { - PRESETS - .iter() - .find(|(preset, _)| *preset == name) - .map(|(_, modules)| *modules) -} +// `preset_modules` is gone (CLOUD-1181): the load site reads the whole manifest +// now, so a second lookup returning only the modules was one more place that +// could answer about a preset without seeing its scope — which is the fact the +// manifest exists to stop being separable. /// One module inside a bundle: its repository-relative path, and nothing else. /// @@ -856,7 +709,7 @@ pub fn load( // consumer who enabled `trunk-basd` should be told, not quietly gated by // nothing. if let Some(name) = rule.preset.as_deref() { - let modules = preset_modules(name).ok_or_else(|| { + let manifest = crate::preset::find(name).ok_or_else(|| { UsageError::raise(format!( "rule `{}` enables the preset `{name}`, which this binary does not ship; \ the ones it does are {}", @@ -864,6 +717,41 @@ pub fn load( preset_names().join(", ") )) })?; + // THE SCOPE CHECK (CLOUD-1181) — AND WHAT IT DOES IS NOT WHAT THE + // ROW PREDICTED, WHICH IS WORTH THE PARAGRAPH. + // + // The row expected a scope mismatch to produce an empty violation + // set: modules read the other surface's `input.*` keys, read + // undefined, refuse nothing, and a dead gate is byte-identical to a + // clean tree. MEASURED, THAT WAS ALREADY REFUSED. With this branch + // disabled and the binary rebuilt, enabling `trunk-based` at `tree` + // still fails to load — `check_tree_paths_are_emittable` catches the + // module reading `input.call` on the tree surface and says so. + // + // So this is not a hole being closed. What it buys is narrower and + // still worth having: the refusal now PRECEDES compilation and names + // the preset the consumer enabled, where the existing one names a + // module inside the binary that the consumer never wrote and cannot + // open. And the manifest's `scope` is a DECLARATION — the row's real + // gap was that "a consumer enabling a preset today cannot see which + // surface it decides", which no refusal fixes because it is a + // question asked before anything is enabled. + // + // Stated rather than absorbed, because shipping this as "closes the + // silent dead gate" would have been a claim about a channel nobody + // measured, which is the defect `.claude/rules/policy-modules.md` + // records against its own earlier revisions. + if manifest.scope != rule.scope { + return Err(UsageError::raise(format!( + "rule `{}` enables the preset `{name}` at scope `{}`, but its modules \ + decide `{}` — at the wrong scope they read keys the engine never builds, \ + so the rule would evaluate and refuse nothing", + rule.id, + rule.scope.as_str(), + manifest.scope.as_str() + ))); + } + let modules = manifest.modules; let sources: Vec<(String, String)> = modules .iter() .map(|(path, source)| ((*path).to_owned(), (*source).to_owned())) diff --git a/crates/batten/src/preset.rs b/crates/batten/src/preset.rs new file mode 100644 index 000000000..c7206d7f4 --- /dev/null +++ b/crates/batten/src/preset.rs @@ -0,0 +1,618 @@ +//! One manifest per vendored preset: its identity, the surface it decides, the +//! modules it ships, and the refusal vocabulary they raise (CLOUD-1181). +//! +//! # Why this module exists rather than three tables +//! +//! A preset used to be three unrelated `const`s that nothing tied together: the +//! name-to-modules table in `policy.rs`, its verdict rows sitting inside +//! `verdict.rs`'s `VENDORED` under a comment, and a branch exempting it from the +//! `[[pattern]]` refusal. Nothing declared a preset, so a preset carried no +//! identity beyond its name, no version, and — the load-bearing omission — no +//! SCOPE. +//! +//! Scope is where the silence costs most. `.claude/rules/policy-modules.md` +//! opens on the class: a module reading a key from the wrong surface evaluates, +//! reads undefined, refuses nothing, and *"a dead gate and a clean tree are +//! byte-identical on the decision surface"*. A consumer enabling a preset could +//! not see which surface it decided, so that mistake was theirs to make blind. +//! +//! # The inversion this ends +//! +//! Every place a preset was exempted from a rule a consumer module obeys, it was +//! exempted **because there was nowhere to write the declaration** — not because +//! the rule did not apply. A preset ships to every consumer while a consumer +//! module reaches one, so those exemptions were pointed the wrong way. The +//! manifest is the place, and it turns exemptions into declarations. +//! +//! # THIS DOES NOT OPEN THE NETWORK, AND THE MANIFEST IS NOT PERMISSION TO +//! +//! CLOUD-129 rejected remote policy fetch and that verdict is **unchanged**. +//! Every manifest here is `include_str!`d at build time: no network, no +//! registry, no trust-on-first-use, and the bytes ship inside the binary the +//! operator already trusts, under the same checksum as everything else in it. +//! A manifest is a declaration FORMAT. Whether a preset may arrive from outside +//! the binary is CLOUD-970's question, and a manifest is what makes that +//! question askable — before this, a third-party preset had no shape to arrive +//! in, so the trust question could not even be posed. Read no further permission +//! into it than that. +//! +//! # Rule 1 binds a manifest exactly as it binds a preset source +//! +//! A field may describe a PRACTICE and may never name a path, a task, a tracker +//! key or an entity. This file is under `crates/**`, so `batten.toml`'s rule-1 +//! `forbid` rows already scan it on every gate invocation; +//! `manifests_are_inside_the_rule_one_glob` asserts that coverage rather than +//! leaving it true by accident. + +use crate::rules::RuleScope; +use crate::verdict::{DeclaredVerdict, VendoredVerdict, admit, read, run}; + +/// One vendored preset, declared once. +#[derive(Debug)] +#[non_exhaustive] +pub struct Manifest { + /// The name a consumer enables, and the only thing they could know before. + pub name: &'static str, + /// The manifest's own version. + /// + /// Separate from the crate version deliberately, and for the reason + /// `spec::SPEC_VERSION` is separate: a version moving with the binary says + /// "the binary changed", which the release tag already says. This moves when + /// what the preset DECIDES changes. + pub version: u32, + /// The surface these modules decide. + /// + /// The field the absence of which was the silent dead gate. A rule enabling + /// this preset at the other scope is refused at LOAD rather than evaluating + /// to an empty violation set — see `policy::load`. + pub scope: RuleScope, + /// The modules, as `(pointer, source)`. The pointer is `/…` + /// rather than a filesystem path: a preset has no path in the consumer's + /// tree, and printing one sends a reader looking for a file that is not there. + pub modules: &'static [(&'static str, &'static str)], + /// The refusal classes these modules raise, with their glosses and remedies. + /// + /// Declared HERE rather than in `verdict.rs`'s native table, which is the + /// change that makes the registry's two directions reachable for a preset: + /// a token a module raises that no row here declares, and a row here that no + /// module raises, are both findings a manifest can now express. + pub verdicts: &'static [VendoredVerdict], + /// The `[[pattern]]` ids these modules would cite if they could. + /// + /// **The declaration site CLOUD-934 needs, and deliberately not its fix.** A + /// preset reaches a consumer who wrote no `[[pattern]]` rows, so + /// `data.batten.patterns["x"]` resolves to undefined there and a preset + /// citing one decides nothing while loading clean — which is why the + /// exemption exists and why a preset still writes its literal inline. + /// Listing the ids is what makes the exemption countable instead of + /// invisible; closing it is CLOUD-934's row and this one does not do its work. + pub patterns: &'static [&'static str], +} + +/// Every vendored preset, in a stable order. +/// +/// The ONE authority. `preset_names`, the modules a preset ships, and the +/// presets' half of the vendored verdict registry are all read off this table — +/// never a hand-maintained second list, which is `surface::SURFACE`'s discipline +/// and the reason a preset cannot be enabled that does not exist. +pub const MANIFESTS: &[Manifest] = &[ + Manifest { + name: "ci-hygiene", + version: 1, + scope: RuleScope::Tree, + modules: &[ + ( + "/spend-is-authorised.rego", + include_str!("policy/presets/ci-hygiene/spend-is-authorised.rego"), + ), + ( + "/wiring-can-be-reached.rego", + include_str!("policy/presets/ci-hygiene/wiring-can-be-reached.rego"), + ), + ], + verdicts: &[ + VendoredVerdict { + id: "cache build loose", + gloss: "a cache-warming build recompiles and writes nothing on every run", + class: "A build that compiles to fill a cache and runs nothing judges nothing, which is \ +why it is exempt from parity rules — and that exemption is what makes it easy to leave running \ +for nothing. Measured: two cache entries carrying the same key across five merges, each cycle \ +compiling for ~145s and saving nothing, because the restore skips saving when the key already \ +exists. One condition reading the restore's hit flag is the whole fix.", + routes: &[read("source read first", "the compile step")], + }, + VendoredVerdict { + id: "cache name unknown", + gloss: "the cache guard names a step that does not exist, so it admits every run", + class: "The other direction of the same defect, and it has the same symptom with no \ +signal. If the action stops emitting the hit flag the expression is empty, the guard holds, and \ +the compile runs — wasteful, but visible in the bill. If the step id is dropped or renamed while \ +the guard keeps naming it, the expression is ALSO empty and the build silently reverts to \ +compiling every time. So the class names both halves: the guard must be present, and the step it \ +reads must exist.", + routes: &[read("source read first", "the restore step")], + }, + VendoredVerdict { + id: "event bind loose", + gloss: "a comment predicate fires from anywhere in a body anyone can write", + class: "An unanchored substring test fires from mid-sentence, from inside backticks, from \ +a quoted block. That makes the repository's own writing ABOUT a trigger an invocation of it, and \ +every artifact that has to name the token in order to be about it a live round. The class is the \ +unanchored read of a body anyone can write, not the one token read that way.", + routes: &[read("source read first", "the job condition")], + }, + VendoredVerdict { + id: "event reach dead", + gloss: "a declared trigger starts a run in which every job skips", + class: "The trigger exists and does nothing: the run list shows a run, and only the job's \ +conclusion says it did not happen. Measured on one lane where a manual trigger was added so it \ +could be exercised without waiting on a late cron, and every job's condition still admitted only \ +the two original events. Judged only where a condition MENTIONS the event name at all, since a \ +workflow that does not discriminate by event answers for every trigger it declares.", + routes: &[read("source read first", "the job conditions")], + }, + VendoredVerdict { + id: "input render dropped", + gloss: "an unquoted comment truncates a value before it ever reaches the forge", + class: "YAML opens a comment at an unquoted space-hash, so a value carrying an \ +interpolation after one parses to the bare text before it and the rest is discarded. Measured: \ +one workflow carried exactly that for a day and 30 consecutive runs reported a title equal to the \ +workflow name, so a caller keying on the interpolated value could never match. Linters pass over \ +the line because a comment is legal YAML, and review reads it as the thing it was meant to be. \ +Read pre-parse, because the parse is what destroys the evidence. Quoting the value is the fix.", + routes: &[read("source read first", "the truncated line")], + }, + VendoredVerdict { + id: "job require unseen", + gloss: "a fan-in enumerates its own dependencies and has gone stale", + class: "Branch protection points at one aggregating job so that adding a leg never needs \ +a ruleset change — which only holds if that job's assertion follows its dependency list by \ +itself. Measured: a fan-in enumerated three of its four dependencies, so a red fourth left green \ +the one check the host requires. A set-wide predicate cannot go stale, because it names nothing.", + routes: &[read("source read first", "the fan-in job")], + }, + VendoredVerdict { + id: "job run early", + gloss: "a job spends a runner on a pull request still being verified locally", + class: "A draft says the author is still verifying locally, and it is also the lever a \ +red run pulls: a lander that re-drafts stops further spend while the failure is diagnosed. A \ +single job missing the guard defeats both, and the run it buys is one nobody reads. Measured on \ +one repository: a workflow triggered by any pull request touching a workflow file spent a runner \ +on every push to a draft for its whole life, and re-drafting did not close the tap.", + routes: &[read("source read first", "the job's condition")], + }, + VendoredVerdict { + id: "job start same", + gloss: "two scheduled workflows contend for the same runners at the same minute", + class: "Every scheduled workflow's header tends to claim a staggered slot and nothing \ +checks it, so two pairs drifted onto the same minute and the second pair landed after the first \ +was found. Compared as LITERAL expressions rather than firing times: an every-30-minutes \ +schedule genuinely overlaps every hourly slot, and flagging that would make the class fire \ +forever on a workflow doing nothing wrong.", + routes: &[read("source read first", "the schedule trigger")], + }, + VendoredVerdict { + id: "merge run early", + gloss: "a comment-triggered merge delegates the draft question to the ruleset", + class: "A draft head grades no checks where every pull-request workflow is draft-gated, \ +and a branch ruleset admits that empty set as satisfying required-checks-green. So a merge path \ +that never reads the draft state has no draft check at all, and can advance the trunk to a commit \ +CI never ran on. Deciding not to ask is not the same as asking.", + routes: &[read("source read first", "the merge job")], + }, + VendoredVerdict { + id: "review watch missing", + gloss: "a draft-gated workflow can never be superseded once it skips", + class: "Omitting `types:` defaults to `[opened, synchronize, reopened]`. Where the jobs \ +are draft-gated, a pull request created as a draft mints a skipped run on `opened`, and with no \ +`ready_for_review` there is no event left that could replace it — a waiter correctly refuses to \ +read a skip as an answer and polls forever. Measured as a deadlock across two pull requests at \ +once, both fully green but for one such name.", + routes: &[read( + "source read first", + "the pull_request trigger's types", + )], + }, + VendoredVerdict { + id: "workflow declare missing", + gloss: "a workflow can have two runs racing at all", + class: "Superseding is the pull-request half of this and is not the whole of it: a \ +comment- or schedule-triggered workflow never reaches that guard, so the property that matters \ +off the landing path — that a workflow cannot race itself — reaches none of them. Measured: N \ +concurrent comment invocations ran N concurrent attempts to advance a trunk branch, at 245 \ +refusals against 6 merges in half an hour. A scheduled workflow must NOT cancel its own previous \ +tick, so declaring a group is all this asks.", + routes: &[read("source read first", "the workflow")], + }, + VendoredVerdict { + id: "workflow run loose", + gloss: "a branch scope written where filtering is already too late", + class: "A job condition is evaluated AFTER the run exists, so a branch scope expressed \ +only there creates a run and then skips it. Measured on one lane: 1131 inserted-and-skipped runs \ +in 25 hours — no runner minutes, which is why it survived, but 46% of every run in the \ +repository, enough that paginating the run list stops being stable. The filter belongs on the \ +trigger, where it is free.", + routes: &[read("source read first", "the workflow_run trigger")], + }, + VendoredVerdict { + id: "workflow run twice", + gloss: "a pull-request workflow pays out a run its own next push made obsolete", + class: "A landing lap rebases and pushes. Without `cancel-in-progress` the superseded \ +commit's run is billed in full for a verdict nobody will read, and a lander loses the ability to \ +cancel a doomed run by simply pushing the next one. Declaring the group is not enough — the \ +value is what does the work, and it is a boolean rather than the string `true`.", + routes: &[read( + "source read first", + "the workflow's concurrency block", + )], + }, + ], + patterns: &[], + }, + Manifest { + name: "commit-hygiene", + version: 1, + scope: RuleScope::MediatedCall, + modules: &[( + "/no-empty-commit.rego", + include_str!("policy/presets/commit-hygiene/no-empty-commit.rego"), + )], + verdicts: &[VendoredVerdict { + id: "commit ship empty", + gloss: "an empty commit records that somebody wanted a new SHA", + class: "A commit records a change. The reachable use of an empty one is kicking a \ +pipeline, which spends a run to re-ask a question the previous run already answered and \ +leaves a commit in the history no reader can act on. If the goal is a fresh run, re-run \ +the pipeline.", + routes: &[run("task run first", "re-run the pipeline")], + }], + patterns: &[], + }, + Manifest { + name: "landing-loop", + version: 1, + scope: RuleScope::Tree, + modules: &[ + ( + "/graded-head-is-not-regraded.rego", + include_str!("policy/presets/landing-loop/graded-head-is-not-regraded.rego"), + ), + ( + "/already-landed-work-is-not-relanded.rego", + include_str!( + "policy/presets/landing-loop/already-landed-work-is-not-relanded.rego" + ), + ), + ( + "/lease-authorises-the-branch.rego", + include_str!("policy/presets/landing-loop/lease-authorises-the-branch.rego"), + ), + ], + verdicts: &[ + VendoredVerdict { + id: "head grade twice", + gloss: "the forge already judged this commit and a second run would re-ask it", + class: "A commit that has not changed cannot get a different verdict, so a second \ +run over it buys an answer that is already recorded and spends the metered tier to do it. \ +Measured on one consumer's landing bot over a half hour: 400 runs, 248 executed, against 5 \ +merges. Read the recorded verdict rather than asking for it again; if the intent was to \ +judge different work, the commit is what has to change.", + routes: &[ + read("source read first", "the forge record for this commit"), + // THE PRECONDITION IS THE WHOLE OF THIS ROUTE. A re-grade is + // legitimate when the recorded verdict is about the RUNNER rather + // than about the commit — a lost agent, an evicted node, an + // infrastructure fault — because that verdict answers a question + // nobody asked. It is not legitimate because the answer was + // unwelcome, which is the case this condition exists to exclude. + admit( + "path admit first", + "the recorded verdict is about a runner fault rather than about this commit", + ), + ], + }, + VendoredVerdict { + id: "lease grant other", + gloss: "a live landing lease names a different branch, and no reservation names this one", + class: "A landing lease is how a fleet keeps two branches from buying overlapping CI for \ +a trunk only one of them can fast-forward onto. This branch is neither the holder nor the \ +successor admitted behind it, so a matrix spent now is a matrix the holder's merge invalidates. \ +Wait for the lease to lapse or be released, or reserve the slot behind the holder — the loop that \ +does both lives outside the engine, which only reads the answer. Every reading this refusal \ +cannot take ALLOWS: an unreadable lease stops every job in the fleet, where waving one matrix \ +through costs one matrix.", + routes: &[ + read( + "lease read first", + "the lease grading recorded for this branch", + ), + // The wedged holder, and it is narrow on purpose. The lease grades + // LIVENESS rather than PROGRESS (CLOUD-499), so a holder that beats + // steadily while making none holds forever and starves the fleet. + // That is the case this admits, and it is not "the wait was + // inconvenient" — a holder that is merely slow is the mechanism + // working. + admit( + "lease admit first", + "the holder is wedged rather than slow — it is beating without advancing, so waiting for a lapse it keeps renewing starves the fleet indefinitely", + ), + ], + }, + VendoredVerdict { + id: "patch ship twice", + gloss: "the target already carries this branch's changes, so landing them again buys nothing", + class: "A landing attempt over work the target already has runs a matrix, holds the \ +fleet's landing slot while it does, and merges a no-op or a conflict. The answer is decided by \ +PATCH IDENTITY rather than by reachability, which is what makes it trustworthy here: a rebased, \ +squash-merged or cherry-picked branch leaves the same change on the target under a different \ +commit with no ancestry path back, and on a fast-forward trunk that is the ordinary way work \ +lands. Close the branch, or rebase onto the target and see what is genuinely left.", + routes: &[ + read("record read first", "the landing verdict for this target"), + // The one legitimate re-land, and it is narrow on purpose. Patch + // identity answers about CONTENT, so deliberately re-applying a + // change the target once carried and later reverted is + // indistinguishable from never having landed it — the same bytes, + // arriving for a different reason. That is the case this admits, and + // it is not "the answer was inconvenient". + admit( + "patch admit first", + "the change is being deliberately re-applied after the target reverted it, so identical content is the intent rather than a duplicate", + ), + ], + }, + ], + patterns: &[], + }, + Manifest { + name: "pinned-toolchain", + version: 1, + scope: RuleScope::MediatedCall, + modules: &[( + "/pinned-program-via-the-pin.rego", + include_str!("policy/presets/pinned-toolchain/pinned-program-via-the-pin.rego"), + )], + verdicts: &[VendoredVerdict { + id: "pin reach loose", + gloss: "a program the project's pin provides was reached around the pin", + class: "The pinned toolchain is what makes one machine's run mean anything about \ +another's, and it supplies an ENVIRONMENT as well as a binary. A program reached around \ +it runs a different version, or the same version without the variables the project sets \ +— and the failure that produces looks like the failure being investigated rather than \ +like a wrong invocation. Measured on one consumer: sixty runs of a test suite died on an \ +unset variable instead of on the assertion, and the report that followed was published \ +as three claims about the tree, all false.", + routes: &[run( + "task run first", + "run the declared task, or invoke the program through the pin", + )], + }], + patterns: &[], + }, + Manifest { + name: "shell-hygiene", + version: 1, + scope: RuleScope::Tree, + modules: &[ + ( + "/shebang-names-its-language.rego", + include_str!("policy/presets/shell-hygiene/shebang-names-its-language.rego"), + ), + ( + "/sibling-resolves.rego", + include_str!("policy/presets/shell-hygiene/sibling-resolves.rego"), + ), + ], + verdicts: &[ + VendoredVerdict { + id: "program name unnamed", + gloss: "the file runs a shell and its name does not say so", + class: "Every instrument that selects by extension — a formatter, a linter, a \ +CI path filter — covers this file silently and exits 0. A green run over it therefore \ +means nothing was looked at rather than nothing was found, which is worse than a red \ +one. Name the language in the filename, or declare the file's coverage another way.", + routes: &[run("patch run first", "git mv")], + }, + VendoredVerdict { + id: "program resolve missing", + gloss: "a run-time sibling path is computed and the tree carries no such file", + class: "The shape resolves a path beside the running program and then guards it \ +with a test that exits 0, so the reference does not fail — it goes silent, and the \ +behaviour it was reaching for simply never happens. A path that must exist should be \ +asserted rather than tested.", + routes: &[read("source read first", "the computed path")], + }, + ], + patterns: &[], + }, + Manifest { + name: "trunk-based", + version: 1, + scope: RuleScope::MediatedCall, + modules: &[( + "/no-force-push.rego", + include_str!("policy/presets/trunk-based/no-force-push.rego"), + )], + verdicts: &[VendoredVerdict { + id: "trunk push forced", + gloss: "a force push rewrites a shared branch under whoever already fetched it", + class: "Rewriting a published branch invalidates every checkout of it that \ +already exists, and the holder finds out by having their next pull fail in a way that \ +looks like their own mistake. `--force-with-lease` refuses when the remote moved, which \ +is the same operation with the one check that makes it safe.", + routes: &[run("patch run first", "git push --force-with-lease")], + }], + patterns: &[], + }, +]; + +/// Every vendored preset's name, in a stable order. +#[must_use] +pub fn names() -> Vec<&'static str> { + MANIFESTS.iter().map(|manifest| manifest.name).collect() +} + +/// The manifest for a named preset, or `None` when nothing ships under it. +#[must_use] +pub fn find(name: &str) -> Option<&'static Manifest> { + MANIFESTS.iter().find(|manifest| manifest.name == name) +} + +/// Every verdict row the vendored presets declare, as the registry carries them. +/// +/// Chained onto the native rows by [`crate::verdict::vendored`]. Derived rather +/// than declared a second time: before this, adding a preset class meant editing +/// a table in another module under a comment, and nothing tied the two together. +#[must_use] +pub fn verdict_rows() -> Vec { + MANIFESTS + .iter() + .flat_map(|manifest| manifest.verdicts.iter()) + .map(crate::verdict::declared_from) + .collect() +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::{MANIFESTS, names}; + + use std::collections::BTreeSet; + + /// Every class a preset's modules raise is declared by its own manifest. + /// + /// The direction a consumer module already gets from `check_verdicts_are_declared`, + /// now reachable for a preset because there is somewhere to declare it. + /// Before the manifest a preset's rows lived in another module's table under + /// a comment, so this question had no side to ask. + /// + /// Read from the module SOURCE, which is the only honest reading: the + /// alternative is to trust that the manifest and the modules agree, which is + /// the assumption the manifest exists to remove. + #[test] + fn every_class_a_preset_raises_is_declared_by_its_own_manifest() { + for manifest in MANIFESTS { + let declared: BTreeSet<&str> = manifest.verdicts.iter().map(|entry| entry.id).collect(); + for (pointer, source) in manifest.modules { + for token in raised_in(source) { + assert!( + declared.contains(token.as_str()), + "`{}` raises `{token}` in `{pointer}`, which its manifest does not \ + declare — the refusal would carry no gloss and no route", + manifest.name + ); + } + } + } + } + + /// And the mirror: a class no module raises fails. + /// + /// The anti-vacuity direction the `[[verdict]]` registry already enforces + /// in-tree and which a preset was exempt from, not because the rule did not + /// apply but because there was nowhere to write the declaration. A class no + /// gate reaches reads as coverage while its routes have never been walked. + #[test] + fn every_class_a_manifest_declares_is_raised_by_one_of_its_modules() { + for manifest in MANIFESTS { + let raised: BTreeSet = manifest + .modules + .iter() + .flat_map(|(_, source)| raised_in(source)) + .collect(); + for entry in manifest.verdicts { + assert!( + raised.contains(entry.id), + "`{}` declares `{}`, which none of its modules raise", + manifest.name, + entry.id + ); + } + } + } + + /// One name, one manifest. + #[test] + fn no_preset_is_declared_twice() { + let mut seen = names(); + let before = seen.len(); + seen.sort_unstable(); + seen.dedup(); + assert_eq!(before, seen.len(), "two manifests share a preset name"); + } + + /// A class belongs to exactly one preset. + /// + /// Two manifests declaring one token would collide in the registry, and + /// `policy::registry_for` refuses a collision between the vendored and + /// consumer halves rather than within the vendored half — so nothing else + /// asks this. + #[test] + fn no_class_is_declared_by_two_presets() { + let mut seen: Vec<&str> = MANIFESTS + .iter() + .flat_map(|manifest| manifest.verdicts.iter().map(|entry| entry.id)) + .collect(); + let before = seen.len(); + seen.sort_unstable(); + seen.dedup(); + assert_eq!( + before, + seen.len(), + "a class is declared by two presets, so the registry cannot say which raises it" + ); + } + + /// Rule 1 reaches a manifest as it reaches a preset source. + /// + /// Asserted rather than assumed, per the row: this file is under `crates/**` + /// so `batten.toml`'s rule-1 `forbid` rows already scan it, and what is + /// checked here is that the file is where that glob can see it. + #[test] + fn manifests_are_inside_the_rule_one_glob() { + let here = std::path::Path::new(file!()); + assert!( + here.starts_with("crates"), + "the manifests must live where rule 1's glob reaches them, not at {}", + here.display() + ); + } + + /// The classes a module raises, read from its source. + /// + /// A refusal is `{rule, verdict, subjects}` and its class is a STRING + /// LITERAL under `verdict` (`.claude/rules/policy-modules.md`), so what is + /// matched is that SHAPE: a `"rule":` key, then the `"verdict":` that + /// follows it in the same object. + /// + /// AN EARLIER VERSION MATCHED `"verdict":` ANYWHERE AND CLAIMED OVER-READING + /// WAS THE SAFE DIRECTION. It is not, and the assertion above said so + /// immediately: `landing-loop`'s own test fixtures build landing records + /// whose rows carry a `verdict` COLUMN, so `landed` and three siblings read + /// as raised classes and the manifest was accused of not declaring them. + /// Over-reading makes the raised-set too big, which fails the first + /// assertion on inputs that raise nothing — the noisy direction, not the + /// safe one. + /// + /// It also declines to read a COMPOSED verdict (`"verdict": columns[1]`), + /// and that is correct rather than a gap: the ABI refuses a class composed + /// at runtime, because a class a reader cannot look up is not a class. + fn raised_in(source: &str) -> Vec { + let mut found = Vec::new(); + for rest in source.split("\"rule\":").skip(1) { + let Some(after) = rest.find("\"verdict\": \"") else { + continue; + }; + let tail = &rest[after + "\"verdict\": \"".len()..]; + // Only when the `verdict` key belongs to the SAME object: a later + // block's key would be past the object's close. + if rest[..after].contains("\n}") { + continue; + } + if let Some(end) = tail.find('"') { + found.push(tail[..end].to_owned()); + } + } + found + } +} diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index 9f0475452..6a3c0db91 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -1127,23 +1127,34 @@ pub fn native_tokens() -> BTreeSet<&'static str> { /// `String`s and cannot be a `const`. [`vendored`] converts. The alternative — /// building the table at runtime from literals — puts the same data one /// indirection further from the reader for nothing. -struct VendoredVerdict { - id: &'static str, - gloss: &'static str, - class: &'static str, - routes: &'static [VendoredRoute], +#[derive(Debug)] +pub struct VendoredVerdict { + /// The three-word class token. + pub id: &'static str, + /// The one-line summary. + pub gloss: &'static str, + /// The prose a reader dereferences through `batten policy explain`. + pub class: &'static str, + /// The declared remedies. + pub routes: &'static [VendoredRoute], } /// One vendored route. See [`VendoredVerdict`]. -struct VendoredRoute { - id: &'static str, - kind: RouteKind, - target: &'static str, - precondition: Option<&'static str>, +#[derive(Debug)] +pub struct VendoredRoute { + /// The route's own three-word id. + pub id: &'static str, + /// Which kind of remedy it is. + pub kind: RouteKind, + /// What it points at. + pub target: &'static str, + /// The condition an override route states. + pub precondition: Option<&'static str>, } /// A `command`-kind route, which is most of them. -const fn run(id: &'static str, target: &'static str) -> VendoredRoute { +#[must_use] +pub const fn run(id: &'static str, target: &'static str) -> VendoredRoute { VendoredRoute { id, kind: RouteKind::Command, @@ -1153,7 +1164,8 @@ const fn run(id: &'static str, target: &'static str) -> VendoredRoute { } /// A `document`-kind route. -const fn read(id: &'static str, target: &'static str) -> VendoredRoute { +#[must_use] +pub const fn read(id: &'static str, target: &'static str) -> VendoredRoute { VendoredRoute { id, kind: RouteKind::Document, @@ -1169,7 +1181,8 @@ const fn read(id: &'static str, target: &'static str) -> VendoredRoute { /// unread, and the precondition is the whole payload — it is what /// [`crate::admission::questions_for`] renders the first question from. A helper /// that took a target would invite one to be written and then silently ignored. -const fn admit(id: &'static str, precondition: &'static str) -> VendoredRoute { +#[must_use] +pub const fn admit(id: &'static str, precondition: &'static str) -> VendoredRoute { VendoredRoute { id, kind: RouteKind::Override, @@ -1556,265 +1569,6 @@ it in. A row that cannot resolve is a rule that will report a missing scanner at it was supposed to decide something.", routes: &[read("config read first", "batten.toml")], }, - // ── vendored presets ──────────────────────────────────────────────────── - VendoredVerdict { - id: "commit ship empty", - gloss: "an empty commit records that somebody wanted a new SHA", - class: "A commit records a change. The reachable use of an empty one is kicking a \ -pipeline, which spends a run to re-ask a question the previous run already answered and \ -leaves a commit in the history no reader can act on. If the goal is a fresh run, re-run \ -the pipeline.", - routes: &[run("task run first", "re-run the pipeline")], - }, - VendoredVerdict { - id: "trunk push forced", - gloss: "a force push rewrites a shared branch under whoever already fetched it", - class: "Rewriting a published branch invalidates every checkout of it that \ -already exists, and the holder finds out by having their next pull fail in a way that \ -looks like their own mistake. `--force-with-lease` refuses when the remote moved, which \ -is the same operation with the one check that makes it safe.", - routes: &[run("patch run first", "git push --force-with-lease")], - }, - VendoredVerdict { - id: "pin reach loose", - gloss: "a program the project's pin provides was reached around the pin", - class: "The pinned toolchain is what makes one machine's run mean anything about \ -another's, and it supplies an ENVIRONMENT as well as a binary. A program reached around \ -it runs a different version, or the same version without the variables the project sets \ -— and the failure that produces looks like the failure being investigated rather than \ -like a wrong invocation. Measured on one consumer: sixty runs of a test suite died on an \ -unset variable instead of on the assertion, and the report that followed was published \ -as three claims about the tree, all false.", - routes: &[run( - "task run first", - "run the declared task, or invoke the program through the pin", - )], - }, - VendoredVerdict { - id: "program name unnamed", - gloss: "the file runs a shell and its name does not say so", - class: "Every instrument that selects by extension — a formatter, a linter, a \ -CI path filter — covers this file silently and exits 0. A green run over it therefore \ -means nothing was looked at rather than nothing was found, which is worse than a red \ -one. Name the language in the filename, or declare the file's coverage another way.", - routes: &[run("patch run first", "git mv")], - }, - VendoredVerdict { - id: "program resolve missing", - gloss: "a run-time sibling path is computed and the tree carries no such file", - class: "The shape resolves a path beside the running program and then guards it \ -with a test that exits 0, so the reference does not fail — it goes silent, and the \ -behaviour it was reaching for simply never happens. A path that must exist should be \ -asserted rather than tested.", - routes: &[read("source read first", "the computed path")], - }, - VendoredVerdict { - id: "job run early", - gloss: "a job spends a runner on a pull request still being verified locally", - class: "A draft says the author is still verifying locally, and it is also the lever a \ -red run pulls: a lander that re-drafts stops further spend while the failure is diagnosed. A \ -single job missing the guard defeats both, and the run it buys is one nobody reads. Measured on \ -one repository: a workflow triggered by any pull request touching a workflow file spent a runner \ -on every push to a draft for its whole life, and re-drafting did not close the tap.", - routes: &[read("source read first", "the job's condition")], - }, - VendoredVerdict { - id: "workflow run twice", - gloss: "a pull-request workflow pays out a run its own next push made obsolete", - class: "A landing lap rebases and pushes. Without `cancel-in-progress` the superseded \ -commit's run is billed in full for a verdict nobody will read, and a lander loses the ability to \ -cancel a doomed run by simply pushing the next one. Declaring the group is not enough — the \ -value is what does the work, and it is a boolean rather than the string `true`.", - routes: &[read( - "source read first", - "the workflow's concurrency block", - )], - }, - VendoredVerdict { - id: "workflow declare missing", - gloss: "a workflow can have two runs racing at all", - class: "Superseding is the pull-request half of this and is not the whole of it: a \ -comment- or schedule-triggered workflow never reaches that guard, so the property that matters \ -off the landing path — that a workflow cannot race itself — reaches none of them. Measured: N \ -concurrent comment invocations ran N concurrent attempts to advance a trunk branch, at 245 \ -refusals against 6 merges in half an hour. A scheduled workflow must NOT cancel its own previous \ -tick, so declaring a group is all this asks.", - routes: &[read("source read first", "the workflow")], - }, - VendoredVerdict { - id: "review watch missing", - gloss: "a draft-gated workflow can never be superseded once it skips", - class: "Omitting `types:` defaults to `[opened, synchronize, reopened]`. Where the jobs \ -are draft-gated, a pull request created as a draft mints a skipped run on `opened`, and with no \ -`ready_for_review` there is no event left that could replace it — a waiter correctly refuses to \ -read a skip as an answer and polls forever. Measured as a deadlock across two pull requests at \ -once, both fully green but for one such name.", - routes: &[read( - "source read first", - "the pull_request trigger's types", - )], - }, - VendoredVerdict { - id: "workflow run loose", - gloss: "a branch scope written where filtering is already too late", - class: "A job condition is evaluated AFTER the run exists, so a branch scope expressed \ -only there creates a run and then skips it. Measured on one lane: 1131 inserted-and-skipped runs \ -in 25 hours — no runner minutes, which is why it survived, but 46% of every run in the \ -repository, enough that paginating the run list stops being stable. The filter belongs on the \ -trigger, where it is free.", - routes: &[read("source read first", "the workflow_run trigger")], - }, - VendoredVerdict { - id: "event bind loose", - gloss: "a comment predicate fires from anywhere in a body anyone can write", - class: "An unanchored substring test fires from mid-sentence, from inside backticks, from \ -a quoted block. That makes the repository's own writing ABOUT a trigger an invocation of it, and \ -every artifact that has to name the token in order to be about it a live round. The class is the \ -unanchored read of a body anyone can write, not the one token read that way.", - routes: &[read("source read first", "the job condition")], - }, - VendoredVerdict { - id: "merge run early", - gloss: "a comment-triggered merge delegates the draft question to the ruleset", - class: "A draft head grades no checks where every pull-request workflow is draft-gated, \ -and a branch ruleset admits that empty set as satisfying required-checks-green. So a merge path \ -that never reads the draft state has no draft check at all, and can advance the trunk to a commit \ -CI never ran on. Deciding not to ask is not the same as asking.", - routes: &[read("source read first", "the merge job")], - }, - VendoredVerdict { - id: "event reach dead", - gloss: "a declared trigger starts a run in which every job skips", - class: "The trigger exists and does nothing: the run list shows a run, and only the job's \ -conclusion says it did not happen. Measured on one lane where a manual trigger was added so it \ -could be exercised without waiting on a late cron, and every job's condition still admitted only \ -the two original events. Judged only where a condition MENTIONS the event name at all, since a \ -workflow that does not discriminate by event answers for every trigger it declares.", - routes: &[read("source read first", "the job conditions")], - }, - VendoredVerdict { - id: "job start same", - gloss: "two scheduled workflows contend for the same runners at the same minute", - class: "Every scheduled workflow's header tends to claim a staggered slot and nothing \ -checks it, so two pairs drifted onto the same minute and the second pair landed after the first \ -was found. Compared as LITERAL expressions rather than firing times: an every-30-minutes \ -schedule genuinely overlaps every hourly slot, and flagging that would make the class fire \ -forever on a workflow doing nothing wrong.", - routes: &[read("source read first", "the schedule trigger")], - }, - VendoredVerdict { - id: "job require unseen", - gloss: "a fan-in enumerates its own dependencies and has gone stale", - class: "Branch protection points at one aggregating job so that adding a leg never needs \ -a ruleset change — which only holds if that job's assertion follows its dependency list by \ -itself. Measured: a fan-in enumerated three of its four dependencies, so a red fourth left green \ -the one check the host requires. A set-wide predicate cannot go stale, because it names nothing.", - routes: &[read("source read first", "the fan-in job")], - }, - VendoredVerdict { - id: "cache build loose", - gloss: "a cache-warming build recompiles and writes nothing on every run", - class: "A build that compiles to fill a cache and runs nothing judges nothing, which is \ -why it is exempt from parity rules — and that exemption is what makes it easy to leave running \ -for nothing. Measured: two cache entries carrying the same key across five merges, each cycle \ -compiling for ~145s and saving nothing, because the restore skips saving when the key already \ -exists. One condition reading the restore's hit flag is the whole fix.", - routes: &[read("source read first", "the compile step")], - }, - VendoredVerdict { - id: "cache name unknown", - gloss: "the cache guard names a step that does not exist, so it admits every run", - class: "The other direction of the same defect, and it has the same symptom with no \ -signal. If the action stops emitting the hit flag the expression is empty, the guard holds, and \ -the compile runs — wasteful, but visible in the bill. If the step id is dropped or renamed while \ -the guard keeps naming it, the expression is ALSO empty and the build silently reverts to \ -compiling every time. So the class names both halves: the guard must be present, and the step it \ -reads must exist.", - routes: &[read("source read first", "the restore step")], - }, - VendoredVerdict { - id: "input render dropped", - gloss: "an unquoted comment truncates a value before it ever reaches the forge", - class: "YAML opens a comment at an unquoted space-hash, so a value carrying an \ -interpolation after one parses to the bare text before it and the rest is discarded. Measured: \ -one workflow carried exactly that for a day and 30 consecutive runs reported a title equal to the \ -workflow name, so a caller keying on the interpolated value could never match. Linters pass over \ -the line because a comment is legal YAML, and review reads it as the thing it was meant to be. \ -Read pre-parse, because the parse is what destroys the evidence. Quoting the value is the fix.", - routes: &[read("source read first", "the truncated line")], - }, - VendoredVerdict { - id: "head grade twice", - gloss: "the forge already judged this commit and a second run would re-ask it", - class: "A commit that has not changed cannot get a different verdict, so a second \ -run over it buys an answer that is already recorded and spends the metered tier to do it. \ -Measured on one consumer's landing bot over a half hour: 400 runs, 248 executed, against 5 \ -merges. Read the recorded verdict rather than asking for it again; if the intent was to \ -judge different work, the commit is what has to change.", - routes: &[ - read("source read first", "the forge record for this commit"), - // THE PRECONDITION IS THE WHOLE OF THIS ROUTE. A re-grade is - // legitimate when the recorded verdict is about the RUNNER rather - // than about the commit — a lost agent, an evicted node, an - // infrastructure fault — because that verdict answers a question - // nobody asked. It is not legitimate because the answer was - // unwelcome, which is the case this condition exists to exclude. - admit( - "path admit first", - "the recorded verdict is about a runner fault rather than about this commit", - ), - ], - }, - VendoredVerdict { - id: "patch ship twice", - gloss: "the target already carries this branch's changes, so landing them again buys nothing", - class: "A landing attempt over work the target already has runs a matrix, holds the \ -fleet's landing slot while it does, and merges a no-op or a conflict. The answer is decided by \ -PATCH IDENTITY rather than by reachability, which is what makes it trustworthy here: a rebased, \ -squash-merged or cherry-picked branch leaves the same change on the target under a different \ -commit with no ancestry path back, and on a fast-forward trunk that is the ordinary way work \ -lands. Close the branch, or rebase onto the target and see what is genuinely left.", - routes: &[ - read("record read first", "the landing verdict for this target"), - // The one legitimate re-land, and it is narrow on purpose. Patch - // identity answers about CONTENT, so deliberately re-applying a - // change the target once carried and later reverted is - // indistinguishable from never having landed it — the same bytes, - // arriving for a different reason. That is the case this admits, and - // it is not "the answer was inconvenient". - admit( - "patch admit first", - "the change is being deliberately re-applied after the target reverted it, so identical content is the intent rather than a duplicate", - ), - ], - }, - VendoredVerdict { - id: "lease grant other", - gloss: "a live landing lease names a different branch, and no reservation names this one", - class: "A landing lease is how a fleet keeps two branches from buying overlapping CI for \ -a trunk only one of them can fast-forward onto. This branch is neither the holder nor the \ -successor admitted behind it, so a matrix spent now is a matrix the holder's merge invalidates. \ -Wait for the lease to lapse or be released, or reserve the slot behind the holder — the loop that \ -does both lives outside the engine, which only reads the answer. Every reading this refusal \ -cannot take ALLOWS: an unreadable lease stops every job in the fleet, where waving one matrix \ -through costs one matrix.", - routes: &[ - read( - "lease read first", - "the lease grading recorded for this branch", - ), - // The wedged holder, and it is narrow on purpose. The lease grades - // LIVENESS rather than PROGRESS (CLOUD-499), so a holder that beats - // steadily while making none holds forever and starves the fleet. - // That is the case this admits, and it is not "the wait was - // inconvenient" — a holder that is merely slow is the mechanism - // working. - admit( - "lease admit first", - "the holder is wedged rather than slow — it is beating without advancing, so waiting for a lapse it keeps renewing starves the fleet indefinitely", - ), - ], - }, ]; /// Every class the binary ships, as the registry carries them. @@ -1824,28 +1578,43 @@ through costs one matrix.", /// a question about the pair, and this function knows only one of them. #[must_use] pub fn vendored() -> Vec { + // Native rows here, preset rows from their own manifests (CLOUD-1181). The + // presets' half used to sit in this same table under a comment, so a preset + // class and the preset that raises it were declared in different modules + // with nothing tying them together. VENDORED .iter() - .map(|entry| DeclaredVerdict { - id: entry.id.to_owned(), - gloss: entry.gloss.to_owned(), - class: entry.class.to_owned(), - routes: entry - .routes - .iter() - .map(|route| Route { - id: route.id.to_owned(), - kind: route.kind, - target: route.target.to_owned(), - precondition: route.precondition.map(str::to_owned), - }) - .collect(), - successor: None, - withdrawn: None, - }) + .map(declared_from) + .chain(crate::preset::verdict_rows()) .collect() } +/// One vendored row, as the registry carries it. +/// +/// Shared with [`crate::preset`] so the two halves of the vendored registry +/// cannot be projected differently — which is the same "one authority per fact" +/// reason the manifests exist at all. +#[must_use] +pub fn declared_from(entry: &VendoredVerdict) -> DeclaredVerdict { + DeclaredVerdict { + id: entry.id.to_owned(), + gloss: entry.gloss.to_owned(), + class: entry.class.to_owned(), + routes: entry + .routes + .iter() + .map(|route| Route { + id: route.id.to_owned(), + kind: route.kind, + target: route.target.to_owned(), + precondition: route.precondition.map(str::to_owned), + }) + .collect(), + successor: None, + withdrawn: None, + } +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index a0ad92175..fd8230725 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -144,6 +144,7 @@ mod policy_tree; mod policy_whole_set; mod pr_watch; mod prebuilt_lint; +mod preset_manifest; mod preset_segments; mod primitives; mod privileged_lane; diff --git a/crates/batten/tests/it/preset_manifest.rs b/crates/batten/tests/it/preset_manifest.rs new file mode 100644 index 000000000..1433a99a8 --- /dev/null +++ b/crates/batten/tests/it/preset_manifest.rs @@ -0,0 +1,119 @@ +//! End-to-end tests over the compiled binary for the preset manifests +//! (CLOUD-1181). +//! +//! The manifest's own `#[cfg(test)]` tier holds the two registry directions over +//! the tables. These drive the ENGINE, which is what proves a manifest field is +//! read at load rather than merely declared — the distinction +//! `.claude/rules/policy-modules.md` opens on. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use common::Fixture; + +/// Enabling a preset at the wrong scope is refused at load, naming the preset. +/// +/// # What this does and does not claim +/// +/// It does NOT claim to close a silent dead gate. Measured with the check +/// disabled and the binary rebuilt, this same config already failed to load: +/// the module-level input-key check catches `trunk-based` reading `input.call` +/// on the tree surface. What the manifest buys is that the refusal precedes +/// compilation and names the PRESET a consumer enabled, rather than a module +/// inside the binary they never wrote and cannot open. +#[test] +fn a_preset_enabled_at_the_wrong_scope_is_refused_naming_the_preset() { + let root = Fixture::new("preset-wrong-scope") + .config( + "version = 1\n\n\ + [[rule]]\n\ + id = \"wrong-scope\"\n\ + kind = \"policy\"\n\ + scope = \"tree\"\n\ + sources = [\"**/*.md\"]\n\ + preset = \"trunk-based\"\n\ + severity = \"deny\"\n", + ) + .build(); + let output = common::run(&root, &["check"]); + let stderr = String::from_utf8_lossy(&output.stderr); + // Exit 1: a config that will not load is a statement about the invocation, + // never a verdict about the repository. + assert_eq!( + output.status.code(), + Some(batten::exit::ExitCode::Usage.code()), + "a scope mismatch is a config fault: {stderr}" + ); + assert!( + stderr.contains("trunk-based") && stderr.contains("mediated_call"), + "the refusal names the preset and the scope its modules decide: {stderr}" + ); +} + +/// The anti-vacuity mirror: the same preset at its own scope loads. +/// +/// Without this the case above is satisfied by a build that refuses every +/// preset, which would name the right one every time and prove nothing. +#[test] +fn the_same_preset_at_its_declared_scope_loads() { + let root = Fixture::new("preset-right-scope") + .config( + "version = 1\n\n\ + [[rule]]\n\ + id = \"right-scope\"\n\ + kind = \"policy\"\n\ + scope = \"mediated_call\"\n\ + preset = \"trunk-based\"\n\ + severity = \"deny\"\n", + ) + .build(); + let output = common::run(&root, &["check"]); + assert_eq!( + output.status.code(), + Some(batten::exit::ExitCode::Success.code()), + "the preset must load at the scope its manifest declares: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Every preset a manifest declares can actually be enabled. +/// +/// The reachability arm. A manifest naming a preset the loader cannot resolve +/// would be a declaration with nothing behind it, and `preset_names()` is now +/// derived from these — so the published schema would offer a consumer a name +/// that fails at load. +#[test] +fn every_declared_preset_can_be_enabled_at_its_own_scope() { + for manifest in batten::preset::MANIFESTS { + let root = Fixture::new(&format!("preset-enable-{}", manifest.name)) + .config(&format!( + "version = 1\n\n\ + [[rule]]\n\ + id = \"enable\"\n\ + kind = \"policy\"\n\ + scope = \"{}\"\n\ + {}\ + preset = \"{}\"\n\ + severity = \"deny\"\n", + manifest.scope.as_str(), + if manifest.scope == batten::rules::RuleScope::Tree { + "sources = [\"**/*.md\"]\n" + } else { + "" + }, + manifest.name, + )) + .build(); + let output = common::run(&root, &["check"]); + assert_ne!( + output.status.code(), + Some(batten::exit::ExitCode::Usage.code()), + "`{}` is declared but cannot be enabled: {}", + manifest.name, + String::from_utf8_lossy(&output.stderr) + ); + } +} diff --git a/policy/module-layering.rego b/policy/module-layering.rego index 4564942fd..647801e24 100644 --- a/policy/module-layering.rego +++ b/policy/module-layering.rego @@ -77,6 +77,13 @@ declared_modules := { # is the binary entry point rather than a `pub mod` of the library, and it is # declared rather than excluded: it is a file in the judged set, and a # selector carve-out would be an exemption where a placement is honest. + # `preset` arrived with CLOUD-1181 and this rule named it before a human did + # — the same property the entries above record, working again. It sits BELOW + # `policy` and `verdict`: both read it, and it reads neither. That direction is + # the manifest's whole point — a preset's identity, scope, modules and + # vocabulary are one declaration that the two consumers project, rather than + # three tables that had to know about each other. + "preset", "brief", "main", "selfwrite", # `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` diff --git a/schema/batten.local.schema.json b/schema/batten.local.schema.json index 437f7842d..38386711c 100644 --- a/schema/batten.local.schema.json +++ b/schema/batten.local.schema.json @@ -1007,12 +1007,12 @@ "null" ], "enum": [ + "ci-hygiene", "commit-hygiene", - "trunk-based", - "shell-hygiene", + "landing-loop", "pinned-toolchain", - "ci-hygiene", - "landing-loop" + "shell-hygiene", + "trunk-based" ] }, "produces": { diff --git a/schema/batten.schema.json b/schema/batten.schema.json index 716ffdb8a..ce474c7b8 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -2917,12 +2917,12 @@ "null" ], "enum": [ + "ci-hygiene", "commit-hygiene", - "trunk-based", - "shell-hygiene", + "landing-loop", "pinned-toolchain", - "ci-hygiene", - "landing-loop" + "shell-hygiene", + "trunk-based" ] }, "produces": { From c8b4b84616a69ab46d8aa104425dbbea1fd3d306 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 02:01:29 +0000 Subject: [PATCH 11/12] docs: the extension guide names all ten rule kinds and how to reach a preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README's extension section was titled "three surfaces" while `RuleKind` had TEN variants. It named `command` and nothing else — not `forbid`, `shape`, `ratchet`, `receipt`, `pipeline`, `judge`, `secrets`, `document` or `policy`, and not rego modules, bundles or presets. Neither starter config mentioned a preset at all. That is a defect rather than a docs backlog item because CLOUD-836 vendored the presets on an explicit argument — a consumer should not have to author every predicate from scratch — and THAT ARGUMENT WAS UNREALISED WHILE THE FRONT DOOR DID NOT MENTION THEM. A consumer following the documented path wrote `command` rules by hand, which is the state the presets were shipped to end. The section now carries a row per kind, with the narrowest-kind guidance that makes the table decidable: a `command` rule spawns a process and every other kind is decided from facts the boundary already resolved, which is why only `command` is barred from the read-only `check` surface. `exec_pattern` and `fail_on_warning` move to their own table, because they are not rule kinds and listing them beside ten that are was part of what made "three surfaces" sound complete. Both starter configs gain a worked preset row, commented out, with the scope warning beside it — scope is the field to get wrong, and getting it wrong is refused at load rather than quietly deciding nothing. A CENSUS OVER THE ENUM, NOT A LIST — and the list is exactly what went stale here. `RuleKind::ALL` and `preset::MANIFESTS` are read directly, so a kind or a preset added later fails without anyone remembering to add a row. The heading's own number is held to the enum too: it was the first thing that was wrong and the last thing anyone would check. THE FIRST VERSION OF THE CENSUS DID NOT DISCRIMINATE, AND ONLY THE PROBE SAID SO. It searched the whole section for `` `policy` ``. Deleting the `policy` row from the table — reproducing the exact defect this row was filed for — left the word in the subsection heading below, and the probe came back GREEN over a guide a consumer scanning the table could not use. Rewritten to assert the TABLE ROW, which is the reach-for surface, it fails naming `["policy"]`. A gate whose first run passes is what CLOUD-418 warns about, and this is that warning paying off. The cases live in `extension_surfaces.rs` rather than a new file, because that suite already EXECUTES every example in this section against the compiled binary and pins the heading it documents — my first attempt was a second file that broke its heading assertion. The documented preset row is executed there too, on that suite's own discipline: a worked example is a claim about what the binary does. Refs: CLOUD-936 --- README.md | 83 +++++++++-- batten.example.toml | 30 ++++ crates/batten/src/starter.toml | 30 ++++ crates/batten/tests/it/extension_surfaces.rs | 147 ++++++++++++++++++- 4 files changed, 280 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ed2e4958f..92e9750e9 100644 --- a/README.md +++ b/README.md @@ -288,17 +288,82 @@ _diagnostic_, so it never returns `2` — every failure it can report is the config-or-usage class, and a harness must never read "this checkout is misconfigured" as a policy denial. -## Extending Batten: three surfaces, and which to reach for +## Extending Batten: ten rule kinds, and which to reach for Any predicate you can express as a command plus an exit code is expressible in -Batten. There are three ways to do it, and the failure mode is picking the wrong -one — so the boundary matters more than the mechanics. - -| What you are gating on | Reach for | Where it is configured | -| ------------------------------------------------------- | ------------------------ | -------------------------------- | -| A **file's contents** | a `command` rule kind | `[[rule]]` with `kind="command"` | -| A **command's output**, when the tool lies about exit 0 | `exec` output predicates | `[[exec_pattern]]` | -| An **existing warn finding**, to make it block | `fail_on_warning` | a top-level key | +Batten — and most of them need no command at all. The failure mode is picking the +wrong kind, so the boundary matters more than the mechanics. + +**Reach for the narrowest kind that fits.** A `command` rule spawns a process, +which can read any file and reach the network; every other kind is decided from +facts the boundary already resolved. That is why a `command` rule runs only under +`batten enforce` while the rest are admitted to the read-only `check` surface. + +| What you are gating on | Reach for | Where it is configured | +| ------------------------------------------------------------------ | ---------- | --------------------------------- | +| A **literal string** banned from matched files | `forbid` | `[[rule]]` with `kind="forbid"` | +| A **file's contents**, judged by a program you supply | `command` | `[[rule]]` with `kind="command"` | +| A **command line** an agent is about to run | `shape` | `[[rule]]` with `kind="shape"` | +| A **count that must not grow** — a budget you are paying down | `ratchet` | `[[rule]]` with `kind="ratchet"` | +| Whether a **verification receipt** exists and still answers | `receipt` | `[[rule]]` with `kind="receipt"` | +| The **shape of a pipeline** — how a call is composed | `pipeline` | `[[rule]]` with `kind="pipeline"` | +| A judgement a **model** makes, recorded with its own no-fix reason | `judge` | `[[rule]]` with `kind="judge"` | +| **Credentials** reaching a file, via a pinned scanner | `secrets` | `[[rule]]` with `kind="secrets"` | +| A **document's** own structure | `document` | `[[rule]]` with `kind="document"` | +| A **relationship between facts** no single row can express | `policy` | `[[rule]]` with `kind="policy"` | + +Two surfaces are not rule kinds and are configured on their own: + +| What you are gating on | Reach for | Where it is configured | +| ------------------------------------------------------- | ------------------------ | ---------------------- | +| A **command's output**, when the tool lies about exit 0 | `exec` output predicates | `[[exec_pattern]]` | +| An **existing warn finding**, to make it block | `fail_on_warning` | a top-level key | + +### Gating on a relationship between facts — a `policy` rule + +The other kinds are each one predicate over one object. A `policy` rule is a +[Rego](https://www.openpolicyagent.org/docs/latest/policy-language/) module +deciding over the whole resolved fact set, which is what makes a predicate over +the _relationship between_ facts expressible at all — the engine's own rule loop +is flat, and no row can consume another's verdict. + +```toml +[[rule]] +id = "no-orphan-workflow" +kind = "policy" +scope = "tree" +sources = [".github/workflows/*.yml"] +module = "policy/no-orphan-workflow.rego" +severity = "deny" +``` + +A module is **deny-only by construction** — there is no allow spelling — so +enabling one can never weaken policy, which is what preserves the raise-only +invariant above. A refusal it raises is `{rule, verdict, subjects}`, and the +`verdict` is a declared class rather than free prose, so +`batten policy explain ` reaches the remedy from any refusal. + +### Presets: the batteries, and how to switch one on + +Batten ships policy modules for common practices, compiled into the binary. They +are the reason a new repository does not have to author every predicate from +scratch — the same shape Conftest, Semgrep, ESLint and Clippy all take. + +```toml +[[rule]] +id = "trunk-based" +kind = "policy" +scope = "mediated_call" +preset = "trunk-based" +severity = "deny" +``` + +`batten config show` lists the presets this binary ships. Each declares the +**scope** its modules decide, and enabling one at the other scope is refused at +load rather than quietly deciding nothing. + +There is **no network and no registry**: a preset's bytes ship inside the binary +you already trust, under the same checksum as the rest of it. Everything a consumer adds is **raise-only** (§8): a git-ignored `batten.local.toml` may add a rule or a pattern, never redefine or remove one the diff --git a/batten.example.toml b/batten.example.toml index b030f56f8..5a73f443d 100644 --- a/batten.example.toml +++ b/batten.example.toml @@ -497,3 +497,33 @@ raw = ["span_text"] max_payload_bytes = 16384 run = "my-judge --model {{model}}" model = "some-model-id" + +# THE BATTERIES: policy presets, compiled into the binary. +# +# Every rule above is one predicate over one object. A `policy` rule is a Rego +# module deciding over the whole resolved fact set, which is what makes a +# predicate over the RELATIONSHIP between facts expressible — the engine's rule +# loop is flat, so no row can consume another's verdict. +# +# You do not have to write one to get value from them. Batten ships modules for +# common practices, and enabling one is a rule row naming it. `batten config show` +# lists what this binary carries; today that is `ci-hygiene`, `commit-hygiene`, +# `landing-loop`, `pinned-toolchain`, `shell-hygiene` and `trunk-based`. +# +# SCOPE IS THE FIELD TO GET RIGHT. A preset's modules read one surface's facts: +# `tree` for the working tree, `mediated_call` for the single command a hook is +# adjudicating. Enabling one at the wrong scope is refused at LOAD, naming both — +# it does not quietly evaluate and decide nothing. +# +# A preset is deny-only, like every module, so switching one on can only ADD +# refusals. There is no network and no registry: the bytes ship inside the binary +# you already trust, under the same checksum as the rest of it. +# +# Uncomment to enable one: +# +# [[rule]] +# id = "trunk-based" +# kind = "policy" +# scope = "mediated_call" +# preset = "trunk-based" +# severity = "deny" diff --git a/crates/batten/src/starter.toml b/crates/batten/src/starter.toml index de296cdb2..00389da3e 100644 --- a/crates/batten/src/starter.toml +++ b/crates/batten/src/starter.toml @@ -100,3 +100,33 @@ scope = "tree" # this list is config and a tracked path that cannot be read is unreadable config. [epoch] tracked = ["batten.toml"] + +# THE BATTERIES: policy presets, compiled into the binary. +# +# Every rule above is one predicate over one object. A `policy` rule is a Rego +# module deciding over the whole resolved fact set, which is what makes a +# predicate over the RELATIONSHIP between facts expressible — the engine's rule +# loop is flat, so no row can consume another's verdict. +# +# You do not have to write one to get value from them. Batten ships modules for +# common practices, and enabling one is a rule row naming it. `batten config show` +# lists what this binary carries; today that is `ci-hygiene`, `commit-hygiene`, +# `landing-loop`, `pinned-toolchain`, `shell-hygiene` and `trunk-based`. +# +# SCOPE IS THE FIELD TO GET RIGHT. A preset's modules read one surface's facts: +# `tree` for the working tree, `mediated_call` for the single command a hook is +# adjudicating. Enabling one at the wrong scope is refused at LOAD, naming both — +# it does not quietly evaluate and decide nothing. +# +# A preset is deny-only, like every module, so switching one on can only ADD +# refusals. There is no network and no registry: the bytes ship inside the binary +# you already trust, under the same checksum as the rest of it. +# +# Uncomment to enable one: +# +# [[rule]] +# id = "trunk-based" +# kind = "policy" +# scope = "mediated_call" +# preset = "trunk-based" +# severity = "deny" diff --git a/crates/batten/tests/it/extension_surfaces.rs b/crates/batten/tests/it/extension_surfaces.rs index 629ff68b0..03acf9305 100644 --- a/crates/batten/tests/it/extension_surfaces.rs +++ b/crates/batten/tests/it/extension_surfaces.rs @@ -29,7 +29,7 @@ use std::path::PathBuf; use common::{Fixture, StateHome, at_root, batten, scratch}; /// The README section these examples are drawn from. -const SECTION: &str = "## Extending Batten: three surfaces, and which to reach for"; +const SECTION: &str = "## Extending Batten: ten rule kinds, and which to reach for"; fn readme() -> String { fs::read_to_string(at_root("README.md")).expect("read README.md") @@ -341,3 +341,148 @@ fn only_exec_claims_a_channel_carrying_codes_batten_did_not_choose() { "the section must keep the property fail-open rests on: no `2` is minted here" ); } + +// --- the guide is COMPLETE, not merely correct (CLOUD-936) ------------------ +// +// The cases above execute the examples the section carries. These ask whether +// the section carries an example for everything the engine has — a different +// question, and the one that was silently wrong: the heading said "three +// surfaces" over a table of three while `RuleKind` had ten variants, so every +// test here passed over a guide that omitted a whole rule kind and the entire +// preset mechanism. +// +// A CENSUS OVER THE ENUM, NOT A LIST. A list is precisely what went stale, and +// `facts.rs` records the same lesson in its own words: "a list is what was +// already wrong here: an eighth variant would join the enum and go unasserted in +// silence." + +/// The extension section, bounded at the next top-level heading. +/// +/// Bounded so a kind named elsewhere in the README cannot satisfy a claim about +/// the extension GUIDE, which is the surface a consumer is sent to. +fn extension_section() -> String { + let readme = readme(); + let start = readme + .find("## Extending Batten") + .expect("the README carries an extension section"); + let rest = &readme[start..]; + let end = rest[3..] + .find("\n## ") + .map_or(rest.len(), |offset| offset + 3); + rest[..end].to_owned() +} + +/// Every kind has a row in the WHICH-TO-REACH-FOR TABLE, not merely a mention. +/// +/// THE FIRST VERSION OF THIS ASSERTION DID NOT DISCRIMINATE, AND THE PROBE IS +/// WHAT SAID SO. It searched the whole section for `` `policy` ``; deleting the +/// `policy` row from the table left the word in the subsection heading below, so +/// the test passed over the exact defect this row was filed for — a consumer +/// scanning the table to pick a kind would not find it. Measured, not reasoned: +/// the probe went green and the assertion was rewritten. +/// +/// The table is the surface, so the table is what is asserted. +#[test] +fn the_extension_guide_gives_every_rule_kind_a_row_in_the_table() { + let section = extension_section(); + let rows: Vec<&str> = section + .lines() + .filter(|line| line.starts_with('|')) + .collect(); + let missing: Vec<&str> = batten::rules::RuleKind::ALL + .iter() + .map(|kind| kind.as_str()) + .filter(|token| !rows.iter().any(|row| row.contains(&format!("`{token}`")))) + .collect(); + assert!( + missing.is_empty(), + "README.md's which-to-reach-for table has no row for {missing:?} — a shipped \ + rule kind a consumer scanning the table to pick one cannot find" + ); +} + +/// The heading counts the kinds it goes on to list. +/// +/// The heading was the first thing that was wrong and the last thing anyone +/// would check. A heading naming a number is a claim; this holds it to the enum. +#[test] +fn the_extension_headings_count_matches_the_enum() { + let section = extension_section(); + let heading = section.lines().next().expect("the section has a heading"); + let spelled = [ + "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", + "twelve", + ]; + let expected = spelled + .get(batten::rules::RuleKind::ALL.len() - 1) + .expect("the spelled-out numbers cover the enum"); + assert!( + heading.contains(expected), + "the heading says `{heading}` over {} rule kinds — it should say `{expected}`", + batten::rules::RuleKind::ALL.len() + ); +} + +/// Every shipped preset is named in the config a new repository starts from. +/// +/// CLOUD-836 vendored the presets so a consumer would not author every predicate +/// from scratch. That argument was unrealised while the starter mentioned none of +/// them: the feature shipped and the documented path did not reach it. +#[test] +fn the_starter_config_names_every_shipped_preset() { + for file in ["crates/batten/src/starter.toml", "batten.example.toml"] { + let text = + fs::read_to_string(at_root(file)).unwrap_or_else(|_| panic!("{file} is readable")); + let missing: Vec<&str> = batten::preset::MANIFESTS + .iter() + .map(|manifest| manifest.name) + .filter(|name| !text.contains(name)) + .collect(); + assert!( + missing.is_empty(), + "{file} names no {missing:?} — a preset the binary ships that the config a \ + consumer starts from does not mention" + ); + } +} + +/// And the guide shows how to switch one on, not merely that they exist. +/// +/// Separate from the name census: listing six names satisfies that one while +/// leaving a reader with no idea how to reach any of them. +#[test] +fn the_extension_guide_shows_how_to_enable_a_preset() { + let section = extension_section(); + assert!( + section.contains("preset = \""), + "README.md's extension section shows no `preset =` row, so a reader is told \ + presets exist and not how to reach one" + ); +} + +/// The documented preset row is EXECUTED, like every other example here. +/// +/// This suite's whole discipline: a worked example is a claim about what the +/// binary does. A preset example that does not load would be the documentation +/// defect this row is about, one layer down. +#[test] +fn the_documented_preset_example_loads() { + let (repo, home) = repo_with( + "readme-preset", + "version = 1\n\n\ + [[rule]]\n\ + id = \"trunk-based\"\n\ + kind = \"policy\"\n\ + scope = \"mediated_call\"\n\ + preset = \"trunk-based\"\n\ + severity = \"deny\"\n", + ); + let (code, _, stderr) = run(&repo, &home, &["check"]); + assert_eq!(code, 0, "the documented preset row must load: {stderr}"); + // And the README carries the row this just ran, so deleting the example + // cannot leave this passing. + assert!( + extension_section().contains("preset = \"trunk-based\""), + "the README no longer carries the preset example this case executes" + ); +} From 27228e2b8d5db0a3f1e2c286dcbd2e66ce8a10e2 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 02:05:21 +0000 Subject: [PATCH 12/12] fix(config): refresh the prune basis its own gate refused this branch on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `target-prune` refused the lap: `[prune.*.basis]` declared 152 tracked test files against a live 164, twelve past a tolerance of 10. That is the gate working — the floor it defends is `keep x stems x size`, and one taken against a smaller stem count passes and then lets the build write more than it budgeted for, arriving as a rustc IO error inside a test run rather than as a disk fault. The bundle this rides on added two compiled-binary tiers, so the count moved because the tree did. THE FLOORS DELIBERATELY DO NOT MOVE, which is the 2026-09-01 entry's reasoning unchanged rather than a shortcut. Free space was never the problem — the refusing lap reported 10733MB free against a 7938MB warm floor — and both floors sit far above what the grouped-target tree needs. A floor too high only refuses laps; one too low fails silently. Moving them down needs the independent measurement the basis block names (a build from an empty `target` for cold, a minimal post-prune tree for warm), which is CLOUD-1158's and was not taken here. Refreshing the count without claiming a floor measurement I did not take is the honest half of the remedy, and the block says so in those words. `count` and `measured` move together, as the block instructs: a count refreshed without a new basis pointer is the same staleness wearing a newer number. The two tiers that moved the count are `config_fault_class.rs` (CLOUD-1313) and `preset_manifest.rs` (CLOUD-1181); the other ten stems are drift this basis had not been refreshed for since 2026-09-01. Refs: CLOUD-1181 Admits: cb24a92dc8d57532c8bdeaa21ca74cdeb19991a47e510056835dd03e7ee1f0cb Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: cbc1fad4bc94f2936ddb68ede6e776e18b0456b9 Admits-epoch: 61e0bc9804b24107d5e35ed765eb8824404890b6ef57bf00754cc2e0b3f2a629 Admits-author: alec@wenzowski.com Admits-prev: bfe4071d4e5e287d41ddb6e4aaf4ddf0204429d4dbf38e9bf082f307c7aa2b84 Admits-answer-lost: `mise run verify` cannot run at all, so this branch cannot be readied or landed. The floor being defended would also stay measured against a tree twelve test stems smaller, which is the staleness the basis block exists to surface. Admits-answer-precondition: `target-prune` refused this branch because `[prune.*.basis]`'s declared count is twelve behind the live tree, and the block's own instruction is to move `count` and `measured` together. The basis lives in batten.toml and nowhere else; no surface verb re-measures it, and the gate that demands the change is the one reading the file. Admits-answer-rejected-route: `config read first` names batten.toml, which is the file being refused. `patch run first` (`git restore`) reverts the write rather than performing it, and reverting is precisely what leaves the gate refusing. --- batten.toml | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/batten.toml b/batten.toml index fc9a06e91..8d69c082d 100644 --- a/batten.toml +++ b/batten.toml @@ -5389,13 +5389,13 @@ keep = 2 mb = 7938 worst_mb = 7938 multiplier = 1 -measured = "2026-09-01" +measured = "2026-09-02" [prune.cold] mb = 18984 worst_mb = 18984 multiplier = 1 -measured = "2026-09-01" +measured = "2026-09-02" # THE BASIS EACH FLOOR WAS MEASURED AGAINST (CLOUD-1158), because `measured` is a # pointer to a basis and not the basis itself. @@ -5468,14 +5468,32 @@ measured = "2026-09-01" # exactly this basis move. Refreshing the count without claiming a floor # measurement I did not take is the honest half of the remedy. +# THE 2026-09-02 MOVE, AND IT IS THE 2026-09-01 MOVE AGAIN, DELIBERATELY. +# A bundle connecting the refusal ABI added two compiled-binary tiers — +# `config_fault_class.rs` and `preset_manifest.rs` — and the live count reached +# 164, twelve past the basis and outside the tolerance. That is this gate working: +# the floor it was defending was taken against a smaller tree. +# +# `count` moves to the live 164 with `measured`, as the block above instructs, and +# THE FLOORS AGAIN DELIBERATELY DO NOT MOVE. The reasoning is unchanged and is +# worth restating rather than assumed: both are far above what the grouped-target +# tree needs — the lap that tripped this reported 10733MB free against a 7938MB +# warm floor, so free space was never the problem — and a floor too high only +# refuses laps while one too low fails silently inside a test run. Moving them +# down needs the independent measurement this block names (a build from an empty +# `target` for cold, a minimal post-prune tree for warm), which is CLOUD-1158's +# and was not taken here. Refreshing the count without claiming a floor +# measurement I did not take is the honest half of the remedy, exactly as the +# entry above says. + [prune.warm.basis] glob = "crates/batten/tests/**/*.rs" -count = 152 +count = 164 tolerance = 10 [prune.cold.basis] glob = "crates/batten/tests/**/*.rs" -count = 152 +count = 164 tolerance = 10 # THE REGROWABLE ROOTS THE ESCALATION MAY DROP (CLOUD-1157), in the order it drops