From 4af419fb9a1842d91f463a1e40c9fde856d0c5c7 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Wed, 2 Sep 2026 06:14:40 +0000 Subject: [PATCH] =?UTF-8?q?Report=20what=20the=20diff=20touched=20once,=20?= =?UTF-8?q?and=20let=20each=20consumer=20apply=20its=20own=20scope=20?= =?UTF-8?q?=E2=80=94=20the=20consumed-row=20deletion=20arm=20was=20false?= =?UTF-8?q?=20on=20every=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `diff_sides` applied the parser's `.dag` scope to its own answer, so the only available statement of "what did this change touch" was already narrowed to one consumer's question. A second consumer downstream asks a different one: `roster_touched` is about `ADMISSION_ROSTER_REL_PATH`, a `.rs` file. A `.rs` path cannot survive a `.dag` filter, so `roster_touched` was false on every production run, `consumed_due` could never be true, and the consumed-row deletion obligation gunbc#9824 declared could never come due. The repair is not a second path list — that is two representations of one fact, and the next reader must know which is which, which is this defect one step later. `diff_sides` now reports the diff once, unfiltered; `in_sweep_scope` is applied at the point of use, per side, for the two baseline-reconstruction reads that actually ask the parser's question. The verdict moves from the executor into the wall as `namespace_wave_admission::wave_admission_refusal`. It was interleaved with printing inside a private function of a binary, so no test could reach the arm CI depends on. The executor keeps the receipts and asks the wall for the verdict. The nine DCH-1 rows are deleted by their own dissolve-on trigger (#9985 merging, which has fired). That is not housekeeping riding along: this commit makes `roster_touched` reachable and touches the roster file, so under the arm it enables those rows would come due and refuse it. The change is its own first positive control. Three paragraphs of the roster's ledger referred to RLM-2b deictically — rows "above", deltas "below", a cohort that "stays" — and those referents are deleted, so the sentences had become false in the present tense inside the ledger whose subject is rows outliving their truth. Tense and position words corrected, no claim altered, correction recorded. Evidence (tests/namespace_wave_admission.rs, 50/50 green; three mutations each red exactly the intended test and nothing else): - producer RED `the_roster_path_reaches_the_side_the_roster_touched_predicate_reads` — reds under the restored filter. - decider RED `a_consumed_row_comes_due_on_the_roster_touching_run_and_on_no_other` — reds both when `consumed_due` drops `roster_touched` (refuses bystanders) and when the arm is forced dead. The report is produced by `adjudicate` over authored trees, not hand-built. - positive control `touching_the_roster_with_no_consumed_row_is_admitted`. - `a_rename_contributes_its_source_to_the_base_side_and_its_destination_to_the_head_side` redded on the scope move, as it should: its assertion moved to the parser's question and now asserts both halves. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Et86vcNb8YdMQSSPc3pbhR --- src/v1/stage0/src/bin/claim_executor.rs | 38 +-- src/v1/stage0/src/namespace_wave_admission.rs | 236 +++++++++--------- .../stage0/tests/namespace_wave_admission.rs | 143 ++++++++++- 3 files changed, 268 insertions(+), 149 deletions(-) diff --git a/src/v1/stage0/src/bin/claim_executor.rs b/src/v1/stage0/src/bin/claim_executor.rs index c57f7615519..8a55f444064 100644 --- a/src/v1/stage0/src/bin/claim_executor.rs +++ b/src/v1/stage0/src/bin/claim_executor.rs @@ -1383,13 +1383,13 @@ fn report_wave_admission_outcome( } nwa::WaveAdmissionOutcome::NotEvaluated { reason } => { eprintln!("required-ci: namespace-wave-admission NotEvaluated — {reason}"); - Some("namespace-wave-admission (NotEvaluated)".to_string()) + nwa::wave_admission_refusal(outcome) } nwa::WaveAdmissionOutcome::Adjudicated { base, head, report, - roster_touched, + roster_touched: _, } => { let p = &report.population; eprintln!( @@ -1417,38 +1417,18 @@ fn report_wave_admission_outcome( for consumed in &report.consumed_admissions { eprintln!("required-ci: namespace-wave-admission CONSUMED ADMISSION {consumed}"); } - let unadjudicated = nwa::report_unadjudicated(report); - // AN UNMATCHED ADMISSION REFUSES. A row provable against neither side is a - // permission standing over nothing — author error, and leaving it means the roster - // stops being a fact about the corpus. - // - // A CONSUMED ADMISSION REFUSES ONLY THE ROSTER'S OWN PATH. Its relocation already - // holds at the base (a positive proof, printed above), so for an unrelated run it is - // an inert typed receipt; billing its cleanup to that run was the externalized - // degradation eight dissolution PRs paid for. The deletion is due — and enforced — - // on the first change that touches the roster file itself, which every future - // relocation PR does by construction. The window is honest: consumed rows persist, - // visible in every run's receipts, until the roster's next touch. - let consumed_due = *roster_touched && !report.consumed_admissions.is_empty(); - if unadjudicated.is_empty() && report.stale_admissions.is_empty() && !consumed_due { + // THE VERDICT IS THE WALL'S, NOT THE PRINTER'S. This function owns the receipts + // because it owns a stderr; `wave_admission_refusal` owns whether the run refuses, + // so the arm that decides it can be exercised by a test on the path CI runs rather + // than only from inside this binary. + let refusal = nwa::wave_admission_refusal(outcome); + if refusal.is_none() { eprintln!( "required-ci: namespace-wave-admission ADMITTED — every delta is \ auto-admitted or named by a transition admission" ); - return None; } - Some(format!( - "namespace-wave-admission ({} unadjudicated delta(s), {} stale admission(s), {} \ - consumed admission(s){})", - unadjudicated.len(), - report.stale_admissions.len(), - report.consumed_admissions.len(), - if consumed_due { - " due for deletion on this roster-touching change" - } else { - "" - } - )) + refusal } } } diff --git a/src/v1/stage0/src/namespace_wave_admission.rs b/src/v1/stage0/src/namespace_wave_admission.rs index 72212394adc..217f84cabe6 100644 --- a/src/v1/stage0/src/namespace_wave_admission.rs +++ b/src/v1/stage0/src/namespace_wave_admission.rs @@ -516,12 +516,12 @@ pub struct TransitionAdmission { /// judging, and a transport module cannot be the authority for a member of an identity the plan /// module mints. The constant therefore moves to the module that owns the digest -- same spelling, /// same value, one declaring module, no second declaration left behind and no requalification. The -/// two deltas below are the CLI's own call sites now resolving to the new declarer. This paragraph +/// two rows it added were the CLI's own call sites resolving to the new declarer. That paragraph /// previously reported them as `closure blast radius: 0 module(s)`, which gunbc#9908 has since made /// a wrong sentence rather than a stale one: closure is a pure function of MEMBERSHIP, so a binding /// row is not asked the question and now carries `None` and renders no clause at all. Quoting a -/// measured zero for it would be the exact conflation that change removed. They dissolve when this -/// PR merges, by the same trigger as every shrink above. +/// measured zero for it would be the exact conflation that change removed. Their declared trigger +/// was that PR merging; the fourteenth entry records it firing and the rows are gone. /// THIRTEENTH DISSOLUTION (2026-09-01). The seventeen XL-0T rows above were removed by THEIR OWN /// dissolve-on trigger, which the paragraph declaring them states as "#9907 merging". #9907 merged /// to main at 14:02:39; the first merge of main into this branch that carried them was made at @@ -538,7 +538,7 @@ pub struct TransitionAdmission { /// cohorts, MINUS every cohort whose trigger has fired as of the base being merged, asked of each /// side independently. /// FOURTEENTH DISSOLUTION (2026-09-02), AND IT IS THE THIRTEENTH'S OWN LESSON APPLIED TO THE ROWS -/// THAT TAUGHT IT. RLM-2b's two rows above declare their trigger as "#9832 merging". #9832 IS the +/// THAT TAUGHT IT. RLM-2b's two rows declared their trigger as "#9832 merging". #9832 IS the /// base this change merges -- main's head is that merge -- so their lifetime ended at the moment /// this merge began. Base and head both carry the constant's relocation, no run can produce those /// deltas, and both rows would report stale, refusing every unrelated PR exactly as the seventeen @@ -548,8 +548,17 @@ pub struct TransitionAdmission { /// DISSOLUTION RULES, and asking the trigger question of the kept rows only is how the previous /// cohort outlived itself by ten minutes. Keeping RLM-2b's rows through this merge because they /// arrived from main would be that identical mistake one iteration later, with the roles swapped. -/// The recipe was run against BOTH sides: this change's own cohort has an unfired trigger and -/// stays; the imported cohort's has fired and goes. +/// The recipe was run against BOTH sides: that change's own cohort had an unfired trigger and +/// stayed; the imported cohort's had fired and went. +/// +/// A TENSE CORRECTION, MADE BY THE FIFTEENTH DISSOLUTION AND RECORDED RATHER THAN SILENTLY APPLIED. +/// The three paragraphs above were written from inside the commits that made them, and referred to +/// their cohorts DEICTICALLY -- rows "above", deltas "below", a cohort that "stays". Every one of +/// those referents has since been deleted, so sentences that were true when authored became prose +/// asserting a present-tense fact that is false, inside the very ledger whose subject is rows +/// outliving their truth. Only the tense and the position words were changed; no claim was altered. +/// This is DESIGN section 3's standing rule reaching prose: cite the cohort by NAME, never by where +/// it sits, because a ledger's positions are exactly what its own dissolution rule destroys. /// FIFTEENTH TRANSITION (2026-09-02, DCH-1, gunbc#9985). The messages/tool-use wire shape moved /// WHOLE from `extdeps.llm.anthropic` to `extdeps.llm.anthropic_messages_api`, the specification /// module more than one implementation cites. No declaration was renamed and no name was minted: @@ -567,107 +576,23 @@ pub struct TransitionAdmission { /// DISSOLVE-ON: #9985 merging. Base and head then both name the specification module, no run can /// produce these deltas, all nine report stale, and they are removed by that trigger -- a stale row /// here refuses every unrelated PR in the repository. -pub const NAMESPACE_TRANSITION_ADMISSIONS: &[TransitionAdmission] = &[ - TransitionAdmission { - label: - "DCH-1 messages wire shape hoisted to its specification module 2026-09-02 (gunbc#9985)", - subject: AdmissionSubject::Binding { - module: "extdeps.llm.anthropic_rest", - in_declaration: "llm.Anthropic", - spelling: "AnthropicChatMessage", - target: "extdeps.llm.anthropic_messages_api", - }, - disposition: NamespaceDeltaDisposition::TargetChanged, - }, - TransitionAdmission { - label: - "DCH-1 messages wire shape hoisted to its specification module 2026-09-02 (gunbc#9985)", - subject: AdmissionSubject::Binding { - module: "extdeps.llm.anthropic_rest", - in_declaration: "llm.Anthropic", - spelling: "AnthropicMessages200Body", - target: "extdeps.llm.anthropic_messages_api", - }, - disposition: NamespaceDeltaDisposition::TargetChanged, - }, - TransitionAdmission { - label: - "DCH-1 messages wire shape hoisted to its specification module 2026-09-02 (gunbc#9985)", - subject: AdmissionSubject::Binding { - module: "extdeps.llm.anthropic_rest", - in_declaration: "llm.Anthropic", - spelling: "AnthropicStopReason", - target: "extdeps.llm.anthropic_messages_api", - }, - disposition: NamespaceDeltaDisposition::TargetChanged, - }, - TransitionAdmission { - label: - "DCH-1 messages wire shape hoisted to its specification module 2026-09-02 (gunbc#9985)", - subject: AdmissionSubject::Binding { - module: "extdeps.llm.anthropic_rest", - in_declaration: "llm.Anthropic", - spelling: "current_api_version", - target: "extdeps.llm.anthropic_messages_api", - }, - disposition: NamespaceDeltaDisposition::TargetChanged, - }, - TransitionAdmission { - label: - "DCH-1 messages wire shape hoisted to its specification module 2026-09-02 (gunbc#9985)", - subject: AdmissionSubject::Binding { - module: "test.claim.extdeps_llm_type_grounding_witness", - in_declaration: "w_anthropic_messages_object_type", - spelling: "Assistant", - target: "extdeps.llm.anthropic_messages_api", - }, - disposition: NamespaceDeltaDisposition::TargetChanged, - }, - TransitionAdmission { - label: - "DCH-1 messages wire shape hoisted to its specification module 2026-09-02 (gunbc#9985)", - subject: AdmissionSubject::Binding { - module: "test.claim.extdeps_llm_type_grounding_witness", - in_declaration: "w_anthropic_messages_object_type", - spelling: "Standard", - target: "extdeps.llm.anthropic_messages_api", - }, - disposition: NamespaceDeltaDisposition::TargetChanged, - }, - TransitionAdmission { - label: - "DCH-1 messages wire shape hoisted to its specification module 2026-09-02 (gunbc#9985)", - subject: AdmissionSubject::Binding { - module: "v2.test.lens_disposition_redundancy.disposition_redundancy_test", - in_declaration: "redundancy_region1_anthropic_green_against_nonmatching_present", - spelling: "structural_coverage_gap_anthropic_tool_result_nested_block_wire_payloads", - target: "extdeps.llm.anthropic_messages_api", - }, - disposition: NamespaceDeltaDisposition::TargetChanged, - }, - TransitionAdmission { - label: - "DCH-1 messages wire shape hoisted to its specification module 2026-09-02 (gunbc#9985)", - subject: AdmissionSubject::Binding { - module: "v2.test.lens_disposition_redundancy.disposition_redundancy_test", - in_declaration: "redundancy_region1_anthropic_marks_nonempty", - spelling: "structural_coverage_gap_anthropic_tool_result_nested_block_wire_payloads", - target: "extdeps.llm.anthropic_messages_api", - }, - disposition: NamespaceDeltaDisposition::TargetChanged, - }, - TransitionAdmission { - label: - "DCH-1 messages wire shape hoisted to its specification module 2026-09-02 (gunbc#9985)", - subject: AdmissionSubject::Binding { - module: "v2.test.lens_disposition_redundancy.disposition_redundancy_test", - in_declaration: "redundancy_region1_fires_when_a_real_mark_successor_present", - spelling: "structural_coverage_gap_anthropic_tool_result_nested_block_wire_payloads", - target: "extdeps.llm.anthropic_messages_api", - }, - disposition: NamespaceDeltaDisposition::TargetChanged, - }, -]; +/// FOURTEENTH DISSOLUTION, AND THE FIRST ONE A MECHANISM CAN SEE (2026-09-02). The nine DCH-1 +/// rows authored by gunbc#9985 are removed by their own dissolve-on trigger, which is that pull +/// request merging: c2cd45dcff9 IS that merge, so `extdeps.llm.anthropic_rest` already imports the +/// four hoisted spellings from `extdeps.llm.anthropic_messages_api` on the base of every run, and +/// `admission_consumed_at_base` proves the relocation the rows admit. Like the RLM-2b pair before +/// them, they were BORN CONSUMED — authored in the same commit that merged their subject, so no +/// run after that commit could ever match them. +/// +/// THIS DELETION IS NOT HOUSEKEEPING ATTACHED TO AN UNRELATED CHANGE; IT IS THIS CHANGE'S OWN +/// FIRST POSITIVE CONTROL. The commit removing them is also the commit that makes +/// `roster_touched` reachable, and it touches this file, so under the arm it enables these nine +/// rows would come due and refuse it. A change that turns a wall on and leaves standing exactly +/// the population that wall refuses would be reporting a green it did not earn. +/// +/// EMPTY IS THE RESTING STATE and empty is not permissive: with no rows, a run with no delta +/// passes and a run with a real delta refuses it as UNADJUDICATED. +pub const NAMESPACE_TRANSITION_ADMISSIONS: &[TransitionAdmission] = &[]; /// The denominators a green must name (DESIGN §5): a run that cannot say what it covered is an /// instrument failure wearing coverage's clothes. @@ -1399,6 +1324,59 @@ pub enum WaveAdmissionOutcome { /// deletion obligation. pub const ADMISSION_ROSTER_REL_PATH: &str = "src/v1/stage0/src/namespace_wave_admission.rs"; +/// Whether one adjudicated run REFUSES, and the sentence naming why — the wall's verdict, held by +/// the wall. +/// +/// IT LIVES HERE SO ITS RED IS AUTHORABLE WHERE THE VERDICT IS ACTUALLY REACHED. The decision was +/// interleaved with the executor's printing, in a private function of a binary, so nothing outside +/// that binary could construct the refusal and no test could discriminate the roster-touched arm +/// on the path CI runs. DESIGN §4b puts that squarely: a wall whose RED cannot be authored on the +/// acceptance path is a decoration, and the missing harness is the trigger rather than a ceiling. +/// The executor keeps the receipts — it is the thing with a stderr — and asks this for the verdict, +/// so "does this run refuse" has one authority instead of one authority and one printer. +/// +/// AN UNMATCHED ADMISSION REFUSES. A row provable against neither side is a permission standing +/// over nothing — author error, and leaving it means the roster stops being a fact about the +/// corpus. +/// +/// A CONSUMED ADMISSION REFUSES ONLY THE ROSTER'S OWN PATH. Its relocation already holds at the +/// base (a positive proof), so for an unrelated run it is an inert typed receipt; billing its +/// cleanup to that run was the externalized degradation eight dissolution PRs paid for. The +/// deletion is due — and enforced — on the first change that touches the roster file itself, +/// which every future relocation PR does by construction. +pub fn wave_admission_refusal(outcome: &WaveAdmissionOutcome) -> Option { + match outcome { + WaveAdmissionOutcome::NoSubject { head: _ } => None, + WaveAdmissionOutcome::NotEvaluated { reason: _ } => { + Some("namespace-wave-admission (NotEvaluated)".to_string()) + } + WaveAdmissionOutcome::Adjudicated { + base: _, + head: _, + report, + roster_touched, + } => { + let unadjudicated = report_unadjudicated(report); + let consumed_due = *roster_touched && !report.consumed_admissions.is_empty(); + if unadjudicated.is_empty() && report.stale_admissions.is_empty() && !consumed_due { + return None; + } + Some(format!( + "namespace-wave-admission ({} unadjudicated delta(s), {} stale admission(s), {} \ + consumed admission(s){})", + unadjudicated.len(), + report.stale_admissions.len(), + report.consumed_admissions.len(), + if consumed_due { + " due for deletion on this roster-touching change" + } else { + "" + } + )) + } + } +} + /// Run git in the workspace and return stdout with TRAILING whitespace removed, or a refusal /// naming the command. /// @@ -1434,7 +1412,18 @@ pub fn git_stdout(workspace: &Path, args: &[&str]) -> Result { /// `run_dag_parse_sweep`'s: build output, and the parser's deliberately malformed fixtures. A /// base file the head sweep would not read must not enter the base index, or the two sides are /// measured by different instruments. -fn in_sweep_scope(rel: &str) -> bool { +/// +/// THIS ANSWERS THE PARSER'S QUESTION AND NOTHING ELSE, AND IT IS APPLIED AT THE POINT OF USE. +/// It used to be applied inside `diff_sides`, so the only available answer to "what did this +/// change touch" was already narrowed to `.dag` — and a second consumer asking a DIFFERENT +/// question read that narrowed list as if it were the diff. `roster_touched` asks about a `.rs` +/// path, a `.rs` path cannot survive a `.dag` filter, and the arm it fed was therefore false on +/// every production run: an upstream filter written for one question silently deciding another, +/// with nothing joining them (`gunbc.recurring_failure_mode` `incidental_denominator_as_wall`). +/// The repair is not a second path list — that would be two representations of one fact, the same +/// class one step later. `diff_sides` reports what the diff touched, once; every consumer applies +/// the scope its own question needs, here. +pub fn in_sweep_scope(rel: &str) -> bool { rel.ends_with(".dag") && DAG_PARSE_SWEEP_ROOTS .iter() @@ -1455,8 +1444,14 @@ fn in_sweep_scope(rel: &str) -> bool { /// /// So the diff is read rename-aware, `--name-status -z -M`, each entry contributing to the sides /// SEPARATELY: a rename gives its destination to head and its source to base, an addition only a -/// head path, a deletion only a base path, a modification the same path to both. Scope is applied -/// per side, because a rename may cross the sweep boundary either way. +/// head path, a deletion only a base path, a modification the same path to both. +/// +/// THE SIDES ARE UNFILTERED, WHICH IS WHAT MAKES THIS ONE AUTHORITY FOR WHAT THE DIFF TOUCHED. +/// Scope is not applied here: it belongs to the QUESTION being asked, not to the diff, and two +/// consumers downstream ask different ones — the base-index reconstruction wants the parser's +/// `in_sweep_scope`, and `roster_touched` wants a `.rs` path that predicate can never admit. +/// A rename may still cross a scope boundary either way, so each consumer applies its own scope +/// PER SIDE at its call site. pub fn diff_sides(name_status_z: &str) -> (Vec, Vec) { let mut head_touched = Vec::new(); let mut base_side = Vec::new(); @@ -1478,8 +1473,6 @@ pub fn diff_sides(name_status_z: &str) -> (Vec, Vec) { head_touched.push(first.to_string()); } } - head_touched.retain(|p| in_sweep_scope(p)); - base_side.retain(|p| in_sweep_scope(p)); (head_touched, base_side) } @@ -1562,9 +1555,18 @@ pub fn run_required_wave_admission( )?; let (head_touched, base_side) = diff_sides(&name_status); + // THE PARSER'S SCOPE IS APPLIED HERE, WHERE THE PARSER'S QUESTION IS ASKED, AND NOWHERE ELSE. + // `head_touched` and `base_side` are what the diff touched; these two are what the baseline + // reconstruction may read. Filtering per side rather than once is not redundancy: a rename may + // cross the sweep boundary in either direction, which is why `diff_sides` splits the sides in + // the first place. Everything below that asks a DIFFERENT question — `roster_touched` — reads + // the unfiltered list, because its subject is a `.rs` path this predicate cannot admit. + let head_parsed: Vec<&String> = head_touched.iter().filter(|p| in_sweep_scope(p)).collect(); + let base_parsed: Vec<&String> = base_side.iter().filter(|p| in_sweep_scope(p)).collect(); + let mut base_index = DeclarationIndex::default(); for record in index_records(head_index) { - if !head_touched.iter().any(|c| c == &record.rel_path) { + if !head_parsed.iter().any(|c| *c == &record.rel_path) { crate::cli_run::declaration_index::index_insert(&mut base_index, record.clone()); } } @@ -1579,8 +1581,8 @@ pub fn run_required_wave_admission( let base_paths = git_stdout(&workspace, &["ls-tree", "-r", "--name-only", &base])?; let base_paths: std::collections::BTreeSet = base_paths.lines().map(|l| l.trim().to_string()).collect(); - for rel in &base_side { - if !base_paths.contains(rel) { + for rel in &base_parsed { + if !base_paths.contains(*rel) { // Genuinely added by this change: no base side to read, established by the listing. continue; } @@ -1606,6 +1608,10 @@ pub fn run_required_wave_admission( } } + // READ FROM THE UNFILTERED HEAD SIDE. This is the whole subject of the repair: the roster is a + // `.rs` file, so while `diff_sides` narrowed its answer to the parser's `.dag` question this + // predicate was false on every production run and the consumed-row deletion obligation it + // gates could never come due. let roster_touched = head_touched.iter().any(|p| p == ADMISSION_ROSTER_REL_PATH); let report = adjudicate(&base_index, head_index, NAMESPACE_TRANSITION_ADMISSIONS); Ok(WaveAdmissionOutcome::Adjudicated { diff --git a/src/v1/stage0/tests/namespace_wave_admission.rs b/src/v1/stage0/tests/namespace_wave_admission.rs index a31b3bbd737..f4bb4258019 100644 --- a/src/v1/stage0/tests/namespace_wave_admission.rs +++ b/src/v1/stage0/tests/namespace_wave_admission.rs @@ -14,9 +14,9 @@ use std::path::{Path, PathBuf}; use v1_compiler::cli_run::namespace_wave_admission::{ - adjudicate, base_records, diff_sides, disposition_label, report_unadjudicated, - AdmissionSubject, DeltaSubject, NamespaceDeltaDisposition, TransitionAdmission, - WaveAdmissionReport, + adjudicate, base_records, diff_sides, disposition_label, in_sweep_scope, report_unadjudicated, + wave_admission_refusal, AdmissionSubject, DeltaSubject, NamespaceDeltaDisposition, + TransitionAdmission, WaveAdmissionOutcome, WaveAdmissionReport, ADMISSION_ROSTER_REL_PATH, }; use v1_compiler::cli_run::run_dag_parse_sweep; @@ -841,10 +841,143 @@ fn a_rename_contributes_its_source_to_the_base_side_and_its_destination_to_the_h "an ordinary modification is the one status whose two sides ARE the same path" ); + // THIS ASSERTION MOVED RATHER THAN DIED, AND WHERE IT MOVED TO IS THE POINT. It used to read + // "a non-`.dag` path enters neither side", because `diff_sides` applied the parser's scope + // itself -- which is exactly what made `roster_touched` unreachable, since the roster is a + // `.rs` file. `diff_sides` now reports what the diff touched, unfiltered, and the parser's + // question is asked by `in_sweep_scope` at the point of use. Both halves are asserted here: + // the diff carries the paths, and the parser's scope still refuses them. let (head, base) = diff_sides("M\0src/v1/stage0/src/lib.rs\0R100\0README.md\0LICENSE\0"); + assert_eq!( + head, + vec![ + "src/v1/stage0/src/lib.rs".to_string(), + "LICENSE".to_string() + ], + "the head side reports what the diff touched, unfiltered -- the modification contributes \ + to both sides and the rename contributes its destination" + ); + assert_eq!( + base, + vec![ + "src/v1/stage0/src/lib.rs".to_string(), + "README.md".to_string() + ], + "the base side reports what the diff touched, unfiltered, still split rename-aware" + ); assert!( - head.is_empty() && base.is_empty(), - "scope is applied per side: a non-`.dag` path enters neither" + !in_sweep_scope("src/v1/stage0/src/lib.rs") + && !in_sweep_scope("README.md") + && !in_sweep_scope("LICENSE"), + "the parser's scope still admits none of them: the filter moved, it did not weaken" + ); +} + +/// THE PRODUCER RED FOR THE DEAD ARM. `roster_touched` asks whether this run's diff touches the +/// admission roster's own source file, and the roster is a `.rs` file. While `diff_sides` filtered +/// its own answer to the parser's `.dag` question, this path could not appear in the list the +/// predicate reads, so the predicate was FALSE ON EVERY PRODUCTION RUN and the consumed-row +/// deletion obligation it gates could never come due. +/// +/// This is the discriminating RED: against the previous implementation the returned head side is +/// empty and this assertion fails. It is authored on the production path -- `diff_sides` is the +/// one producer `run_required_wave_admission` calls -- and not over a hand-built value. +#[test] +fn the_roster_path_reaches_the_side_the_roster_touched_predicate_reads() { + let (head, base) = diff_sides(&format!("M\0{ADMISSION_ROSTER_REL_PATH}\0")); + assert!( + head.iter().any(|p| p == ADMISSION_ROSTER_REL_PATH), + "the head side must carry the roster path, or `roster_touched` is false by construction: \ + head={head:?}" + ); + assert!( + base.iter().any(|p| p == ADMISSION_ROSTER_REL_PATH), + "an ordinary modification reports the same path on both sides: base={base:?}" + ); + assert!( + !in_sweep_scope(ADMISSION_ROSTER_REL_PATH), + "and the parser must still not read it: this is the second question, asked separately" + ); +} + +/// The two sides of one scenario that really does leave a consumed row standing, wrapped as the +/// outcome the executor judges. Only `roster_touched` and the two revision strings are supplied; +/// the report itself is produced by `adjudicate` over authored trees, so what is being judged is a +/// real consumed admission rather than a shape. +fn adjudicated_with_a_consumed_row(name: &str, roster_touched: bool) -> WaveAdmissionOutcome { + let sides = [ + ("home.dag", HOME), + ("other.dag", OTHER), + ("consumer.dag", CONSUMER_IMPORTS_OTHER), + ]; + let report = compare_with(name, &sides, &sides, AUTHORED_LIKE_PRODUCTION); + assert!( + !report.consumed_admissions.is_empty() && report.stale_admissions.is_empty(), + "fixture precondition: exactly a consumed row and no refusal -- consumed={:?} stale={:?}", + report.consumed_admissions, + report.stale_admissions + ); + WaveAdmissionOutcome::Adjudicated { + base: "base".to_string(), + head: "head".to_string(), + report, + roster_touched, + } +} + +/// THE DECIDER RED, AND IT DISCRIMINATES ON THE ARM RATHER THAN ON THE ROW. One consumed row, two +/// runs: the run that touches the roster must refuse and name the obligation, and the run that does +/// not must be admitted. An implementation that refuses on the presence of a consumed row alone +/// reds the second assertion; one that never refuses -- which is what the `.dag` filter produced -- +/// reds the first. +/// +/// The verdict is asked of `wave_admission_refusal`, which is the function the executor now calls, +/// so this runs on the acceptance path rather than restating it. +#[test] +fn a_consumed_row_comes_due_on_the_roster_touching_run_and_on_no_other() { + let due = wave_admission_refusal(&adjudicated_with_a_consumed_row("consumed_due", true)); + let refusal = due.expect( + "a consumed row standing on a change that touches the roster file must refuse: this is \ + the whole deletion obligation, and it was unreachable while `diff_sides` filtered the \ + roster path out of the side `roster_touched` reads", + ); + assert!( + refusal.contains("due for deletion on this roster-touching change"), + "the refusal must name the obligation rather than a count alone: {refusal}" + ); + + let bystander = + wave_admission_refusal(&adjudicated_with_a_consumed_row("consumed_inert", false)); + assert_eq!( + bystander, None, + "and a run that does not touch the roster must still be ADMITTED -- billing the cleanup \ + to bystanders is the externalized degradation gunbc#9824 removed and this arm must not \ + reintroduce it" + ); +} + +/// The positive control for the surrounding machinery: with no admissions at all, a run that +/// touches the roster file is admitted. Without this, the test above could pass because the +/// roster-touched arm refuses unconditionally. +#[test] +fn touching_the_roster_with_no_consumed_row_is_admitted() { + let sides = [ + ("home.dag", HOME), + ("other.dag", OTHER), + ("consumer.dag", CONSUMER_IMPORTS_OTHER), + ]; + let report = compare_with("roster_touched_empty_roster", &sides, &sides, &[]); + let outcome = WaveAdmissionOutcome::Adjudicated { + base: "base".to_string(), + head: "head".to_string(), + report, + roster_touched: true, + }; + assert_eq!( + wave_admission_refusal(&outcome), + None, + "an empty roster is not permissive, but it is also not a refusal: touching the file with \ + nothing due must pass" ); }