diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index 73084dd12..16aa5bb8a 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -1386,7 +1386,8 @@ pub const RETIRED_KEYS: &[(&str, &str)] = &[( /// /// [`trust::load_base`]: crate::trust::load_base pub fn parse_base(text: &str, source: &str) -> Result { - let mut table: toml::Table = toml::from_str(text).map_err(|err| config_error(source, &err))?; + let mut table: toml::Table = + toml::from_str(text).map_err(|err| config_error(source, text, &err))?; // Nothing is reported when a key is dropped: the report this feeds is a // comparison of two policies, and "the base declared a key this build no // longer has" is a fact about the build rather than about either policy. @@ -1518,7 +1519,8 @@ pub fn parse_override(text: &str, source: &str) -> Result { prune_unresolvable::(text, binary_is_behind_the_config(source, text)); let config = match pruned.config { Some(config) => config, - None => toml::from_str(&pruned.text).map_err(|err| config_error(source, &err))?, + None => toml::from_str(&pruned.text) + .map_err(|err| config_error(source, &pruned.text, &err))?, }; (config, pruned.dropped) }; @@ -1967,8 +1969,35 @@ fn names_an_unknown_key(rendered: &str) -> bool { //MUTANT-SUITE crates/batten/tests/it/config_skew.rs //MUTANT skew-reads-as-malformed|s@ if !names_an_unknown_key(&rendered) {@ if true {@|an_unknown_key_names_the_rebuild //MUTANT every-parse-error-blames-skew|s@ if !names_an_unknown_key(&rendered) {@ if false {@|a_malformed_config_does_not_mention_a_rebuild -pub(crate) fn config_error(source: &str, err: &toml::de::Error) -> anyhow::Error { +pub(crate) fn config_error(source: &str, text: &str, err: &toml::de::Error) -> anyhow::Error { let rendered = err.to_string(); + // THE SYNTAX PROBE, AND IT RUNS ONLY HERE — ON THE ERROR PATH (CLOUD-1677). + // + // `toml::de::Error` is the one type for two very different faults, and the + // rendering hides it: a missing field and an invalid type both arrive as + // "TOML parse error at line N, column C", exactly like a stray brace. Reading + // the message cannot tell them apart, and `names_an_unknown_key` answers a + // third question again — measured on the `[[fact]]`-with-no-`returns` fixture, + // which is a SCHEMA fault that renders as a parse error and was classed as + // unreadable by the message alone. + // + // A `Table` parse answers it exactly: if the bytes are well-formed TOML then + // whatever failed was the SCHEMA over them, and the file still has rows a + // build can read. This is the probe the comment in `parse_ungated` records as + // removed for costing a parse on the hot path — it is free here, because + // nothing reaches this function until a parse has already failed. + if toml::from_str::(text).is_err() { + // CLASSED, AND THE CLASS IS A DISCRIMINATOR RATHER THAN A LABEL. This is + // the one config fault with no partial function left to preserve — the + // file is not TOML, so no row is readable and none can be enforced. Every + // other fault leaves the rest of the file deciding, which is what lets an + // agent be told to repair the broken part instead of losing the gate + // surface entirely. `Native::ConfigUnreadable` carries the full argument. + return UsageError::raise_as( + crate::verdict::Native::ConfigUnreadable, + format!("invalid config {source}: {err}"), + ); + } if !names_an_unknown_key(&rendered) { return UsageError::raise(format!("invalid config {source}: {err}")); } @@ -3042,7 +3071,8 @@ fn parse_ungated(text: &str, source: &str) -> Result { let pruned = prune_unresolvable::(text, binary_is_behind_the_config(source, text)); let config = match pruned.config { Some(config) => config, - None => toml::from_str(&pruned.text).map_err(|err| config_error(source, &err))?, + None => toml::from_str(&pruned.text) + .map_err(|err| config_error(source, &pruned.text, &err))?, }; (config, pruned.dropped) }; diff --git a/crates/batten/src/exec.rs b/crates/batten/src/exec.rs index 07e9d9511..9bf4d15af 100644 --- a/crates/batten/src/exec.rs +++ b/crates/batten/src/exec.rs @@ -1742,7 +1742,15 @@ pub(crate) fn piped( // relative name cannot arise and handing a directory would only be a guess at // one. // `Drop`: both callers of this entry point parse the string it returns. - piped_through(root, None, path.to_str()?, args, stdin, Diagnostics::Drop) + piped_through( + root, + None, + path.to_str()?, + args, + stdin, + Diagnostics::Drop, + &[], + ) } /// The one spawn both piped entry points share. @@ -1777,16 +1785,38 @@ fn piped_through( args: &[String], stdin: &str, diagnostics: Diagnostics, + published: &[(String, Option)], ) -> Option<(i32, String)> { let mut child = crate::rules::spawn_resolving(resolve_root, program, |resolved, extra| { - Command::new(OsString::from(resolved)) + let mut command = Command::new(OsString::from(resolved)); + command .args(extra.iter().map(OsString::from)) .args(args) .current_dir(root) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(diagnostics.redirection()) - .spawn() + .stderr(diagnostics.redirection()); + // THE BET TRAVELS TO EVERY GATE THAT READS THE COMMIT RANGE, not only to + // `verify`'s (CLOUD-1770). `BATTEN_SPEC_BASE` is the boundary + // `claimed-keys` narrows on, and it was published into the verify child's + // environment alone — so `closing-key-check`, which runs at a later step + // and delegates to the same reader, saw no bet and counted the holder's + // borrowed keys as this branch's own. Measured: two lease acquisitions + // spent and handed back on keys the speculation adopted. + // A `None` REMOVES rather than skips, and the difference is the whole + // reason this is `Option` (measured: this file's own suite runs INSIDE a + // `land` gate, which exports the variable, so a child that merely + // inherited it read a bet that was not its own). Publishing must be a + // FUNCTION of the bet — a lap with none outstanding has to say so, or a + // stale value from an outer process narrows the inner lap's commit range + // against a base it never borrowed. + for (name, value) in published { + match value { + Some(value) => command.env(name, value), + None => command.env_remove(name), + }; + } + command.spawn() }) .ok()?; // TAKEN AND DROPPED EVEN WHEN EMPTY, because a gate that reads stdin blocks @@ -1975,13 +2005,22 @@ pub(crate) fn piped_argv( argv: &[String], stdin: &str, diagnostics: Diagnostics, + published: &[(String, Option)], ) -> Option<(i32, String)> { let (program, operands) = argv.split_first()?; // `Some(root)`, where [`piped`] passes `None`: the first word here is a NAME // the ladder resolves, so rung 3 needs a directory to read a shebang out of. // That one argument IS the difference between the two entry points, which is // why they share [`piped_through`] and not a signature. - piped_through(root, Some(root), program, operands, stdin, diagnostics) + piped_through( + root, + Some(root), + program, + operands, + stdin, + diagnostics, + published, + ) } /// This process's next dispatch number, for the live-capture key. diff --git a/crates/batten/src/land.rs b/crates/batten/src/land.rs index ee84e9c33..50ec0d5de 100644 --- a/crates/batten/src/land.rs +++ b/crates/batten/src/land.rs @@ -1597,7 +1597,12 @@ pub enum Readied { /// right way round: a gate that cannot run has not passed, and treating it as /// clean is exactly how a retired or renamed gate goes silently dead. #[must_use] -pub fn ready(root: &Path, gates: &[Vec], body: &str) -> Readied { +pub fn ready( + root: &Path, + gates: &[Vec], + body: &str, + published: &[(String, Option)], +) -> Readied { if body.trim().is_empty() { return Readied::Clear; } @@ -1611,8 +1616,15 @@ pub fn ready(root: &Path, gates: &[Vec], body: &str) -> Readied { // is identical across every possible finding is not a pointer (review of // #848). let gate = argv.join(" "); + // `published` CARRIES THE BET (CLOUD-1770). A body gate that reads the + // PR's commit range — `closing-key-check` does, through `claimed-keys` — + // cannot otherwise tell a commit this branch authored from one the + // speculation adopted, and counts the holder's keys as this branch's + // stranded ones. CLOUD-748 fixed that for `claim-race-check` by + // publishing the base into `verify`'s child; this is the same boundary + // reaching the same reader through its other caller. let Some((code, output)) = - crate::exec::piped_argv(root, argv, body, crate::exec::Diagnostics::Keep) + crate::exec::piped_argv(root, argv, body, crate::exec::Diagnostics::Keep, published) else { return Readied::Unrunnable { gate }; }; @@ -1838,7 +1850,7 @@ pub fn admits_the_landing(root: &Path, gates: &[Vec], pr: &str) -> Admit let gate = with_pr.join(" "); with_pr.push(pr.to_owned()); let Some((code, output)) = - crate::exec::piped_argv(root, &with_pr, "", crate::exec::Diagnostics::Keep) + crate::exec::piped_argv(root, &with_pr, "", crate::exec::Diagnostics::Keep, &[]) else { // AN ADVISORY GATE THAT WILL NOT RUN IS NOT A REFUSAL EITHER, which // is the same reading one line down rather than a separate decision: @@ -3041,13 +3053,13 @@ mod tests { )]]; assert_eq!( - super::ready(&root, &gate, " \n "), + super::ready(&root, &gate, " \n ", &[]), super::Readied::Clear, "a body the fetch never produced says nothing, so there is nothing to judge" ); assert_eq!( - super::ready(&root, &gate, "Closes CLOUD-1"), + super::ready(&root, &gate, "Closes CLOUD-1", &[]), super::Readied::Unrunnable { gate: String::from("batten-no-such-program-for-the-ready-phase"), }, @@ -3077,7 +3089,7 @@ mod tests { ]]; assert_eq!( - super::ready(&root, &gate, "Closes CLOUD-1"), + super::ready(&root, &gate, "Closes CLOUD-1", &[]), super::Readied::Unrunnable { gate: String::from( "batten-no-such-runner-for-the-ready-phase run closing-key-check" @@ -3087,12 +3099,67 @@ mod tests { ); } + /// **THE BET REACHES A BODY GATE, AND FOR ITS WHOLE LIFE IT DID NOT** + /// (CLOUD-1770). + /// + /// `BATTEN_SPEC_BASE` is the boundary `claimed-keys` narrows the commit range + /// on. It was published into `verify`'s child environment alone, so + /// `closing-key-check` — a LATER step delegating to that same reader — saw no + /// bet and counted the lease holder's borrowed commits as keys this branch + /// had served and stranded. Measured over one session: two lease acquisitions + /// taken, spent on a 21–28 minute gate, and handed straight back. + /// + /// The gate here is `sh -c` over the variable, so its verdict is a fact about + /// the CHILD's environment rather than about this process's — a case reading + /// `std::env::var` would pass against the defect it exists to catch. + #[test] + fn a_body_gate_is_told_which_base_the_lap_borrowed() { + let root = std::env::temp_dir(); + let gate = vec![vec![ + String::from("sh"), + String::from("-c"), + String::from("test -n \"$BATTEN_SPEC_BASE\""), + ]]; + let published = vec![( + String::from(crate::speculation::PUBLISHED_AS), + Some(String::from("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), + )]; + // A lap with NO bet, spelled as the removal it has to be. `&[]` would + // leave whatever the parent exported in place — and this suite runs + // inside a `land` gate that exports exactly this variable, which is how + // the first version of this case failed against its own subject. + let unpublished = vec![(String::from(crate::speculation::PUBLISHED_AS), None)]; + + assert_eq!( + super::ready(&root, &gate, "Closes CLOUD-1", &published), + super::Readied::Clear, + "a speculative lap must tell its body gates which base it borrowed" + ); + + // **THE MIRROR, and without it the case above passes on any environment + // that happens to carry the variable** — a developer's shell, an outer + // `land`, or a sibling test leaking one. That is not hypothetical: this + // suite runs as a child of the gate `mise run land` drives, which + // publishes this very variable, and the first version of this case read + // that outer bet and failed. + // + // So "no bet" is an explicit REMOVAL rather than an omission, and the + // mechanism now matches the claim: publication is a function of the bet. + assert!( + matches!( + super::ready(&root, &gate, "Closes CLOUD-1", &unpublished), + super::Readied::Refused { .. } + ), + "a lap carrying no bet must publish no base" + ); + } + /// No declared gates is a clear ready, and the distinction from `Unrunnable` /// is the optional-versus-dead one the driver's own header states. #[test] fn a_consumer_declaring_no_body_gates_is_clear_rather_than_unrunnable() { assert_eq!( - super::ready(&std::env::temp_dir(), &[], "Closes CLOUD-1"), + super::ready(&std::env::temp_dir(), &[], "Closes CLOUD-1", &[]), super::Readied::Clear ); } diff --git a/crates/batten/src/lease.rs b/crates/batten/src/lease.rs index 5f69088ac..5497fd849 100644 --- a/crates/batten/src/lease.rs +++ b/crates/batten/src/lease.rs @@ -2134,6 +2134,77 @@ impl Local { /// /// A first sighting is `0`, and a value this clone cannot record is `0` too — /// a corroboration clock that cannot be kept has corroborated nothing. + /// Remember that this clone released the lease because its run went RED. + /// + /// **Here rather than on the lease body, and per CLONE rather than per + /// process**, which are two separate constraints and both bind. The body has + /// no field-addition discipline (CLOUD-1769) so a "why" on the tombstone + /// would split a mixed fleet; and `acquire` runs as a different process from + /// the lap that went red, so a value held in memory would be gone by the time + /// the question is asked. This directory is exactly the surface that spans + /// both — the same one `held_for`'s corroboration clock already uses. + /// + /// **KEYED TO THE INSTANT IT WAS TAKEN**, which CLOUD-1170 decided as answer + /// 3 and which a bare marker gets wrong: *a liveness RECORD, not a liveness + /// read, keyed to the reading's own instant so a stale record does not + /// answer.* Written without one, a red from an abandoned lap would still be + /// standing this clone aside a week later. + /// + /// The instant is SUPPLIED rather than read here — answer 2 of the same row, + /// and the property [`turn`] already has. Nothing in this module reads a + /// clock. + /// + /// Silent on failure, like every write here: a clone that cannot record its + /// own red re-acquires as it always did, which is the behaviour this replaces + /// rather than a new hazard. + pub fn record_red(&self, now: i64) { + let _ = std::fs::create_dir_all(&self.dir); + let _ = std::fs::write(self.dir.join("released-red"), format!("{now}\n")); + } + + /// Forget it — this clone has taken the lease again, or landed cleanly. + /// + /// **The backoff is one turn, never a sentence.** A clone that stood aside + /// once has paid it; leaving the mark would have a single red exclude the + /// clone from every future acquisition, which is a fleet down one worker for + /// the life of the checkout. + pub fn clear_red(&self) { + let _ = std::fs::remove_file(self.dir.join("released-red")); + } + + /// What this clone did with the lease it last held, as of `now`. + /// + /// **A RECORD OLDER THAN `max_age` DOES NOT ANSWER** (CLOUD-1170). A backoff + /// is a courtesy owed to the waiters who were behind us at the moment we + /// broke the promise; once a lease term has passed, those waiters have long + /// since taken their turn and the debt is not owed to whoever is there now. + /// An unbounded marker would be this clone standing aside for strangers over + /// a red nobody remembers — the stale record answering, which is the failure + /// that row names. + /// + /// **The stale record is REAPED on read**, the shape `alive.sh` already had + /// and the one CLOUD-1170 carries forward: leaving it would have every later + /// call re-derive the same staleness from the same bytes. + /// + /// An unreadable or unparseable record is [`Recent::Clean`] — this clone + /// owes nothing it cannot establish, which is the same fail-toward-motion + /// direction the rest of this module takes for a reading it could not make. + #[must_use] + pub fn recent(&self, now: i64, max_age: i64) -> Recent { + let path = self.dir.join("released-red"); + let Ok(raw) = std::fs::read_to_string(&path) else { + return Recent::Clean; + }; + let Ok(at) = raw.trim().parse::() else { + return Recent::Clean; + }; + if now.saturating_sub(at) > max_age { + let _ = std::fs::remove_file(&path); + return Recent::Clean; + } + Recent::ReleasedRed + } + #[must_use] pub fn held_for(&self, name: &str, token: &str, now: i64) -> i64 { let path = self.dir.join(name); @@ -2168,6 +2239,54 @@ pub enum Turn { Wait, } +/// What this clone did with the lease it last held. +/// +/// **A CALLER'S READING, NEVER A FIELD ON THE BODY**, and the distinction is a +/// constraint rather than a preference. The obvious spelling is a "why" on the +/// tombstone — but the lease body has no field-addition discipline (CLOUD-1769), +/// so the first new field either splits a mixed fleet or is silently unreadable +/// to older clones. Nothing here needs one: the clone that must back off is the +/// same process that went red, so it already knows. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Recent { + /// Nothing to answer for: a first acquisition, or a clean release. + #[default] + Clean, + /// This clone released the lease because its run went RED. + /// + /// Taking the lease is a promise to go green. Going red breaks it, and + /// every waiter that speculated on this branch has to re-bet — so the + /// clone that broke it stands aside rather than winning the race it just + /// lost. + ReleasedRed, +} + +/// Everything [`turn`] needs that is not the lease itself. +/// +/// **A STRUCT BECAUSE `clippy::too_many_arguments` SAID SO, and collecting them +/// is the fix that lint is for** — `hook.rs`'s own note says as much, and this +/// file's neighbours record an `#[expect]` arguing an arity was fine being +/// rejected in review. Five loose numbers of which three are durations on +/// different clocks is exactly the call the lint exists to make unreadable. +/// +/// They belong together on their own terms too: every field is a reading THIS +/// CLONE took before asking, never a fact off the lease body. That is the +/// property that keeps [`turn`] a pure function over readings, and the reason +/// none of these may be derived from the lease's own `expires`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Reading { + /// How long this clone has seen the lease's sha unchanged, on ITS clock. + pub held_for: i64, + /// How long the holder's progress token has been unchanged, on ITS clock. + pub progress_for: i64, + /// How many beats of no progress make a beating holder stealable. + pub stall_beats: i64, + /// This clone's now. + pub now: i64, + /// What this clone did with the lease it last held. + pub recent: Recent, +} + /// Decide a [`Turn`] over one observation. /// /// **`held_for` and `progress_for` are DURATIONS ON THIS CLOCK**, measured by @@ -2185,16 +2304,23 @@ pub enum Turn { /// every holder that cannot see its own progress. Releasing a lease wrongly /// costs its holder one lap; stealing one wrongly puts two holders on the same /// trunk. +/// +/// # `recent` IS THE ONLY ARM THAT DECLINES A LEASE THIS CLONE COULD HAVE +/// +/// Every other reading here asks whether taking the lease is PERMITTED. +/// [`Recent::ReleasedRed`] asks whether it is decent: the lease is genuinely +/// free and this clone is genuinely allowed it, and it stands aside anyway +/// because it just broke the promise the lease represents. It is bounded by an +/// admitted `next` precisely so that it cannot become a deadlock — see the arm. #[must_use] -pub fn turn( - terms: &Terms, - observed: &Observed, - holder: &str, - held_for: i64, - progress_for: i64, - stall_beats: i64, - now: i64, -) -> Turn { +pub fn turn(terms: &Terms, observed: &Observed, holder: &str, reading: Reading) -> Turn { + let Reading { + held_for, + progress_for, + stall_beats, + now, + recent, + } = reading; // GARBAGE IS WAIT, and it is the one place this differs from `authorises`: // taking a ref nobody can read means overwriting whatever a stray push put // there, and a well-meant fix that races a real holder is worse than waiting @@ -2216,6 +2342,27 @@ pub fn turn( return Turn::Mine; } if body.released() { + // BACKING OFF AFTER OUR OWN RED, and this is the arm that makes `next` + // buy ORDERING rather than only overlap. + // + // `authorises` has always read `next` — a reserved successor may spend + // the overlapping matrix — but nothing read it when the lease actually + // freed, so `turn` was first-CAS-wins and the branch that had just gone + // red was as likely to win as anyone. Measured: eight `land` runs and + // ~3h of a held lease, re-acquired by the same clone every lap. + // + // THE BACKOFF LAPSES WHEN THE POOL IS EMPTY, which is the half that + // keeps this from being a deadlock: with no `next` admitted there is + // nobody to stand aside FOR, and a single-clone fleet that redded must + // still be able to take its own lease back and fix the thing. Standing + // aside forever for nobody would stop the fleet to punish one branch. + // + // `next != holder` because a clone reserved as its own successor is not + // somebody else, and reading it as one would be the same deadlock by a + // longer route. + if recent == Recent::ReleasedRed && !body.next.is_empty() && body.next != holder { + return Turn::Wait; + } return Turn::Take(format!("took the lease {} released", body.holder)); } if body.expired(now) && held_for >= terms.beat { @@ -3661,7 +3808,18 @@ mod tests { why: String::from("the ref carries no lease body"), }; assert_eq!( - turn(&Terms::default(), &garbage, "me", 10_000, 10_000, 60, 1000), + turn( + &Terms::default(), + &garbage, + "me", + Reading { + held_for: 10_000, + progress_for: 10_000, + stall_beats: 60, + now: 1000, + recent: Recent::Clean + }, + ), Turn::Wait ); } @@ -3892,7 +4050,18 @@ mod tests { fn an_absent_lease_is_taken_without_corroboration() { // A statement rather than a deduction, so no clock and no beat. assert!(matches!( - turn(&Terms::default(), &Observed::Absent, "me", 0, 0, 60, 100), + turn( + &Terms::default(), + &Observed::Absent, + "me", + Reading { + held_for: 0, + progress_for: 0, + stall_beats: 60, + now: 100, + recent: Recent::Clean + }, + ), Turn::Take(_) )); } @@ -3900,7 +4069,18 @@ mod tests { #[test] fn a_released_lease_is_taken_without_corroboration() { assert!(matches!( - turn(&Terms::default(), &body("them", 0, ""), "me", 0, 0, 60, 100), + turn( + &Terms::default(), + &body("them", 0, ""), + "me", + Reading { + held_for: 0, + progress_for: 0, + stall_beats: 60, + now: 100, + recent: Recent::Clean + }, + ), Turn::Take(_) )); } @@ -3916,15 +4096,29 @@ mod tests { &terms, &body("them", 100, ""), "me", - terms.beat - 1, - 0, - 60, - 200 + Reading { + held_for: terms.beat - 1, + progress_for: 0, + stall_beats: 60, + now: 200, + recent: Recent::Clean + }, ), Turn::Wait ); assert!(matches!( - turn(&terms, &body("them", 100, ""), "me", terms.beat, 0, 60, 200), + turn( + &terms, + &body("them", 100, ""), + "me", + Reading { + held_for: terms.beat, + progress_for: 0, + stall_beats: 60, + now: 200, + recent: Recent::Clean + }, + ), Turn::Take(_) )); } @@ -3940,10 +4134,13 @@ mod tests { &terms, &body("them", 100_000, "1.2"), "me", - 0, - 60 * terms.beat, - 60, - 200 + Reading { + held_for: 0, + progress_for: 60 * terms.beat, + stall_beats: 60, + now: 200, + recent: Recent::Clean + }, ), Turn::Take(_) )); @@ -3962,10 +4159,13 @@ mod tests { &terms, &body("them", 100_000, ""), "me", - 0, - 1_000_000, - 60, - 200 + Reading { + held_for: 0, + progress_for: 1_000_000, + stall_beats: 60, + now: 200, + recent: Recent::Clean + }, ), Turn::Wait ); @@ -3978,22 +4178,146 @@ mod tests { &Terms::default(), &body("me", 100_000, ""), "me", - 0, - 0, - 60, - 200 + Reading { + held_for: 0, + progress_for: 0, + stall_beats: 60, + now: 200, + recent: Recent::Clean + }, ), Turn::Mine ); } + /// A body carrying an admitted successor. + fn with_next(holder: &str, expires: i64, next: &str) -> Observed { + let Observed::Held { sha, mut body } = body(holder, expires, "") else { + unreachable!("`body` builds a held observation") + }; + body.next = String::from(next); + Observed::Held { sha, body } + } + + /// **A CLONE THAT WENT RED STANDS ASIDE FOR THE ADMITTED SUCCESSOR.** + /// + /// Taking the lease is a promise to go green. Breaking it makes every waiter + /// that speculated on this branch re-bet, so winning the very race it just + /// lost is the behaviour that cost eight `land` runs and ~3h of held lease + /// in one measured session. `next` has always been read by `authorises` for + /// OVERLAP; this is the first thing that reads it for ORDERING (CLOUD-1043). + #[test] + fn a_clone_that_released_on_red_does_not_immediately_re_take() { + assert_eq!( + turn( + &Terms::default(), + &with_next("me", 0, "them"), + "me", + Reading { + held_for: 0, + progress_for: 0, + stall_beats: 60, + now: 100, + recent: Recent::ReleasedRed + }, + ), + Turn::Wait, + "the clone that broke the promise must not win the race it just lost" + ); + } + + /// **THE ANTI-DEADLOCK MIRROR, and without it this is a fleet outage.** + /// + /// With no successor admitted there is nobody to stand aside FOR, and a + /// single-clone fleet that went red must still be able to take its own lease + /// back and fix the thing. Standing aside for nobody would stop the fleet in + /// order to punish one branch — strictly worse than the stall being fixed. + #[test] + fn a_red_clone_still_takes_a_lease_nobody_else_is_waiting_for() { + assert!( + matches!( + turn( + &Terms::default(), + &body("me", 0, ""), + "me", + Reading { + held_for: 0, + progress_for: 0, + stall_beats: 60, + now: 100, + recent: Recent::ReleasedRed + }, + ), + Turn::Take(_) + ), + "an empty pool means the backoff has nobody to defer to" + ); + } + + /// A clone reserved as its OWN successor is not somebody else. + /// + /// Reading it as one would be the same deadlock by a longer route: the clone + /// stands aside for itself, forever, and the lease is never taken again. + #[test] + fn a_red_clone_reserved_as_its_own_successor_still_takes_the_lease() { + assert!(matches!( + turn( + &Terms::default(), + &with_next("me", 0, "me"), + "me", + Reading { + held_for: 0, + progress_for: 0, + stall_beats: 60, + now: 100, + recent: Recent::ReleasedRed + }, + ), + Turn::Take(_) + )); + } + + /// **AND A CLEAN CLONE IS UNAFFECTED**, which is what keeps the arm from + /// being a general slowdown. Identical inputs, `Recent::Clean`: the lease is + /// taken exactly as it always was. Without this case the backoff is + /// satisfied by a `turn` that waits on every released lease. + #[test] + fn a_clean_clone_takes_a_released_lease_with_a_successor_admitted() { + assert!(matches!( + turn( + &Terms::default(), + &with_next("them", 0, "them"), + "me", + Reading { + held_for: 0, + progress_for: 0, + stall_beats: 60, + now: 100, + recent: Recent::Clean + }, + ), + Turn::Take(_) + )); + } + #[test] fn this_clones_own_expired_lease_is_taken_rather_than_assumed() { // `Mine` is a claim about a LIVE lease. A holder that was paused past its // TTL must re-take rather than carry on believing it holds one. let terms = Terms::default(); assert!(matches!( - turn(&terms, &body("me", 100, ""), "me", terms.beat, 0, 60, 200), + turn( + &terms, + &body("me", 100, ""), + "me", + Reading { + held_for: terms.beat, + progress_for: 0, + stall_beats: 60, + now: 200, + recent: Recent::Clean + }, + ), Turn::Take(_) )); } diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index a9b933c08..f3d8814ef 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -6726,7 +6726,10 @@ fn run_land( // the gate this tree carries no borrowed range while it does. let mut standing = speculation::Bet::default(); let _ = speculation::recover(root, &mut standing); - run_land_verify(root, &standing, &branch, None, out, err) + // `&mut` CARRIES NOTHING HERE, and that is the point: a hand-run + // verify has no suspicion outstanding, so the discrimination arms + // are inert and this stays the read-only verb it reads as. + run_land_verify(root, &mut standing, &branch, None, out, err) } cli::LandCommand::FastForward => run_land_fast_forward(root, &branch, out, err), cli::LandCommand::Replay { reference, resolve } => { @@ -7297,10 +7300,10 @@ fn run_land_lap( // driver instead of through a flag. land::Step::Replay => run_land_replay(root, url, reference, branch, &[], out)?, land::Step::Verify => { - run_land_verify(root, &bet, branch, Some(reference), out, err)? + run_land_verify(root, &mut bet, branch, Some(reference), out, err)? } land::Step::Lease => run_land_lease(root, branch, out, err)?, - land::Step::Ready => run_land_ready(root, branch, &mut ledger, out, err)?, + land::Step::Ready => run_land_ready(root, branch, &bet, &mut ledger, out, err)?, land::Step::Push => run_land_push(root, url, branch, out)?, land::Step::Wait => { let (code, verdict) = run_land_wait(root, reference, branch, out, err)?; @@ -7562,6 +7565,22 @@ fn unwind_lap( pipeline::Compensation::ReleaseLease => match (&holder, lease::terms(root)) { (Some(holder), Ok(terms)) => { lease_hand_back(root, &terms, holder, now); + // THIS ARM IS THE RED ONE, AND THAT IS WHY THE MARK GOES + // HERE RATHER THAN IN `lease_hand_back`. + // + // Both release paths call that function; only this one is an + // UNWIND — a lap that took the lease, promised to go green, + // and did not. The landing path releases the same way and + // must not be marked, or every clean landing would make its + // own clone stand aside next time. + // + // Taking the lease is a promise. Breaking it means every + // waiter that speculated on this branch has to re-bet, so + // the clone that broke it backs off and lets the admitted + // successor in (CLOUD-1043). + if let Ok(git_dir) = git::git_dir(root) { + lease::Local::under(&git_dir).record_red(now); + } writeln!(out, "land: undo — the landing lease is handed back")?; } // Not an error: a lap that never took the lease owes nothing, and @@ -7666,6 +7685,12 @@ fn settle_the_bet( "land: adopting an unsettled speculation left by an earlier run; settling it before anything is pushed" )?; } + // BEFORE the `live()` guard, and that is the point of it being a separate + // call (CLOUD-1306). A judgement about a poisoned base is most worth having + // when no bet is outstanding, because that is exactly when `place_the_bet` + // is about to ask `would_rebet` whether to borrow it again. Loading it only + // on the speculating path would have the memory die every time it mattered. + speculation::recall_refusal(root, bet); if !bet.live() { return Ok(None); } @@ -7715,6 +7740,23 @@ fn settle_the_bet( // nothing to unwind. speculation::Settle::Pending | speculation::Settle::Nothing => Ok(None), speculation::Settle::Lost => unwind_the_bet(root, url, branch, bet, reference, out, err), + // POISONED (CLOUD-1306): the holder is still winning and its tree will + // not go green, so this branch cannot land behind it however long it + // waits. The UNWIND IS THE SAME ONE `Lost` takes — that is the whole + // shape of the fix, a second caller for an arm that already exists + // rather than new machinery. + // + // What differs is only what is said and what is kept: the base stays in + // `bet.refused` and on `REFUSED_REF`, so `speculate` will not borrow it + // again on the next lap or in the next invocation. + speculation::Settle::Poisoned => { + writeln!( + out, + "land: the speculation is POISONED — {} will not pass the gate, so this branch cannot land behind it; unwinding onto {tracking} and not betting on it again", + short(&base) + )?; + unwind_the_bet(root, url, branch, bet, reference, out, err) + } } } @@ -8011,6 +8053,14 @@ fn place_the_bet( // second guard's own comment reasons that "`run_land_replay` immediately // fetches a fresh trunk", which it does — one step AFTER this runs. advance_trunk(root, reference); + // THIS LANDING ALREADY DECLINED TO PUBLISH ONE (CLOUD-1681). The `Push` + // precheck unwound a bet that reached the publish, so betting again buys + // nothing before this branch lands and costs another unwind at the same row. + // Checked FIRST, because it is a fact about us rather than about the holder + // and no reading below can change it. + if bet.declined { + return Ok(()); + } let tracking = land::tracking_ref(reference); // THE HOLDER ALREADY LANDED. Their head is on the trunk, so an ordinary replay // reaches it and a bet would borrow a range that is not borrowed. @@ -8236,6 +8286,13 @@ fn landed_for_real(root: &Path, url: &str, branch: &str, out: &mut dyn Write) -> // best-effort either way, and doing it first means a slow remote // delete cannot widen the window another branch waits through. hand_back_the_lease(root, branch, out); + // AND THE PROMISE WAS KEPT, so nothing is owed to the pool. Clearing + // here as well as on the next acquisition is belt and braces on the + // one path that definitively earns it: a clone that lands must never + // be carrying a stand-aside from an earlier lap. + if let Ok(git_dir) = git::git_dir(root) { + lease::Local::under(&git_dir).clear_red(); + } // THE BRANCH HAS DONE ITS WHOLE JOB (CLOUD-349, CLOUD-1471). Only // here, never on a stop: an abandoned branch is evidence and has to // survive, while a landed one left behind is how a short-lived branch @@ -8636,7 +8693,11 @@ fn run_land_replay( /// `prune`'s deletes. A consumer needing that writes a script and names it. fn run_land_verify( root: &Path, - bet: &speculation::Bet, + // `&mut` FOR THE DISCRIMINATION, and for nothing else (CLOUD-1306). The gate + // is the one reading that can tell a poisoned base from this branch's own + // defect, so it is the one place a suspicion can be raised or settled. A + // shared borrow here is what kept the verdict inside the lap that found it. + bet: &mut speculation::Bet, branch: &str, reference: Option<&str>, out: &mut dyn Write, @@ -8686,6 +8747,25 @@ fn run_land_verify( match verified { land::Verified::Clean(head) => { writeln!(out, "land: {head} passed the configured gate")?; + // THE DISCRIMINATION, AND IT COST NO EXTRA GATE RUN (CLOUD-1306). + // + // A base under suspicion was refused on the BORROWED tree; this gate + // just ran on a tree that no longer carries it. Green here is the + // second half of the predicate the row states — "the same failure + // does not reproduce off the borrowed base" — so the failure was the + // holder's and the suspicion becomes a judgement. + // + // The ref is written only HERE, on a reading that actually + // discriminated. A suspicion recorded at the refusal would name every + // holder a branch with its own defect ever waited behind. + if let Some(confirmed) = bet.confirm_refusal() { + let _ = gitwrite::set_ref(root, speculation::REFUSED_REF, &confirmed); + writeln!( + out, + "land: this tree passes off {}'s base, so that failure was the holder's; not speculating on it again", + short(&confirmed) + )?; + } Ok(ExitCode::Success) } // A REFUSAL IS A VERDICT ABOUT THE REPOSITORY, so `2`. The gate's own @@ -8742,10 +8822,26 @@ fn run_land_verify( // holds this branch unborrowed. land::Refusal::Tree => { if let Some(base) = bet.published() { + let base = base.to_owned(); + // RAISE THE SUSPICION, WHICH IS WHAT ENDS THE STALL. + // + // The predecessor stopped here and told a human to run + // the discriminating re-verify by hand — and following + // that advice exactly put the waiter back into the same + // bet, because nothing recorded that THIS base had been + // tried. The diagnosis was per-invocation; the bet is + // per-lease. Recording it is the difference. + // + // Still `Violation` below: this lap stops exactly as it + // did before, because the failure genuinely might be + // ours and nothing has discriminated it yet. What + // changes is that the NEXT lap settles `Poisoned`, + // unwinds, and does not re-borrow the same head. + bet.suspect(&base); writeln!( err, "::error:: land: this tree is SPECULATIVE — it carries {} borrowed from {base}, so the failure may not be yours.", - short(base) + short(&base) )?; // THE BASE REF IS THE LAP'S AND A HAND-DRIVEN VERIFY HAS // NONE, so it is `Option` rather than a guess. `batten @@ -8771,6 +8867,20 @@ fn run_land_verify( " If it still fails off the borrowed base, it is yours." )?; } else { + // NO BET, AND THE GATE IS STILL RED — SO IT IS OURS. + // + // The anti-vacuity half (CLOUD-1306). This is the lap + // after an unwind: the borrowed range is gone and the + // same failure reproduced, which is the row's own + // "red again ⇒ the failure is the waiter's" and the case + // its acceptance names — *a waiter whose own tree is red + // still stops.* + // + // Dropping the suspicion rather than promoting it is + // what stops a branch with a genuine defect accumulating + // a refusal against every holder it ever waited behind + // and quietly giving up speculation altogether. + bet.clear_suspicion(); writeln!(err, "::error:: land: reproduce and fix locally.")?; } } @@ -9217,6 +9327,7 @@ fn trunk_watch(reference: &str, base: &str, repo: &str, interval: u64) -> main_w fn run_land_ready( root: &Path, branch: &str, + bet: &speculation::Bet, ledger: &mut land::Ledger, out: &mut dyn Write, err: &mut dyn Write, @@ -9240,12 +9351,30 @@ fn run_land_ready( // `Drop`: this string is PARSED as the body, so a client's notice // on stderr would become text the author never wrote. The gates // below take `Keep`, because their stderr IS their reason. - .and_then(|argv| exec::piped_argv(root, argv, "", exec::Diagnostics::Drop)) + // NO BET PUBLISHED TO THE FETCH, deliberately. This call reads the + // pull request's BODY; the bet is a fact about the commit RANGE, and + // handing it to a client that does not read one would be a variable + // in an environment for no reader. + .and_then(|argv| exec::piped_argv(root, argv, "", exec::Diagnostics::Drop, &[])) .filter(|(code, _)| *code == 0) .map(|(_, body)| body) .unwrap_or_default(); - match land::ready(root, &gates, &body) { + // THE SAME PUBLICATION `run_land_verify` MAKES, and for the same reason + // one step later (CLOUD-1770). `Bet::published` is `None` with no bet + // outstanding, so the variable is simply absent from a non-speculative + // lap's gates — the publication stays a function of the bet rather than a + // side effect kept in step with it. + // ONE ENTRY ALWAYS, and its VALUE is the bet. An empty list would leave + // an inherited `BATTEN_SPEC_BASE` in place — and this lap's own gates run + // as children of a `verify` that exports it, so "no bet" has to be said + // out loud rather than left unsaid (measured: the case for this asserted + // the mirror and read an outer process's bet). + let published: Vec<(String, Option)> = vec![( + speculation::PUBLISHED_AS.to_owned(), + bet.published().map(str::to_owned), + )]; + match land::ready(root, &gates, &body, &published) { land::Readied::Clear => { writeln!(out, "land: {} body gate(s) clear", gates.len())?; } @@ -9985,6 +10114,34 @@ fn asks_before_the_step( )?; Ok(Answered::Stop(exit::ExitCode::Violation)) } + // A LIVE BET NEVER REACHES THE PUBLISH (CLOUD-1681). + // + // The unwind is done HERE rather than left to the lap's compensations, + // and that is the arm's correctness rather than its convenience: no + // `Compensation` drops a bet — `Nothing`, `Redraft`, `Abandon`, + // `ReleaseLease` — so answering `Lap` alone would carry the borrowed + // range straight into the next lap and back to this row. + // + // `unwind_the_bet` is the same drop `Settle::Lost` already takes, so + // this is a second caller for an existing arm rather than new machinery. + Some(pipeline::Precheck::BetLive) if bet.live() => { + writeln!( + out, + "land: a speculation is still outstanding, so this head is not publishable — unwinding and lapping onto real trunk" + )?; + if let Some(code) = unwind_the_bet(root, url, branch, bet, reference, out, err)? { + // The unwind itself refused — a tree it could not rewind is the + // one thing that stops rather than laps, because carrying on + // would push another branch's commits under this one. + return Ok(Answered::Stop(code)); + } + // AND WE DO NOT BET AGAIN THIS LANDING, which is what makes the lap + // terminate. `place_the_bet`'s own guards are about the HOLDER, so + // without this the next lap re-bets the same one, arrives back here, + // and unwinds again until the lap budget is spent. + bet.declined = true; + Ok(Answered::Lap) + } _ => Ok(Answered::Go), } } @@ -10446,10 +10603,13 @@ fn run_lease_acquire( terms, &observed, &holder, - held_for, - progress_for, - lease_stall_beats(), - now, + lease::Reading { + held_for, + progress_for, + stall_beats: lease_stall_beats(), + now, + recent: local.recent(now, terms.ttl), + }, ) { lease::Turn::Mine => { writeln!(out, "lease: already held by this clone")?; @@ -10498,6 +10658,12 @@ fn run_lease_acquire( match lease::cas(terms, &observed, &body, now) { Ok(lease::Outcome::Applied) => { lease_receipt(root, branch, now + terms.ttl); + // THE BACKOFF IS ONE TURN AND IT IS SPENT HERE. Holding the + // mark past a successful acquisition would exclude this + // clone from every future lease over one red run — a fleet + // permanently down a worker, which is a worse failure than + // the one the backoff fixes. + local.clear_red(); writeln!(out, "lease: {why}")?; Ok(ExitCode::Success) } @@ -10778,7 +10944,7 @@ fn note_release(root: &Path) { let Some(argv) = land::body_gates(&declared).into_iter().next() else { return; }; - let _ = exec::piped_argv(root, &argv, "", exec::Diagnostics::Keep); + let _ = exec::piped_argv(root, &argv, "", exec::Diagnostics::Keep, &[]); } /// `lease release`: a tombstone, never a delete. @@ -12088,16 +12254,10 @@ fn run_hook( out: &mut dyn Write, err: &mut dyn Write, ) -> Result { - let mut raw = String::new(); - if std::io::stdin().read_to_string(&mut raw).is_err() { - output::message(mode, Verbosity::Normal, err, UNREADABLE_STDIN)?; - return Ok(ExitCode::Success); - } - let bypass = std::env::var_os(hook::BYPASS_ENV).is_some_and(|value| !value.is_empty()); - let Some(mut envelope) = hook::decode(harness, &raw) else { - output::message(mode, Verbosity::Normal, err, UNDECODABLE_PAYLOAD)?; + let Some((raw, mut envelope)) = read_envelope(harness, mode, err)? else { return Ok(ExitCode::Success); }; + let bypass = std::env::var_os(hook::BYPASS_ENV).is_some_and(|value| !value.is_empty()); // THE WRITE TARGET IS READ AS THE REPOSITORY READS IT (CLOUD-1133), and this // is the one place that can do it: `decode` is pure and has no repository, // and the readers below — the protected gate, and any module over @@ -12115,8 +12275,7 @@ fn run_hook( // The note rides the ladder above `normal`, because on the hosts where this // is reachable it is the ordinary state rather than news. let capabilities = harness.capabilities(); - if !capabilities.emits(envelope.event) && envelope.event != hook::Event::Unrecognized { - let note = unsupported_event_note(harness, &capabilities, envelope.event); + if let Some(note) = undeclared_event_note(harness, &capabilities, envelope.event) { output::message(mode, Verbosity::Verbose, err, ¬e)?; return Ok(ExitCode::Success); } @@ -12226,18 +12385,7 @@ fn run_hook( // end-of-turn surface. The retired shell hook this replaces paid ~330-440ms // at the same boundary; `perf`'s `passthrough` and `noop` arms are pre-tool // shapes and are untouched by this clause. - let adjudicable = !envelope.command.is_empty() - || envelope.writes.is_some() - || envelope.event == hook::Event::Stop - // A FOURTH TIME, and for a mint rather than a verdict (CLOUD-856). Session - // start carries no command, no write and no tool name, so this predicate - // was false there and config was never loaded — which means the receipt - // this event exists to mint could not know which manifests were declared. - // The cost is one config load per SESSION, not per call, which is the - // same trade the `Stop` clause above makes, and it buys the whole reason - // `Fact::Document` can stay `None` on the mediated path. - || envelope.event == hook::Event::SessionStart - || (envelope.event == hook::Event::PreTool && !envelope.raw_tool.is_empty()); + let adjudicable = is_adjudicable(&envelope); // A BYPASSED CALL NOW PAYS THE CONFIG READ, and that invariant is retired // deliberately rather than eroded. // @@ -12256,8 +12404,39 @@ fn run_hook( // arms, ~0.7 ms against a 100 ms budget. `!adjudicable` keeps its old // behaviour, because an event with nothing to adjudicate has no protected // gate to run either, and that is the arm the hot path actually rides. + // A DECLARATION NOTHING COULD READ IS THE ONE FAULT THAT REFUSES, and the + // narrowness is the decision rather than caution (CLOUD-1677). + // + // Gates are registered fail-open, so a gate that fails open is INERT — it + // neither allows nor denies, it is absent. A config fault is therefore never + // a choice between refusing and allowing: it is a choice between keeping the + // enforcement surface we still have and losing it entirely. An unknown key, a + // version this build is too old for, a table whose validator refused — each + // leaves every other row readable and enforceable, and leaves an agent that + // can still be TOLD to repair the broken one. Refusing there would trade a + // working partial surface for nothing. + // + // `Native::ConfigUnreadable` is the one class with no partial function left: + // the file is not TOML, so no row is readable and none can be enforced. Then + // the refusal is the only signal available, and the hatch below is how the + // container gets back. + //MUTANT-SUITE crates/batten/tests/it/adjudicate_absent.rs + //MUTANT unloadable-config-allows|s@ Err(unreadable) if unreadable_declaration(\&unreadable) => {@ Err(unreadable) if false \&\& unreadable_declaration(\&unreadable) => {@|a_config_this_build_cannot_load_denies_rather_than_failing_open let (policy, waivers) = if adjudicable { - load_policy(overrides, harness)? + match load_policy(overrides, harness) { + Ok(loaded) => loaded, + // The declared hatch, read before the refusal so a stale binary + // meeting a newer config leaves a container recoverable, not bricked. + Err(_) if bypass => (hook::Policy::declaring_nothing(harness), Vec::new()), + Err(unreadable) if unreadable_declaration(&unreadable) => { + return deny_unadjudicable(harness, &envelope, &unreadable, mode, out, err); + } + // EVERY OTHER FAULT KEEPS ITS OLD BEHAVIOUR, deliberately. Today that + // is still a whole-file refusal at exit `1`, which is the outcome the + // argument above says is wrong — making these actually preserve the + // rows they can read is its own change, over `prune_unresolvable`. + Err(other) => return Err(other), + } } else { (hook::Policy::declaring_nothing(harness), Vec::new()) }; @@ -12499,6 +12678,237 @@ fn run_hook( render(harness, &envelope, decision, &rendering, mode, out, err) } +/// The note for an event this host does not declare, or `None` to carry on. +/// +/// **`Unrecognized` is not undeclared**, and collapsing the two is why this is a +/// named predicate rather than an inline `&&`: an event nobody could parse has +/// no capability row to be absent from, so it falls through to the ordinary +/// path instead of being reported as a host that offers less. +/// +/// Returning the note rather than a `bool` keeps [`unsupported_event_note`]'s +/// call beside the condition that earns it — a caller that tested one and +/// rendered the other could report an event the table actually declares. +fn undeclared_event_note( + harness: hook::Harness, + capabilities: &hook::Capabilities, + event: hook::Event, +) -> Option { + (!capabilities.emits(event) && event != hook::Event::Unrecognized) + .then(|| unsupported_event_note(harness, capabilities, event)) +} + +/// Whether this envelope has anything for the config to decide about. +/// +/// **The gate on whether a call pays a config read at all**, which is why the +/// hot path stays cheap: `perf`'s `passthrough` arm — a `Read` with a +/// `file_path`, no command, no write — takes the `false` branch, and its +/// below-`noop` reading comes from doing so. +/// +/// Every clause was added by a measurement rather than by symmetry, and the +/// history is the argument for keeping them enumerated here: +/// +/// * a command or a write is the original shape; +/// * `Stop` carries neither, so a `mediated_call` module registered for the end +/// of turn could not run at all — a dead gate whose own suite stayed green, +/// because a `with input as` case fabricates the shape the boundary never +/// built (CLOUD-1051); +/// * `SessionStart` likewise, and for a MINT rather than a verdict (CLOUD-856): +/// the receipt that event exists to write could not know which manifests were +/// declared. One config load per session, not per call; +/// * a `PreTool` call naming a tool is the shape a tool-keyed row exists to +/// judge, and without it such a row was loaded for no call that could match. +fn is_adjudicable(envelope: &hook::Envelope) -> bool { + !envelope.command.is_empty() + || envelope.writes.is_some() + || envelope.event == hook::Event::Stop + || envelope.event == hook::Event::SessionStart + || (envelope.event == hook::Event::PreTool && !envelope.raw_tool.is_empty()) +} + +/// The mediated call on stdin, or `None` where the call must simply proceed. +/// +/// **THE TWO FAIL-OPEN BOUNDARIES, TOGETHER BECAUSE THEY ARE ONE ANSWER.** Stdin +/// that will not read and a payload that will not decode are both "the engine +/// does not know what this call IS", and neither may block it: a guard must never +/// be the reason a session cannot proceed. That is the opposite side of +/// [`unreadable_declaration`], where the engine knows the call perfectly well and +/// has been told it cannot enforce the rules over it. +/// +/// **Loud, never silent** (CLOUD-43). A guard that cannot read its input is a gate +/// that did not run, and the silent version of that is byte-identical to a clean +/// allow — the false green this engine exists to catch, in the one place nobody +/// would think to look. +/// **The RAW bytes travel with the decoded value**, because `dispatch_handlers` +/// hands a declared handler the payload as it arrived. Re-reading stdin for it is +/// not an option — the stream is consumed — and re-serializing the envelope would +/// hand a handler a document the host never sent. +fn read_envelope( + harness: hook::Harness, + mode: Mode, + err: &mut dyn Write, +) -> Result> { + let mut raw = String::new(); + if std::io::stdin().read_to_string(&mut raw).is_err() { + output::message(mode, Verbosity::Normal, err, UNREADABLE_STDIN)?; + return Ok(None); + } + let Some(envelope) = hook::decode(harness, &raw) else { + output::message(mode, Verbosity::Normal, err, UNDECODABLE_PAYLOAD)?; + return Ok(None); + }; + Ok(Some((raw, envelope))) +} + +/// Whether this load failure is a declaration nothing could read at all. +/// +/// **Positively identified, never inferred from an absence.** The tempting +/// spelling is "carries no declared class", and it is wrong: the +/// unsupported-version and `min_batten_version` refusals carry none either, and +/// both leave every row in the file readable. Keying on absence would refuse +/// those too — and every future unclassed refusal after them, silently widening +/// what denies. +/// +/// So the loader says which one this is. `config_error` already separates a +/// syntax failure from an unknown key, and since CLOUD-1677 its syntax arm raises +/// under [`verdict::Native::ConfigUnreadable`]. +fn unreadable_declaration(err: &anyhow::Error) -> bool { + err.downcast_ref::() + .and_then(|usage| usage.verdict) + .is_some_and(|class| class == verdict::Native::ConfigUnreadable) +} + +/// Refuse a call whose rules this build could not load (CLOUD-1677). +/// +/// Lifted out of [`run_hook`] rather than left inline because that function is +/// already at its line budget, and a boundary this load-bearing should be +/// readable on its own rather than as a match arm nine levels in. +/// +/// # Certainty denies; guessing allows +/// +/// The load used to propagate with `?`, raising a [`UsageError`] — exit `1` — +/// and [`crate::exit`] makes only `2` a denial precisely so no FAILURE path can +/// block a call. So a harness read this whole class as a non-blocking hook error +/// and ran the mediated tool anyway. Measured over one 5-day session: 1,149 +/// calls proceeded unjudged through seven windows of a mid-edit `batten.toml`, +/// and ~456 more through a preset the installed build did not ship — the +/// unknown-preset arm in [`crate::policy`] raises exactly this error. +/// +/// This is the discrimination `UNREADABLE_STDIN` sits on the other side of, and +/// the one CLOUD-1572 drew one level down. Where the engine is GUESSING about +/// the call — stdin it could not read, a payload that would not decode, an event +/// the host does not declare — allowing is right, because nothing is known and +/// refusing would make Batten the reason a session cannot proceed. Here the +/// engine has READ its own authority and been told it cannot enforce it: the +/// rule set is named, and unavailable. Proceeding is not caution then, it is a +/// gate reporting a clean allow over rules it never ran. +/// +/// # The surfaces stay separate +/// +/// `doctor` still never answers `2` — a diagnosis is not a policy verdict — and +/// the CLI verbs still raise a usage error over a config they cannot read. The +/// mediated boundary is the one place where "cannot judge" must not resolve to +/// "proceed", because here the alternative is a tool call nobody looked at. +/// +/// # The hatch is read before this is reached +/// +/// [`run_hook`] takes the bypass arm first, and that is what keeps a container +/// recoverable rather than bricked: a stale binary meeting a newer config +/// refuses every call until one of them moves, so the operator's declared escape +/// has to survive exactly the state that needs it. +/// +/// **A DECISION, NOT AN ERROR, WHICH IS WHY IT RENDERS.** [`render`] owns the +/// per-harness deny channel: Claude Code answers in its JSON decision object at +/// exit `0`, where the document *is* the deny, and the neutral adapter answers +/// [`ExitCode::Violation`]. Raising a [`Denial`] here would send `2` to the one +/// host that reads the document instead of the number. +/// +/// **The rendering carries no ceiling and the general hatch**, because both live +/// on the policy that would not load. `None` reads downstream as "no declared +/// bound" rather than as a bound of zero, which is the direction that keeps a +/// refusal about an unreadable config from being truncated by a value nobody +/// could read. +fn deny_unadjudicable( + harness: hook::Harness, + envelope: &hook::Envelope, + unreadable: &dyn std::fmt::Display, + mode: Mode, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + // THE FIRST LINE ONLY, AND THAT IS NON-NEGOTIABLE RULE 4 RATHER THAN BREVITY. + // + // A `toml` parse error renders as a multi-line span — a header naming the + // position, then the OFFENDING SOURCE LINE with a caret under it. Interpolating + // the whole thing puts a byte of the unreadable config into a refusal that + // reaches the model, the host's log and the transcript, which is exactly the + // payload rule 4 keeps out of every report this engine writes. Measured by + // `the_declaration_that_would_not_parse_is_named_without_quoting_it`, which + // fails on the un-truncated form. + // + // The first line is the POINTER and loses nothing an operator needs: for a + // parse failure it is `TOML parse error at line L, column C`, and for the + // skew and unknown-key arms the whole message is one line already — the + // `max_age = 0` and `command_matcher` diagnostics ride through intact, which + // is what keeps the repair as findable as it was when this arm exited `1`. + let pointer = unreadable + .to_string() + .lines() + .next() + .unwrap_or_default() + .to_owned(); + let refusal = Refusal::new( + "engine-cannot-adjudicate", + format!( + "this build could not load the rules it is registered to enforce, so nothing judged \ + this call: {pointer}" + ), + // No remedy the ENGINE may declare: the repair is rebuilding or + // reinstalling the binary, or fixing the config, and each is the + // consumer's own command (non-negotiable rule 1). + Fix::None, + ); + let rendering = Rendering { + hatch: hook::BYPASS_ENV, + ceiling: None, + }; + // WHICH CHANNEL CARRIED THE REFUSAL IS `render`'S OWN ANSWER, and reading it + // here is what lets the number say could-not-look without ever spending the + // refusal to do it (CLOUD-1677's exit-code half). + // + // `render` denies through the host's protocol: a document harness gets its + // decision object and answers `Ok`, while the neutral adapter's ONLY deny + // channel is the number, so it answers `Err(Denial)` — `run_hook`'s own + // contract says as much. The two arms below are therefore not a preference + // between codes, they are the two protocols. + match render( + harness, + envelope, + hook::Decision::Deny(refusal), + &rendering, + mode, + out, + err, + ) { + // The DOCUMENT already refuses, so the number is free to be honest: §6–§7 + // reserve `3` for could-not-look, and nothing was judged about this call. + // `Internal` rather than `Violation` also keeps `exit.rs`'s guarantee + // whole — `Usage` and `Internal` are the only codes a failure of Batten's + // own may produce, *so that fail-open is structural* — and an unreadable + // declaration is such a failure. Answering `2` here would have bought the + // refusal twice and spent that guarantee for the second copy. + Ok(_) => Ok(ExitCode::Internal), + // THE NUMBER IS THIS HARNESS'S ONLY CHANNEL, so it stays the deny. Turning + // it into `3` would be honest about the cause and silent about the verdict, + // which is the fail-open this row exists to close. + // + // **The cost is stated rather than absorbed** (CLOUD-1677's Replay asks for + // the per-harness table): on this adapter the boundary cannot say BOTH + // "refused" and "nothing could be read", so the could-not-look half is + // unavailable there until that protocol grows a way to carry it. + Err(denial) => Err(denial), + } +} + /// Run the refusing row's declared repair, and say what the boundary decides now. /// /// **Every other decision passes straight through**, and most `Deny`s do too: diff --git a/crates/batten/src/lint.rs b/crates/batten/src/lint.rs index 011cf024f..9bddf29d2 100644 --- a/crates/batten/src/lint.rs +++ b/crates/batten/src/lint.rs @@ -401,7 +401,7 @@ pub fn smells( // same message it would anywhere else rather than this module's own. let config = config::parse(text, source)?; let located: Located = - toml::from_str(text).map_err(|err| config::config_error(source, &err))?; + toml::from_str(text).map_err(|err| config::config_error(source, text, &err))?; let mut found = Vec::new(); diff --git a/crates/batten/src/pipeline.rs b/crates/batten/src/pipeline.rs index dfa8b7c9f..4c5e5186a 100644 --- a/crates/batten/src/pipeline.rs +++ b/crates/batten/src/pipeline.rs @@ -51,6 +51,11 @@ //! That is raise-only in the same spirit as the deny-only Rego surface: a //! consumer may compose any pipeline, but not one that leaks spend. +// CLOUD-1681: dropping the `Push` row's precheck restores the path from a +// pending bet to a publish, which is the state measured twice. +//MUTANT-SUITE crates/batten/tests/it/land_speculation.rs +//MUTANT live-bet-reaches-the-push|s@ precheck: Some(Precheck::BetLive),@ precheck: None,@|a_live_bet_is_refused_by_the_row_that_would_publish_it + use crate::land::Step; /// A step's declared undo, run when a lap leaves without landing. @@ -243,6 +248,44 @@ pub enum Precheck { /// that reads as a narrowing: the next reader should find the gap written /// down rather than infer its absence. LeaseHeld, + /// Is a speculation still outstanding on the head we are about to publish? + /// + /// **THE INVARIANT THIS FILE ALREADY QUOTES AND DID NOT HOLD** (CLOUD-1681). + /// `mise-tasks/land.sh`, verbatim above: *"there is no path from a losing bet + /// to a push, which is what makes speculating safe rather than merely fast."* + /// [`Settle::Lost`] has no such path — [`Self::BetSettled`] unwinds it at the + /// top of the lap. [`Settle::Pending`] does, because that precheck + /// deliberately KEEPS a pending bet, and this row carried no precheck at all. + /// + /// # A PENDING BET IS EXACTLY AS UNPUBLISHABLE AS A LOST ONE + /// + /// A bet names the holder's SHAS, and the holder lands by rebase — which + /// mints new ones for the same patches. So a published pending head does not + /// merely risk going stale: the moment the holder lands it is DIVERGENT, and + /// the fast-forward is impossible by construction rather than by race. The + /// bet is unwinnable at the instant it is placed, so publishing it guarantees + /// a wasted matrix rather than risking one. + /// + /// Measured twice — four commits of another branch published under this one + /// on 2026-09-08, eight on 2026-09-10, the second costing a full matrix on a + /// head that could not merge when it was graded. + /// + /// **It answers `Lap`, never `Stop`, and that is the design.** `Stop` strands + /// the waiter behind CLOUD-1306's poisoned base indefinitely, trading a + /// wasted matrix for an unbounded stall. `Lap` drops the borrowed range and + /// re-enters: replay onto real trunk, verify the UNSPECULATED tree, publish + /// this branch's own commits alone — one extra local verify and zero CI. + /// + /// **Speculation keeps its whole value.** A holder that lands during `verify` + /// settles [`Settle::Landed`], the bet is forgotten, nothing unwinds, and the + /// lap publishes the pre-linearized head exactly as before. This fires only + /// where the bet failed to pay off — precisely where carrying the borrowed + /// range is worthless AND harmful. + /// + /// [`Settle::Lost`]: crate::speculation::Settle::Lost + /// [`Settle::Pending`]: crate::speculation::Settle::Pending + /// [`Settle::Landed`]: crate::speculation::Settle::Landed + BetLive, } /// A declared landing pipeline. @@ -450,7 +493,11 @@ impl Default for Pipeline { step: Step::Push, effectful: true, compensate: Compensation::ReleaseLease, - precheck: None, + // THE ROW THAT PUBLISHES IS THE ROW THAT ASKS (CLOUD-1681). + // `Replay` asks whether the bet is worth KEEPING; this asks + // whether it is fit to PUBLISH, and the two answers differ + // for a pending bet — which is the whole gap. + precheck: Some(Precheck::BetLive), }, StepRow { step: Step::Ready, @@ -530,6 +577,46 @@ mod tests { ); } + /// **THE PUBLISHING ROW ASKS BEFORE IT PUBLISHES** (CLOUD-1681). + /// + /// `mise-tasks/land.sh`'s invariant, quoted in this file's own header, is + /// *"there is no path from a losing bet to a push"* — and for the whole of + /// this table's life the `Push` row carried `precheck: None` while `Replay`, + /// `Ready` and the commit point each carried one. A pending bet reached the + /// publish, twice measured. + /// + /// **The mirror is the half that discriminates.** Asserting only that `Push` + /// has a precheck passes over a table where every row has one, which would be + /// its own defect — `Verify` spends the gate and must not be gated on the bet, + /// or a speculating lap could never verify the tree it speculated. + #[test] + fn the_push_row_asks_about_the_bet_and_the_verify_row_does_not() { + let shipped = Pipeline::default(); + let precheck = |step: Step| { + shipped + .steps + .iter() + .find(|row| row.step == step) + .and_then(|row| row.precheck) + }; + + assert_eq!( + precheck(Step::Push), + Some(Precheck::BetLive), + "the row that publishes must ask whether the head is publishable" + ); + assert_eq!( + precheck(Step::Verify), + None, + "gating the gate on the bet would stop a speculating lap verifying at all" + ); + assert_eq!( + precheck(Step::Replay), + Some(Precheck::BetSettled), + "and the settle stays where it was — the two ask different questions" + ); + } + /// **`Abandon` IS THE ONLY ATTEMPT-OWED UNDO, and the mirror is what makes /// this case discriminate.** Without the second half, a predicate returning /// `true` for everything would satisfy the first — and that predicate would diff --git a/crates/batten/src/repair.rs b/crates/batten/src/repair.rs index 69d1b05e6..0126c7365 100644 --- a/crates/batten/src/repair.rs +++ b/crates/batten/src/repair.rs @@ -163,7 +163,7 @@ pub fn run(root: &Path, fix: &str, key: Option<&str>, applicability: Applicabili // `Diagnostics::Drop`: the repair's own chatter is not a finding, and a // consumer's command could print anything at all. let Some((code, _output)) = - crate::exec::piped_argv(root, &words, "", crate::exec::Diagnostics::Drop) + crate::exec::piped_argv(root, &words, "", crate::exec::Diagnostics::Drop, &[]) else { // The program would not resolve. A declared repair naming something this // host does not have is a config defect, and the ordinary refusal is the diff --git a/crates/batten/src/speculation.rs b/crates/batten/src/speculation.rs index 7e58503a6..529a4f8cb 100644 --- a/crates/batten/src/speculation.rs +++ b/crates/batten/src/speculation.rs @@ -8,22 +8,31 @@ //! be already correct when it lands. The second is the bet, and the whole of the //! machinery below exists because a bet can be wrong. //! -//! # THIS IS A CONSERVING PORT, AND ONE KNOWN DEFECT TRAVELS WITH IT +//! # A BET CAN BE LOST, AND IT CAN ALSO BE POISONED //! -//! `settle` has THREE outcomes — the holder landed, the bet is still open, the -//! bet lost — and **no arm for a base whose tree is poisoned**: one that will -//! not pass `verify`. CLOUD-1306 is that gap, and it is deliberately NOT fixed -//! here. A port that improved behaviour could not be shown to conserve it, and -//! being able to say "this does what the bash did" is the whole discipline that -//! makes a 4,700-line retirement reviewable. +//! `settle` has FOUR outcomes: the holder landed, the bet is still open, the bet +//! lost — and the base is **poisoned**, meaning its tree will not pass `verify`. +//! The port that created this module conserved the bash's three and carried the +//! fourth as a declared gap so the retirement stayed reviewable as a port; +//! CLOUD-1306 is that gap and this is its close. //! -//! What the gap costs, so nobody reads its absence as completeness: a waiter +//! What it cost while it was open, recorded because the shape recurs: a waiter //! linearizes onto a head that cannot go green, `settle` reads the bet as still -//! open every lap (the holder is still there and `main` has not moved, which is -//! exactly what "pending" looks like), and [`Bet::would_rebet`] bets on the same -//! holder again. Every waiter behind that holder stalls together. The fix is -//! CLOUD-1306's and belongs in one change that can be reviewed as a behaviour -//! change rather than smuggled into a port. +//! open every lap — the holder is still there and `main` has not moved, which is +//! exactly what "pending" looks like — and [`Bet::would_rebet`] bets on the same +//! holder again. Every waiter behind that holder stalls together. Measured over +//! three consecutive invocations on PR #815, all three exited identically. +//! +//! **The discriminator is the re-verify off the borrowed base**, and it is the +//! one the error message already told a human to run by hand: red on the +//! borrowed tree and green on our own means the failure is the holder's, so the +//! bet is poisoned rather than merely open. Red on both is OURS and stops the +//! lap exactly as before — [`Settle::Poisoned`] is an arm, never a hatch. +//! +//! **A poisoned base is REMEMBERED on a ref** ([`REFUSED_REF`]), not merely in +//! this process. The measured stall was three separate `land` invocations, so a +//! memory that died with the process would have unwound the first lap and +//! re-bet on the same refused head at the start of the next one. //! //! # Every failure is a FALLBACK, never a stop //! @@ -38,6 +47,13 @@ //! an unknown ancestry are all *stale*, because failing open there would make a //! network blip the thing that lands somebody else's work. +// CLOUD-1306's own Ready block names both of these, and they are the two +// directions the arm can fail in: forgetting the judgement (the stall returns) +// and applying it to everything (a waiter with its own defect never stops). +//MUTANT-SUITE crates/batten/src/speculation.rs +//MUTANT poisoned-bet-rebet|s@ if self.refused.as_deref() == Some(candidate) {@ if false {@|forgetting_a_bet_keeps_the_base_that_would_not_go_green +//MUTANT own-red-tree-unwinds|s@ if bet.refused.as_deref() == Some(base) {@ if true {@|a_waiter_whose_own_tree_is_red_still_settles_pending + use std::path::Path; use anyhow::Result; @@ -58,6 +74,20 @@ pub const BASE_REF: &str = "refs/batten-spec/base"; /// while answering a question about it. pub const LIVE_REF: &str = "refs/batten-spec/live"; +/// The ref a base KNOWN to fail `verify` is remembered under. +/// +/// A THIRD ref, and it outlives the bet it refused. [`BASE_REF`] records a bet +/// that exists and is deleted the moment one settles; this records a judgement +/// about a commit, which stays true after the bet built on it is gone. Reusing +/// either of the other two would delete the memory at exactly the point it +/// starts being useful. +/// +/// A ref rather than a field alone because the measured stall (CLOUD-1306, PR +/// #815) was three separate `land` invocations. [`Bet::refused`] is this +/// process's copy; without the ref, lap one unwinds and the next invocation bets +/// on the same refused head again — which is the stall, one process later. +pub const REFUSED_REF: &str = "refs/batten-spec/refused"; + /// The variable a bet is published to the child process under. /// /// `verify` runs `claim-race-check`, which reads `claimed-keys`, which cannot @@ -78,14 +108,27 @@ pub enum Settle { /// Undecided. The holder is still landing and this branch is already behind /// it, so the tree is kept. /// - /// **This is the arm CLOUD-1306's poisoned base hides in.** A base that will - /// never go green is indistinguishable here from one that simply has not - /// landed yet, and the module header says why that is conserved rather than - /// fixed. + /// **A base that will never go green USED TO HIDE HERE**, indistinguishable + /// from one that simply has not landed yet. It no longer does — but only + /// because something took the discriminating reading and recorded it. With + /// no such reading this arm is still the honest answer, which is why a + /// re-verify that cannot run settles `Pending` rather than [`Self::Poisoned`]. Pending, /// The bet cannot come true: the holder is gone, or `main` moved and took /// something else. The borrowed range is dropped. Lost, + /// The bet is still live and its base is KNOWN not to go green (CLOUD-1306). + /// + /// **APPENDED, NEVER INSERTED.** This enum carries no `repr` and its + /// discriminants are compared across builds by anything that stores one, so + /// a variant placed mid-enum silently renumbers every variant after it — + /// `verdict::Native` paid for that lesson in this same branch. + /// + /// Distinct from [`Self::Lost`] because the action differs in one way that + /// matters: a lost bet's base may be bet on again the moment the holder is + /// back, and a poisoned one must not be until the tree that refused it + /// moves. Both unwind; only this one is remembered ([`REFUSED_REF`]). + Poisoned, } /// Whether the bet is still on the branch that is about to land. @@ -168,6 +211,62 @@ pub struct Bet { /// it, and reading it as "no more bets at all" would give up speculating for /// the rest of the landing over one bad candidate. pub conflicts: Option, + /// The holder's base that is KNOWN to fail `verify` (CLOUD-1306). + /// + /// **`conflicts`'s sibling, and the pair is not one field.** A base that + /// conflicts cannot be replayed onto at all; a base that is poisoned replays + /// perfectly and then refuses the gate. They are learned at different + /// moments — the replay and the verify — and a branch can hit either without + /// the other, so collapsing them would make each answer the other's question. + /// + /// Survives [`Bet::forget`] for `conflicts`' own stated reason: it records a + /// judgement about a COMMIT, which stays true of that commit after the bet + /// built on it settles. Forgetting it is what made CLOUD-369's refusal + /// unreachable, and the same forgetting here would re-bet on the refused + /// head on the very next lap. + /// + /// An `Option` rather than a `bool` for the same reason `conflicts` + /// is: refusing to re-bet needs to know WHICH base. A flag cannot tell the + /// poisoned holder from the one that replaced it, and reading it as "no more + /// bets at all" would abandon speculation for the rest of the landing over + /// one bad candidate. + pub refused: Option, + /// A base the gate refused ON THE BORROWED TREE, not yet discriminated. + /// + /// **THE THIRD CONJUNCT OF CLOUD-1306's PREDICATE, AND IT COSTS NO EXTRA + /// GATE RUN.** The row states the reading as "replay onto `origin/main` and + /// re-run"; run literally that would buy a second full gate inside a lap + /// that has just spent one — measured at ~25 minutes on this container. + /// + /// The NEXT LAP'S verify is that same re-run. So a refusal on a speculative + /// tree records the base here and unwinds, and the lap that follows decides + /// it: green off our own base means the failure was the holder's and this is + /// promoted to [`Bet::refused`]; red again means it was ours all along and + /// this is dropped without ever becoming a judgement. + /// + /// **[`Bet::would_rebet`] refuses a suspect as firmly as a refused base**, + /// and it must: re-borrowing the head under suspicion on the very next lap + /// would re-poison the tree and destroy the reading that was about to + /// discriminate it. + pub suspect: Option, + /// This landing has already declined to publish a speculation (CLOUD-1681). + /// + /// **THE TERMINATION HALF, and without it the precheck is a spin.** The + /// `Push` row's precheck unwinds a live bet and laps; nothing in the lap + /// otherwise remembers that, so `place_the_bet` would bet on the same holder + /// at the top of the next lap, reach the same precheck, and unwind again — + /// bounded by the lap budget rather than by the one extra local verify the + /// design promises. + /// + /// A `bool` rather than a base, unlike [`Bet::conflicts`] and + /// [`Bet::refused`]: those record a judgement about a COMMIT and stay true of + /// it afterwards. This records a decision about THIS LANDING — we got as far + /// as the push with a bet outstanding, so speculating again buys nothing + /// before this branch lands. Naming a base would invite re-betting on the + /// next holder and paying the same unwind a second time. + /// + /// Survives [`Bet::forget`], because `forget` is what the unwind calls. + pub declined: bool, } impl Bet { @@ -191,27 +290,68 @@ impl Bet { /// /// `false` for the same candidate twice — the one-outstanding-bet rule above. /// - /// **AND `true` AGAIN ONCE THE BET IS FORGOTTEN, WHICH IS CLOUD-1306's OTHER - /// HALF.** A poisoned base settles as [`Settle::Pending`] and is never - /// forgotten, so this correctly answers `false` and the waiter sits. Where - /// the bet IS dropped, nothing here remembers that this candidate was already - /// tried, so the next lap bets on the same holder again. Conserved; the fix - /// is CLOUD-1306's. - /// - /// **A base known to CONFLICT is a different question and this now answers - /// it** (review of #848). That one is not about a tree that will not go - /// green — it is a replay this clone already attempted and watched fail, so + /// **A base known to CONFLICT is a different question and this answers it** + /// (review of #848). That one is not about a tree that will not go green — + /// it is a replay this clone already attempted and watched fail, so /// re-attempting it is guaranteed waste rather than a gamble whose odds - /// changed. [`Bet::conflicts`] records which base, and it is the one thing - /// here that survives the bet not being placed. + /// changed. [`Bet::conflicts`] records which base. + /// + /// **AND A BASE KNOWN TO BE POISONED IS A THIRD** (CLOUD-1306). Once the bet + /// on it is unwound, `base` no longer names it, so without + /// [`Bet::refused`] this would answer `true` on the very next lap and + /// re-borrow the tree that just refused the gate. That is the stall the whole + /// arm exists to end, and it reappears the moment this stops consulting it. + /// + /// Both memories survive the bet not being placed, which is what makes them + /// useful here rather than merely recorded. #[must_use] pub fn would_rebet(&self, candidate: &str) -> bool { if self.conflicts.as_deref() == Some(candidate) { return false; } + if self.refused.as_deref() == Some(candidate) { + return false; + } + // A SUSPECT IS REFUSED AS FIRMLY AS A JUDGEMENT, and for a sharper + // reason: re-borrowing the head that is currently under suspicion would + // put the borrowed range back into the tree and destroy the very reading + // — the next lap's own verify — that was about to discriminate it. + if self.suspect.as_deref() == Some(candidate) { + return false; + } self.base.as_deref() != Some(candidate) } + /// The borrowed tree was refused: hold this base pending discrimination. + /// + /// Records nothing if some other base is already under suspicion — one + /// outstanding question at a time, for [`Bet`]'s own one-bet reason. + pub fn suspect(&mut self, base: &str) { + if self.suspect.is_none() { + self.suspect = Some(base.to_owned()); + } + } + + /// The next lap went GREEN off our own base: the suspicion was the holder's. + /// + /// Returns the base to remember, so the caller writes [`REFUSED_REF`] only + /// where a judgement was actually reached. + pub fn confirm_refusal(&mut self) -> Option { + let confirmed = self.suspect.take()?; + self.refused = Some(confirmed.clone()); + Some(confirmed) + } + + /// The next lap was red off our own base too: the failure is OURS. + /// + /// **The anti-vacuity half, and dropping it is what would make every stop + /// look like a poisoning.** A branch with a genuine defect refuses the gate + /// on every base, so without this it would accumulate a refusal against each + /// holder it ever waited behind and stop speculating altogether. + pub fn clear_suspicion(&mut self) { + self.suspect = None; + } + /// Drop the bet's own bookkeeping. The REF is the caller's to delete. /// /// **`pushed` IS CLEARED AND `conflicts` IS NOT, and the asymmetry is the @@ -225,6 +365,16 @@ impl Bet { /// base KNOWN to conflict, which stays true of that base after the bet built /// on some other one is settled. Forgetting it is what made CLOUD-369's /// mechanism unreachable. + /// + /// **`refused` survives for the identical reason** (CLOUD-1306), and it is + /// the field with the most to lose by not doing so: a poisoned bet is + /// settled by UNWINDING it, so this runs on the exact path that would + /// otherwise erase the judgement one line before the next lap re-bets on it. + /// + /// **`suspect` survives too, and its case is sharper still.** Suspicion is + /// RAISED on the unwind path — this function's own caller — so clearing it + /// here would delete the question in the same breath as asking it, and the + /// next lap would have nothing to discriminate. pub fn forget(&mut self) { self.base = None; self.undo = None; @@ -234,7 +384,12 @@ impl Bet { } #[cfg(test)] - /// A settled bet carries nothing forward but the base it will not re-bet on. + /// A settled bet carries nothing forward but the bases it will not re-bet on. + /// + /// Both memories are excluded here rather than asserted absent: `conflicts` + /// and `refused` are judgements about commits, and the cases beside this one + /// assert each SURVIVES. A spelling that required them cleared would make the + /// two suites contradict each other. fn is_forgotten(&self) -> bool { self.base.is_none() && self.undo.is_none() @@ -283,7 +438,7 @@ impl Bet { /// gone. #[must_use] pub fn settle(bet: &Bet, main_now: Option<&str>, base_on_main: bool, live: Live) -> Settle { - let Some(_) = bet.base.as_deref() else { + let Some(base) = bet.base.as_deref() else { return Settle::Nothing; }; @@ -295,6 +450,27 @@ pub fn settle(bet: &Bet, main_now: Option<&str>, base_on_main: bool, live: Live) return Settle::Landed; } + // POISONED (CLOUD-1306), and its position between `Landed` and the liveness + // arms is the whole of its correctness. + // + // AFTER `Landed`, because a base the trunk has already taken is not poisoned + // whatever a stale judgement says — the tree that refused it is now `main`'s + // problem and unwinding off it would drop commits we are already correctly + // linearized on. + // + // BEFORE the liveness arms, because every one of them reads a live holder + // that has not moved `main` as [`Settle::Pending`] — which is exactly what a + // poisoned holder looks like, and precisely the arm this used to hide in. + // + // The judgement is the CALLER's reading, taken by re-verifying off the + // borrowed base and recorded on [`REFUSED_REF`]. This function only asks + // whether the bet outstanding is the one that was refused; a re-verify that + // could not run records nothing and falls through to `Pending`, which is the + // could-not-look direction the module header requires. + if bet.refused.as_deref() == Some(base) { + return Settle::Poisoned; + } + // An ADOPTED bet has no `main_at_bet` — the process that recorded it is gone // — so the "has main moved" arm cannot judge it. The lease can: it reads who // holds it NOW and whether the base is still on the branch about to land, @@ -366,6 +542,24 @@ pub fn carries(dir: &Path, candidate: &str, tip: &str) -> bool { crate::gitwrite::carries(dir, candidate, tip) } +/// Load the refused-base judgement this clone recorded in an earlier process. +/// +/// **Separate from [`recover`], and not folded into it, because the two answer +/// different questions.** `recover` asks whether a BET is outstanding and +/// returns early when one is; a judgement about a poisoned base is worth reading +/// whether or not this process is speculating, and is most worth reading when it +/// is not — that is the moment [`Bet::would_rebet`] is about to be asked. +/// +/// Fail-open and silent, like every other reading in this module: a ref store +/// that will not answer leaves the memory empty, which costs a wasted lap rather +/// than a wrong verdict. +pub fn recall_refusal(dir: &Path, bet: &mut Bet) { + if bet.refused.is_some() { + return; + } + bet.refused = crate::git::resolve_ref(dir, REFUSED_REF).ok().flatten(); +} + /// Adopt a bet this process did not place. /// /// Runs BEFORE the ordinary settle, so the settle that follows is the ordinary @@ -421,6 +615,9 @@ mod tests { recovered: true, pushed: true, conflicts: Some(String::from("feedface")), + refused: Some(String::from("baddecaf")), + suspect: Some(String::from("d15ea5e0")), + declined: true, }; bet.forget(); assert!(bet.is_forgotten(), "a settled bet carried state forward"); @@ -443,6 +640,30 @@ mod tests { ); } + /// **AND SO DOES THE POISONED ONE, WHICH IS THE FIELD WITH THE MOST TO LOSE** + /// (CLOUD-1306). A poisoned bet is settled BY unwinding it, so `forget` runs + /// on the exact path that would otherwise erase the judgement one line before + /// the next lap re-bets on the same refused head — the measured stall, + /// reproduced one process later. + #[test] + fn forgetting_a_bet_keeps_the_base_that_would_not_go_green() { + let mut bet = Bet { + base: Some(String::from(HOLDER)), + refused: Some(String::from(HOLDER)), + ..Bet::default() + }; + bet.forget(); + assert_eq!(bet.refused.as_deref(), Some(HOLDER)); + assert!( + !bet.would_rebet(HOLDER), + "a base that would not go green is still refused after the bet unwinds" + ); + assert!( + bet.would_rebet(MOVED), + "one poisoned candidate must not abandon speculation altogether" + ); + } + const HOLDER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const MAIN: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const MOVED: &str = "cccccccccccccccccccccccccccccccccccccccc"; @@ -695,4 +916,92 @@ mod tests { ); assert!(!bet.live()); } + + /// A bet whose base was recorded as refused settles POISONED. + /// + /// The inputs are deliberately the ones that spell a healthy `Pending`: the + /// holder is live, `main` has not moved, and the base is not on the trunk. + /// That is the whole defect — a poisoned holder is indistinguishable from a + /// winning one on every reading EXCEPT the recorded judgement, so a case + /// that varied anything else would pass against the old code too. + #[test] + fn a_base_recorded_as_refused_settles_poisoned() { + let bet = Bet { + refused: Some(String::from(HOLDER)), + ..placed() + }; + assert_eq!( + settle(&bet, Some(MAIN), false, Live::Yes), + Settle::Poisoned, + "a live holder whose tree will not go green is not merely pending" + ); + } + + /// **THE ANTI-VACUITY MIRROR, and without it the arm above is satisfied by a + /// `settle` that answers `Poisoned` to everything.** + /// + /// The waiter's OWN tree being red records nothing — the re-verify off the + /// borrowed base reproduces the failure, so the judgement is never written — + /// and the identical inputs must still settle `Pending` and stop the lap + /// exactly as before. CLOUD-1306's acceptance names this case in as many + /// words: *a waiter whose own tree is red still stops.* + #[test] + fn a_waiter_whose_own_tree_is_red_still_settles_pending() { + assert_eq!( + settle(&placed(), Some(MAIN), false, Live::Yes), + Settle::Pending, + "nothing was recorded against this base, so the failure is the waiter's" + ); + } + + /// A judgement about a DIFFERENT base does not poison this bet. + /// + /// The memory outlives the bet it refused, so a stale entry naming a holder + /// that has since been replaced would unwind a perfectly good speculation + /// every lap — the `Option` earning its keep over a `bool`. + #[test] + fn a_refusal_recorded_against_another_base_leaves_this_bet_alone() { + let bet = Bet { + refused: Some(String::from(MOVED)), + ..placed() + }; + assert_eq!( + settle(&bet, Some(MAIN), false, Live::Yes), + Settle::Pending, + "the refused base is not the one this bet is on" + ); + } + + /// **A POISONED BASE THAT LANDED ANYWAY IS `Landed`, NOT `Poisoned`.** + /// + /// The trunk taking the base settles the question whatever a stale judgement + /// says, and this is why the arm sits AFTER `Landed` rather than before it. + /// Reversed, this would unwind a branch off commits it is already correctly + /// linearized on — dropping landed work to honour an obsolete opinion. + #[test] + fn a_refused_base_that_reached_the_trunk_still_settles_landed() { + let bet = Bet { + refused: Some(String::from(HOLDER)), + ..placed() + }; + assert_eq!( + settle(&bet, Some(MAIN), true, Live::Yes), + Settle::Landed, + "the trunk took it, so the judgement is history rather than a reason to unwind" + ); + } + + /// With no bet outstanding a recorded refusal decides nothing. + /// + /// `refused` survives `forget`, so it is routinely populated while `base` is + /// `None`. Reading it before the `Nothing` guard would have this answer + /// `Poisoned` for a branch that is not speculating at all. + #[test] + fn a_recorded_refusal_with_no_bet_is_still_nothing() { + let bet = Bet { + refused: Some(String::from(HOLDER)), + ..Bet::default() + }; + assert_eq!(settle(&bet, Some(MAIN), false, Live::Yes), Settle::Nothing); + } } diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index ff125021b..067b79ad8 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -1258,6 +1258,39 @@ pub enum Native { /// than in a consumer `[[verdict]]` row. No consumer can name the class, /// because the engine is what takes the plan and what compares it. PlanReadStale, + /// The declaration could not be READ AT ALL — not valid TOML. + /// + /// **APPENDED, NEVER INSERTED.** This enum carries no `repr`, so a variant + /// added in the middle shifts every later discriminant and + /// `enum_no_repr_variant_discriminant_changed` reads the whole tail as + /// broken — measured here, where placing it beside the other config classes + /// moved eighteen of them. Position is API; the reading order below is not. + /// + /// **Deliberately not in [`Native::CONFIG_FAULTS`]**, which is the per-TABLE + /// set and is censused in both directions against `config.rs`'s own list. This + /// one has no table: it is raised before any table exists, by the parse that + /// every table's validator runs after. + /// + /// # Why it needs a class when the other parse failures do not + /// + /// A class here is not for `explain` — it is the DISCRIMINATOR the mediated + /// boundary acts on (CLOUD-1677). Gates are registered fail-open, and a gate + /// that fails open is inert: it neither allows nor denies, it is absent. So a + /// config fault is never a choice between refusing and allowing, it is a + /// choice between keeping the enforcement surface we still have and losing it + /// entirely. + /// + /// An unknown key, a version this build is too old for, a row whose validator + /// refused — each leaves every OTHER row readable and enforceable, and leaves + /// an agent that can still be told to repair the one that is broken. Failing + /// the whole load there buys nothing and costs the surface that would have + /// carried the repair instruction. + /// + /// A file that is not TOML is the one case with no partial function to + /// preserve: zero rows are readable, so refusing the call is the only signal + /// left, and the declared hatch is the recovery path. That asymmetry is why + /// this class exists and why it is exactly one class wide. + ConfigUnreadable, } impl Native { @@ -1291,6 +1324,7 @@ impl Native { Native::CallFixSilent, Native::ContentRefused, Native::KeyMissing, + Native::ConfigUnreadable, Native::VerbTableRefused, Native::PatternTableRefused, Native::VerdictTableRefused, @@ -1373,6 +1407,7 @@ impl Native { Native::CallFixSilent => "call fix silent", Native::ContentRefused => "input write refused", Native::KeyMissing => "issue name missing", + Native::ConfigUnreadable => "config read refused", Native::VerbTableRefused => "verb declare refused", Native::PatternTableRefused => "pattern declare refused", Native::VerdictTableRefused => "verdict declare refused", @@ -1891,6 +1926,20 @@ it serves.", // 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: "config read refused", + gloss: "the declaration is not TOML, so no rule in it could be read", + class: "Every other config fault leaves the rest of the file deciding -- an unknown key \ +costs its own row, a table whose validator refuses names that table, and a version this build \ +is too old for still says so. Each of those keeps a working gate surface and an agent that can \ +be told to repair the broken part. This one has no partial function to preserve: the bytes are \ +not TOML, so zero rows are readable and nothing is enforced. That is why it is the one class \ +the mediated boundary refuses a call under, rather than reporting and proceeding -- a gate that \ +fails open is inert, and an inert gate over an unreadable authority is the false green this \ +engine exists to catch.", + routes: &[read("config read first", "batten.toml")], + applicability: Applicability::Advice, + }, VendoredVerdict { id: "verb declare refused", gloss: "the verb table would not load", @@ -2367,6 +2416,7 @@ mod tests { | Native::VerdictTrailing | Native::RunOrphaned | Native::CeilingExceeded + | Native::ConfigUnreadable | Native::ShapeRefused | Native::CallRetryNow | Native::CallFixSilent diff --git a/crates/batten/tests/it/adjudicate_absent.rs b/crates/batten/tests/it/adjudicate_absent.rs new file mode 100644 index 000000000..300938ce4 --- /dev/null +++ b/crates/batten/tests/it/adjudicate_absent.rs @@ -0,0 +1,185 @@ +//! A call this build cannot adjudicate is DENIED, never allowed by a failure. +//! +//! The tier that proves the engine does not fail open when it has read its own +//! authority and been told it cannot enforce it. Unit cases over `adjudicate` +//! cannot host this: the defect is not in the decision, it is in what the +//! BOUNDARY does with a load that failed, and only the compiled binary answers +//! that — `mediated_admission.rs`'s header records the same lesson, where unit +//! cases passed while the binary allowed the write. +//! +//! # The mirror is not decoration +//! +//! `a_loadable_config_still_allows_an_ordinary_call` is what stops this being +//! satisfied by an adjudicator that denies everything. A fail-closed hook that +//! refuses each call is not a fix, it is an outage — CLOUD-1688's falsifier says +//! so in as many words, and that is why the pair lands together. +//! +//! # Scope +//! +//! The CONFIG half of CLOUD-1688: a `batten.toml` this build cannot load. The +//! VERB half — a registration spelling a subcommand the installed binary does +//! not have — needs `doctor` to interrogate the installed artifact rather than +//! itself, because a self-check runs in the build that mise resolves and never +//! in the one the hook does. It lands with that part and belongs in this file. + +// 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 std::path::{Path, PathBuf}; + +use common::{Fixture, run_with_stdin}; + +/// A `batten.toml` mid-edit, which is the largest measured bucket: seven windows +/// across one 5-day session, 1,149 calls, every one of them unjudged. +const WILL_NOT_PARSE: &str = "version = 1\nthis is not toml\n"; + +/// A config that loads and declares nothing this call matches. +const LOADS: &str = "version = 1\n"; + +/// A fixture carrying `body` as its committed authority. +fn fixture(name: &str, body: &str) -> PathBuf { + Fixture::new(name) + .config(body) + .file("notes.md", "ordinary\n") + .git() + .base_commit() + .build() +} + +/// A Claude Code `PreToolUse` envelope carrying a command, so the boundary +/// treats it as adjudicable and reaches the config load at all. +fn payload() -> String { + "{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\ + \"tool_input\":{\"command\":\"echo hello\"}}" + .to_owned() +} + +/// Adjudicate on the neutral adapter, where the VERDICT IS THE NUMBER. +/// +/// `exit-code` rather than `claude-code` for the two cases that ask what the +/// code is: on Claude Code the deny is the JSON document at exit `0`, so a case +/// asserting a number there would assert the wrong channel. The document is +/// checked separately below. +fn code(dir: &Path) -> Option { + run_with_stdin(dir, &["adjudicate", "--harness", "exit-code"], &payload()) + .status + .code() +} + +#[test] +fn a_config_this_build_cannot_load_denies_rather_than_failing_open() { + // WAS `1`, WHICH A HARNESS READS AS A NON-BLOCKING HOOK ERROR, so the + // mediated tool ran with nothing judging it (CLOUD-1677). + // + // `2` HERE AND `3` ON A DOCUMENT HARNESS, and the split is the protocol + // rather than a preference: this adapter's ONLY deny channel is the number, + // so the number has to carry the refusal. Where the decision object carries + // it instead, the number is free to say could-not-look — the case below + // asserts that side. + let dir = fixture("adjudicate-unloadable", WILL_NOT_PARSE); + assert_eq!( + code(&dir), + Some(2), + "a call nothing could judge must be refused, not allowed by the failure" + ); +} + +#[test] +fn a_loadable_config_still_allows_an_ordinary_call() { + // THE MIRROR. Without it the case above is satisfied by an adjudicator that + // denies every call in the fleet, which is an outage wearing a fix's clothes. + let dir = fixture("adjudicate-loadable", LOADS); + assert_eq!( + code(&dir), + Some(0), + "a config that loads and refuses nothing must still allow" + ); +} + +#[test] +fn the_declared_hatch_still_reaches_a_clone_whose_config_will_not_load() { + // What keeps a container recoverable rather than bricked. A stale binary + // meeting a newer config refuses every call until one of them moves, so the + // operator's declared escape has to survive exactly the state that needs it. + // + // `common::batten()` scrubs every bypass variable by construction, so setting + // one here is the only way it is present — a case that inherited it from the + // developer's shell would pass without testing anything. + use std::io::Write as _; + use std::process::Stdio; + + let dir = fixture("adjudicate-hatch", WILL_NOT_PARSE); + let mut child = common::batten() + .args(["adjudicate", "--harness", "exit-code"]) + .current_dir(&dir) + .env("BATTEN_HOOK_BYPASS", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the binary runs"); + child + .stdin + .as_mut() + .expect("stdin is piped") + .write_all(payload().as_bytes()) + .expect("the payload is writable"); + let output = child.wait_with_output().expect("the binary answers"); + assert_eq!( + output.status.code(), + Some(0), + "the declared hatch must still pass a call the engine cannot judge" + ); +} + +#[test] +fn on_claude_code_the_refusal_is_the_document_rather_than_the_number() { + // THE CHANNEL IS PER-HARNESS AND THE NUMBER IS NOT (`run_hook`'s own + // contract). This host reads the JSON decision object and ignores the code, + // so a deny raised as `2` here would be a refusal nobody receives — which is + // the reason this arm renders rather than raising a `Denial`. + let dir = fixture("adjudicate-document", WILL_NOT_PARSE); + let output = run_with_stdin( + dir.as_path(), + &["adjudicate", "--harness", "claude-code"], + &payload(), + ); + let rendered = String::from_utf8_lossy(&output.stdout); + // The row's falsifier names the field rather than the word: a `contains("deny")` + // would pass on a document that merely mentioned it, including one that said + // the opposite. + assert!( + rendered.contains(r#""permissionDecision":"deny""#), + "the decision object must carry the deny: {rendered}" + ); + // AND THE NUMBER IS FREE TO BE HONEST, which is the half that needs the + // document to exist. §6-§7 reserve `3` for could-not-look, and `exit.rs` + // keeps `Usage` and `Internal` the only codes a failure of Batten's own may + // produce *so that fail-open is structural*. Answering `2` here would buy the + // refusal a second time and spend that guarantee for the copy. + assert_eq!( + output.status.code(), + Some(3), + "where the document refuses, the number says nothing was judged" + ); +} + +#[test] +fn the_declaration_that_would_not_parse_is_named_without_quoting_it() { + // Non-negotiable rule 4, and the row asks for this clause by name: the reason + // carries the parse position, never the config's contents. The fixture's body + // is `this is not toml`, so its presence in the output would be the leak. + let dir = fixture("adjudicate-pointer-only", WILL_NOT_PARSE); + let output = run_with_stdin( + dir.as_path(), + &["adjudicate", "--harness", "claude-code"], + &payload(), + ); + let rendered = String::from_utf8_lossy(&output.stdout); + assert!( + !rendered.contains("this is not toml"), + "a refusal about an unreadable config must not quote it: {rendered}" + ); +} diff --git a/crates/batten/tests/it/cli.rs b/crates/batten/tests/it/cli.rs index 1a1df93e3..ce96741e7 100644 --- a/crates/batten/tests/it/cli.rs +++ b/crates/batten/tests/it/cli.rs @@ -2680,24 +2680,56 @@ fn hook_allows_when_no_authority_is_configured() { } #[test] -fn hook_fails_open_and_loud_on_an_unloadable_authority() { - // The opposite case, and the one CLOUD-40 could not reach because `hook` - // loaded no config: an authority that EXISTS and cannot be read means the - // rules the operator wrote are not being applied. Allowing silently there - // would be the false green the engine exists to catch, so it is a usage - // error — loud on stderr, exit 1, and structurally not a deny, because §7 - // spends 2 on the verdict alone. +fn hook_refuses_and_is_loud_on_an_unloadable_authority() { + // RENAMED FROM `hook_fails_open_and_loud_…`, because the first half stopped + // being true (CLOUD-1677) — and ONLY for this fixture's fault. + // + // The reasoning it was written on is what the row acted on: an authority that + // EXISTS and cannot be read means the operator's rules are not being applied, + // and allowing silently is the false green the engine exists to catch. What + // was wrong is that "loud" was the whole remedy — exit `1` is a NON-BLOCKING + // hook error, so the tool ran anyway and the loudness reached a log nobody + // gates on. Measured: 1,149 calls proceeded through seven windows of a + // mid-edit `batten.toml`, which is itself a protected path, so the gate + // guarding the config stopped guarding it during the one operation that + // changes it. + // + // **THIS FIXTURE IS `not toml at all`, WHICH IS THE WHOLE SCOPE.** A config + // that parses and merely fails a validator, or names a key this build + // predates, still answers `1` — every other row in it remains readable and + // enforceable, and an agent can still be told to repair the broken one. + // Refusing there would trade a working partial gate surface for nothing. + // Here there is no partial surface: nothing parsed, so nothing is enforced. let dir = repo_with_config("hook-broken-authority", "this is not toml at all\n"); for harness in harnesses() { let output = run_hook_in(&dir, harness, &claude_payload("gh pr view 42"), false); let code = output.status.code(); - assert_eq!(code, Some(1), "{harness}: an unreadable authority is usage"); - assert_ne!(code, Some(2), "{harness}: must never deny"); - assert!(output.stdout.is_empty(), "{harness}: no decision document"); + let stdout = String::from_utf8_lossy(&output.stdout); + // WHAT THIS CASE PROVES IS THAT NO HARNESS FAILS OPEN, and it deliberately + // does not assert the channel. Six protocols write six different decision + // documents, so a `permissionDecision` assertion inside this loop tests + // Claude Code's spelling five times and passes it off as coverage. The + // per-protocol detail — the document on a host that reads one, the `2` on + // the neutral adapter whose only channel is the number — belongs where it + // can be stated exactly, which is `adjudicate_absent.rs`. + // + // `1` was the whole defect: non-blocking, so the call ran. `0` would be a + // clean allow over an authority nothing could read. + assert!( + matches!(code, Some(2 | 3)), + "{harness}: a call under an unreadable authority must not proceed, got {code:?}" + ); + // NEVER SILENT, AND NEVER ANONYMOUS — the half of the original case that + // was always right, asked of whichever channel actually carried the + // refusal. The original asked stderr of every harness, which was true + // while the answer was always an exit-`1` diagnostic; now a document + // harness puts the reason in the document and leaves stderr empty, so + // asserting stderr alone would fail on the hosts that refuse best. let stderr = String::from_utf8_lossy(&output.stderr); + let spoken = format!("{stderr}{stdout}"); assert!( - stderr.contains("batten.toml"), - "{harness}: the failure names the file, got: {stderr}" + spoken.contains("batten.toml"), + "{harness}: the failure names the file, got: {spoken}" ); } } diff --git a/crates/batten/tests/it/config_forward_compatible.rs b/crates/batten/tests/it/config_forward_compatible.rs index 69a9018e3..c1a623148 100644 --- a/crates/batten/tests/it/config_forward_compatible.rs +++ b/crates/batten/tests/it/config_forward_compatible.rs @@ -658,14 +658,33 @@ fn the_report_never_tells_the_reader_to_install_an_older_release() { /// fault from a well-formed row naming a key from a newer schema, and collapsing /// the two is what produced the defect — a prune that swallowed a syntax error /// would load "no rules configured" over a broken file. +/// +/// **AND SINCE CLOUD-1677 THE REFUSAL REACHES THE CALL.** The claim above is +/// unchanged and is exactly why: a newer-schema row leaves every other row +/// readable, so the file still decides and an agent can be told to repair the one +/// key. A file that is not TOML leaves nothing — zero rows readable, nothing +/// enforced — and a gate that fails open there is inert, which is the false green +/// the engine exists to catch. So this is the one config fault that denies. +/// +/// It was `1`: the usage code, non-blocking, and the `rm /` below simply ran. #[test] fn a_file_that_is_not_toml_is_still_refused() { let dir = repo("config-forward-broken", "[[rule\nbroken\n"); - let (code, _, stderr) = adjudicate(&dir); - assert_eq!(code, Some(1), "malformed TOML is a usage error: {stderr}"); + let (code, stdout, stderr) = adjudicate(&dir); + // THE DENY IS THE JSON, as the case above states for this harness. What + // differs here is the number beside it: with the document carrying the + // refusal, the code is free to say could-not-look — §6-§7's `3` — rather than + // claiming a verdict was reached. `exit.rs` keeps `Usage` and `Internal` the + // only codes a Batten failure produces, so refusing costs that guarantee + // nothing. + assert_eq!(code, Some(3), "nothing could be judged: {stdout} {stderr}"); + assert!( + stdout.contains(r#""permissionDecision":"deny""#), + "a call under an unreadable authority is refused: {stdout}" + ); assert!( - stderr.contains("TOML parse error"), - "the refusal says the file is not TOML: {stderr}" + format!("{stdout}{stderr}").contains("TOML parse error"), + "the refusal says the file is not TOML: {stdout} {stderr}" ); } diff --git a/crates/batten/tests/it/land_speculation.rs b/crates/batten/tests/it/land_speculation.rs new file mode 100644 index 000000000..82fa1511b --- /dev/null +++ b/crates/batten/tests/it/land_speculation.rs @@ -0,0 +1,138 @@ +//! A live bet never reaches the publish (CLOUD-1681). +//! +//! # What this tier is for +//! +//! `mise-tasks/land.sh`'s invariant, quoted verbatim in `pipeline.rs`: +//! +//! > there is no path from a losing bet to a push, which is what makes +//! > speculating safe rather than merely fast. +//! +//! `Settle::Lost` has no such path — `Precheck::BetSettled` unwinds it at the top +//! of the lap. `Settle::Pending` had one, because that precheck deliberately +//! KEEPS a pending bet and the `Step::Push` row carried `precheck: None`. +//! Measured twice: four commits of another branch published under this one on +//! 2026-09-08, eight on 2026-09-10 — the second costing a full CI matrix on a +//! head that could not merge when it was graded. +//! +//! # Why a pending bet is exactly as unpublishable as a lost one +//! +//! A bet names the holder's SHAS, and the holder lands by rebase, which mints new +//! ones for the same patches. So a published pending head is not merely at risk +//! of going stale — the moment the holder lands it is DIVERGENT, and the +//! fast-forward is impossible by construction rather than by race. +//! +//! # The composition is the subject, not a lap +//! +//! Every case here decides over `Pipeline::default()` and `speculation::Bet`, +//! both of which are pure. Driving a real lap would need a remote, a lease and a +//! matrix to answer a question the step table already answers — and the defect +//! was never in the lap's execution, it was in a row that declared no question. + +// Panicking on a failed assertion is how a test fails loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use batten::land::Step; +use batten::pipeline::{Pipeline, Precheck}; +use batten::speculation::Bet; + +/// The row that publishes, and the question it now asks. +fn precheck_of(step: Step) -> Option { + Pipeline::default() + .steps + .iter() + .find(|row| row.step == step) + .and_then(|row| row.precheck) +} + +/// A bet that is outstanding, as `place_the_bet` leaves one. +fn placed() -> Bet { + Bet { + base: Some(String::from("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), + undo: Some(String::from("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")), + main_at_bet: Some(String::from("cccccccccccccccccccccccccccccccccccccccc")), + ..Bet::default() + } +} + +/// **A LAP HOLDING A LIVE BET DOES NOT REACH THE PUSH.** +/// +/// The two halves of the reading are asserted together because either alone is +/// satisfiable by the defect: the row could declare the precheck while the bet +/// answers `false`, or the bet could be live over a row that asks nothing. The +/// pair is what the lap actually evaluates. +#[test] +fn a_live_bet_is_refused_by_the_row_that_would_publish_it() { + assert_eq!( + precheck_of(Step::Push), + Some(Precheck::BetLive), + "the row that publishes must ask whether this head is publishable" + ); + assert!( + placed().live(), + "a placed bet is outstanding, which is the state the precheck refuses" + ); +} + +/// **THE MIRROR — A SETTLED BET STILL PUSHES.** +/// +/// Without this the case above is satisfied by a pipeline that never publishes +/// at all, which is an outage rather than a fix. A holder that lands during +/// `verify` settles `Landed`, `drop_the_bet` forgets it, and the lap publishes +/// the pre-linearized head exactly as before — which is the whole value of +/// speculating and the reason this refuses `live()` rather than "a bet was ever +/// placed". +#[test] +fn a_settled_bet_still_reaches_the_push() { + let mut settled = placed(); + settled.forget(); + assert!( + !settled.live(), + "a settled bet is not outstanding, so the precheck admits the push" + ); +} + +/// **AND THE GATE DOES NOT SPREAD TO THE ROW THAT SPENDS THE GATE.** +/// +/// `Verify` must stay unguarded on the bet, or a speculating lap could never +/// verify the tree it speculated — which is the point of speculating. Asserting +/// only that `Push` carries a precheck would pass over a table that gated every +/// row, so this is where the arm's boundary is written down. +#[test] +fn the_verify_row_is_not_gated_on_the_bet() { + assert_eq!( + precheck_of(Step::Verify), + None, + "gating the gate on the bet would stop a speculating lap verifying at all" + ); + assert_eq!( + precheck_of(Step::Replay), + Some(Precheck::BetSettled), + "and the settle stays on Replay — the two rows ask different questions" + ); +} + +/// **THE TERMINATION PROPERTY, which the row's own §3 does not state.** +/// +/// The precheck answers `Lap`, and no `Compensation` drops a bet — `Nothing`, +/// `Redraft`, `Abandon`, `ReleaseLease`. `place_the_bet`'s own guards are about +/// the HOLDER (already landed; trunk has passed it), so without a memory of the +/// decision the next lap re-bets the same holder, arrives back at this row, and +/// unwinds again until the lap budget is spent — rather than the one extra local +/// verify and zero CI the design promises. +/// +/// `declined` is that memory, and it survives `forget` because `forget` is what +/// the unwind calls. +#[test] +fn a_declined_landing_does_not_speculate_again() { + let mut bet = placed(); + bet.declined = true; + bet.forget(); + assert!( + bet.declined, + "the decision must outlive the unwind that made it, or the lap spins" + ); + assert!( + !bet.live(), + "and the borrowed range is gone, which is what the next lap replays without" + ); +} diff --git a/crates/batten/tests/it/lease_health.rs b/crates/batten/tests/it/lease_health.rs index 65fb69cb8..2f6fa12d4 100644 --- a/crates/batten/tests/it/lease_health.rs +++ b/crates/batten/tests/it/lease_health.rs @@ -261,7 +261,7 @@ fn a_land_with_no_registry_entry_publishes_nothing() { /// disarming it. #[test] fn a_stalled_holder_is_stealable_once_its_beat_has_published() { - use batten::lease::{Body, Observed, Terms, Turn, turn}; + use batten::lease::{Body, Observed, Reading, Recent, Terms, Turn, turn}; let terms = Terms::default(); let stall = 60; @@ -284,10 +284,13 @@ fn a_stalled_holder_is_stealable_once_its_beat_has_published() { &terms, &observed("1700000000.1700000030"), "clone-b", - stalled_for, - stalled_for, - stall, - 1_999_999, + Reading { + held_for: stalled_for, + progress_for: stalled_for, + stall_beats: stall, + now: 1_999_999, + recent: Recent::Clean, + }, ); assert!( matches!(took, Turn::Take(_)), @@ -299,10 +302,13 @@ fn a_stalled_holder_is_stealable_once_its_beat_has_published() { &terms, &observed(""), "clone-b", - stalled_for, - stalled_for, - stall, - 1_999_999, + Reading { + held_for: stalled_for, + progress_for: stalled_for, + stall_beats: stall, + now: 1_999_999, + recent: Recent::Clean, + }, ); assert_eq!( waited, diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index 70c2072bb..a983d312a 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -42,6 +42,7 @@ mod acquisition_metric; mod acquisition_sweep; mod address_resolve; mod address_transport; +mod adjudicate_absent; mod admission; mod admission_narrowing; mod advisory_drain; @@ -158,6 +159,7 @@ mod land_entry_gates; mod land_forge_reads; mod land_hand_stepping; mod land_lap; +mod land_speculation; mod land_verify_advice; mod landed_check; mod landing_roster; diff --git a/mise.toml b/mise.toml index 4c33e7cff..2e1feade9 100644 --- a/mise.toml +++ b/mise.toml @@ -617,7 +617,7 @@ CI_FANIN_WORKFLOW = ".github/workflows/ci.yml" BATS_TEST_TIMEOUT = "300" REGORUS_OPA_COMPLIANCE = "1.2.0" REGORUS_OPA_COMPLIANCE_FOR = "0.11" -MUTANT_GATES = "mise,attestation-check,engine-checks-green,engine-config,engine-doctor,engine-landed,engine-perf,engine-mcp,engine-pinned,engine-ready,engine-verdict,engine-wiring,engine-surface,agentic-experiment-record,awk-regex-check,bats-invocation,batten-glob-check,board-diff-overlap,board-payloads,board-sweep,branch-age-check,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-order-is-stated,claimed-keys,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,egress-fencing,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,fixture-forks,forge-verdict-required,graph-check,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,in-progress-drain,install-check,land-divergence-assert,landed-check,landing-loop,landing-roster-guarded,leased-push,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,merged-pr-keys,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutation-declared-case,no-doctests,nonverdict-assert,ntia-check,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep-check,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-lint,reclaim-census,release-assets-check,release-due,release-provision-parity,release-tag-shape,release-tracking-check,released,remedy-authorship,repetition-without-progress,report-only-check,review-answered,review-dispatched,run-shape,rust-paths-check,sbom,sbom-inventory,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,test-targets,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,spawn-widening,nextest-slow,engine-lease,engine-handler" +MUTANT_GATES = "mise,attestation-check,engine-checks-green,engine-config,engine-doctor,engine-landed,engine-perf,engine-mcp,engine-pinned,engine-ready,engine-verdict,engine-wiring,engine-surface,agentic-experiment-record,awk-regex-check,bats-invocation,batten-glob-check,board-diff-overlap,board-payloads,board-sweep,branch-age-check,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-order-is-stated,claimed-keys,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,egress-fencing,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,fixture-forks,forge-verdict-required,graph-check,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,in-progress-drain,install-check,land-divergence-assert,landed-check,landing-loop,landing-roster-guarded,leased-push,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,merged-pr-keys,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutation-declared-case,no-doctests,nonverdict-assert,ntia-check,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep-check,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-lint,reclaim-census,release-assets-check,release-due,release-provision-parity,release-tag-shape,release-tracking-check,released,remedy-authorship,repetition-without-progress,report-only-check,review-answered,review-dispatched,run-shape,rust-paths-check,sbom,sbom-inventory,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,test-targets,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,spawn-widening,nextest-slow,engine-lease,engine-handler,engine-speculation,engine-pipeline" # --- GitHub reachability behind an egress proxy (Claude Code web sandbox etc.) --- # mise resolves every tool's release through GitHub's *API* host, api.github.com.