From 7b0e09b659c831a7d4e551eeca8e22c0cb465e98 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 17 Sep 2026 05:34:49 +0000 Subject: [PATCH] fix(admission): a mint addressing no finding of a rule that fired refuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The anchor resolver bound `call:` whenever `(rule, subject)` matched no stored finding, and `apply_admissions` only ever looks up a `Finding` anchor — so a tree-scope override was articulated, spent, and queried by nothing. The ambiguity arm one line down refuses precisely to avoid "an override that appears to work"; the zero arm walked into it silently. Measured 2026-09-06: nine admissions issued and spent, eight findings unmoved. The open question the row required settling first — why the match returned zero while the findings were live — resolves to rule SELECTION, not write ordering: `admission_anchor` never reads the findings store, it runs the rule live, and `declared.id == rule` selected nothing because `--rule` carries a predicate id. The `policy::publishers_of` widening already fixed that. What survives is the silence, plus a subject spelling nothing tells the caller. So zero now splits on whether the rule PRODUCED anything here. Nothing produced still falls back to the HEAD exactly as every binding did — every mediated refusal, every engine-side name no bundle publishes, every delta-scoped row over a base that will not resolve. A rule that fired whose findings the subject addresses none of refuses, and names the subjects the scan did see, because a finding's pointer is one subject while a refusal line renders them all. Keyed on what the scan produced rather than on the row's declared `scope`: `scope` is `Tree` by default, and `publishers_of` selects rows without consulting scope at all, so a scope-keyed guard would fire by omission and refuse a delta-scoped row over an empty base — when the break-glass is needed most. The resolution also moves ahead of the articulation, because it can now refuse and the answers are held in memory and never persisted. Both `[[waiver]]` rows keyed to this row's removal go with it, and the write to the protected `batten.toml` they live in is articulated below. Refs: CLOUD-1551 Admits: 447623761002e664dd553982c5d991ee37dfc4385d7b259d248e2a55003d5f92 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:55ba1908fc6041bb83e4eb51002d6aa048abd2d1 Admits-epoch: 461ff9cd2dcc92a40cf05b40b0f5d8061f8cd6400341dfbc554b55ecebb93be6 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: CLOUD-1551's acceptance requires both waivers removed in the same change; leaving them lands the fix with its own evidence still suppressed Admits-answer-precondition: the change IS the removal of two [[waiver]] rows, which live in batten.toml and nowhere else, so no narrower surface can express it Admits-answer-rejected-route: git restore would discard the removal, and config read first points at the very file this change edits Admits: 4cf1c94ddf6722387227c1e372fc97790e946bc43c515b5668fa85134b494343 Admits-rule: issue file same Admits-verdict: issue file same Admits-subject: crates/batten/src/lib.rs Admits-anchor: finding:b80118c0a8c1a1ae6b5f99d8d1b997c5d84655ce5217cc29d1a5484c28feea23 Admits-epoch: e62b450968208680e4ed7d5cd6b5be263da64f3598c306ced4a0b8d845f3564d Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: this diff does not touch admit_mediated at all; the divergence is a mediated-surface policy choice about which classes become admissible, which CLOUD-1826 says outright must be decided rather than defaulted into, so folding it in would widen a fix into a policy change under cover of a landing Admits-answer-precondition: CLOUD-1826 DOCUMENTS the boundary of the change being landed: it exists because planning CLOUD-1551 established that the Refusal::subject divergence is NOT this row cause, and it names lib.rs:13845 and lib.rs:18289 as the evidence for that boundary rather than as work deferred out of this diff Admits-answer-rejected-route: task run first would close a row whose own body defers the decision it exists to frame; task run other would put a closing key in the PR body for work this PR does not implement; task run last cannot apply, since the citation IS the boundary statement and stripping lib.rs from the row would leave it evidence-free --- batten.toml | 80 ----------- crates/batten/src/lib.rs | 102 +++++++++++-- crates/batten/tests/it/admission.rs | 213 ++++++++++++++++++++++++++-- 3 files changed, 286 insertions(+), 109 deletions(-) diff --git a/batten.toml b/batten.toml index 7d3fd80a7..0b0921171 100644 --- a/batten.toml +++ b/batten.toml @@ -8059,86 +8059,6 @@ expires = "2026-10-31" # The expiry is short on purpose. PR #842 carries CLOUD-1387's fix; this should # be removed by whoever lands it, and the date is what forces the question if # they do not. -# CLOUD-1547's own row, refused by the gate it is landing beside — and the two -# reasons it cannot be cleared are both mechanism defects rather than facts about -# this branch. -# -# The row is NOT a punt: this PR implements CLOUD-1547 and closes it, so its §1 -# names these paths because they are the files this change edits. That is exactly -# what `closes` exempts. The exemption cannot fire here because the `pr-closes` -# record is minted from a `gh pr view --jq .body` call and there is **no `gh` on -# this host** — so `closes` is empty, and empty is not `closes_unreadable` either, -# because nothing ever attempted the call for the blocked store to record. -# CLOUD-1126 named that shape and CLOUD-1481 carries the residual. -# -# AND THE CLASS'S OWN OVERRIDE ROUTE IS SPENT WITHOUT SUPPRESSING. `issue file -# same` declares `path admit first`; nine admissions were issued and spent against -# these subjects and the findings did not move. The consumption is NOT missing — -# `admission::Anchor::Finding` exists for exactly this and `apply_admissions` -# suppresses stored findings by fingerprint. What fails is the ANCHOR RESOLVER: -# `lib.rs:5428` matches stored findings on `(rule, path)`, and on ZERO matches it -# falls back to `Anchor::Call`. Measured here, with the findings live and being -# reported in the same session: `Admits-anchor: call:2dee1726…`, and nothing -# queries a call anchor for a tree finding. The ambiguity arm refuses precisely -# to avoid "an override that appears to work"; the zero arm walks into it. -# CLOUD-1551, which also records why the match returned zero as the open question. -# -# A `[[waiver]]` and never `severity = "allow"`: this names the rule, states why, -# and LAPSES. Short on purpose — it should be removed by whoever lands CLOUD-1551, -# and the date is what forces the question if they do not. UNNARROWED because the -# eight findings are eight paths under one row, and a `path =` would need eight -# rows that all lapse together anyway. -# -# IT NAMES THE PREDICATE, NOT THE RULE, and that is the narrow spelling rather -# than a slip. `waiver::apply` filters FINDINGS and a policy finding carries the -# predicate id, so this suppresses `filed-over-own-diff` and leaves -# `filed-unrefined` — the rule's other predicate — still deciding. -# -# `config-lint` refused exactly this spelling until the same change repaired it: -# `waiver-names-no-rule` read only `config.rules`, so it blessed `filed-here` -# (which suppresses nothing) and refused this one (which does). CLOUD-1553 is -# that inversion; the fix is in `lint.rs` in this commit, because a wrongly -# refusing gate is a defect to repair rather than a ticket to file. -[[waiver]] -rule = "issue file same" -reason = "CLOUD-1547 is implemented and closed by this PR, so `closes` is the exemption that applies; it cannot fire because `pr-closes` is minted from a `gh pr view` call and there is no `gh` on this host (CLOUD-1126, residual on CLOUD-1481). The class's own `path admit first` override route does not consume — eight admissions were spent and the findings did not move, because the anchor resolver falls back to a `call:` anchor on zero stored-finding matches and nothing queries one for a tree finding (CLOUD-1551). Remove this with CLOUD-1551." -expires = "2026-10-11" - -# THE SIBLING ARM OF THE SAME DEFECT, and the arm above was scoped to half of it. -# `filed-over-own-diff` and `filed-and-left-open` are two predicates in one module -# (`policy/filed-here.rego`) and CLOUD-1551 defeats the override route for both; -# only the first was waived, because only the first had fired yet. -# -# MEASURED HERE, and it is a sharper mechanism than "the override suppresses -# nothing". It suppresses until HEAD moves. Two consecutive laps of `mise run -# land` over one tree, one finding, one spent admission (`06b322be`, anchored -# `call:550939ab`): lap 1 replayed nothing and reported 0; lap 2 replayed onto a -# trunk that had moved and reported 1. The rebase rewrote `550939ab` as -# `7b705e64` — `git cat-file` still finds the object and -# `git merge-base --is-ancestor` exits 1 — so the anchor addressed a commit the -# branch no longer contains. -# -# WHICH MAKES THE NARROW ROUTE STRUCTURALLY UNAVAILABLE RATHER THAN FRAGILE, and -# that is why this is a waiver rather than another admission. `override request` -# anchors at the current HEAD, and the commit carrying the resulting `Admits:` -# block BECOMES a new HEAD, so a re-mint is stale the moment it lands; a commit -# on top has the same shape. And this waiver cannot be narrowed by `path` either: -# `Waiver::path` is a path glob and this rule's subject is `{"artifact": id}` with -# no path in it. Rule-wide is the only shape available, which is exactly the cost -# CLOUD-1551 imposes and exactly why it should be paid down rather than lived in. -# -# The articulation the admission was meant to carry is not lost — it is in -# `13c0dd5e`'s message, hash-bound, where a reviewer reads it. What CLOUD-1635 is -# and why it is deferred: `mcp-allow-check.sh` is a governed shell gate, the fix -# was attempted as an edit, `shell-rule-retired` refused it correctly, and the row -# is re-scoped to the retirement `rules/toolchain.md` says it always was. -# -# Remove this with CLOUD-1551, alongside its sibling above. -[[waiver]] -rule = "issue file held" -reason = "CLOUD-1551 defeats this rule's override route the same way it defeats `filed-over-own-diff` above, and measurably worse: an admission binds `call:`, so the replay every landing lap performs when trunk moves orphans the anchor and the spent admission stops suppressing. Measured on two consecutive laps of one tree — lap 1 replayed nothing and reported 0, lap 2 replayed and reported 1, with `06b322be` spent throughout. Re-minting cannot escape it, since the commit carrying the `Admits:` block becomes the new HEAD; and `Waiver::path` cannot narrow this rule, whose subject is an artifact id rather than a path. The deferral's articulation is hash-bound in `13c0dd5e` regardless. Remove this with CLOUD-1551." -expires = "2026-10-11" - [[waiver]] rule = "claim mint absent" reason = "CLOUD-1387: `captured::reduce` selects the first capture whose bytes MENTION the key rather than the one it is the subject of, so a response quoting the row shadows its real payload and `present` answers false over a row that is on a project. Fix is open in PR #842; remove this waiver with it." diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index c127ef708..787979e2e 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -6200,12 +6200,22 @@ fn run_override_spend( /// `a_mint_for_a_mediated_rule_anchors_the_call_not_a_tree_finding` pins it, /// because the property lives two files away from the code that relies on it. /// -/// **Falls back to [`admission::Anchor::Call`] rather than failing**, and the -/// fallback is never weaker than what shipped before: a situation with no tree -/// finding behind it — a mediated refusal, or a protocol-level mint — binds the -/// HEAD exactly as every binding did, so nothing that worked stops working. What -/// it cannot do is suppress a tree finding, because `apply_admissions` builds a -/// `Finding` anchor and the two tokens are tagged apart. +/// **Falls back to [`admission::Anchor::Call`] rather than failing, but ONLY +/// where the rule produced no finding at all** (CLOUD-1551). That fallback is +/// never weaker than what shipped before: a situation with no tree finding +/// behind it — a mediated refusal, a protocol-level mint, a delta-scoped row +/// whose base will not resolve — binds the HEAD exactly as every binding did, so +/// nothing that worked stops working. What it cannot do is suppress a tree +/// finding, because `apply_admissions` builds a `Finding` anchor and the two +/// tokens are tagged apart. +/// +/// **A rule that FIRED and a subject that addressed none of it refuses**, on the +/// ambiguity arm's reasoning below and for the identical reason: the caller +/// would answer the questions, spend the address, and suppress nothing. The +/// refusal names the subjects the scan did see, because a mismatch here is +/// usually a spelling — a finding's pointer is the first path-bearing subject or +/// the first subject rendered (CLOUD-1051), while a refusal line renders all of +/// them joined by a space. /// /// **Ambiguity REFUSES rather than falling back**, and the asymmetry with the /// absent case is the point. Two findings sharing `(rule, path)` mean the pair @@ -6328,9 +6338,11 @@ fn admission_anchor( // answer for an engine-side rule name, which is a legitimate thing to // mint against — `protected-mutation` has a real override route. // - // Silent, deliberately: reporting the rule as unknown here would decide - // CLOUD-1551's open question about an anchor that binds `call:` when - // nothing matches, and this row must not settle that by accident. + // Silent, and CLOUD-1551 settled it that way rather than leaving it + // open: a rule nothing publishes produced no finding, which is the same + // state the zero arm below falls back on. The refusal there is reserved + // for a rule that FIRED and a subject that addressed none of it, and + // this return cannot be that. if publishers.is_empty() { return head(); } @@ -6383,11 +6395,59 @@ fn admission_anchor( matched.dedup(); match matched.len() { 1 => Ok(admission::Anchor::Finding(matched.remove(0))), - // NOTHING TO ANCHOR falls back, and the fallback is never weaker than - // what shipped: a situation with no tree finding behind it — a mediated - // refusal, or a protocol-level mint — binds the HEAD exactly as every - // binding did. - 0 => head(), + // ZERO SPLITS IN TWO, AND THE SPLIT IS CLOUD-1551 (with CLOUD-1374 and + // CLOUD-1378 folded into it). One arm is the honest fallback this + // always was; the other is the ambiguity arm's defect wearing a smaller + // count, and it shipped silent. + // + // THE DISCRIMINATOR IS WHAT THE RULE PRODUCED, NEVER THE ROW'S DECLARED + // SCOPE. `scope` is `RuleScope::Tree` by default, so a row that omits + // the key reads as tree-scoped; and the widening above selects rows + // through `policy::publishers_of`, which reads a bundle's declared set + // and never consults scope at all. "A tree-scoped row was selected" + // therefore fires by omission, and it would refuse a delta-scoped row + // over an empty base — precisely when a caller needs the break-glass. + // What the two arms differ on is whether there was a finding to + // address, and this scan already holds that fact. + 0 => { + let mut named: Vec<&str> = scan + .findings + .iter() + .filter(|finding| finding.rule == rule) + .map(|finding| finding.path.as_str()) + .collect(); + named.sort_unstable(); + named.dedup(); + if named.is_empty() { + // NOTHING THIS RULE PRODUCED, so there is no tree finding to + // address and the fallback is never weaker than what shipped: a + // situation with no tree finding behind it — a mediated + // refusal, a protocol-level mint, a row whose base would not + // resolve — binds the HEAD exactly as every binding did. + return head(); + } + // THE RULE FIRED AND THE SUBJECT ADDRESSED NONE OF IT, which is the + // ambiguity arm's failure mode reached by a different count. + // `apply_admissions` looks up a `Finding` anchor, so a `Call` one + // minted for a tree finding is queried by nothing: the caller + // answers the questions, spends the address, and suppresses + // nothing. Measured 2026-09-06 on the CLOUD-1547 branch — nine + // admissions issued and spent, eight findings unmoved. + // + // IT NAMES WHAT THE SCAN DID SEE, because that is the fix + // (CLOUD-122) and the cause is a spelling more often than not: a + // finding's pointer is the first path-bearing subject, or the first + // subject RENDERED when it carries none (CLOUD-1051), while a + // refusal line renders every subject joined by a space. A caller + // copying that line mints a subject no finding carries. Pointers + // rather than content (rule 4) — the same strings `check` prints. + Err(UsageError::raise(format!( + "no finding for rule `{rule}` names subject `{subject}`, so an admission bound \ + to it would suppress nothing; that rule names {} subject(s) here: {}", + named.len(), + named.join(", ") + ))) + } // AMBIGUITY REFUSES, because falling back here is a silent no-op. The // caller would be handed an address, spend it, and suppress nothing: // `apply_admissions` looks up a `Finding` anchor, so a `Call` one stored @@ -6462,6 +6522,19 @@ fn run_override_request( ))); }; + // BEFORE THE ARTICULATION IS ASKED FOR, never after it has been written + // (CLOUD-1551). Resolving the anchor can now REFUSE — a rule that fired + // whose findings this subject addresses none of — and the answers are held + // in memory and never persisted, so refusing below would charge the caller + // three written paragraphs and throw them away. Where a harness wires this + // verb as a declared route and feeds the answers from a file, that is a + // loop an agent thrashes in rather than a one-off cost. An unbindable mint + // now refuses before anything is composed. + // + // The scan this pays for is the narrowed one (CLOUD-1571), so the + // questions-only path costs one rule over one subject rather than the tree. + let anchor = admission_anchor(root, &config, rule, subject)?; + let mut raw = String::new(); if std::io::stdin().read_to_string(&mut raw).is_err() { raw.clear(); @@ -6480,7 +6553,6 @@ fn run_override_request( return Ok(ExitCode::Usage); } - let anchor = admission_anchor(root, &config, rule, subject)?; // The SAME epoch `config epoch` reports, resolved through the same function, // so an admission cannot bind a generation the caller could not look up. let (epoch, _) = epoch::describe(root, None)?; diff --git a/crates/batten/tests/it/admission.rs b/crates/batten/tests/it/admission.rs index cb51ebdeb..9e13125e8 100644 --- a/crates/batten/tests/it/admission.rs +++ b/crates/batten/tests/it/admission.rs @@ -865,12 +865,59 @@ violation contains { } "#; -fn admits_fixture(name: &str) -> PathBuf { +/// Both files, so a case can spend against one finding and watch the other +/// survive (CLOUD-1551). +/// +/// The harvesting property needs TWO findings to be about anything. Asserting it +/// against a subject with no finding at all measures the fallback instead, which +/// is the arm CLOUD-1551 removed. +const BOTH: &str = r#" +package batten.admits + +import rego.v1 + +rules contains "always-refuses" + +violation contains { + "rule": "always-refuses", + "verdict": "always probe probe", + "subjects": [{"path": path}], +} if { + some path in ["a.rs", "b.rs"] +} +"#; + +/// Conditioned on the tree, so a fixture decides whether the rule fires at all. +/// +/// That is the discriminator the zero arm splits on (CLOUD-1551): the same rule +/// and the same subject, differing only in whether there was a finding to +/// address. An unconditional module cannot express the fail-open half. +const CONDITIONAL: &str = r#" +package batten.admits + +import rego.v1 + +rules contains "always-refuses" + +violation contains { + "rule": "always-refuses", + "verdict": "always probe probe", + "subjects": [{"path": "a.rs"}], +} if { + "a.rs" in input.tree.tracked +} +"#; + +/// [`admits_fixture`]'s body with the module and the tracked files chosen by the +/// caller — the two axes CLOUD-1551's cases vary, and a third near-copy of this +/// builder is what naming them buys out. +fn admits_fixture_of(name: &str, module: &str, files: &[&str]) -> PathBuf { let root = common::scratch(&format!("admits-{name}")); common::write(&root, "batten.toml", ADMITS); - common::write(&root, "policy-admits/gate.rego", ALWAYS); - common::write(&root, "a.rs", "fn main() {}\n"); - common::write(&root, "b.rs", "fn other() {}\n"); + common::write(&root, "policy-admits/gate.rego", module); + for file in files { + common::write(&root, file, "fn main() {}\n"); + } common::git_in(&root, &["init", "-q", "-b", "main"]); common::git_in(&root, &["add", "-A"]); common::git_in(&root, &["commit", "-qm", "seed"]); @@ -881,6 +928,21 @@ fn admits_fixture(name: &str) -> PathBuf { root } +fn admits_fixture(name: &str) -> PathBuf { + admits_fixture_of(name, ALWAYS, &["a.rs", "b.rs"]) +} + +/// How many records the store holds, which is `0` for a mint that refused. +/// +/// An absent directory and an empty one are the same answer here: the fixture +/// removes the store at setup, so either means nothing was written. +fn records_in(root: &Path) -> usize { + admission::store_dir(root) + .ok() + .and_then(|store| std::fs::read_dir(store).ok()) + .map_or(0, |entries| entries.filter_map(Result::ok).count()) +} + /// Mint and spend one admission for `subject`, through the verbs a human uses. fn spend_for(root: &Path, subject: &str, reason: &str) -> String { let issued = common::run_with_stdin( @@ -1137,23 +1199,146 @@ fn an_admission_for_another_subject_admits_nothing() { // The harvesting case at the suppression surface: one legitimate override // must not clear every finding the same rule raises. // - // Under CLOUD-1125's anchor this holds for a second reason worth naming: - // `always-refuses` names `a.rs` in the module itself, so its finding is - // `Scope`-kind — `(rule, path)`, no span — and a mint for `b.rs` resolves no - // finding at all, falling back to a `Call` anchor the tree path cannot match. - // Editing `a.rs` is deliberately NOT expected to expire an admission over it: - // a scope finding's identity carries no content, which is the identity the - // findings store already dedups by rather than a weaker one chosen here. - let root = admits_fixture("other-subject"); - spend_for(&root, "b.rs", "this case is about the subject term"); + // REWRITTEN OVER TWO FINDINGS (CLOUD-1551). It used to mint for `b.rs` + // against a module that only refuses `a.rs`, and its own comment said what + // it was really measuring: "a mint for `b.rs` resolves no finding at all, + // falling back to a `Call` anchor the tree path cannot match". That is the + // silent arm this row removed, so the case was asserting the defect. The + // property it exists for needs a second finding to be about anything at + // all — spend against one of two, and the other must survive. + // + // Editing `a.rs` is deliberately NOT expected to expire an admission over + // it: a scope finding's identity carries no content, which is the identity + // the findings store already dedups by rather than a weaker one chosen here. + let root = admits_fixture_of("other-subject", BOTH, &["a.rs", "b.rs"]); + let address = spend_for(&root, "a.rs", "this case is about the subject term"); let after = common::run(&root, &["check"]); assert_eq!( after.status.code(), Some(batten::exit::ExitCode::Violation.code()), - "the finding on a.rs is untouched by an admission for b.rs: {}", + "the finding on b.rs is untouched by an admission for a.rs: {}", common::stderr(&after) ); + // AND THE RIGHT ONE MOVED. Without this the case passes over a run that + // admitted nothing at all, which is the vacuous reading of "still refuses". + let reported = common::stderr(&after); + assert!( + reported.contains("admitted a.rs") && reported.contains(&address), + "the admission spent on a.rs did clear a.rs: {reported}" + ); + assert!( + !reported.contains("admitted b.rs"), + "and it did not reach b.rs: {reported}" + ); +} + +#[test] +fn a_mint_addressing_no_finding_of_a_rule_that_fired_refuses_rather_than_binding_the_call() { + // CLOUD-1551, and the arm the ambiguity arm was already defended against. + // + // `always-refuses` names `a.rs` and nothing else, so a mint for `b.rs` + // matches no finding. Binding `call:` there is a silent no-op: + // `apply_admissions` looks up a `Finding` anchor, so the caller answers + // three questions, spends the address, and suppresses nothing. Measured + // 2026-09-06 — nine admissions issued and spent, eight findings unmoved. + // + // Fails by: against the binary as it stood, `override request` exits 0, the + // record is written with a `call:` anchor, and nothing says so. + let root = admits_fixture("addresses-nothing"); + let refused = common::run_with_stdin( + &root, + &[ + "override", + "request", + "--rule", + "always-refuses", + "--verdict", + "always probe probe", + "--subject", + "b.rs", + ], + "precondition=the refusal is the fixture's point\nlost=nothing\n\ + rejected-route=admits fix probe has nothing to change\n", + ); + assert_eq!( + refused.status.code(), + Some(batten::exit::ExitCode::Usage.code()), + "a subject addressing none of what the rule produced refuses: {}", + common::stderr(&refused) + ); + // THE REFUSAL NAMES THE FIX (CLOUD-122), because the cause is a spelling + // more often than not and the caller cannot see the finding's own pointer + // from here. + let said = common::stderr(&refused); + assert!( + said.contains("a.rs"), + "it names the subject the scan did see: {said}" + ); + // AND NOTHING WAS MINTED. A refusal that still wrote the record would leave + // a spendable address behind, which is the no-op with an extra step. + assert_eq!( + records_in(&root), + 0, + "a refused mint writes no record: {said}" + ); +} + +#[test] +fn a_mint_for_a_rule_that_produced_no_finding_still_falls_back_to_the_call() { + // THE FAIL-OPEN HALF, and the conjunct the case above does not reach + // (CLOUD-418). The refusal is keyed on the rule having FIRED, never on the + // row's declared scope — `scope` defaults to `Tree` when a row omits it, and + // `policy::publishers_of` selects rows without consulting scope at all, so a + // scope-keyed guard would refuse a delta-scoped row over an empty base: + // exactly when a caller needs the break-glass. + // + // Same rule, same subject, same authority as the case above. The only thing + // that differs is whether `a.rs` is in the tree for the module to name. + // + // Fails by: key the refusal on the row's scope instead and this exits 1. + use batten::admission::{Anchor, Record}; + + let root = admits_fixture_of("produced-nothing", CONDITIONAL, &["b.rs"]); + let clean = common::run(&root, &["check"]); + assert_eq!( + clean.status.code(), + Some(batten::exit::ExitCode::Success.code()), + "the premise: with `a.rs` absent the rule produces nothing: {}", + common::stderr(&clean) + ); + + let issued = common::run_with_stdin( + &root, + &[ + "override", + "request", + "--rule", + "always-refuses", + "--verdict", + "always probe probe", + "--subject", + "a.rs", + ], + "precondition=the refusal is the fixture's point\nlost=nothing\n\ + rejected-route=admits fix probe has nothing to change\n", + ); + let address = String::from_utf8_lossy(&issued.stdout).trim().to_owned(); + assert_eq!( + address.len(), + 64, + "a rule that produced nothing still mints: {}", + common::stderr(&issued) + ); + + let path = batten::admission::record_path(&root, &address).expect("record path"); + let record: Record = + serde_json::from_slice(&std::fs::read(&path).expect("read")).expect("parse"); + assert!( + matches!(record.binding.anchor, Anchor::Call { .. }), + "and it binds the HEAD exactly as every binding did: {:?}", + record.binding.anchor + ); } #[test]