From b2078e5a457455b7b174a9c52b461f8dd56ca54f Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Fri, 2 Oct 2026 19:12:44 +0000 Subject: [PATCH 1/8] gunbc test: refuse a binary not built at HEAD (StaleBinary / BinaryFreshnessUndecided) Modeled in gunbc.target_invocation (BuiltIdentity, HeadObservation, BinaryFreshness, assess_binary_freshness), mirrored in target_invocation_host test_verb_at_head. Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/target_invocation.dag | 77 +++++++ .../claim/target_invocation_witness_test.dag | 48 ++++ src/v1/stage0/src/main.rs | 5 +- src/v1/stage0/src/target_invocation_host.rs | 205 ++++++++++++++++++ 4 files changed, 334 insertions(+), 1 deletion(-) diff --git a/dag/gunbc/target_invocation.dag b/dag/gunbc/target_invocation.dag index 7d722bb3e99..58995a49027 100644 --- a/dag/gunbc/target_invocation.dag +++ b/dag/gunbc/target_invocation.dag @@ -266,3 +266,80 @@ fn invocation_refusal_rendered(cause: TargetInvocationRefusal) -> String { concat("gunbc test: target has no bound producer: ", render_label(l: t)) } } + +// IS THE BINARY ANSWERING FOR THE TREE IT IS ABOUT TO READ? +// +// Every instrument behind `gunbc test` is compiled into the binary that runs it, and reads a live +// tree beside it. A binary built at an older commit answers for code that is no longer HEAD, and +// its reading is reported under the current head's name. That is a silent wrong answer, and it +// recurred four times in one day before this check existed. So the invocation's first act is to +// ask whether the binary's build identity names HEAD, and refuse when it does not or when nothing +// can say. +// +// EQUALITY OF COMMIT, NOT A GUESS ABOUT WHICH PATHS MATTER. A rule like "stale only if a seed +// path changed" needs a closed list of the files the binary depends on, and the seed +// `include_str!`s `.dag` and tool files from outside its crate directories, so any such list is a +// heuristic that would pass exactly the skew it missed. A `.dag`-only commit therefore forces a +// rebuild. That cost is priced, not hidden. +// +// UNCOMMITTED EDITS ARE THE SUBJECT, NOT THE BINARY. A clean-built binary at HEAD over a dirty +// tree is fresh: the edits are the live input the instrument reads, which `InvocationOutcome` +// already disclaims binding to. They are named on the fresh arm so the reading says what it read +// over. A binary built FROM a dirty tree has no commit its bytes are equal to, so it is +// unverifiable, not fresh. +type BuiltIdentity + = BuiltAtCommit { commit: String } + | BuiltAtDirtyCommit { commit: String } + | BuiltAtTree { tree: String } + +type HeadObservation + = HeadObserved { commit: String, tree: String, uncommitted: List } + | HeadUnobservable { reason: String } + +type BinaryFreshness + = BinaryAtHead { head: String, uncommitted: List } + | BinaryStale { built: String, head: String } + | BinaryFreshnessUndecided { built: String, reason: String } + +fn assess_binary_freshness(built: BuiltIdentity, head: HeadObservation) -> BinaryFreshness { + match head { + HeadUnobservable { reason: r } => + BinaryFreshnessUndecided { built: built_identity_rendered(built: built), reason: r } + HeadObserved { commit: c, tree: t, uncommitted: u } => + match built { + BuiltAtCommit { commit: b } => + if b == c { BinaryAtHead { head: c, uncommitted: u } } else { BinaryStale { built: b, head: c } } + BuiltAtTree { tree: b } => + if b == t { BinaryAtHead { head: c, uncommitted: u } } else { BinaryStale { built: concat("tree ", b), head: concat("tree ", t) } } + BuiltAtDirtyCommit { commit: b } => + BinaryFreshnessUndecided { + built: concat(b, "-dirty"), + reason: "the binary was built from an uncommitted tree, so no commit names its bytes; commit and rebuild" + } + } + } +} + +fn built_identity_rendered(built: BuiltIdentity) -> String { + match built { + BuiltAtCommit { commit: b } => b + BuiltAtDirtyCommit { commit: b } => concat(b, "-dirty") + BuiltAtTree { tree: b } => concat("tree ", b) + } +} + +// Only `BinaryAtHead` admits the invocation; both other arms terminate `InvocationRefused` (exit +// 2, no observation) with their line, never a warning printed beside a reading. +fn binary_freshness_rendered(f: BinaryFreshness) -> String { + match f { + BinaryAtHead { head: h, uncommitted: _ } => + concat("gunbc test: binary at HEAD ", h) + BinaryStale { built: b, head: h } => + concat(concat(concat(concat( + "gunbc test: REFUSED cause=StaleBinary — this binary was built at ", b), " but HEAD is "), h), + "; rebuild before measuring") + BinaryFreshnessUndecided { built: b, reason: r } => + concat(concat(concat( + "gunbc test: REFUSED cause=BinaryFreshnessUndecided built=", b), " — "), r) + } +} diff --git a/dag/test/claim/target_invocation_witness_test.dag b/dag/test/claim/target_invocation_witness_test.dag index 058e9c58e31..843bdf42e43 100644 --- a/dag/test/claim/target_invocation_witness_test.dag +++ b/dag/test/claim/target_invocation_witness_test.dag @@ -41,6 +41,10 @@ import gunbc.target_binding { BareChannelEntryAbsent, BareChannelIndexRefused, } import gunbc.target_invocation { + BuiltIdentity, BuiltAtCommit, BuiltAtDirtyCommit, BuiltAtTree, + HeadObservation, HeadObserved, HeadUnobservable, + BinaryFreshness, BinaryAtHead, BinaryStale, BinaryFreshnessUndecided, + assess_binary_freshness, TargetInvocationRoute, TargetInvocationRouted, TargetInvocationRouteRefused, TargetInvocationRefusal, OperandNotALabel, InvocationRegistryRefused, InvocationLabelNotCanonical, @@ -1096,3 +1100,47 @@ test fn a_refused_single_target_renders_the_label_grammars_located_cause() -> Bo _ => false } } + +// STALE-BINARY CONTROLS. Before `assess_binary_freshness` existed every arm below ran its +// instrument; the stale and dirty arms are the reds that old behaviour lacked. +fn freshness_head() -> HeadObservation { + HeadObserved { commit: "c2", tree: "t2", uncommitted: ["dag/x.dag"] } +} + +test fn a_binary_built_at_an_older_commit_is_refused_as_stale() -> Bool { + match assess_binary_freshness(built: BuiltAtCommit { commit: "c1" }, head: freshness_head()) { + BinaryStale { built: b, head: h } => b == "c1" && h == "c2" + _ => false + } +} + +test fn a_binary_at_head_is_admitted_and_carries_the_uncommitted_paths() -> Bool { + match assess_binary_freshness(built: BuiltAtCommit { commit: "c2" }, head: freshness_head()) { + BinaryAtHead { head: h, uncommitted: u } => h == "c2" && count(u) == 1 + _ => false + } +} + +test fn a_dirty_build_or_an_unobservable_head_is_undecided_not_fresh() -> Bool { + let dirty = match assess_binary_freshness(built: BuiltAtDirtyCommit { commit: "c2" }, head: freshness_head()) { + BinaryFreshnessUndecided { built: _, reason: _ } => true + _ => false + } + let blind = match assess_binary_freshness(built: BuiltAtCommit { commit: "c2" }, head: HeadUnobservable { reason: "no git" }) { + BinaryFreshnessUndecided { built: _, reason: _ } => true + _ => false + } + dirty && blind +} + +test fn a_tree_identity_is_compared_against_the_head_tree() -> Bool { + let at = match assess_binary_freshness(built: BuiltAtTree { tree: "t2" }, head: freshness_head()) { + BinaryAtHead { head: _, uncommitted: _ } => true + _ => false + } + let stale = match assess_binary_freshness(built: BuiltAtTree { tree: "t1" }, head: freshness_head()) { + BinaryStale { built: _, head: _ } => true + _ => false + } + at && stale +} diff --git a/src/v1/stage0/src/main.rs b/src/v1/stage0/src/main.rs index 593ab524cb8..3b3b5626536 100644 --- a/src/v1/stage0/src/main.rs +++ b/src/v1/stage0/src/main.rs @@ -795,7 +795,10 @@ fn retained_dispatch(command: RetainedCommands, dry_run: bool) -> ! { // each refuses instrument producers by design, so either would answer a question this // verb is not asking. RetainedCommands::Test { target } => { - let outcome = cli_run::target_invocation_host::test_verb(&target); + let outcome = cli_run::target_invocation_host::test_verb_at_head( + &target, + env!("GUNBC_BUILD_IDENTITY"), + ); Verdict { status: cli_run::target_invocation_host::invocation_exit_status( outcome.termination, diff --git a/src/v1/stage0/src/target_invocation_host.rs b/src/v1/stage0/src/target_invocation_host.rs index f3c0b46f0d7..055fed7fe64 100644 --- a/src/v1/stage0/src/target_invocation_host.rs +++ b/src/v1/stage0/src/target_invocation_host.rs @@ -1649,6 +1649,170 @@ fn test_operand_set_form_refusal_rendered(operand: &str) -> String { ) } +/// `gunbc.target_invocation` `BuiltIdentity`, mirrored. Classified from `GUNBC_BUILD_IDENTITY`, +/// whose three spellings build.rs owns: `<40 hex>`, `<40 hex>-dirty`, `tree::`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BuiltIdentity { + AtCommit(String), + AtDirtyCommit(String), + AtTree(String), +} + +pub fn built_identity_from(identity: &str) -> BuiltIdentity { + if let Some(tree) = identity.strip_prefix("tree:") { + // `tree:sha1:` -> the hex `git rev-parse HEAD^{tree}` prints. + let hex = tree.rsplit(':').next().unwrap_or(tree); + return BuiltIdentity::AtTree(hex.to_string()); + } + match identity.strip_suffix("-dirty") { + Some(commit) => BuiltIdentity::AtDirtyCommit(commit.to_string()), + None => BuiltIdentity::AtCommit(identity.to_string()), + } +} + +/// `gunbc.target_invocation` `HeadObservation`, mirrored. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HeadObservation { + Observed { + commit: String, + tree: String, + uncommitted: Vec, + }, + Unobservable(String), +} + +/// `gunbc.target_invocation` `BinaryFreshness`, mirrored. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BinaryFreshness { + AtHead { + head: String, + uncommitted: Vec, + }, + Stale { + built: String, + head: String, + }, + Undecided { + built: String, + reason: String, + }, +} + +fn built_identity_rendered(built: &BuiltIdentity) -> String { + match built { + BuiltIdentity::AtCommit(b) => b.clone(), + BuiltIdentity::AtDirtyCommit(b) => format!("{b}-dirty"), + BuiltIdentity::AtTree(b) => format!("tree {b}"), + } +} + +/// `gunbc.target_invocation` `assess_binary_freshness`, mirrored arm for arm. +pub fn assess_binary_freshness(built: &BuiltIdentity, head: &HeadObservation) -> BinaryFreshness { + match head { + HeadObservation::Unobservable(reason) => BinaryFreshness::Undecided { + built: built_identity_rendered(built), + reason: reason.clone(), + }, + HeadObservation::Observed { + commit, + tree, + uncommitted, + } => match built { + BuiltIdentity::AtCommit(b) if b == commit => BinaryFreshness::AtHead { + head: commit.clone(), + uncommitted: uncommitted.clone(), + }, + BuiltIdentity::AtCommit(b) => BinaryFreshness::Stale { + built: b.clone(), + head: commit.clone(), + }, + BuiltIdentity::AtTree(b) if b == tree => BinaryFreshness::AtHead { + head: commit.clone(), + uncommitted: uncommitted.clone(), + }, + BuiltIdentity::AtTree(b) => BinaryFreshness::Stale { + built: format!("tree {b}"), + head: format!("tree {tree}"), + }, + BuiltIdentity::AtDirtyCommit(b) => BinaryFreshness::Undecided { + built: format!("{b}-dirty"), + reason: "the binary was built from an uncommitted tree, so no commit names its bytes; commit and rebuild".to_string(), + }, + }, + } +} + +/// `gunbc.target_invocation` `binary_freshness_rendered`, mirrored. +pub fn binary_freshness_rendered(f: &BinaryFreshness) -> String { + match f { + BinaryFreshness::AtHead { head, .. } => format!("gunbc test: binary at HEAD {head}"), + BinaryFreshness::Stale { built, head } => format!( + "gunbc test: REFUSED cause=StaleBinary — this binary was built at {built} but HEAD is {head}; rebuild before measuring" + ), + BinaryFreshness::Undecided { built, reason } => { + format!("gunbc test: REFUSED cause=BinaryFreshnessUndecided built={built} — {reason}") + } + } +} + +fn git_line(args: &[&str]) -> Result { + let out = std::process::Command::new("git") + .args(args) + .current_dir(cli_run::process_workspace_root()) + .output() + .map_err(|e| format!("git {}: {e}", args.join(" ")))?; + if !out.status.success() { + return Err(format!( + "git {} exited {:?}: {}", + args.join(" "), + out.status.code(), + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string()) +} + +/// The effectful half: what the checkout's HEAD is. Any git failure is `Unobservable`, which +/// refuses -- never a pass because the question could not be asked. +fn observe_head() -> HeadObservation { + let observed = (|| { + let commit = git_line(&["rev-parse", "HEAD"])?; + let tree = git_line(&["rev-parse", "HEAD^{tree}"])?; + let status = git_line(&["status", "--porcelain"])?; + let uncommitted = status + .lines() + .map(|l| l.get(3..).unwrap_or(l).to_string()) + .collect(); + Ok::<_, String>(HeadObservation::Observed { + commit, + tree, + uncommitted, + }) + })(); + observed.unwrap_or_else(HeadObservation::Unobservable) +} + +/// THE INVOCATION'S FIRST ACT: refuse a binary that does not answer for HEAD +/// (`gunbc.target_invocation` `assess_binary_freshness`), then route. Kept beside `test_verb` +/// rather than inside it so the routing tests stay a function of the operand alone. +pub fn test_verb_at_head(operand: &str, build_identity: &str) -> InvocationOutcome { + let freshness = assess_binary_freshness(&built_identity_from(build_identity), &observe_head()); + let line = binary_freshness_rendered(&freshness); + match freshness { + BinaryFreshness::AtHead { uncommitted, .. } => { + eprintln!("{line} uncommitted_paths={}", uncommitted.len()); + for path in uncommitted.iter().take(20) { + eprintln!(" uncommitted {path}"); + } + test_verb(operand) + } + BinaryFreshness::Stale { .. } | BinaryFreshness::Undecided { .. } => InvocationOutcome { + termination: Termination::Refused, + message: line, + }, + } +} + pub fn test_verb(operand: &str) -> InvocationOutcome { let pattern = match parse_target_pattern(operand) { Ok(pattern) => pattern, @@ -2516,3 +2680,44 @@ mod native_route_termination_tests { assert_eq!(out.termination, Termination::ObservationDidNotHold); } } + +#[cfg(test)] +mod binary_freshness_tests { + use super::*; + + /// THE OLD BEHAVIOUR RAN THE INSTRUMENT ON EVERY ARM BELOW; the stale and undecided arms are + /// the refusals it lacked. Mirrors `test.claim.target_invocation_witness`'s freshness tests. + #[test] + fn a_stale_or_unverifiable_binary_is_refused_before_any_instrument_runs() { + let head = HeadObservation::Observed { + commit: "c2".into(), + tree: "t2".into(), + uncommitted: vec!["dag/x.dag".into()], + }; + let at = |id: &str| assess_binary_freshness(&built_identity_from(id), &head); + assert!(matches!(at("c1"), BinaryFreshness::Stale { .. })); + assert!(matches!(at("c2"), BinaryFreshness::AtHead { .. })); + assert!(matches!(at("c2-dirty"), BinaryFreshness::Undecided { .. })); + assert!(matches!(at("tree:sha1:t2"), BinaryFreshness::AtHead { .. })); + assert!(matches!(at("tree:sha1:t1"), BinaryFreshness::Stale { .. })); + assert!(matches!( + assess_binary_freshness( + &built_identity_from("c2"), + &HeadObservation::Unobservable("no git".into()) + ), + BinaryFreshness::Undecided { .. } + )); + // THE ROUTE, NOT ONLY THE DECISION: an all-zero commit is never HEAD, so the verb must + // refuse with status 2 and never reach the self-host producer. + let stale = test_verb_at_head( + "//gunbc/instruments:self-host", + "0000000000000000000000000000000000000000", + ); + assert_eq!(stale.termination, Termination::Refused); + assert!( + stale.message.contains("cause=StaleBinary"), + "{}", + stale.message + ); + } +} From 634ddaccd69379b83b1f8ebc259ee8e68ef070e9 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Fri, 2 Oct 2026 19:34:41 +0000 Subject: [PATCH 2/8] freshness keyed on the binary's dep-info input set, not HEAD equality (calm-boar-904 ruling) Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/target_invocation.dag | 126 +++--- .../claim/target_invocation_witness_test.dag | 47 +-- src/v1/stage0/src/main.rs | 5 +- src/v1/stage0/src/target_invocation_host.rs | 398 ++++++++++++------ 4 files changed, 359 insertions(+), 217 deletions(-) diff --git a/dag/gunbc/target_invocation.dag b/dag/gunbc/target_invocation.dag index 58995a49027..32080f07e87 100644 --- a/dag/gunbc/target_invocation.dag +++ b/dag/gunbc/target_invocation.dag @@ -270,76 +270,92 @@ fn invocation_refusal_rendered(cause: TargetInvocationRefusal) -> String { // IS THE BINARY ANSWERING FOR THE TREE IT IS ABOUT TO READ? // // Every instrument behind `gunbc test` is compiled into the binary that runs it, and reads a live -// tree beside it. A binary built at an older commit answers for code that is no longer HEAD, and -// its reading is reported under the current head's name. That is a silent wrong answer, and it -// recurred four times in one day before this check existed. So the invocation's first act is to -// ask whether the binary's build identity names HEAD, and refuse when it does not or when nothing -// can say. +// tree beside it. A binary whose inputs have changed since it was built answers for code that is +// no longer there, and its reading is reported under the current tree's name -- a silent wrong +// answer, four incidents in one day before this check existed. So the invocation's first act is +// to ask whether the binary still answers for its inputs, and refuse when it does not or when +// nothing can say. // -// EQUALITY OF COMMIT, NOT A GUESS ABOUT WHICH PATHS MATTER. A rule like "stale only if a seed -// path changed" needs a closed list of the files the binary depends on, and the seed -// `include_str!`s `.dag` and tool files from outside its crate directories, so any such list is a -// heuristic that would pass exactly the skew it missed. A `.dag`-only commit therefore forces a -// rebuild. That cost is priced, not hidden. +// THE INPUT SET IS THE ONE RUSTC RECORDED, NOT A LIST WE AUTHOR. Cargo's dep-info beside the +// binary (`.d`) lists every file the compile read, `include_str!` files included, so the +// set is exact, not a path heuristic. Keying on HEAD equality instead would refuse a correct binary +// after every unrelated commit, which teaches people to route around the check +// (calm-boar-904's ruling on #13007). // -// UNCOMMITTED EDITS ARE THE SUBJECT, NOT THE BINARY. A clean-built binary at HEAD over a dirty -// tree is fresh: the edits are the live input the instrument reads, which `InvocationOutcome` -// already disclaims binding to. They are named on the fresh arm so the reading says what it read -// over. A binary built FROM a dirty tree has no commit its bytes are equal to, so it is -// unverifiable, not fresh. -type BuiltIdentity - = BuiltAtCommit { commit: String } - | BuiltAtDirtyCommit { commit: String } - | BuiltAtTree { tree: String } +// The comparison is cargo's own rule, an input newer than the binary. So a refusal here means +// exactly "cargo build would rebuild". It is NOT content-exact: an input whose mtime moved +// without its bytes changing refuses too, as cargo would rebuild. A content-exact check needs a +// digest recorded AFTER the compile that writes dep-info, which no in-tree build hook can do. +// +// An input outside this worktree is the shared-target hazard: `/cargo-target` serves a binary +// another worktree built, and its dep-info names that worktree's files. Nothing here can say +// whether those files match ours, so that is undecided, not fresh. +// +// The host classifies each input (the effectful mtime and path reads); this function decides +// the precedence: undecided beats stale beats fresh, so an unjudgeable input is never masked by a +// staleness verdict and a stale input never by a fresh one. +type BinaryInput + = InputNotNewer { path: String } + | InputNewer { path: String } + | InputMissing { path: String } + | InputOutsideWorktree { path: String } -type HeadObservation - = HeadObserved { commit: String, tree: String, uncommitted: List } - | HeadUnobservable { reason: String } +type BinaryInputObservation + = InputsObserved { binary: String, inputs: List } + | DepInfoUnreadable { binary: String, reason: String } type BinaryFreshness - = BinaryAtHead { head: String, uncommitted: List } - | BinaryStale { built: String, head: String } - | BinaryFreshnessUndecided { built: String, reason: String } + = BinaryFresh { binary: String, inputs: Int } + | BinaryStale { binary: String, input: String, why: String } + | BinaryFreshnessUndecided { binary: String, reason: String } -fn assess_binary_freshness(built: BuiltIdentity, head: HeadObservation) -> BinaryFreshness { - match head { - HeadUnobservable { reason: r } => - BinaryFreshnessUndecided { built: built_identity_rendered(built: built), reason: r } - HeadObserved { commit: c, tree: t, uncommitted: u } => - match built { - BuiltAtCommit { commit: b } => - if b == c { BinaryAtHead { head: c, uncommitted: u } } else { BinaryStale { built: b, head: c } } - BuiltAtTree { tree: b } => - if b == t { BinaryAtHead { head: c, uncommitted: u } } else { BinaryStale { built: concat("tree ", b), head: concat("tree ", t) } } - BuiltAtDirtyCommit { commit: b } => - BinaryFreshnessUndecided { - built: concat(b, "-dirty"), - reason: "the binary was built from an uncommitted tree, so no commit names its bytes; commit and rebuild" - } +// One input's step. An undecided verdict absorbs every later input; a stale one absorbs every +// later input except an outside one, which outranks it. +fn binary_freshness_step(acc: BinaryFreshness, input: BinaryInput) -> BinaryFreshness { + match acc { + BinaryFreshnessUndecided { binary: _, reason: _ } => acc + BinaryStale { binary: b, input: _, why: _ } => + match input { + InputOutsideWorktree { path: p } => binary_built_elsewhere(binary: b, path: p) + _ => acc + } + BinaryFresh { binary: b, inputs: n } => + match input { + InputOutsideWorktree { path: p } => binary_built_elsewhere(binary: b, path: p) + InputNewer { path: p } => BinaryStale { binary: b, input: p, why: "changed after the binary was built" } + InputMissing { path: p } => BinaryStale { binary: b, input: p, why: "no longer exists" } + InputNotNewer { path: _ } => BinaryFresh { binary: b, inputs: n + 1 } } } } -fn built_identity_rendered(built: BuiltIdentity) -> String { - match built { - BuiltAtCommit { commit: b } => b - BuiltAtDirtyCommit { commit: b } => concat(b, "-dirty") - BuiltAtTree { tree: b } => concat("tree ", b) +fn binary_built_elsewhere(binary: String, path: String) -> BinaryFreshness { + BinaryFreshnessUndecided { + binary: binary, + reason: concat(concat("its dep-info names an input outside this worktree (", path), + "); it was built from another checkout, so nothing here says whether it answers for this one") + } +} + +fn assess_binary_freshness(observed: BinaryInputObservation) -> BinaryFreshness { + match observed { + DepInfoUnreadable { binary: b, reason: r } => BinaryFreshnessUndecided { binary: b, reason: r } + InputsObserved { binary: b, inputs: is } => + fold(is, init: BinaryFresh { binary: b, inputs: 0 }, f: fn(acc, i) { + binary_freshness_step(acc: acc, input: i) + }) } } -// Only `BinaryAtHead` admits the invocation; both other arms terminate `InvocationRefused` (exit +// Only `BinaryFresh` admits the invocation; both other arms terminate `InvocationRefused` (exit // 2, no observation) with their line, never a warning printed beside a reading. fn binary_freshness_rendered(f: BinaryFreshness) -> String { match f { - BinaryAtHead { head: h, uncommitted: _ } => - concat("gunbc test: binary at HEAD ", h) - BinaryStale { built: b, head: h } => - concat(concat(concat(concat( - "gunbc test: REFUSED cause=StaleBinary — this binary was built at ", b), " but HEAD is "), h), - "; rebuild before measuring") - BinaryFreshnessUndecided { built: b, reason: r } => - concat(concat(concat( - "gunbc test: REFUSED cause=BinaryFreshnessUndecided built=", b), " — "), r) + BinaryFresh { binary: b, inputs: _ } => concat("gunbc test: binary fresh against its dep-info: ", b) + BinaryStale { binary: b, input: p, why: w } => + concat(concat(concat(concat(concat(concat( + "gunbc test: REFUSED cause=StaleBinary — ", b), ": input "), p), " "), w), "; rebuild before measuring") + BinaryFreshnessUndecided { binary: b, reason: r } => + concat(concat(concat("gunbc test: REFUSED cause=BinaryFreshnessUndecided — ", b), ": "), r) } } diff --git a/dag/test/claim/target_invocation_witness_test.dag b/dag/test/claim/target_invocation_witness_test.dag index 843bdf42e43..4d6e92e2b15 100644 --- a/dag/test/claim/target_invocation_witness_test.dag +++ b/dag/test/claim/target_invocation_witness_test.dag @@ -41,9 +41,9 @@ import gunbc.target_binding { BareChannelEntryAbsent, BareChannelIndexRefused, } import gunbc.target_invocation { - BuiltIdentity, BuiltAtCommit, BuiltAtDirtyCommit, BuiltAtTree, - HeadObservation, HeadObserved, HeadUnobservable, - BinaryFreshness, BinaryAtHead, BinaryStale, BinaryFreshnessUndecided, + BinaryInput, InputNotNewer, InputNewer, InputMissing, InputOutsideWorktree, + BinaryInputObservation, InputsObserved, DepInfoUnreadable, + BinaryFreshness, BinaryFresh, BinaryStale, BinaryFreshnessUndecided, assess_binary_freshness, TargetInvocationRoute, TargetInvocationRouted, TargetInvocationRouteRefused, TargetInvocationRefusal, @@ -1102,45 +1102,36 @@ test fn a_refused_single_target_renders_the_label_grammars_located_cause() -> Bo } // STALE-BINARY CONTROLS. Before `assess_binary_freshness` existed every arm below ran its -// instrument; the stale and dirty arms are the reds that old behaviour lacked. -fn freshness_head() -> HeadObservation { - HeadObserved { commit: "c2", tree: "t2", uncommitted: ["dag/x.dag"] } +// instrument; the stale and undecided arms are the reds that old behaviour lacked. The fresh arm +// is the positive control that an unrelated commit no longer refuses: no input moved. +fn observed(inputs: List) -> BinaryInputObservation { + InputsObserved { binary: "bin", inputs: inputs } } -test fn a_binary_built_at_an_older_commit_is_refused_as_stale() -> Bool { - match assess_binary_freshness(built: BuiltAtCommit { commit: "c1" }, head: freshness_head()) { - BinaryStale { built: b, head: h } => b == "c1" && h == "c2" +test fn a_binary_whose_inputs_did_not_move_is_fresh() -> Bool { + match assess_binary_freshness(observed: observed(inputs: [InputNotNewer { path: "a.rs" }, InputNotNewer { path: "b.dag" }])) { + BinaryFresh { binary: _, inputs: n } => n == 2 _ => false } } -test fn a_binary_at_head_is_admitted_and_carries_the_uncommitted_paths() -> Bool { - match assess_binary_freshness(built: BuiltAtCommit { commit: "c2" }, head: freshness_head()) { - BinaryAtHead { head: h, uncommitted: u } => h == "c2" && count(u) == 1 +test fn the_first_changed_input_is_named_as_stale() -> Bool { + match assess_binary_freshness(observed: observed(inputs: [InputNotNewer { path: "a.rs" }, InputNewer { path: "b.rs" }, InputMissing { path: "c.rs" }])) { + BinaryStale { binary: _, input: p, why: _ } => p == "b.rs" _ => false } } -test fn a_dirty_build_or_an_unobservable_head_is_undecided_not_fresh() -> Bool { - let dirty = match assess_binary_freshness(built: BuiltAtDirtyCommit { commit: "c2" }, head: freshness_head()) { - BinaryFreshnessUndecided { built: _, reason: _ } => true +test fn an_input_from_another_worktree_outranks_staleness() -> Bool { + match assess_binary_freshness(observed: observed(inputs: [InputNewer { path: "b.rs" }, InputOutsideWorktree { path: "/w/other/a.rs" }])) { + BinaryFreshnessUndecided { binary: _, reason: _ } => true _ => false } - let blind = match assess_binary_freshness(built: BuiltAtCommit { commit: "c2" }, head: HeadUnobservable { reason: "no git" }) { - BinaryFreshnessUndecided { built: _, reason: _ } => true - _ => false - } - dirty && blind } -test fn a_tree_identity_is_compared_against_the_head_tree() -> Bool { - let at = match assess_binary_freshness(built: BuiltAtTree { tree: "t2" }, head: freshness_head()) { - BinaryAtHead { head: _, uncommitted: _ } => true - _ => false - } - let stale = match assess_binary_freshness(built: BuiltAtTree { tree: "t1" }, head: freshness_head()) { - BinaryStale { built: _, head: _ } => true +test fn unreadable_dep_info_is_undecided_not_fresh() -> Bool { + match assess_binary_freshness(observed: DepInfoUnreadable { binary: "bin", reason: "no bin.d" }) { + BinaryFreshnessUndecided { binary: _, reason: _ } => true _ => false } - at && stale } diff --git a/src/v1/stage0/src/main.rs b/src/v1/stage0/src/main.rs index 3b3b5626536..11f41a859ef 100644 --- a/src/v1/stage0/src/main.rs +++ b/src/v1/stage0/src/main.rs @@ -795,10 +795,7 @@ fn retained_dispatch(command: RetainedCommands, dry_run: bool) -> ! { // each refuses instrument producers by design, so either would answer a question this // verb is not asking. RetainedCommands::Test { target } => { - let outcome = cli_run::target_invocation_host::test_verb_at_head( - &target, - env!("GUNBC_BUILD_IDENTITY"), - ); + let outcome = cli_run::target_invocation_host::test_verb_checked(&target); Verdict { status: cli_run::target_invocation_host::invocation_exit_status( outcome.termination, diff --git a/src/v1/stage0/src/target_invocation_host.rs b/src/v1/stage0/src/target_invocation_host.rs index 055fed7fe64..6141b34f147 100644 --- a/src/v1/stage0/src/target_invocation_host.rs +++ b/src/v1/stage0/src/target_invocation_host.rs @@ -1649,161 +1649,237 @@ fn test_operand_set_form_refusal_rendered(operand: &str) -> String { ) } -/// `gunbc.target_invocation` `BuiltIdentity`, mirrored. Classified from `GUNBC_BUILD_IDENTITY`, -/// whose three spellings build.rs owns: `<40 hex>`, `<40 hex>-dirty`, `tree::`. +/// `gunbc.target_invocation` `BinaryInput`, mirrored. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum BuiltIdentity { - AtCommit(String), - AtDirtyCommit(String), - AtTree(String), +pub enum BinaryInput { + NotNewer(String), + Newer(String), + Missing(String), + OutsideWorktree(String), } -pub fn built_identity_from(identity: &str) -> BuiltIdentity { - if let Some(tree) = identity.strip_prefix("tree:") { - // `tree:sha1:` -> the hex `git rev-parse HEAD^{tree}` prints. - let hex = tree.rsplit(':').next().unwrap_or(tree); - return BuiltIdentity::AtTree(hex.to_string()); - } - match identity.strip_suffix("-dirty") { - Some(commit) => BuiltIdentity::AtDirtyCommit(commit.to_string()), - None => BuiltIdentity::AtCommit(identity.to_string()), - } -} - -/// `gunbc.target_invocation` `HeadObservation`, mirrored. +/// `gunbc.target_invocation` `BinaryInputObservation`, mirrored. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum HeadObservation { +pub enum BinaryInputObservation { Observed { - commit: String, - tree: String, - uncommitted: Vec, + binary: String, + inputs: Vec, + }, + DepInfoUnreadable { + binary: String, + reason: String, }, - Unobservable(String), } /// `gunbc.target_invocation` `BinaryFreshness`, mirrored. #[derive(Debug, Clone, PartialEq, Eq)] pub enum BinaryFreshness { - AtHead { - head: String, - uncommitted: Vec, + Fresh { + binary: String, + inputs: usize, }, Stale { - built: String, - head: String, + binary: String, + input: String, + why: String, }, Undecided { - built: String, + binary: String, reason: String, }, } -fn built_identity_rendered(built: &BuiltIdentity) -> String { - match built { - BuiltIdentity::AtCommit(b) => b.clone(), - BuiltIdentity::AtDirtyCommit(b) => format!("{b}-dirty"), - BuiltIdentity::AtTree(b) => format!("tree {b}"), +fn binary_built_elsewhere(binary: &str, path: &str) -> BinaryFreshness { + BinaryFreshness::Undecided { + binary: binary.to_string(), + reason: format!( + "its dep-info names an input outside this worktree ({path}); it was built from another checkout, so nothing here says whether it answers for this one" + ), } } -/// `gunbc.target_invocation` `assess_binary_freshness`, mirrored arm for arm. -pub fn assess_binary_freshness(built: &BuiltIdentity, head: &HeadObservation) -> BinaryFreshness { - match head { - HeadObservation::Unobservable(reason) => BinaryFreshness::Undecided { - built: built_identity_rendered(built), - reason: reason.clone(), +/// `gunbc.target_invocation` `binary_freshness_step`, mirrored arm for arm. +fn binary_freshness_step(acc: BinaryFreshness, input: &BinaryInput) -> BinaryFreshness { + match acc { + BinaryFreshness::Undecided { .. } => acc, + BinaryFreshness::Stale { ref binary, .. } => match input { + BinaryInput::OutsideWorktree(p) => binary_built_elsewhere(binary, p), + _ => acc, }, - HeadObservation::Observed { - commit, - tree, - uncommitted, - } => match built { - BuiltIdentity::AtCommit(b) if b == commit => BinaryFreshness::AtHead { - head: commit.clone(), - uncommitted: uncommitted.clone(), + BinaryFreshness::Fresh { binary, inputs } => match input { + BinaryInput::OutsideWorktree(p) => binary_built_elsewhere(&binary, p), + BinaryInput::Newer(p) => BinaryFreshness::Stale { + binary, + input: p.clone(), + why: "changed after the binary was built".to_string(), }, - BuiltIdentity::AtCommit(b) => BinaryFreshness::Stale { - built: b.clone(), - head: commit.clone(), + BinaryInput::Missing(p) => BinaryFreshness::Stale { + binary, + input: p.clone(), + why: "no longer exists".to_string(), }, - BuiltIdentity::AtTree(b) if b == tree => BinaryFreshness::AtHead { - head: commit.clone(), - uncommitted: uncommitted.clone(), - }, - BuiltIdentity::AtTree(b) => BinaryFreshness::Stale { - built: format!("tree {b}"), - head: format!("tree {tree}"), - }, - BuiltIdentity::AtDirtyCommit(b) => BinaryFreshness::Undecided { - built: format!("{b}-dirty"), - reason: "the binary was built from an uncommitted tree, so no commit names its bytes; commit and rebuild".to_string(), + BinaryInput::NotNewer(_) => BinaryFreshness::Fresh { + binary, + inputs: inputs + 1, }, }, } } +/// `gunbc.target_invocation` `assess_binary_freshness`, mirrored. +pub fn assess_binary_freshness(observed: &BinaryInputObservation) -> BinaryFreshness { + match observed { + BinaryInputObservation::DepInfoUnreadable { binary, reason } => { + BinaryFreshness::Undecided { + binary: binary.clone(), + reason: reason.clone(), + } + } + BinaryInputObservation::Observed { binary, inputs } => inputs.iter().fold( + BinaryFreshness::Fresh { + binary: binary.clone(), + inputs: 0, + }, + binary_freshness_step, + ), + } +} + /// `gunbc.target_invocation` `binary_freshness_rendered`, mirrored. pub fn binary_freshness_rendered(f: &BinaryFreshness) -> String { match f { - BinaryFreshness::AtHead { head, .. } => format!("gunbc test: binary at HEAD {head}"), - BinaryFreshness::Stale { built, head } => format!( - "gunbc test: REFUSED cause=StaleBinary — this binary was built at {built} but HEAD is {head}; rebuild before measuring" + BinaryFreshness::Fresh { binary, .. } => { + format!("gunbc test: binary fresh against its dep-info: {binary}") + } + BinaryFreshness::Stale { binary, input, why } => format!( + "gunbc test: REFUSED cause=StaleBinary — {binary}: input {input} {why}; rebuild before measuring" ), - BinaryFreshness::Undecided { built, reason } => { - format!("gunbc test: REFUSED cause=BinaryFreshnessUndecided built={built} — {reason}") + BinaryFreshness::Undecided { binary, reason } => { + format!("gunbc test: REFUSED cause=BinaryFreshnessUndecided — {binary}: {reason}") + } + } +} + +/// The inputs of a cargo dep-info file: the first rule's prerequisites, `\ `-escaped spaces +/// unescaped. Cargo's uplifted `.d` is one rule, `: ...`. +pub fn dep_info_inputs(dep_info: &str) -> Result, String> { + let rule = dep_info + .lines() + .find(|l| !l.trim().is_empty() && !l.starts_with('#')) + .ok_or("dep-info carries no rule")?; + let (_, prerequisites) = rule + .split_once(": ") + .ok_or("dep-info rule has no `: ` separator")?; + let mut inputs = Vec::new(); + let mut current = String::new(); + let mut chars = prerequisites.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\\' if chars.peek() == Some(&' ') => { + current.push(' '); + chars.next(); + } + ' ' => { + if !current.is_empty() { + inputs.push(std::mem::take(&mut current)); + } + } + other => current.push(other), } } + if !current.is_empty() { + inputs.push(current); + } + if inputs.is_empty() { + return Err("dep-info lists no inputs".to_string()); + } + Ok(inputs) } -fn git_line(args: &[&str]) -> Result { - let out = std::process::Command::new("git") - .args(args) - .current_dir(cli_run::process_workspace_root()) - .output() - .map_err(|e| format!("git {}: {e}", args.join(" ")))?; - if !out.status.success() { - return Err(format!( - "git {} exited {:?}: {}", - args.join(" "), - out.status.code(), - String::from_utf8_lossy(&out.stderr).trim() - )); +/// THE CRATE THE BINARY WAS BUILT FROM is the directory of the first `Cargo.toml` its dep-info +/// lists; the checkout is that manifest's `src/v1/stage0` ancestor. Comparing that ONE root +/// against ours is the "built elsewhere" test -- not a list of excluded prefixes. Registry and +/// sysroot inputs are outside every checkout and are judged by mtime like any other input. +/// +/// Git paths are skipped, with a reason: build.rs watches `HEAD`, `index`, `packed-refs` and the +/// branch ref only to re-stamp `GUNBC_BUILD_IDENTITY`, the version string. `packed-refs` moves on +/// any fetch in any worktree, so judging them would refuse a binary whose code did not change -- +/// the spurious refusal this check exists not to make. The cost is that a fresh binary may print +/// an older commit as its identity; its bytes are still those that commit built. +pub fn classify_binary_inputs( + inputs: &[String], + workspace: &std::path::Path, + built_at: std::time::SystemTime, +) -> Vec { + let build_root = inputs + .iter() + .find(|p| p.ends_with("/src/v1/stage0/Cargo.toml")) + .map(|p| p.trim_end_matches("/src/v1/stage0/Cargo.toml").to_string()); + let mut out = Vec::new(); + if let Some(root) = &build_root { + if std::path::Path::new(root) != workspace { + out.push(BinaryInput::OutsideWorktree(root.clone())); + } } - Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string()) + for input in inputs { + if input.contains("/.git/") { + continue; + } + let classified = match std::fs::metadata(input).and_then(|m| m.modified()) { + Err(_) => BinaryInput::Missing(input.clone()), + Ok(modified) if modified > built_at => BinaryInput::Newer(input.clone()), + Ok(_) => BinaryInput::NotNewer(input.clone()), + }; + out.push(classified); + } + out } -/// The effectful half: what the checkout's HEAD is. Any git failure is `Unobservable`, which -/// refuses -- never a pass because the question could not be asked. -fn observe_head() -> HeadObservation { +/// The effectful half: read the running binary's dep-info and stat its inputs. Every failure is +/// `DepInfoUnreadable`, which refuses -- never a pass because the question could not be asked. +fn observe_binary_inputs() -> BinaryInputObservation { + let exe = match std::env::current_exe() { + Ok(exe) => exe, + Err(e) => { + return BinaryInputObservation::DepInfoUnreadable { + binary: "".to_string(), + reason: e.to_string(), + } + } + }; + let binary = exe.display().to_string(); + let dep_info_path = exe.with_extension("d"); let observed = (|| { - let commit = git_line(&["rev-parse", "HEAD"])?; - let tree = git_line(&["rev-parse", "HEAD^{tree}"])?; - let status = git_line(&["status", "--porcelain"])?; - let uncommitted = status - .lines() - .map(|l| l.get(3..).unwrap_or(l).to_string()) - .collect(); - Ok::<_, String>(HeadObservation::Observed { - commit, - tree, - uncommitted, - }) + let built_at = std::fs::metadata(&exe) + .and_then(|m| m.modified()) + .map_err(|e| format!("cannot read the binary's mtime: {e}"))?; + let text = std::fs::read_to_string(&dep_info_path) + .map_err(|e| format!("cannot read dep-info {}: {e}", dep_info_path.display()))?; + let inputs = dep_info_inputs(&text)?; + Ok::<_, String>(classify_binary_inputs( + &inputs, + &cli_run::process_workspace_root(), + built_at, + )) })(); - observed.unwrap_or_else(HeadObservation::Unobservable) + match observed { + Ok(inputs) => BinaryInputObservation::Observed { binary, inputs }, + Err(reason) => BinaryInputObservation::DepInfoUnreadable { binary, reason }, + } } -/// THE INVOCATION'S FIRST ACT: refuse a binary that does not answer for HEAD +/// THE INVOCATION'S FIRST ACT: refuse a binary that no longer answers for its own inputs /// (`gunbc.target_invocation` `assess_binary_freshness`), then route. Kept beside `test_verb` /// rather than inside it so the routing tests stay a function of the operand alone. -pub fn test_verb_at_head(operand: &str, build_identity: &str) -> InvocationOutcome { - let freshness = assess_binary_freshness(&built_identity_from(build_identity), &observe_head()); - let line = binary_freshness_rendered(&freshness); +pub fn test_verb_checked(operand: &str) -> InvocationOutcome { + test_verb_after(operand, &assess_binary_freshness(&observe_binary_inputs())) +} + +fn test_verb_after(operand: &str, freshness: &BinaryFreshness) -> InvocationOutcome { + let line = binary_freshness_rendered(freshness); match freshness { - BinaryFreshness::AtHead { uncommitted, .. } => { - eprintln!("{line} uncommitted_paths={}", uncommitted.len()); - for path in uncommitted.iter().take(20) { - eprintln!(" uncommitted {path}"); - } + BinaryFreshness::Fresh { inputs, .. } => { + eprintln!("{line} inputs={inputs}"); test_verb(operand) } BinaryFreshness::Stale { .. } | BinaryFreshness::Undecided { .. } => InvocationOutcome { @@ -2688,36 +2764,98 @@ mod binary_freshness_tests { /// THE OLD BEHAVIOUR RAN THE INSTRUMENT ON EVERY ARM BELOW; the stale and undecided arms are /// the refusals it lacked. Mirrors `test.claim.target_invocation_witness`'s freshness tests. #[test] - fn a_stale_or_unverifiable_binary_is_refused_before_any_instrument_runs() { - let head = HeadObservation::Observed { - commit: "c2".into(), - tree: "t2".into(), - uncommitted: vec!["dag/x.dag".into()], + fn freshness_precedence_matches_the_model() { + let obs = |inputs: Vec| BinaryInputObservation::Observed { + binary: "bin".into(), + inputs, }; - let at = |id: &str| assess_binary_freshness(&built_identity_from(id), &head); - assert!(matches!(at("c1"), BinaryFreshness::Stale { .. })); - assert!(matches!(at("c2"), BinaryFreshness::AtHead { .. })); - assert!(matches!(at("c2-dirty"), BinaryFreshness::Undecided { .. })); - assert!(matches!(at("tree:sha1:t2"), BinaryFreshness::AtHead { .. })); - assert!(matches!(at("tree:sha1:t1"), BinaryFreshness::Stale { .. })); + let fresh = assess_binary_freshness(&obs(vec![ + BinaryInput::NotNewer("a".into()), + BinaryInput::NotNewer("b".into()), + ])); + assert!(matches!(fresh, BinaryFreshness::Fresh { inputs: 2, .. })); + match assess_binary_freshness(&obs(vec![ + BinaryInput::NotNewer("a".into()), + BinaryInput::Newer("b".into()), + BinaryInput::Missing("c".into()), + ])) { + BinaryFreshness::Stale { input, .. } => assert_eq!(input, "b"), + other => panic!("{other:?}"), + } assert!(matches!( - assess_binary_freshness( - &built_identity_from("c2"), - &HeadObservation::Unobservable("no git".into()) - ), + assess_binary_freshness(&obs(vec![ + BinaryInput::Newer("b".into()), + BinaryInput::OutsideWorktree("/w/other".into()), + ])), + BinaryFreshness::Undecided { .. } + )); + assert!(matches!( + assess_binary_freshness(&BinaryInputObservation::DepInfoUnreadable { + binary: "bin".into(), + reason: "none".into() + }), BinaryFreshness::Undecided { .. } )); - // THE ROUTE, NOT ONLY THE DECISION: an all-zero commit is never HEAD, so the verb must - // refuse with status 2 and never reach the self-host producer. - let stale = test_verb_at_head( - "//gunbc/instruments:self-host", - "0000000000000000000000000000000000000000", + } + + /// The observation over REAL files: a file touched after the build instant is named; a + /// git watch path is skipped; another checkout's manifest is outside; escaped spaces parse. + #[test] + fn dep_info_classification_over_real_files() { + let dir = std::env::temp_dir().join(format!("freshness-{}", std::process::id())); + let ws = dir.join("ws"); + std::fs::create_dir_all(ws.join("src/v1/stage0")).unwrap(); + let old = ws.join("old file.rs"); + std::fs::write(&old, "x").unwrap(); + let built_at = std::time::SystemTime::now(); + std::thread::sleep(std::time::Duration::from_millis(20)); + let new = ws.join("new.rs"); + std::fs::write(&new, "y").unwrap(); + let manifest = ws.join("src/v1/stage0/Cargo.toml"); + std::fs::write(&manifest, "").unwrap(); + let text = format!( + "/t/gunbc: {} {} {} {} /nowhere/.git/index\n", + manifest.display(), + old.display().to_string().replace(' ', "\\ "), + new.display(), + ws.join("gone.rs").display() ); - assert_eq!(stale.termination, Termination::Refused); + let inputs = dep_info_inputs(&text).unwrap(); + assert_eq!(inputs.len(), 5); + let classified = classify_binary_inputs(&inputs, &ws, built_at); + assert_eq!( + classified.len(), + 4, + "the .git path is skipped: {classified:?}" + ); + assert!(classified.contains(&BinaryInput::NotNewer(old.display().to_string()))); + assert!(classified.contains(&BinaryInput::Newer(new.display().to_string()))); + assert!(classified.contains(&BinaryInput::Missing( + ws.join("gone.rs").display().to_string() + ))); + let elsewhere = classify_binary_inputs(&inputs, &dir.join("other"), built_at); + assert_eq!( + elsewhere[0], + BinaryInput::OutsideWorktree(ws.display().to_string()) + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// THE ROUTE: a stale verdict refuses with status 2 and never reaches the producer. + #[test] + fn a_stale_binary_is_refused_before_any_instrument_runs() { + let stale = BinaryFreshness::Stale { + binary: "bin".into(), + input: "a.rs".into(), + why: "changed after the binary was built".into(), + }; + let outcome = test_verb_after("//gunbc/instruments:self-host", &stale); + assert_eq!(outcome.termination, Termination::Refused); assert!( - stale.message.contains("cause=StaleBinary"), + outcome.message.contains("cause=StaleBinary"), "{}", - stale.message + outcome.message ); + assert!(outcome.message.contains("a.rs")); } } From 38dfdad6beb0872b2bd8f17061615131d734c9b3 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Fri, 2 Oct 2026 20:06:20 +0000 Subject: [PATCH 3/8] binary_freshness_step: exhaustive arms, no wildcard over BinaryInput (non-fold residue roster) Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/target_invocation.dag | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dag/gunbc/target_invocation.dag b/dag/gunbc/target_invocation.dag index 32080f07e87..77950b977fb 100644 --- a/dag/gunbc/target_invocation.dag +++ b/dag/gunbc/target_invocation.dag @@ -317,7 +317,9 @@ fn binary_freshness_step(acc: BinaryFreshness, input: BinaryInput) -> BinaryFres BinaryStale { binary: b, input: _, why: _ } => match input { InputOutsideWorktree { path: p } => binary_built_elsewhere(binary: b, path: p) - _ => acc + InputNewer { path: _ } => acc + InputMissing { path: _ } => acc + InputNotNewer { path: _ } => acc } BinaryFresh { binary: b, inputs: n } => match input { From bb07647ab4d24b91d7ee5e1883edf63798387f90 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Fri, 2 Oct 2026 20:23:07 +0000 Subject: [PATCH 4/8] seed growth: enrol the freshness mirror in gunbc.target_invocation_seed_growth Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/target_invocation_seed_growth.dag | 62 ++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/dag/gunbc/target_invocation_seed_growth.dag b/dag/gunbc/target_invocation_seed_growth.dag index 1ac3a7a9e48..bc2dd66ba24 100644 --- a/dag/gunbc/target_invocation_seed_growth.dag +++ b/dag/gunbc/target_invocation_seed_growth.dag @@ -473,9 +473,69 @@ data target_invocation_seed_growth_justification: SeedGrowthJustification = Seed module_path: "v1_compiler.behavioral_receipt_host", decl_name: "run_plan", field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "BinaryInput", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "BinaryInputObservation", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "BinaryFreshness", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "binary_built_elsewhere", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "binary_freshness_step", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "assess_binary_freshness", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "binary_freshness_rendered", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "dep_info_inputs", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "classify_binary_inputs", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "observe_binary_inputs", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "test_verb_checked", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "test_verb_after", + field: WholeDeclaration } ], - reason: "The verb's leaf producer is host Rust because its subject is a filesystem walk the hermetic floor refuses; the route around that leaf is hand-mirrored because none of the three modeled modules is in the v1 seed's emitted closure, and the two alternatives are worse -- routing through the interpreter makes it load-bearing for a NEW capability while two lanes delete it, and growing the emitter's closure to reach extdeps.bazel.label is a larger seed change than the verb it serves. What the growth buys is that the repository's instruments become addressable by label at all: before it, no target in this repository could be run by name. The diagnostic-census producer and its cli_run carriers joined under the same reasoning: the whole-tree compile-clean closure is a host effect no hermetic witness may run, and the census restores the reachability the gunbc#9160 sweep removed with the unrostered bin. The 2026-09-09 raw-census rework added the identity, disposition, refusal and specimen carriers: binding the observation to its source vector, compiler, policy, schema and closure is hashing and a total-match specimen over CompilerDiagnostic, both host facts. The 2026-09-20 pattern admission added the parse_target_pattern mirror (TargetPattern, TargetPatternRefusal, target_pattern_refusal_text, parse_pattern_package) beside the parse_label mirror it delegates the single-target form to, plus test_operand_set_form_refusal_rendered: a SET form is admitted by the grammar and REFUSED by the route with status 2 (operator ruling 2026-09-21), because its only admissible executor is the native test route and no interpreter delegation may stand in for it -- so that growth was the admission mirror and one refusal text, and no selection or execution semantics. In the same change the host's OperandNotALabel refusal arm and label_refusal_rendered deleted as unconstructible: every label refusal now arrives inside PatternLabelRefused. THAT SCOPE SENTENCE NO LONGER DESCRIBES THIS SEAM, AND IT IS CORRECTED HERE RATHER THAN LEFT STANDING (v2 foundation package 9). The native test route is now reachable by operand, and both of the things the old sentence excluded are present: SELECTION -- target_pattern_within with target_pattern_package, package_within and render_target_pattern, the mirror of the containment that decides WHICH executor answers an operand, plus native_route_default_pattern and its rendered form, the one seed-side spelling of the native universe that the lane runner and the verb both consume; and EXECUTION SEMANTICS -- run_native_test_route, which spawns the emitted compiler over the operand's pattern and partitions its answer, with native_lane_runner's NativeRouteOutcome, NativeRunAdmission and run_required_v2_native_inner carrying that partition as a typed value so no consumer recovers it from rendered text. This is the first execution semantics on this seam and it is counted as such: DESIGN section 7 asks for a declared row that is COUNTABLE, and a row whose reason excludes what the file now holds is worse than an uncounted declaration, because it tells a reader the seed is smaller than it is. THE MIGRATION TRIGGER IS UNCHANGED BY THIS GROWTH and is the same one the route has always waited on: these mirrors dissolve when the modeled modules enter an emitted closure, at which point the route is the .dag fold and this file is deleted rather than trimmed. A LATER GROWTH ADDED THE MEMBER-SCOPED STANDING and is counted here too: NativeMemberVerdict and NativeMemberTermination mirror v2.compiler.native_test_vocabulary NativeTestVerdict at its discriminant and gunbc.instrument_targets native_route_member_termination's result, native_member_termination mirrors that fold ARM FOR ARM INCLUDING ITS PRECEDENCE, and native_member_outcome maps the result onto this verb's termination. That growth is NOT a new authority -- the fold is modeled and its precedence is argued in the .dag source -- BUT ITS PARITY WITH THE MODEL IS UNCHECKED, AND AN EARLIER VERSION OF THIS SENTENCE CLAIMED OTHERWISE (review 70196). It said the mirror carries a discriminating red so that drift from the model is detectable. That is rung inflation of exactly the kind DESIGN section 4b(1) names, and it contradicted this row's own standing sentence two paragraphs above: what is NOT claimed is that the mirror is checked, only that it is enumerated. WHAT THE TWO TESTS ACTUALLY DO: the Rust test the_precedence_matches_the_modeled_fold compares native_member_termination against literals hard-coded in Rust, and the .dag witnesses compare native_route_member_termination against literals authored in .dag. NOTHING COMPARES THE TWO. Change the precedence in the model and update its .dag witness to match, and both suites stay green while the host answers differently -- the Rust test catches a Rust-side edit, which is the mirror drifting from its own test rather than from its model. AND THAT TEST DOES NOT RUN ON THE MERGE PATH AT ALL: cargo test -p v1-compiler --lib is executed by no CI step, which is the declared drop gunbc.rung_drop rust_unit_tests_off_the_merge_path. So this growth sits at the SAME mitigatable rung as the rest of this row and for the same reason, and its parity is carried by review diligence. WHAT WOULD CLIMB IT is a parity check that runs the same discriminating cases through BOTH folds and compares the results -- which needs the model executed from the host, and would additionally need a CI step that runs it before it establishes anything. It dissolves on the SAME trigger as the rest of this row.", + reason: "The verb's leaf producer is host Rust because its subject is a filesystem walk the hermetic floor refuses; the route around that leaf is hand-mirrored because none of the three modeled modules is in the v1 seed's emitted closure, and the two alternatives are worse -- routing through the interpreter makes it load-bearing for a NEW capability while two lanes delete it, and growing the emitter's closure to reach extdeps.bazel.label is a larger seed change than the verb it serves. What the growth buys is that the repository's instruments become addressable by label at all: before it, no target in this repository could be run by name. The diagnostic-census producer and its cli_run carriers joined under the same reasoning: the whole-tree compile-clean closure is a host effect no hermetic witness may run, and the census restores the reachability the gunbc#9160 sweep removed with the unrostered bin. The 2026-09-09 raw-census rework added the identity, disposition, refusal and specimen carriers: binding the observation to its source vector, compiler, policy, schema and closure is hashing and a total-match specimen over CompilerDiagnostic, both host facts. The 2026-09-20 pattern admission added the parse_target_pattern mirror (TargetPattern, TargetPatternRefusal, target_pattern_refusal_text, parse_pattern_package) beside the parse_label mirror it delegates the single-target form to, plus test_operand_set_form_refusal_rendered: a SET form is admitted by the grammar and REFUSED by the route with status 2 (operator ruling 2026-09-21), because its only admissible executor is the native test route and no interpreter delegation may stand in for it -- so that growth was the admission mirror and one refusal text, and no selection or execution semantics. In the same change the host's OperandNotALabel refusal arm and label_refusal_rendered deleted as unconstructible: every label refusal now arrives inside PatternLabelRefused. THAT SCOPE SENTENCE NO LONGER DESCRIBES THIS SEAM, AND IT IS CORRECTED HERE RATHER THAN LEFT STANDING (v2 foundation package 9). The native test route is now reachable by operand, and both of the things the old sentence excluded are present: SELECTION -- target_pattern_within with target_pattern_package, package_within and render_target_pattern, the mirror of the containment that decides WHICH executor answers an operand, plus native_route_default_pattern and its rendered form, the one seed-side spelling of the native universe that the lane runner and the verb both consume; and EXECUTION SEMANTICS -- run_native_test_route, which spawns the emitted compiler over the operand's pattern and partitions its answer, with native_lane_runner's NativeRouteOutcome, NativeRunAdmission and run_required_v2_native_inner carrying that partition as a typed value so no consumer recovers it from rendered text. This is the first execution semantics on this seam and it is counted as such: DESIGN section 7 asks for a declared row that is COUNTABLE, and a row whose reason excludes what the file now holds is worse than an uncounted declaration, because it tells a reader the seed is smaller than it is. THE MIGRATION TRIGGER IS UNCHANGED BY THIS GROWTH and is the same one the route has always waited on: these mirrors dissolve when the modeled modules enter an emitted closure, at which point the route is the .dag fold and this file is deleted rather than trimmed. A LATER GROWTH ADDED THE MEMBER-SCOPED STANDING and is counted here too: NativeMemberVerdict and NativeMemberTermination mirror v2.compiler.native_test_vocabulary NativeTestVerdict at its discriminant and gunbc.instrument_targets native_route_member_termination's result, native_member_termination mirrors that fold ARM FOR ARM INCLUDING ITS PRECEDENCE, and native_member_outcome maps the result onto this verb's termination. That growth is NOT a new authority -- the fold is modeled and its precedence is argued in the .dag source -- BUT ITS PARITY WITH THE MODEL IS UNCHECKED, AND AN EARLIER VERSION OF THIS SENTENCE CLAIMED OTHERWISE (review 70196). It said the mirror carries a discriminating red so that drift from the model is detectable. That is rung inflation of exactly the kind DESIGN section 4b(1) names, and it contradicted this row's own standing sentence two paragraphs above: what is NOT claimed is that the mirror is checked, only that it is enumerated. WHAT THE TWO TESTS ACTUALLY DO: the Rust test the_precedence_matches_the_modeled_fold compares native_member_termination against literals hard-coded in Rust, and the .dag witnesses compare native_route_member_termination against literals authored in .dag. NOTHING COMPARES THE TWO. Change the precedence in the model and update its .dag witness to match, and both suites stay green while the host answers differently -- the Rust test catches a Rust-side edit, which is the mirror drifting from its own test rather than from its model. AND THAT TEST DOES NOT RUN ON THE MERGE PATH AT ALL: cargo test -p v1-compiler --lib is executed by no CI step, which is the declared drop gunbc.rung_drop rust_unit_tests_off_the_merge_path. So this growth sits at the SAME mitigatable rung as the rest of this row and for the same reason, and its parity is carried by review diligence. WHAT WOULD CLIMB IT is a parity check that runs the same discriminating cases through BOTH folds and compares the results -- which needs the model executed from the host, and would additionally need a CI step that runs it before it establishes anything. It dissolves on the SAME trigger as the rest of this row. THE 2026-10-02 FRESHNESS GROWTH (gunbc#13007) added the binary-freshness mirror: BinaryInput, BinaryInputObservation, BinaryFreshness, binary_built_elsewhere, binary_freshness_step, assess_binary_freshness and binary_freshness_rendered mirror gunbc.target_invocation arm for arm, and dep_info_inputs, classify_binary_inputs, observe_binary_inputs, test_verb_checked and test_verb_after are the effectful half -- reading the running binary's cargo dep-info and stating each input's mtime, which no .dag fold can do. Its parity with the model is unchecked in exactly the sense stated above for the member fold, and it dissolves on the same trigger.", owning_dissolution_lane: "v1-hand-queue-drain" as RoadmapNodeId, trigger: "Discharge each declaration when its modeled realization is emitted rather than authored. The target_invocation_host subset deletes when gunbc.target_invocation and gunbc.instrument_targets enter the emitted closure of whatever renders gunbc's CLI; the behavioral_receipt_host subset deletes when the three already-modeled behavioral producers gain emitted native realization. A PARTIAL migration is admissible and expected: label admission dissolves when extdeps.bazel.label is emitted, independently of either producer family. Moving a declaration to another host file or adding a parity witness does not discharge it -- both leave the hand-authored declaration standing.\n\nTHE floor_memory_supervisor SUBSET HAS A DIFFERENT AND HONESTLY WEAKER TRIGGER, stated separately because assimilating it to the sentence above would promise a discharge that is not in sight. Its operations are resource effects -- read /proc/self/cgroup, spawn a child, wait on it, read /sys/fs/cgroup after the child is gone -- and NONE of the four has a bound native realization. The mechanism once named here was wrong and is corrected rather than softened: it read that resource operations resolve only inside a workflow function realized by the seed INTERPRETER. There is no workflow function -- gunbc#11528 deleted the concept -- and admissibility is the `uses` clause, an effect requirement. The measured refusal `receiver type Primitive(Filesystem) establishes no method surface` off a plain fn stands; what it establishes is that a resource METHOD needs a declared requirement, not that an emitted binary cannot perform an effect. So this subset does NOT discharge by the emitter reaching gunbc.instrument_targets, and it does not discharge on the Filesystem work of demand_engine_program D11 either, because spawn and wait are outside that denominator. It discharges when the substrate can express a SUPERVISED CHILD PROCESS -- spawn, wait on a termination status, and read a filesystem path after that child is gone -- as modeled effects with typed refusals, at which point the fold replaces this file and the dispatch arm calls it. Until that capability exists the subset is frozen rather than shrinking, and the honest reading is that it is the newest and least dischargeable growth on this row. WHAT DOES NOT DISCHARGE IT: moving the judgment further into .dag (already done -- the verdict is gunbc.floor_memory_demand's and no threshold lives in the Rust), nor adding witnesses over the read, because neither removes a single one of the four effects.", current_boundary: "src/v1/stage0/src/target_invocation_host.rs; src/v1/stage0/src/behavioral_receipt_host.rs (native realization of the already-modeled behavioral producers, relocated from claim_executor); src/v1/stage0/src/cli_run.rs (two #[path] mod lines plus the `floor_memory_supervisor` mod line); src/v1/stage0/src/cli_run/floor_memory_supervisor.rs (the supervised cgroup read behind //gunbc/instruments:floor-memory-qualification: create nothing, spawn the measured child into the supervisor's OWN cgroup, wait, then read memory.peak and memory.events from outside the dead process. It is host Rust because every operation in it is one no .dag fold can perform -- reading /proc/self/cgroup, spawning a process, waiting on it, and reading /sys/fs/cgroup after it is gone -- and because an in-process observer cannot report the terminations that matter: a Drop guard does not run on abort, process::exit or SIGKILL, and Rust's allocation-error handler aborts, so OOM and allocation failure are exactly the cases only an outliving supervisor can report. The JUDGMENT over what it reads is NOT here: it is gunbc.floor_memory_demand qualify_floor_memory_from_readings, and this module deliberately holds no threshold, no arm and no verdict); src/v1/stage0/src/cli_run/compile_clean.rs (the raw diagnostic census carriers: CensusGateDisposition, CensusSeverity, RawDiagnosticClassObservation, CompileCleanDiagnosticCensusIdentity, CompileCleanDiagnosticCensus, CompileCleanDiagnosticCensusRefusal, compile_clean_diagnostic_class_specimen, compile_clean_diagnostic_census, compile_clean_diagnostic_policy_snapshot, census_instance_disposition, compiler_diagnostic_constructor_census, census_gate_tag, census_severity_tag, census_identity_digest, diagnostic_census_refusal_rendered); generated gunbc_cli_dispatch_generated.rs (modeled Commands variant and generic retained-host selection); src/v1/stage0/src/main.rs (retained target producer host method only); dag/gunbc/target_invocation_seed_growth.dag" From 2a5e186431522937bc47232050eef08e638242d4 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Fri, 2 Oct 2026 20:23:25 +0000 Subject: [PATCH 5/8] typo Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/target_invocation_seed_growth.dag | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dag/gunbc/target_invocation_seed_growth.dag b/dag/gunbc/target_invocation_seed_growth.dag index bc2dd66ba24..ec0a5986cad 100644 --- a/dag/gunbc/target_invocation_seed_growth.dag +++ b/dag/gunbc/target_invocation_seed_growth.dag @@ -535,7 +535,7 @@ data target_invocation_seed_growth_justification: SeedGrowthJustification = Seed field: WholeDeclaration } ], - reason: "The verb's leaf producer is host Rust because its subject is a filesystem walk the hermetic floor refuses; the route around that leaf is hand-mirrored because none of the three modeled modules is in the v1 seed's emitted closure, and the two alternatives are worse -- routing through the interpreter makes it load-bearing for a NEW capability while two lanes delete it, and growing the emitter's closure to reach extdeps.bazel.label is a larger seed change than the verb it serves. What the growth buys is that the repository's instruments become addressable by label at all: before it, no target in this repository could be run by name. The diagnostic-census producer and its cli_run carriers joined under the same reasoning: the whole-tree compile-clean closure is a host effect no hermetic witness may run, and the census restores the reachability the gunbc#9160 sweep removed with the unrostered bin. The 2026-09-09 raw-census rework added the identity, disposition, refusal and specimen carriers: binding the observation to its source vector, compiler, policy, schema and closure is hashing and a total-match specimen over CompilerDiagnostic, both host facts. The 2026-09-20 pattern admission added the parse_target_pattern mirror (TargetPattern, TargetPatternRefusal, target_pattern_refusal_text, parse_pattern_package) beside the parse_label mirror it delegates the single-target form to, plus test_operand_set_form_refusal_rendered: a SET form is admitted by the grammar and REFUSED by the route with status 2 (operator ruling 2026-09-21), because its only admissible executor is the native test route and no interpreter delegation may stand in for it -- so that growth was the admission mirror and one refusal text, and no selection or execution semantics. In the same change the host's OperandNotALabel refusal arm and label_refusal_rendered deleted as unconstructible: every label refusal now arrives inside PatternLabelRefused. THAT SCOPE SENTENCE NO LONGER DESCRIBES THIS SEAM, AND IT IS CORRECTED HERE RATHER THAN LEFT STANDING (v2 foundation package 9). The native test route is now reachable by operand, and both of the things the old sentence excluded are present: SELECTION -- target_pattern_within with target_pattern_package, package_within and render_target_pattern, the mirror of the containment that decides WHICH executor answers an operand, plus native_route_default_pattern and its rendered form, the one seed-side spelling of the native universe that the lane runner and the verb both consume; and EXECUTION SEMANTICS -- run_native_test_route, which spawns the emitted compiler over the operand's pattern and partitions its answer, with native_lane_runner's NativeRouteOutcome, NativeRunAdmission and run_required_v2_native_inner carrying that partition as a typed value so no consumer recovers it from rendered text. This is the first execution semantics on this seam and it is counted as such: DESIGN section 7 asks for a declared row that is COUNTABLE, and a row whose reason excludes what the file now holds is worse than an uncounted declaration, because it tells a reader the seed is smaller than it is. THE MIGRATION TRIGGER IS UNCHANGED BY THIS GROWTH and is the same one the route has always waited on: these mirrors dissolve when the modeled modules enter an emitted closure, at which point the route is the .dag fold and this file is deleted rather than trimmed. A LATER GROWTH ADDED THE MEMBER-SCOPED STANDING and is counted here too: NativeMemberVerdict and NativeMemberTermination mirror v2.compiler.native_test_vocabulary NativeTestVerdict at its discriminant and gunbc.instrument_targets native_route_member_termination's result, native_member_termination mirrors that fold ARM FOR ARM INCLUDING ITS PRECEDENCE, and native_member_outcome maps the result onto this verb's termination. That growth is NOT a new authority -- the fold is modeled and its precedence is argued in the .dag source -- BUT ITS PARITY WITH THE MODEL IS UNCHECKED, AND AN EARLIER VERSION OF THIS SENTENCE CLAIMED OTHERWISE (review 70196). It said the mirror carries a discriminating red so that drift from the model is detectable. That is rung inflation of exactly the kind DESIGN section 4b(1) names, and it contradicted this row's own standing sentence two paragraphs above: what is NOT claimed is that the mirror is checked, only that it is enumerated. WHAT THE TWO TESTS ACTUALLY DO: the Rust test the_precedence_matches_the_modeled_fold compares native_member_termination against literals hard-coded in Rust, and the .dag witnesses compare native_route_member_termination against literals authored in .dag. NOTHING COMPARES THE TWO. Change the precedence in the model and update its .dag witness to match, and both suites stay green while the host answers differently -- the Rust test catches a Rust-side edit, which is the mirror drifting from its own test rather than from its model. AND THAT TEST DOES NOT RUN ON THE MERGE PATH AT ALL: cargo test -p v1-compiler --lib is executed by no CI step, which is the declared drop gunbc.rung_drop rust_unit_tests_off_the_merge_path. So this growth sits at the SAME mitigatable rung as the rest of this row and for the same reason, and its parity is carried by review diligence. WHAT WOULD CLIMB IT is a parity check that runs the same discriminating cases through BOTH folds and compares the results -- which needs the model executed from the host, and would additionally need a CI step that runs it before it establishes anything. It dissolves on the SAME trigger as the rest of this row. THE 2026-10-02 FRESHNESS GROWTH (gunbc#13007) added the binary-freshness mirror: BinaryInput, BinaryInputObservation, BinaryFreshness, binary_built_elsewhere, binary_freshness_step, assess_binary_freshness and binary_freshness_rendered mirror gunbc.target_invocation arm for arm, and dep_info_inputs, classify_binary_inputs, observe_binary_inputs, test_verb_checked and test_verb_after are the effectful half -- reading the running binary's cargo dep-info and stating each input's mtime, which no .dag fold can do. Its parity with the model is unchecked in exactly the sense stated above for the member fold, and it dissolves on the same trigger.", + reason: "The verb's leaf producer is host Rust because its subject is a filesystem walk the hermetic floor refuses; the route around that leaf is hand-mirrored because none of the three modeled modules is in the v1 seed's emitted closure, and the two alternatives are worse -- routing through the interpreter makes it load-bearing for a NEW capability while two lanes delete it, and growing the emitter's closure to reach extdeps.bazel.label is a larger seed change than the verb it serves. What the growth buys is that the repository's instruments become addressable by label at all: before it, no target in this repository could be run by name. The diagnostic-census producer and its cli_run carriers joined under the same reasoning: the whole-tree compile-clean closure is a host effect no hermetic witness may run, and the census restores the reachability the gunbc#9160 sweep removed with the unrostered bin. The 2026-09-09 raw-census rework added the identity, disposition, refusal and specimen carriers: binding the observation to its source vector, compiler, policy, schema and closure is hashing and a total-match specimen over CompilerDiagnostic, both host facts. The 2026-09-20 pattern admission added the parse_target_pattern mirror (TargetPattern, TargetPatternRefusal, target_pattern_refusal_text, parse_pattern_package) beside the parse_label mirror it delegates the single-target form to, plus test_operand_set_form_refusal_rendered: a SET form is admitted by the grammar and REFUSED by the route with status 2 (operator ruling 2026-09-21), because its only admissible executor is the native test route and no interpreter delegation may stand in for it -- so that growth was the admission mirror and one refusal text, and no selection or execution semantics. In the same change the host's OperandNotALabel refusal arm and label_refusal_rendered deleted as unconstructible: every label refusal now arrives inside PatternLabelRefused. THAT SCOPE SENTENCE NO LONGER DESCRIBES THIS SEAM, AND IT IS CORRECTED HERE RATHER THAN LEFT STANDING (v2 foundation package 9). The native test route is now reachable by operand, and both of the things the old sentence excluded are present: SELECTION -- target_pattern_within with target_pattern_package, package_within and render_target_pattern, the mirror of the containment that decides WHICH executor answers an operand, plus native_route_default_pattern and its rendered form, the one seed-side spelling of the native universe that the lane runner and the verb both consume; and EXECUTION SEMANTICS -- run_native_test_route, which spawns the emitted compiler over the operand's pattern and partitions its answer, with native_lane_runner's NativeRouteOutcome, NativeRunAdmission and run_required_v2_native_inner carrying that partition as a typed value so no consumer recovers it from rendered text. This is the first execution semantics on this seam and it is counted as such: DESIGN section 7 asks for a declared row that is COUNTABLE, and a row whose reason excludes what the file now holds is worse than an uncounted declaration, because it tells a reader the seed is smaller than it is. THE MIGRATION TRIGGER IS UNCHANGED BY THIS GROWTH and is the same one the route has always waited on: these mirrors dissolve when the modeled modules enter an emitted closure, at which point the route is the .dag fold and this file is deleted rather than trimmed. A LATER GROWTH ADDED THE MEMBER-SCOPED STANDING and is counted here too: NativeMemberVerdict and NativeMemberTermination mirror v2.compiler.native_test_vocabulary NativeTestVerdict at its discriminant and gunbc.instrument_targets native_route_member_termination's result, native_member_termination mirrors that fold ARM FOR ARM INCLUDING ITS PRECEDENCE, and native_member_outcome maps the result onto this verb's termination. That growth is NOT a new authority -- the fold is modeled and its precedence is argued in the .dag source -- BUT ITS PARITY WITH THE MODEL IS UNCHECKED, AND AN EARLIER VERSION OF THIS SENTENCE CLAIMED OTHERWISE (review 70196). It said the mirror carries a discriminating red so that drift from the model is detectable. That is rung inflation of exactly the kind DESIGN section 4b(1) names, and it contradicted this row's own standing sentence two paragraphs above: what is NOT claimed is that the mirror is checked, only that it is enumerated. WHAT THE TWO TESTS ACTUALLY DO: the Rust test the_precedence_matches_the_modeled_fold compares native_member_termination against literals hard-coded in Rust, and the .dag witnesses compare native_route_member_termination against literals authored in .dag. NOTHING COMPARES THE TWO. Change the precedence in the model and update its .dag witness to match, and both suites stay green while the host answers differently -- the Rust test catches a Rust-side edit, which is the mirror drifting from its own test rather than from its model. AND THAT TEST DOES NOT RUN ON THE MERGE PATH AT ALL: cargo test -p v1-compiler --lib is executed by no CI step, which is the declared drop gunbc.rung_drop rust_unit_tests_off_the_merge_path. So this growth sits at the SAME mitigatable rung as the rest of this row and for the same reason, and its parity is carried by review diligence. WHAT WOULD CLIMB IT is a parity check that runs the same discriminating cases through BOTH folds and compares the results -- which needs the model executed from the host, and would additionally need a CI step that runs it before it establishes anything. It dissolves on the SAME trigger as the rest of this row. THE 2026-10-02 FRESHNESS GROWTH (gunbc#13007) added the binary-freshness mirror: BinaryInput, BinaryInputObservation, BinaryFreshness, binary_built_elsewhere, binary_freshness_step, assess_binary_freshness and binary_freshness_rendered mirror gunbc.target_invocation arm for arm, and dep_info_inputs, classify_binary_inputs, observe_binary_inputs, test_verb_checked and test_verb_after are the effectful half -- reading the running binary's cargo dep-info and reading each input's mtime, which no .dag fold can do. Its parity with the model is unchecked in exactly the sense stated above for the member fold, and it dissolves on the same trigger.", owning_dissolution_lane: "v1-hand-queue-drain" as RoadmapNodeId, trigger: "Discharge each declaration when its modeled realization is emitted rather than authored. The target_invocation_host subset deletes when gunbc.target_invocation and gunbc.instrument_targets enter the emitted closure of whatever renders gunbc's CLI; the behavioral_receipt_host subset deletes when the three already-modeled behavioral producers gain emitted native realization. A PARTIAL migration is admissible and expected: label admission dissolves when extdeps.bazel.label is emitted, independently of either producer family. Moving a declaration to another host file or adding a parity witness does not discharge it -- both leave the hand-authored declaration standing.\n\nTHE floor_memory_supervisor SUBSET HAS A DIFFERENT AND HONESTLY WEAKER TRIGGER, stated separately because assimilating it to the sentence above would promise a discharge that is not in sight. Its operations are resource effects -- read /proc/self/cgroup, spawn a child, wait on it, read /sys/fs/cgroup after the child is gone -- and NONE of the four has a bound native realization. The mechanism once named here was wrong and is corrected rather than softened: it read that resource operations resolve only inside a workflow function realized by the seed INTERPRETER. There is no workflow function -- gunbc#11528 deleted the concept -- and admissibility is the `uses` clause, an effect requirement. The measured refusal `receiver type Primitive(Filesystem) establishes no method surface` off a plain fn stands; what it establishes is that a resource METHOD needs a declared requirement, not that an emitted binary cannot perform an effect. So this subset does NOT discharge by the emitter reaching gunbc.instrument_targets, and it does not discharge on the Filesystem work of demand_engine_program D11 either, because spawn and wait are outside that denominator. It discharges when the substrate can express a SUPERVISED CHILD PROCESS -- spawn, wait on a termination status, and read a filesystem path after that child is gone -- as modeled effects with typed refusals, at which point the fold replaces this file and the dispatch arm calls it. Until that capability exists the subset is frozen rather than shrinking, and the honest reading is that it is the newest and least dischargeable growth on this row. WHAT DOES NOT DISCHARGE IT: moving the judgment further into .dag (already done -- the verdict is gunbc.floor_memory_demand's and no threshold lives in the Rust), nor adding witnesses over the read, because neither removes a single one of the four effects.", current_boundary: "src/v1/stage0/src/target_invocation_host.rs; src/v1/stage0/src/behavioral_receipt_host.rs (native realization of the already-modeled behavioral producers, relocated from claim_executor); src/v1/stage0/src/cli_run.rs (two #[path] mod lines plus the `floor_memory_supervisor` mod line); src/v1/stage0/src/cli_run/floor_memory_supervisor.rs (the supervised cgroup read behind //gunbc/instruments:floor-memory-qualification: create nothing, spawn the measured child into the supervisor's OWN cgroup, wait, then read memory.peak and memory.events from outside the dead process. It is host Rust because every operation in it is one no .dag fold can perform -- reading /proc/self/cgroup, spawning a process, waiting on it, and reading /sys/fs/cgroup after it is gone -- and because an in-process observer cannot report the terminations that matter: a Drop guard does not run on abort, process::exit or SIGKILL, and Rust's allocation-error handler aborts, so OOM and allocation failure are exactly the cases only an outliving supervisor can report. The JUDGMENT over what it reads is NOT here: it is gunbc.floor_memory_demand qualify_floor_memory_from_readings, and this module deliberately holds no threshold, no arm and no verdict); src/v1/stage0/src/cli_run/compile_clean.rs (the raw diagnostic census carriers: CensusGateDisposition, CensusSeverity, RawDiagnosticClassObservation, CompileCleanDiagnosticCensusIdentity, CompileCleanDiagnosticCensus, CompileCleanDiagnosticCensusRefusal, compile_clean_diagnostic_class_specimen, compile_clean_diagnostic_census, compile_clean_diagnostic_policy_snapshot, census_instance_disposition, compiler_diagnostic_constructor_census, census_gate_tag, census_severity_tag, census_identity_digest, diagnostic_census_refusal_rendered); generated gunbc_cli_dispatch_generated.rs (modeled Commands variant and generic retained-host selection); src/v1/stage0/src/main.rs (retained target producer host method only); dag/gunbc/target_invocation_seed_growth.dag" From 9dd82fd7c8f81c7adaad3c96426e1a89e3ee1b96 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Fri, 2 Oct 2026 21:25:47 +0000 Subject: [PATCH 6/8] freshness: no build root in dep-info refuses (DepInfoUnreadable), never mtime-only (review 74282) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/v1/stage0/src/target_invocation_host.rs | 37 +++++++++++++-------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/v1/stage0/src/target_invocation_host.rs b/src/v1/stage0/src/target_invocation_host.rs index 6141b34f147..f6069251b5d 100644 --- a/src/v1/stage0/src/target_invocation_host.rs +++ b/src/v1/stage0/src/target_invocation_host.rs @@ -1810,16 +1810,21 @@ pub fn classify_binary_inputs( inputs: &[String], workspace: &std::path::Path, built_at: std::time::SystemTime, -) -> Vec { +) -> Result, String> { + // NO BUILD ROOT IS A REFUSAL, NOT A SKIPPED CHECK (review 74282). If no input names the + // crate manifest, the checkout the binary was built from is unknown, and judging the + // remaining inputs by mtime alone would answer `Fresh` for a binary another worktree built. let build_root = inputs .iter() .find(|p| p.ends_with("/src/v1/stage0/Cargo.toml")) - .map(|p| p.trim_end_matches("/src/v1/stage0/Cargo.toml").to_string()); + .map(|p| p.trim_end_matches("/src/v1/stage0/Cargo.toml").to_string()) + .ok_or_else(|| { + "its dep-info names no src/v1/stage0/Cargo.toml, so the checkout it was built from is unknown" + .to_string() + })?; let mut out = Vec::new(); - if let Some(root) = &build_root { - if std::path::Path::new(root) != workspace { - out.push(BinaryInput::OutsideWorktree(root.clone())); - } + if std::path::Path::new(&build_root) != workspace { + out.push(BinaryInput::OutsideWorktree(build_root.clone())); } for input in inputs { if input.contains("/.git/") { @@ -1832,7 +1837,7 @@ pub fn classify_binary_inputs( }; out.push(classified); } - out + Ok(out) } /// The effectful half: read the running binary's dep-info and stat its inputs. Every failure is @@ -1856,11 +1861,7 @@ fn observe_binary_inputs() -> BinaryInputObservation { let text = std::fs::read_to_string(&dep_info_path) .map_err(|e| format!("cannot read dep-info {}: {e}", dep_info_path.display()))?; let inputs = dep_info_inputs(&text)?; - Ok::<_, String>(classify_binary_inputs( - &inputs, - &cli_run::process_workspace_root(), - built_at, - )) + classify_binary_inputs(&inputs, &cli_run::process_workspace_root(), built_at) })(); match observed { Ok(inputs) => BinaryInputObservation::Observed { binary, inputs }, @@ -2822,7 +2823,7 @@ mod binary_freshness_tests { ); let inputs = dep_info_inputs(&text).unwrap(); assert_eq!(inputs.len(), 5); - let classified = classify_binary_inputs(&inputs, &ws, built_at); + let classified = classify_binary_inputs(&inputs, &ws, built_at).unwrap(); assert_eq!( classified.len(), 4, @@ -2833,11 +2834,19 @@ mod binary_freshness_tests { assert!(classified.contains(&BinaryInput::Missing( ws.join("gone.rs").display().to_string() ))); - let elsewhere = classify_binary_inputs(&inputs, &dir.join("other"), built_at); + let elsewhere = classify_binary_inputs(&inputs, &dir.join("other"), built_at).unwrap(); assert_eq!( elsewhere[0], BinaryInput::OutsideWorktree(ws.display().to_string()) ); + // NO MANIFEST, NO VERDICT: dep-info without the crate's Cargo.toml refuses rather than + // judging the other inputs by mtime alone (review 74282's silent-Fresh case). + let no_manifest = dep_info_inputs(&format!( + "/t/gunbc: {}\n", + old.display().to_string().replace(' ', "\\ ") + )) + .unwrap(); + assert!(classify_binary_inputs(&no_manifest, &dir.join("other"), built_at).is_err()); let _ = std::fs::remove_dir_all(&dir); } From 55e85dd1e800813873521a8c9835e43fa4ac2b19 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Fri, 2 Oct 2026 22:37:54 +0000 Subject: [PATCH 7/8] freshness: anchor at build start (the unit's fingerprint dep-info), not the binary's mtime An input edited after rustc read it but before linking finished read Fresh against the binary's mtime. The anchor is now derived exactly: the binary's hard-link twin in deps/ names the fingerprint unit by hash, and its dep-bin- is cargo's build-start stamp. Any break in that chain refuses. Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/target_invocation.dag | 7 +- dag/gunbc/target_invocation_seed_growth.dag | 12 +- src/v1/stage0/src/target_invocation_host.rs | 175 ++++++++++++++++++-- 3 files changed, 177 insertions(+), 17 deletions(-) diff --git a/dag/gunbc/target_invocation.dag b/dag/gunbc/target_invocation.dag index 77950b977fb..7bdabbb04c7 100644 --- a/dag/gunbc/target_invocation.dag +++ b/dag/gunbc/target_invocation.dag @@ -282,8 +282,11 @@ fn invocation_refusal_rendered(cause: TargetInvocationRefusal) -> String { // after every unrelated commit, which teaches people to route around the check // (calm-boar-904's ruling on #13007). // -// The comparison is cargo's own rule, an input newer than the binary. So a refusal here means -// exactly "cargo build would rebuild". It is NOT content-exact: an input whose mtime moved +// The comparison is cargo's own rule: an input newer than the BUILD START, read from the unit's +// fingerprint dep-info, which cargo rewinds to when compilation began. The binary's own mtime is +// the wrong anchor: an input edited after rustc read it but before the link finished is older +// than the binary and was never compiled. So a refusal here means exactly "cargo build would +// rebuild". It is NOT content-exact: an input whose mtime moved // without its bytes changing refuses too, as cargo would rebuild. A content-exact check needs a // digest recorded AFTER the compile that writes dep-info, which no in-tree build hook can do. // diff --git a/dag/gunbc/target_invocation_seed_growth.dag b/dag/gunbc/target_invocation_seed_growth.dag index ec0a5986cad..4fc0dc6307f 100644 --- a/dag/gunbc/target_invocation_seed_growth.dag +++ b/dag/gunbc/target_invocation_seed_growth.dag @@ -533,9 +533,19 @@ data target_invocation_seed_growth_justification: SeedGrowthJustification = Seed module_path: "v1_compiler.target_invocation_host", decl_name: "test_verb_after", field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "build_start_anchor", + field: WholeDeclaration + }, + DeclarationRef { + module_path: "v1_compiler.target_invocation_host", + decl_name: "observe_binary_inputs_of", + field: WholeDeclaration } ], - reason: "The verb's leaf producer is host Rust because its subject is a filesystem walk the hermetic floor refuses; the route around that leaf is hand-mirrored because none of the three modeled modules is in the v1 seed's emitted closure, and the two alternatives are worse -- routing through the interpreter makes it load-bearing for a NEW capability while two lanes delete it, and growing the emitter's closure to reach extdeps.bazel.label is a larger seed change than the verb it serves. What the growth buys is that the repository's instruments become addressable by label at all: before it, no target in this repository could be run by name. The diagnostic-census producer and its cli_run carriers joined under the same reasoning: the whole-tree compile-clean closure is a host effect no hermetic witness may run, and the census restores the reachability the gunbc#9160 sweep removed with the unrostered bin. The 2026-09-09 raw-census rework added the identity, disposition, refusal and specimen carriers: binding the observation to its source vector, compiler, policy, schema and closure is hashing and a total-match specimen over CompilerDiagnostic, both host facts. The 2026-09-20 pattern admission added the parse_target_pattern mirror (TargetPattern, TargetPatternRefusal, target_pattern_refusal_text, parse_pattern_package) beside the parse_label mirror it delegates the single-target form to, plus test_operand_set_form_refusal_rendered: a SET form is admitted by the grammar and REFUSED by the route with status 2 (operator ruling 2026-09-21), because its only admissible executor is the native test route and no interpreter delegation may stand in for it -- so that growth was the admission mirror and one refusal text, and no selection or execution semantics. In the same change the host's OperandNotALabel refusal arm and label_refusal_rendered deleted as unconstructible: every label refusal now arrives inside PatternLabelRefused. THAT SCOPE SENTENCE NO LONGER DESCRIBES THIS SEAM, AND IT IS CORRECTED HERE RATHER THAN LEFT STANDING (v2 foundation package 9). The native test route is now reachable by operand, and both of the things the old sentence excluded are present: SELECTION -- target_pattern_within with target_pattern_package, package_within and render_target_pattern, the mirror of the containment that decides WHICH executor answers an operand, plus native_route_default_pattern and its rendered form, the one seed-side spelling of the native universe that the lane runner and the verb both consume; and EXECUTION SEMANTICS -- run_native_test_route, which spawns the emitted compiler over the operand's pattern and partitions its answer, with native_lane_runner's NativeRouteOutcome, NativeRunAdmission and run_required_v2_native_inner carrying that partition as a typed value so no consumer recovers it from rendered text. This is the first execution semantics on this seam and it is counted as such: DESIGN section 7 asks for a declared row that is COUNTABLE, and a row whose reason excludes what the file now holds is worse than an uncounted declaration, because it tells a reader the seed is smaller than it is. THE MIGRATION TRIGGER IS UNCHANGED BY THIS GROWTH and is the same one the route has always waited on: these mirrors dissolve when the modeled modules enter an emitted closure, at which point the route is the .dag fold and this file is deleted rather than trimmed. A LATER GROWTH ADDED THE MEMBER-SCOPED STANDING and is counted here too: NativeMemberVerdict and NativeMemberTermination mirror v2.compiler.native_test_vocabulary NativeTestVerdict at its discriminant and gunbc.instrument_targets native_route_member_termination's result, native_member_termination mirrors that fold ARM FOR ARM INCLUDING ITS PRECEDENCE, and native_member_outcome maps the result onto this verb's termination. That growth is NOT a new authority -- the fold is modeled and its precedence is argued in the .dag source -- BUT ITS PARITY WITH THE MODEL IS UNCHECKED, AND AN EARLIER VERSION OF THIS SENTENCE CLAIMED OTHERWISE (review 70196). It said the mirror carries a discriminating red so that drift from the model is detectable. That is rung inflation of exactly the kind DESIGN section 4b(1) names, and it contradicted this row's own standing sentence two paragraphs above: what is NOT claimed is that the mirror is checked, only that it is enumerated. WHAT THE TWO TESTS ACTUALLY DO: the Rust test the_precedence_matches_the_modeled_fold compares native_member_termination against literals hard-coded in Rust, and the .dag witnesses compare native_route_member_termination against literals authored in .dag. NOTHING COMPARES THE TWO. Change the precedence in the model and update its .dag witness to match, and both suites stay green while the host answers differently -- the Rust test catches a Rust-side edit, which is the mirror drifting from its own test rather than from its model. AND THAT TEST DOES NOT RUN ON THE MERGE PATH AT ALL: cargo test -p v1-compiler --lib is executed by no CI step, which is the declared drop gunbc.rung_drop rust_unit_tests_off_the_merge_path. So this growth sits at the SAME mitigatable rung as the rest of this row and for the same reason, and its parity is carried by review diligence. WHAT WOULD CLIMB IT is a parity check that runs the same discriminating cases through BOTH folds and compares the results -- which needs the model executed from the host, and would additionally need a CI step that runs it before it establishes anything. It dissolves on the SAME trigger as the rest of this row. THE 2026-10-02 FRESHNESS GROWTH (gunbc#13007) added the binary-freshness mirror: BinaryInput, BinaryInputObservation, BinaryFreshness, binary_built_elsewhere, binary_freshness_step, assess_binary_freshness and binary_freshness_rendered mirror gunbc.target_invocation arm for arm, and dep_info_inputs, classify_binary_inputs, observe_binary_inputs, test_verb_checked and test_verb_after are the effectful half -- reading the running binary's cargo dep-info and reading each input's mtime, which no .dag fold can do. Its parity with the model is unchecked in exactly the sense stated above for the member fold, and it dissolves on the same trigger.", + reason: "The verb's leaf producer is host Rust because its subject is a filesystem walk the hermetic floor refuses; the route around that leaf is hand-mirrored because none of the three modeled modules is in the v1 seed's emitted closure, and the two alternatives are worse -- routing through the interpreter makes it load-bearing for a NEW capability while two lanes delete it, and growing the emitter's closure to reach extdeps.bazel.label is a larger seed change than the verb it serves. What the growth buys is that the repository's instruments become addressable by label at all: before it, no target in this repository could be run by name. The diagnostic-census producer and its cli_run carriers joined under the same reasoning: the whole-tree compile-clean closure is a host effect no hermetic witness may run, and the census restores the reachability the gunbc#9160 sweep removed with the unrostered bin. The 2026-09-09 raw-census rework added the identity, disposition, refusal and specimen carriers: binding the observation to its source vector, compiler, policy, schema and closure is hashing and a total-match specimen over CompilerDiagnostic, both host facts. The 2026-09-20 pattern admission added the parse_target_pattern mirror (TargetPattern, TargetPatternRefusal, target_pattern_refusal_text, parse_pattern_package) beside the parse_label mirror it delegates the single-target form to, plus test_operand_set_form_refusal_rendered: a SET form is admitted by the grammar and REFUSED by the route with status 2 (operator ruling 2026-09-21), because its only admissible executor is the native test route and no interpreter delegation may stand in for it -- so that growth was the admission mirror and one refusal text, and no selection or execution semantics. In the same change the host's OperandNotALabel refusal arm and label_refusal_rendered deleted as unconstructible: every label refusal now arrives inside PatternLabelRefused. THAT SCOPE SENTENCE NO LONGER DESCRIBES THIS SEAM, AND IT IS CORRECTED HERE RATHER THAN LEFT STANDING (v2 foundation package 9). The native test route is now reachable by operand, and both of the things the old sentence excluded are present: SELECTION -- target_pattern_within with target_pattern_package, package_within and render_target_pattern, the mirror of the containment that decides WHICH executor answers an operand, plus native_route_default_pattern and its rendered form, the one seed-side spelling of the native universe that the lane runner and the verb both consume; and EXECUTION SEMANTICS -- run_native_test_route, which spawns the emitted compiler over the operand's pattern and partitions its answer, with native_lane_runner's NativeRouteOutcome, NativeRunAdmission and run_required_v2_native_inner carrying that partition as a typed value so no consumer recovers it from rendered text. This is the first execution semantics on this seam and it is counted as such: DESIGN section 7 asks for a declared row that is COUNTABLE, and a row whose reason excludes what the file now holds is worse than an uncounted declaration, because it tells a reader the seed is smaller than it is. THE MIGRATION TRIGGER IS UNCHANGED BY THIS GROWTH and is the same one the route has always waited on: these mirrors dissolve when the modeled modules enter an emitted closure, at which point the route is the .dag fold and this file is deleted rather than trimmed. A LATER GROWTH ADDED THE MEMBER-SCOPED STANDING and is counted here too: NativeMemberVerdict and NativeMemberTermination mirror v2.compiler.native_test_vocabulary NativeTestVerdict at its discriminant and gunbc.instrument_targets native_route_member_termination's result, native_member_termination mirrors that fold ARM FOR ARM INCLUDING ITS PRECEDENCE, and native_member_outcome maps the result onto this verb's termination. That growth is NOT a new authority -- the fold is modeled and its precedence is argued in the .dag source -- BUT ITS PARITY WITH THE MODEL IS UNCHECKED, AND AN EARLIER VERSION OF THIS SENTENCE CLAIMED OTHERWISE (review 70196). It said the mirror carries a discriminating red so that drift from the model is detectable. That is rung inflation of exactly the kind DESIGN section 4b(1) names, and it contradicted this row's own standing sentence two paragraphs above: what is NOT claimed is that the mirror is checked, only that it is enumerated. WHAT THE TWO TESTS ACTUALLY DO: the Rust test the_precedence_matches_the_modeled_fold compares native_member_termination against literals hard-coded in Rust, and the .dag witnesses compare native_route_member_termination against literals authored in .dag. NOTHING COMPARES THE TWO. Change the precedence in the model and update its .dag witness to match, and both suites stay green while the host answers differently -- the Rust test catches a Rust-side edit, which is the mirror drifting from its own test rather than from its model. AND THAT TEST DOES NOT RUN ON THE MERGE PATH AT ALL: cargo test -p v1-compiler --lib is executed by no CI step, which is the declared drop gunbc.rung_drop rust_unit_tests_off_the_merge_path. So this growth sits at the SAME mitigatable rung as the rest of this row and for the same reason, and its parity is carried by review diligence. WHAT WOULD CLIMB IT is a parity check that runs the same discriminating cases through BOTH folds and compares the results -- which needs the model executed from the host, and would additionally need a CI step that runs it before it establishes anything. It dissolves on the SAME trigger as the rest of this row. THE 2026-10-02 FRESHNESS GROWTH (gunbc#13007) added the binary-freshness mirror: BinaryInput, BinaryInputObservation, BinaryFreshness, binary_built_elsewhere, binary_freshness_step, assess_binary_freshness and binary_freshness_rendered mirror gunbc.target_invocation arm for arm, and dep_info_inputs, classify_binary_inputs, build_start_anchor, observe_binary_inputs_of, observe_binary_inputs, test_verb_checked and test_verb_after are the effectful half -- reading the running binary's cargo dep-info, its fingerprint unit's build-start stamp, and each input's mtime, which no .dag fold can do. Its parity with the model is unchecked in exactly the sense stated above for the member fold, and it dissolves on the same trigger.", owning_dissolution_lane: "v1-hand-queue-drain" as RoadmapNodeId, trigger: "Discharge each declaration when its modeled realization is emitted rather than authored. The target_invocation_host subset deletes when gunbc.target_invocation and gunbc.instrument_targets enter the emitted closure of whatever renders gunbc's CLI; the behavioral_receipt_host subset deletes when the three already-modeled behavioral producers gain emitted native realization. A PARTIAL migration is admissible and expected: label admission dissolves when extdeps.bazel.label is emitted, independently of either producer family. Moving a declaration to another host file or adding a parity witness does not discharge it -- both leave the hand-authored declaration standing.\n\nTHE floor_memory_supervisor SUBSET HAS A DIFFERENT AND HONESTLY WEAKER TRIGGER, stated separately because assimilating it to the sentence above would promise a discharge that is not in sight. Its operations are resource effects -- read /proc/self/cgroup, spawn a child, wait on it, read /sys/fs/cgroup after the child is gone -- and NONE of the four has a bound native realization. The mechanism once named here was wrong and is corrected rather than softened: it read that resource operations resolve only inside a workflow function realized by the seed INTERPRETER. There is no workflow function -- gunbc#11528 deleted the concept -- and admissibility is the `uses` clause, an effect requirement. The measured refusal `receiver type Primitive(Filesystem) establishes no method surface` off a plain fn stands; what it establishes is that a resource METHOD needs a declared requirement, not that an emitted binary cannot perform an effect. So this subset does NOT discharge by the emitter reaching gunbc.instrument_targets, and it does not discharge on the Filesystem work of demand_engine_program D11 either, because spawn and wait are outside that denominator. It discharges when the substrate can express a SUPERVISED CHILD PROCESS -- spawn, wait on a termination status, and read a filesystem path after that child is gone -- as modeled effects with typed refusals, at which point the fold replaces this file and the dispatch arm calls it. Until that capability exists the subset is frozen rather than shrinking, and the honest reading is that it is the newest and least dischargeable growth on this row. WHAT DOES NOT DISCHARGE IT: moving the judgment further into .dag (already done -- the verdict is gunbc.floor_memory_demand's and no threshold lives in the Rust), nor adding witnesses over the read, because neither removes a single one of the four effects.", current_boundary: "src/v1/stage0/src/target_invocation_host.rs; src/v1/stage0/src/behavioral_receipt_host.rs (native realization of the already-modeled behavioral producers, relocated from claim_executor); src/v1/stage0/src/cli_run.rs (two #[path] mod lines plus the `floor_memory_supervisor` mod line); src/v1/stage0/src/cli_run/floor_memory_supervisor.rs (the supervised cgroup read behind //gunbc/instruments:floor-memory-qualification: create nothing, spawn the measured child into the supervisor's OWN cgroup, wait, then read memory.peak and memory.events from outside the dead process. It is host Rust because every operation in it is one no .dag fold can perform -- reading /proc/self/cgroup, spawning a process, waiting on it, and reading /sys/fs/cgroup after it is gone -- and because an in-process observer cannot report the terminations that matter: a Drop guard does not run on abort, process::exit or SIGKILL, and Rust's allocation-error handler aborts, so OOM and allocation failure are exactly the cases only an outliving supervisor can report. The JUDGMENT over what it reads is NOT here: it is gunbc.floor_memory_demand qualify_floor_memory_from_readings, and this module deliberately holds no threshold, no arm and no verdict); src/v1/stage0/src/cli_run/compile_clean.rs (the raw diagnostic census carriers: CensusGateDisposition, CensusSeverity, RawDiagnosticClassObservation, CompileCleanDiagnosticCensusIdentity, CompileCleanDiagnosticCensus, CompileCleanDiagnosticCensusRefusal, compile_clean_diagnostic_class_specimen, compile_clean_diagnostic_census, compile_clean_diagnostic_policy_snapshot, census_instance_disposition, compiler_diagnostic_constructor_census, census_gate_tag, census_severity_tag, census_identity_digest, diagnostic_census_refusal_rendered); generated gunbc_cli_dispatch_generated.rs (modeled Commands variant and generic retained-host selection); src/v1/stage0/src/main.rs (retained target producer host method only); dag/gunbc/target_invocation_seed_growth.dag" diff --git a/src/v1/stage0/src/target_invocation_host.rs b/src/v1/stage0/src/target_invocation_host.rs index f6069251b5d..9d7658e57e8 100644 --- a/src/v1/stage0/src/target_invocation_host.rs +++ b/src/v1/stage0/src/target_invocation_host.rs @@ -1840,28 +1840,90 @@ pub fn classify_binary_inputs( Ok(out) } -/// The effectful half: read the running binary's dep-info and stat its inputs. Every failure is -/// `DepInfoUnreadable`, which refuses -- never a pass because the question could not be asked. -fn observe_binary_inputs() -> BinaryInputObservation { - let exe = match std::env::current_exe() { - Ok(exe) => exe, - Err(e) => { - return BinaryInputObservation::DepInfoUnreadable { - binary: "".to_string(), - reason: e.to_string(), - } +/// THE ANCHOR IS WHEN COMPILATION STARTED, NOT WHEN THE BINARY WAS LINKED. An input edited after +/// rustc read it but before the link finished is older than the binary and newer than the +/// compile, so comparing against the binary's mtime would answer `Fresh` where cargo rebuilds +/// (calm-boar-904's objection on #13007). Cargo's own comparator is the unit's fingerprint +/// dep-info, `.fingerprint/-/dep-bin-`, whose mtime it rewinds to build start. +/// +/// THE UNIT IS DERIVED, NOT SEARCHED FOR BY NAME. The uplifted executable is a hard link to +/// `deps/-`; that file is found by inode, its `` names exactly one fingerprint +/// directory, and `dep-bin-` in it is the anchor. Many fingerprint directories carry +/// `dep-bin-gunbc` (one per past build configuration), so picking by name or by newest mtime +/// would be a guess. Every break in the chain -- no hard-link twin, two twins, zero or several +/// fingerprint directories -- is an error, which refuses. +pub fn build_start_anchor(exe: &std::path::Path) -> Result { + use std::os::unix::fs::MetadataExt; + let profile_dir = exe + .parent() + .ok_or_else(|| format!("{} has no parent directory", exe.display()))?; + let name = exe + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| format!("{} has no file name", exe.display()))?; + let inode = std::fs::metadata(exe) + .map_err(|e| format!("cannot stat {}: {e}", exe.display()))? + .ino(); + let deps = profile_dir.join("deps"); + let prefix = format!("{name}-"); + let twins: Vec = std::fs::read_dir(&deps) + .map_err(|e| format!("cannot list {}: {e}", deps.display()))? + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.metadata().map(|m| m.ino() == inode).unwrap_or(false)) + .filter_map(|entry| entry.file_name().to_str().map(str::to_string)) + .filter(|file| file.starts_with(&prefix) && !file.ends_with(".d")) + .collect(); + let hash = match twins.as_slice() { + [one] => one[prefix.len()..].to_string(), + _ => { + return Err(format!( + "the binary has {} hard-link twins under {}, so the compile unit that built it is unknown", + twins.len(), + deps.display() + )) } }; + let fingerprint = profile_dir.join(".fingerprint"); + let suffix = format!("-{hash}"); + let units: Vec = std::fs::read_dir(&fingerprint) + .map_err(|e| format!("cannot list {}: {e}", fingerprint.display()))? + .filter_map(|entry| entry.ok().map(|e| e.path())) + .filter(|path| { + path.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.ends_with(&suffix)) + .unwrap_or(false) + }) + .collect(); + let unit = match units.as_slice() { + [one] => one.join(format!("dep-bin-{name}")), + _ => { + return Err(format!( + "{} fingerprint units end in {suffix}, so the build-start anchor is unknown", + units.len() + )) + } + }; + std::fs::metadata(&unit) + .and_then(|m| m.modified()) + .map_err(|e| format!("cannot read the build-start anchor {}: {e}", unit.display())) +} + +/// The effectful half: read the binary's dep-info, anchor at build start, and stat its inputs. +/// Every failure is `DepInfoUnreadable`, which refuses -- never a pass because the question could +/// not be asked. +pub fn observe_binary_inputs_of( + exe: &std::path::Path, + workspace: &std::path::Path, +) -> BinaryInputObservation { let binary = exe.display().to_string(); let dep_info_path = exe.with_extension("d"); let observed = (|| { - let built_at = std::fs::metadata(&exe) - .and_then(|m| m.modified()) - .map_err(|e| format!("cannot read the binary's mtime: {e}"))?; + let build_start = build_start_anchor(exe)?; let text = std::fs::read_to_string(&dep_info_path) .map_err(|e| format!("cannot read dep-info {}: {e}", dep_info_path.display()))?; let inputs = dep_info_inputs(&text)?; - classify_binary_inputs(&inputs, &cli_run::process_workspace_root(), built_at) + classify_binary_inputs(&inputs, workspace, build_start) })(); match observed { Ok(inputs) => BinaryInputObservation::Observed { binary, inputs }, @@ -1869,6 +1931,16 @@ fn observe_binary_inputs() -> BinaryInputObservation { } } +fn observe_binary_inputs() -> BinaryInputObservation { + match std::env::current_exe() { + Ok(exe) => observe_binary_inputs_of(&exe, &cli_run::process_workspace_root()), + Err(e) => BinaryInputObservation::DepInfoUnreadable { + binary: "".to_string(), + reason: e.to_string(), + }, + } +} + /// THE INVOCATION'S FIRST ACT: refuse a binary that no longer answers for its own inputs /// (`gunbc.target_invocation` `assess_binary_freshness`), then route. Kept beside `test_verb` /// rather than inside it so the routing tests stay a function of the operand alone. @@ -2850,6 +2922,81 @@ mod binary_freshness_tests { let _ = std::fs::remove_dir_all(&dir); } + /// THE MID-BUILD EDIT (calm-boar-904): build_start < input_mtime < binary_mtime must be + /// StaleBinary. Against the binary's mtime it read Fresh. Laid out as cargo lays it out: an + /// uplifted binary hard-linked to deps/-, and .fingerprint/-/dep-bin- + /// stamped at build start. A decoy unit with the same file name and a later stamp must not be + /// chosen, because only the hash names the unit. + #[test] + fn an_input_edited_during_the_build_is_stale() { + use std::time::{Duration, SystemTime}; + let dir = std::env::temp_dir().join(format!("anchor-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let ws = dir.join("ws"); + let release = dir.join("target/release"); + std::fs::create_dir_all(ws.join("src/v1/stage0")).unwrap(); + std::fs::create_dir_all(release.join("deps")).unwrap(); + std::fs::create_dir_all(release.join(".fingerprint/pkg-abc123")).unwrap(); + std::fs::create_dir_all(release.join(".fingerprint/pkg-decoy99")).unwrap(); + let t0 = SystemTime::now() - Duration::from_secs(100); + let stamp = |path: &std::path::Path, at: SystemTime| { + std::fs::File::options() + .write(true) + .open(path) + .unwrap() + .set_modified(at) + .unwrap(); + }; + let start = release.join(".fingerprint/pkg-abc123/dep-bin-gunbc"); + std::fs::write(&start, "").unwrap(); + stamp(&start, t0); + let decoy = release.join(".fingerprint/pkg-decoy99/dep-bin-gunbc"); + std::fs::write(&decoy, "").unwrap(); + stamp(&decoy, t0 + Duration::from_secs(80)); + let manifest = ws.join("src/v1/stage0/Cargo.toml"); + std::fs::write(&manifest, "").unwrap(); + stamp(&manifest, t0 - Duration::from_secs(10)); + let edited = ws.join("edited.rs"); + std::fs::write(&edited, "").unwrap(); + stamp(&edited, t0 + Duration::from_secs(30)); + let linked = release.join("deps/gunbc-abc123"); + std::fs::write(&linked, "bin").unwrap(); + stamp(&linked, t0 + Duration::from_secs(60)); + let exe = release.join("gunbc"); + std::fs::hard_link(&linked, &exe).unwrap(); + std::fs::write( + release.join("gunbc.d"), + format!( + "{}: {} {}\n", + exe.display(), + manifest.display(), + edited.display() + ), + ) + .unwrap(); + assert_eq!(build_start_anchor(&exe).unwrap(), t0); + match assess_binary_freshness(&observe_binary_inputs_of(&exe, &ws)) { + BinaryFreshness::Stale { input, .. } => assert_eq!(input, edited.display().to_string()), + other => panic!("a mid-build edit must be stale: {other:?}"), + } + // THE OLD ANCHOR, FOR CONTRAST: against the binary's own mtime the same tree reads fresh. + let at_link = std::fs::metadata(&exe).unwrap().modified().unwrap(); + let inputs = + dep_info_inputs(&std::fs::read_to_string(release.join("gunbc.d")).unwrap()).unwrap(); + assert!(matches!( + assess_binary_freshness(&BinaryInputObservation::Observed { + binary: "b".into(), + inputs: classify_binary_inputs(&inputs, &ws, at_link).unwrap() + }), + BinaryFreshness::Fresh { .. } + )); + // A copied binary has no hard-link twin: undecided, not fresh. + let copied = release.join("copied"); + std::fs::copy(&linked, &copied).unwrap(); + assert!(build_start_anchor(&copied).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + /// THE ROUTE: a stale verdict refuses with status 2 and never reaches the producer. #[test] fn a_stale_binary_is_refused_before_any_instrument_runs() { From 1217bc9f7bcb31a6575e371075fcdb885a53308a Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 3 Oct 2026 04:51:19 +0000 Subject: [PATCH 8/8] freshness: only NotFound is Missing; other stat errors refuse with their own cause (review 74474) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/v1/stage0/src/target_invocation_host.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/v1/stage0/src/target_invocation_host.rs b/src/v1/stage0/src/target_invocation_host.rs index 9d7658e57e8..0fa2bb4a878 100644 --- a/src/v1/stage0/src/target_invocation_host.rs +++ b/src/v1/stage0/src/target_invocation_host.rs @@ -1830,8 +1830,13 @@ pub fn classify_binary_inputs( if input.contains("/.git/") { continue; } + // Only NotFound is `Missing`; any other stat failure (permission, I/O) is a reading we + // could not take, which refuses with its own cause rather than a wrong label (review 74474). let classified = match std::fs::metadata(input).and_then(|m| m.modified()) { - Err(_) => BinaryInput::Missing(input.clone()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + BinaryInput::Missing(input.clone()) + } + Err(e) => return Err(format!("cannot stat dep-info input {input}: {e}")), Ok(modified) if modified > built_at => BinaryInput::Newer(input.clone()), Ok(_) => BinaryInput::NotNewer(input.clone()), };