From ef397bf314de43fd781ee40533140b2c24a4f248 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Wed, 7 Oct 2026 04:21:19 +0700 Subject: [PATCH 1/6] feat(verified): t27c run-record reads the receipts and judges one run (Closes #7072) R2-4 tool half, epic #6655. The rule half (specs/verified/run_record.t27, PR #7061) landed; this is the reader that applies it. t27c run-record reads every .trinity/receipts/-*.json whose spec names the given spec plus the spec's seals in .trinity/seals, collects the four facts -- count, every-receipt-complete by receipt.t27's six-field rule (unknown verdict word = absent), verdict words agreeing, every receipt's toolchain == its cited seal's built_by verbatim (R2-2; a receipt's own seal is the one whose gen_hash_verilog equals its seal_hash) -- and answers run_first_missing, run_complete, and verdict.t27's consumption point (incomplete run => INVALID_NO_RUN before any chain is read). Exit 0 = citable run, 1 = not, 2 = REFUSED (spec does not exist). Twelve fixture tests pin each exit path to run_record.t27's constants, including: unknown verdict word is INCOMPLETE (2), never WORDS_DISAGREE (3); a seal without built_by (every seal minted before #7076) matches no producer; a receipt citing a seal the spec does not hold is a producer mismatch; no receipts at all is TOO_FEW over zero, not a usage error; agreeing FAILs are one complete run (failure_loop owns the rest). Refs #6655, #7058, #7041, #7076. Co-Authored-By: Claude Opus 5.5 (1M context) --- bootstrap/src/main.rs | 14 ++ bootstrap/src/service.rs | 196 +++++++++++++++++++ bootstrap/tests/run_record_reader.rs | 270 +++++++++++++++++++++++++++ tools/policy/foreign-exceptions.txt | 9 + 4 files changed, 489 insertions(+) create mode 100644 bootstrap/tests/run_record_reader.rs diff --git a/bootstrap/src/main.rs b/bootstrap/src/main.rs index 0dd8d1e66d..e0c8e614b2 100644 --- a/bootstrap/src/main.rs +++ b/bootstrap/src/main.rs @@ -289,6 +289,14 @@ enum Commands { #[arg(long, value_delimiter = ',', default_value = "1,7,42")] seeds: Vec, }, + /// THE SERVICE (R2-4): read the receipts a spec's hardware runs wrote + /// (.trinity/receipts) and judge, by specs/verified/run_record.t27, whether + /// they are ONE verified run a verdict record may cite as its run + /// reference. Collects the facts, never repairs the record. + RunRecord { + /// The .t27 spec whose receipts should be read (as t27c silicon recorded them) + input: String, + }, /// THE SERVICE: refuse to start place-and-route on a toolchain that cannot /// produce a valid bitstream. Checks the chipdb, the ORDINAL constids @@ -11719,6 +11727,9 @@ async fn main() -> anyhow::Result<()> { &std::env::current_dir()?, &input, top, busdev_num, wrong_part, seeds, )? } + Commands::RunRecord { input } => { + service::run_run_record(&std::env::current_dir()?, &input)? + } Commands::Preflight { nextpnr_src } => { service::run_preflight(&std::env::current_dir()?, nextpnr_src)? } @@ -12140,6 +12151,9 @@ fn main() -> anyhow::Result<()> { &std::env::current_dir()?, &input, top, busdev_num, wrong_part, seeds, )? } + Commands::RunRecord { input } => { + service::run_run_record(&std::env::current_dir()?, &input)? + } Commands::Preflight { nextpnr_src } => { service::run_preflight(&std::env::current_dir()?, nextpnr_src)? } diff --git a/bootstrap/src/service.rs b/bootstrap/src/service.rs index 5d8801126f..11749902ee 100644 --- a/bootstrap/src/service.rs +++ b/bootstrap/src/service.rs @@ -3369,6 +3369,202 @@ pub fn run_verdict( std::process::exit(1); } +/// R2-4, the tool half (issue #7072): the run-record reader. `t27c silicon` +/// writes one JSON receipt per hardware run into `.trinity/receipts/` +/// (contract specs/verified/receipt.t27, #6943/#7044); this reads every +/// receipt whose `spec` names the given spec, reads the spec's seals in +/// `.trinity/seals/`, and collects the four facts specs/verified/ +/// run_record.t27 (#7061) judges: receipt count, every receipt complete by +/// receipt.t27's six-field rule (an unknown verdict word counts as absent), +/// all verdict words agreeing, and every receipt naming its own seal's +/// producer (R2-2: `toolchain` == the cited seal's `built_by`, verbatim -- a +/// seal without `built_by`, i.e. every seal minted before #7076, matches +/// nothing). A receipt's own seal is the one whose `gen_hash_verilog` equals +/// the receipt's `seal_hash`: the image the device ran, not merely a seal of +/// the same spec. +/// +/// The decision order and the codes are run_record.t27's, mirrored here as +/// I/O plumbing; the fixture tests pin each exit path to the spec's constants. +/// Exit 0: the receipts are one verified run a verdict record may cite. +/// Exit 1: they are not; the first-missing code names why. Exit 2: REFUSED +/// (the spec named does not exist -- judging receipts against a typo would +/// answer TOO_FEW over a population of files nobody meant). +pub fn run_run_record(repo_root: &Path, spec: &str) -> anyhow::Result<()> { + use std::path::Component; + + if !repo_root.join(spec).exists() { + println!("REFUSED -- run-record: no spec at {spec}. The receipts are judged"); + println!("against the spec that ran them; a spec that does not exist has none."); + std::process::exit(2); + } + + // `t27c silicon` may be run from the repo root or a subdir, so a receipt's + // `spec` and the reader's argument can name the same file by different + // relative paths. The same tail-match discipline the seal lookup uses: the + // argument's components must be a suffix of the record's. + let tail_match = |recorded: &str, given: &str| -> bool { + let r: Vec = std::path::Path::new(recorded).components().collect(); + let g: Vec = std::path::Path::new(given).components().collect(); + g.len() <= r.len() && r[r.len() - g.len()..] == g[..] + }; + + let read_dir_json = |dir: &std::path::Path| -> Vec<(String, serde_json::Value)> { + let mut out = Vec::new(); + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return out, + }; + for e in entries.filter_map(|e| e.ok()) { + let p = e.path(); + if p.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + let name = p.file_name().unwrap_or_default().to_string_lossy().to_string(); + if let Ok(t) = std::fs::read_to_string(&p) { + if let Ok(v) = serde_json::from_str(&t) { + out.push((name, v)); + } + } + } + out.sort(); + out + }; + + let get_str = |v: &serde_json::Value, k: &str| -> Option { + v.get(k).and_then(|x| x.as_str()).map(|s| s.to_string()).filter(|s| !s.is_empty()) + }; + + // The spec's seals: (gen_hash_verilog, built_by) for every seal whose + // spec_path names this spec. A reseal adds a row; both stay, and each + // receipt is judged against the one it cites. + let seals: Vec<(String, Option)> = read_dir_json(&repo_root.join(".trinity/seals")) + .into_iter() + .filter(|(_, v)| { + get_str(v, "spec_path").map(|p| tail_match(&p, spec)).unwrap_or(false) + }) + .map(|(_, v)| { + ( + get_str(&v, "gen_hash_verilog").unwrap_or_default(), + get_str(&v, "built_by"), + ) + }) + .collect(); + + struct Row { + file: String, + missing: u8, + word: Option, + producer_ok: bool, + producer_note: String, + } + + let mut rows: Vec = Vec::new(); + for (file, v) in read_dir_json(&repo_root.join(".trinity/receipts")) { + if !get_str(&v, "spec").map(|p| tail_match(&p, spec)).unwrap_or(false) { + continue; + } + // receipt.t27's six fields, in its order, each absent when the record + // does not carry it. The verdict word is present only when it is a + // word verdict.t27 defines (PASS=0, FAIL=1); anything else on that + // field is absent, whatever it says. + let word_raw = v.get("verdict_word").and_then(|x| x.as_u64()); + let word = word_raw.filter(|w| *w <= 1).map(|w| w as u8); + let has = [ + get_str(&v, "device_record").is_some(), + get_str(&v, "full_idcode").is_some(), + word.is_some(), + get_str(&v, "seal_hash").is_some(), + v.get("seeds").and_then(|x| x.as_array()).map(|a| !a.is_empty()).unwrap_or(false), + get_str(&v, "toolchain").is_some(), + ]; + let mut missing: u8 = 0; + for (i, ok) in has.iter().enumerate() { + if !ok { + missing = (i + 1) as u8; + break; + } + } + // The producer fact: the receipt's toolchain, verbatim, against the + // built_by of the seal its seal_hash cites. + let toolchain = get_str(&v, "toolchain").unwrap_or_default(); + let cited = get_str(&v, "seal_hash"); + let (producer_ok, producer_note) = match cited { + None => (false, "no seal hash cited".into()), + Some(h) => match seals.iter().find(|(g, _)| *g == h) { + None => (false, "cites a seal this spec does not hold".into()), + Some((_, None)) => (false, "cited seal carries no built_by".into()), + Some((_, Some(b))) if *b == toolchain => (true, String::new()), + Some((_, Some(b))) => { + (false, format!("toolchain is not the cited seal's built_by ({b})")) + } + }, + }; + rows.push(Row { file, missing, word, producer_ok, producer_note }); + } + + let count = rows.len() as u8; + let all_complete = rows.iter().all(|r| r.missing == 0); + // Agreement is collected over the known words and judged by the rule's + // fixed order -- after completeness -- so an unknown word never reaches it. + let words_agree = { + let known: Vec = rows.iter().filter_map(|r| r.word).collect(); + known.iter().all(|w| *w == known[0]) + }; + let all_producers = rows.iter().all(|r| r.producer_ok); + + // run_record.t27's run_first_missing, mirrored: count, completeness, + // agreement, producer -- in that order, those codes. + let code = if count < 3 { + 1 + } else if !all_complete { + 2 + } else if !words_agree { + 3 + } else if !all_producers { + 4 + } else { + 0 + }; + let name = |c: u8| match c { + 0 => "NONE", + 1 => "RUN_TOO_FEW_RECEIPTS", + 2 => "RUN_RECEIPT_INCOMPLETE", + 3 => "RUN_WORDS_DISAGREE", + _ => "RUN_PRODUCER_MISMATCH", + }; + + println!("Run record for {spec} -- specs/verified/run_record.t27 (R2-4)"); + println!("Receipts: {} (placements needed: 3)", count); + for r in &rows { + let word = match r.word { + Some(0) => "PASS".to_string(), + Some(1) => "FAIL".to_string(), + Some(w) => format!("UNKNOWN({w})"), + None => "absent".to_string(), + }; + if r.missing == 0 && r.producer_ok { + println!(" {} word={word} complete", r.file); + } else if r.missing > 0 { + println!(" {} word={word} incomplete (receipt field {} missing)", r.file, r.missing); + } else { + println!(" {} word={word} complete but {}", r.file, r.producer_note); + } + } + if rows.is_empty() { + println!(" (no receipts name this spec)"); + } + println!("First missing: {} ({})", name(code), code); + println!("Run complete: {}", if code == 0 { "yes" } else { "no" }); + // verdict.t27's consumption point: an incomplete run is no run reference + // at all -- INVALID_NO_RUN (2) -- judged before any chain is read. + if code == 0 { + println!("Verdict run reference: citable -- a verdict record may cite this run"); + } else { + println!("Verdict run reference: INVALID_NO_RUN -- an incomplete run is no run reference"); + } + std::process::exit(if code == 0 { 0 } else { 1 }); +} + pub fn run_silicon( repo_root: &Path, spec: &str, diff --git a/bootstrap/tests/run_record_reader.rs b/bootstrap/tests/run_record_reader.rs new file mode 100644 index 0000000000..605fa57011 --- /dev/null +++ b/bootstrap/tests/run_record_reader.rs @@ -0,0 +1,270 @@ +//! R2-4, the tool half (#7072): `t27c run-record` reads the receipts a spec's +//! hardware runs wrote and judges them by specs/verified/run_record.t27 +//! (#7061). The fixtures are hand-written receipt and seal JSON in temp dirs +//! -- the reader does not need the writer, and #7044 (which writes receipts) +//! can land in either order. +//! +//! The codes pinned here are run_record.t27's constants: +//! RUN_MISSING_NONE=0, RUN_TOO_FEW_RECEIPTS=1, RUN_RECEIPT_INCOMPLETE=2, +//! RUN_WORDS_DISAGREE=3, RUN_PRODUCER_MISMATCH=4 (placements_needed()=3), with +//! receipt.t27's six-field rule underneath (an unknown verdict word counts as +//! absent) and verdict.t27's consumption point (an incomplete run is no run +//! reference: INVALID_NO_RUN before any chain is read). +//! +//! The producer fact needs #7076's `built_by` on the seal: `t27c-bootstrap@ +//! +` written by `seal --save`, which the receipt's `toolchain` +//! must equal verbatim. A seal minted before #7076 carries no `built_by` and +//! therefore matches no producer -- that is the honest reading, pinned below. + +use std::process::Command; + +fn scratch(tag: &str) -> std::path::PathBuf { + static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + std::env::temp_dir().join(format!("t27c-run-record-{tag}-{}-{n}", std::process::id())) +} + +fn t27c(cwd: &std::path::Path, spec: &str) -> (Option, String) { + let out = Command::new(env!("CARGO_BIN_EXE_t27c")) + .args(["run-record", spec]) + .current_dir(cwd) + .output() + .expect("run t27c"); + ( + out.status.code(), + String::from_utf8_lossy(&out.stdout).to_string() + &String::from_utf8_lossy(&out.stderr), + ) +} + +const SEAL_IMAGE: &str = "sha256:face"; +const BUILT_BY: &str = "t27c-bootstrap@9.9.9+deadbee"; + +/// A scratch tree holding one spec and its seal, ready for receipts. Returns +/// the tree root; receipts are written by each test into .trinity/receipts/. +fn tree(tag: &str, built_by: Option<&str>) -> std::path::PathBuf { + let root = scratch(tag); + std::fs::create_dir_all(root.join("specs").join("fpga")).expect("scratch dirs"); + std::fs::create_dir_all(root.join(".trinity/seals")).expect("seals dir"); + std::fs::create_dir_all(root.join(".trinity/receipts")).expect("receipts dir"); + std::fs::write(root.join("specs/fpga/link.t27"), "module Link;\n").expect("spec"); + let mut seal = format!( + "{{\"module\":\"Link\",\"spec_path\":\"specs/fpga/link.t27\",\"gen_hash_verilog\":\"{SEAL_IMAGE}\"" + ); + match built_by { + Some(b) => seal.push_str(&format!(",\"built_by\":\"{b}\"")), + None => seal.push_str(",\"built_by\":null"), + } + seal.push_str("}\n"); + std::fs::write(root.join(".trinity/seals/fpga_Link.json"), seal).expect("seal"); + root +} + +/// One receipt. `word` is the raw verdict_word field: 0=PASS, 1=FAIL, anything +/// else is a word the vocabulary does not define. `seal_hash`/`seeds`/ +/// `toolchain` override the complete-receipt defaults. +fn receipt( + root: &std::path::Path, + name: &str, + word: u64, + seal_hash: Option<&str>, + seeds: Option<&str>, + toolchain: Option<&str>, +) { + let txt = format!( + "{{\"device_record\":\"QMTech Wukong V1\",\"full_idcode\":\"0x03636093\",\"verdict_word\":{word},\"seal_hash\":{},\"seeds\":{},\"toolchain\":{},\"spec\":\"specs/fpga/link.t27\",\"utc_unix\":1770000000}}\n", + match seal_hash { + Some(h) => format!("\"{h}\""), + None => "null".to_string(), + }, + seeds.unwrap_or("[1,7,42]"), + match toolchain { + Some(t) => format!("\"{t}\""), + None => format!("\"{BUILT_BY}\""), + }, + ); + std::fs::write(root.join(".trinity/receipts").join(name), txt).expect("receipt"); +} + +/// THE CONTROL: three complete receipts, agreeing words, every toolchain the +/// cited seal's built_by -- one verified run, citable as a verdict's run +/// reference, exit 0. +#[test] +fn three_complete_agreeing_receipts_are_one_verified_run() { + let root = tree("control", Some(BUILT_BY)); + receipt(&root, "link-1770000000-1.json", 0, Some(SEAL_IMAGE), None, None); + receipt(&root, "link-1770000001-2.json", 0, Some(SEAL_IMAGE), None, None); + receipt(&root, "link-1770000002-3.json", 0, Some(SEAL_IMAGE), None, None); + let (code, text) = t27c(&root, "specs/fpga/link.t27"); + assert_eq!(code, Some(0), "{text}"); + assert!(text.contains("First missing: NONE (0)"), "{text}"); + assert!(text.contains("Run complete: yes"), "{text}"); + assert!(text.contains("may cite this run"), "{text}"); + let _ = std::fs::remove_dir_all(&root); +} + +/// Count is judged first: below three, agreement is vacuous -- it cannot be +/// judged on a set the rule already refuses (run_record.t27). +#[test] +fn two_receipts_are_too_few_no_matter_what_they_say() { + let root = tree("toofew", Some(BUILT_BY)); + receipt(&root, "link-1770000000-1.json", 0, Some(SEAL_IMAGE), None, None); + receipt(&root, "link-1770000001-2.json", 0, Some(SEAL_IMAGE), None, None); + let (code, text) = t27c(&root, "specs/fpga/link.t27"); + assert_eq!(code, Some(1), "{text}"); + assert!(text.contains("RUN_TOO_FEW_RECEIPTS (1)"), "{text}"); + let _ = std::fs::remove_dir_all(&root); +} + +/// No receipts at all is still a count, not a usage error: the answer is +/// TOO_FEW over a population of zero. +#[test] +fn an_empty_receipts_dir_is_zero_receipts_not_a_refusal() { + let root = tree("empty", Some(BUILT_BY)); + let (code, text) = t27c(&root, "specs/fpga/link.t27"); + assert_eq!(code, Some(1), "{text}"); + assert!(text.contains("(no receipts name this spec)"), "{text}"); + assert!(text.contains("RUN_TOO_FEW_RECEIPTS (1)"), "{text}"); + let _ = std::fs::remove_dir_all(&root); +} + +/// A receipt the six-field rule calls incomplete fails the run before words +/// are read, and the line names WHICH receipt. +#[test] +fn a_receipt_without_seeds_fails_the_run_before_words() { + let root = tree("noseeds", Some(BUILT_BY)); + receipt(&root, "link-1770000000-1.json", 0, Some(SEAL_IMAGE), Some("[]"), None); + receipt(&root, "link-1770000001-2.json", 0, Some(SEAL_IMAGE), None, None); + receipt(&root, "link-1770000002-3.json", 0, Some(SEAL_IMAGE), None, None); + let (code, text) = t27c(&root, "specs/fpga/link.t27"); + assert_eq!(code, Some(1), "{text}"); + assert!(text.contains("RUN_RECEIPT_INCOMPLETE (2)"), "{text}"); + assert!(text.contains("link-1770000000-1.json"), "the broken receipt must be named:\n{text}"); + let _ = std::fs::remove_dir_all(&root); +} + +/// An unknown verdict word never reaches the agreement test: receipt.t27 +/// counts it absent, its receipt goes incomplete, and the answer is (2), not +/// (3) -- "a word the vocabulary does not define is not a stricter verdict, it +/// is a typo with authority". +#[test] +fn an_unknown_verdict_word_is_an_absent_word_not_a_disagreement() { + let root = tree("typoword", Some(BUILT_BY)); + receipt(&root, "link-1770000000-1.json", 0, Some(SEAL_IMAGE), None, None); + receipt(&root, "link-1770000001-2.json", 7, Some(SEAL_IMAGE), None, None); + receipt(&root, "link-1770000002-3.json", 1, Some(SEAL_IMAGE), None, None); + let (code, text) = t27c(&root, "specs/fpga/link.t27"); + assert_eq!(code, Some(1), "{text}"); + assert!(text.contains("RUN_RECEIPT_INCOMPLETE (2)"), "{text}"); + assert!(!text.contains("RUN_WORDS_DISAGREE"), "{text}"); + let _ = std::fs::remove_dir_all(&root); +} + +/// Which word the receipts agree on is not the run record's business: agreeing +/// FAILs are one complete run recording a failure (failure_loop owns the rest), +/// so the reference is citable. +#[test] +fn agreeing_failures_are_still_one_complete_run() { + let root = tree("agreeingfail", Some(BUILT_BY)); + receipt(&root, "link-1770000000-1.json", 1, Some(SEAL_IMAGE), None, None); + receipt(&root, "link-1770000001-2.json", 1, Some(SEAL_IMAGE), None, None); + receipt(&root, "link-1770000002-3.json", 1, Some(SEAL_IMAGE), None, None); + let (code, text) = t27c(&root, "specs/fpga/link.t27"); + assert_eq!(code, Some(0), "{text}"); + assert!(text.contains("Run complete: yes"), "{text}"); + let _ = std::fs::remove_dir_all(&root); +} + +/// PASS and FAIL in one set: the words disagree, code 3, judged only after +/// every receipt passed the six-field rule. +#[test] +fn pass_and_fail_in_one_set_disagree() { + let root = tree("disagree", Some(BUILT_BY)); + receipt(&root, "link-1770000000-1.json", 0, Some(SEAL_IMAGE), None, None); + receipt(&root, "link-1770000001-2.json", 0, Some(SEAL_IMAGE), None, None); + receipt(&root, "link-1770000002-3.json", 1, Some(SEAL_IMAGE), None, None); + let (code, text) = t27c(&root, "specs/fpga/link.t27"); + assert_eq!(code, Some(1), "{text}"); + assert!(text.contains("RUN_WORDS_DISAGREE (3)"), "{text}"); + let _ = std::fs::remove_dir_all(&root); +} + +/// A receipt naming a producer other than the cited seal's built_by is a claim +/// about someone else's work (R2-2): code 4, after agreement. +#[test] +fn a_toolchain_that_is_not_the_cited_seals_producer_fails_last() { + let root = tree("otherproducer", Some(BUILT_BY)); + receipt(&root, "link-1770000000-1.json", 0, Some(SEAL_IMAGE), None, None); + receipt(&root, "link-1770000001-2.json", 0, Some(SEAL_IMAGE), None, None); + receipt( + &root, + "link-1770000002-3.json", + 0, + Some(SEAL_IMAGE), + None, + Some("t27c-bootstrap@9.9.9+cafe123"), + ); + let (code, text) = t27c(&root, "specs/fpga/link.t27"); + assert_eq!(code, Some(1), "{text}"); + assert!(text.contains("RUN_PRODUCER_MISMATCH (4)"), "{text}"); + assert!( + text.contains("link-1770000002-3.json") && text.contains("not the cited seal's"), + "the offending receipt and the reason must be named:\n{text}" + ); + let _ = std::fs::remove_dir_all(&root); +} + +/// #7076 is load-bearing: a seal minted before it carries no built_by, and no +/// receipt can match a producer it does not name. That is a mismatch, not an +/// exception -- the reader does not soften R2-2 for old seals. +#[test] +fn a_seal_without_built_by_matches_no_producer() { + let root = tree("oldseal", None); + receipt(&root, "link-1770000000-1.json", 0, Some(SEAL_IMAGE), None, None); + receipt(&root, "link-1770000001-2.json", 0, Some(SEAL_IMAGE), None, None); + receipt(&root, "link-1770000002-3.json", 0, Some(SEAL_IMAGE), None, None); + let (code, text) = t27c(&root, "specs/fpga/link.t27"); + assert_eq!(code, Some(1), "{text}"); + assert!(text.contains("RUN_PRODUCER_MISMATCH (4)"), "{text}"); + assert!(text.contains("carries no built_by"), "{text}"); + let _ = std::fs::remove_dir_all(&root); +} + +/// A receipt citing a seal this spec does not hold (a stale image hash, or +/// another spec's seal) has no producer to match: mismatch, named. +#[test] +fn a_receipt_citing_a_seal_the_spec_does_not_hold() { + let root = tree("strangeseal", Some(BUILT_BY)); + receipt(&root, "link-1770000000-1.json", 0, Some(SEAL_IMAGE), None, None); + receipt(&root, "link-1770000001-2.json", 0, Some(SEAL_IMAGE), None, None); + receipt(&root, "link-1770000002-3.json", 0, Some("sha256:other"), None, None); + let (code, text) = t27c(&root, "specs/fpga/link.t27"); + assert_eq!(code, Some(1), "{text}"); + assert!(text.contains("RUN_PRODUCER_MISMATCH (4)"), "{text}"); + assert!(text.contains("cites a seal this spec does not hold"), "{text}"); + let _ = std::fs::remove_dir_all(&root); +} + +/// A null seal_hash is an incomplete receipt (receipt.t27's fourth field), not +/// a producer question. +#[test] +fn a_null_seal_hash_is_an_incomplete_receipt() { + let root = tree("nullhash", Some(BUILT_BY)); + receipt(&root, "link-1770000000-1.json", 0, None, None, None); + receipt(&root, "link-1770000001-2.json", 0, Some(SEAL_IMAGE), None, None); + receipt(&root, "link-1770000002-3.json", 0, Some(SEAL_IMAGE), None, None); + let (code, text) = t27c(&root, "specs/fpga/link.t27"); + assert_eq!(code, Some(1), "{text}"); + assert!(text.contains("RUN_RECEIPT_INCOMPLETE (2)"), "{text}"); + let _ = std::fs::remove_dir_all(&root); +} + +/// A spec that does not exist is REFUSED (exit 2): judging receipts against a +/// typo would answer TOO_FEW over files nobody meant. +#[test] +fn a_spec_that_does_not_exist_is_refused() { + let root = tree("nospec", Some(BUILT_BY)); + let (code, text) = t27c(&root, "specs/fpga/nosuch.t27"); + assert_eq!(code, Some(2), "{text}"); + assert!(text.contains("REFUSED"), "{text}"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/tools/policy/foreign-exceptions.txt b/tools/policy/foreign-exceptions.txt index 9b6ff2c6a9..9006e58bc8 100644 --- a/tools/policy/foreign-exceptions.txt +++ b/tools/policy/foreign-exceptions.txt @@ -12,6 +12,15 @@ # cli/t27b/src/lower.rs # with a comment line above it: "# owner 2026-10-05, PR #NNNN: ". +# owner 2026-10-06, standing rule ("add the label yourself and do the foreign part", +# translated), issue #7072 (epic #6655, R2-4 tool half): `t27c run-record` reads +# .trinity/receipts and judges the four facts; the decisions are +# specs/verified/run_record.t27 (#7061, on master), the Rust is I/O plumbing. +# main.rs is listed above; this entry covers service.rs (the reader) and its +# integration test. +bootstrap/src/service.rs +bootstrap/tests/run_record_reader.rs + # owner 2026-10-05, via the coordinator ("fix it and do not stop, do what is best"): the parked t27c # gen-zig fixes land as exceptions, one class per PR (#6315, #6451, #6532, #6533, #6295), label # owner-approved-foreign. The debt is #5980 (t27core self-host): these fixes move to t27core there. From b8cb976ba07c2acb5f1297215a411e0030ffa8c6 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Wed, 7 Oct 2026 04:32:06 +0700 Subject: [PATCH 2/6] fix: sort the receipt and seal listings by file name -- serde_json::Value is not Ord (Refs #7072) Co-Authored-By: Claude Opus 5.5 (1M context) --- bootstrap/src/service.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bootstrap/src/service.rs b/bootstrap/src/service.rs index 11749902ee..5d61637c2c 100644 --- a/bootstrap/src/service.rs +++ b/bootstrap/src/service.rs @@ -3426,7 +3426,7 @@ pub fn run_run_record(repo_root: &Path, spec: &str) -> anyhow::Result<()> { } } } - out.sort(); + out.sort_by(|a, b| a.0.cmp(&b.0)); out }; From 35e5945586e7d8fca0c507b65a86f32f1f67d07d Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Wed, 7 Oct 2026 04:41:16 +0700 Subject: [PATCH 3/6] test: pin that disagreement (3) is judged before producers (4) (Refs #7072) Co-Authored-By: Claude Opus 5.5 (1M context) --- bootstrap/tests/run_record_reader.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/bootstrap/tests/run_record_reader.rs b/bootstrap/tests/run_record_reader.rs index 605fa57011..8ac5ada74d 100644 --- a/bootstrap/tests/run_record_reader.rs +++ b/bootstrap/tests/run_record_reader.rs @@ -188,6 +188,22 @@ fn pass_and_fail_in_one_set_disagree() { let _ = std::fs::remove_dir_all(&root); } +/// Order pin: run_record.t27 judges agreement (3) BEFORE producers (4). With +/// both broken, the answer must name the words, not the producer -- a mutant +/// that swaps the last two judgments passes every other test in this file. +#[test] +fn disagreement_is_judged_before_producers_when_both_break() { + let root = tree("order34", Some(BUILT_BY)); + receipt(&root, "link-1770000000-1.json", 0, Some(SEAL_IMAGE), None, None); + receipt(&root, "link-1770000001-2.json", 0, Some(SEAL_IMAGE), None, None); + receipt(&root, "link-1770000002-3.json", 1, Some(SEAL_IMAGE), None, Some("someone-else")); + let (code, text) = t27c(&root, "specs/fpga/link.t27"); + assert_eq!(code, Some(1), "{text}"); + assert!(text.contains("RUN_WORDS_DISAGREE (3)"), "{text}"); + assert!(!text.contains("RUN_PRODUCER_MISMATCH (4)"), "{text}"); + let _ = std::fs::remove_dir_all(&root); +} + /// A receipt naming a producer other than the cited seal's built_by is a claim /// about someone else's work (R2-2): code 4, after agreement. #[test] From 188a3566aac1668768cb2ce50d6e17264d3b816c Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Wed, 7 Oct 2026 06:07:42 +0700 Subject: [PATCH 4/6] chore: resync the PR head after a queue jam that swallowed the pull_request events (Refs #7072) The validate/parse-ratchet workflow runs were never created for b7226bda2 -- GitHub dropped the synchronize events while the runner fleet was starved. An empty commit re-fires them now that the queue is empty. Co-Authored-By: Claude Opus 5.5 (1M context) From 74eaecebae7557e12d4d261a2c9824511429f302 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Wed, 7 Oct 2026 06:47:40 +0700 Subject: [PATCH 5/6] chore: re-fire the pull_request gates (Refs #7072) The dispatched parse-ratchet run cannot derive BASE_SHA (no pull_request context) and failed on that, leaving a blocking red check on the head; its concurrency group (cancel-in-progress) also cancels any real run for the ref. Only a fresh synchronize event produces a verdict -- this is that event. Co-Authored-By: Claude Opus 5.5 (1M context) From 213a1f145230f623570a122131bdf0126c54cbc7 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Wed, 7 Oct 2026 06:57:39 +0700 Subject: [PATCH 6/6] docs: say the exit-code contract in the reader test header (Refs #7072) Co-Authored-By: Claude Opus 5.5 (1M context) --- bootstrap/tests/run_record_reader.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bootstrap/tests/run_record_reader.rs b/bootstrap/tests/run_record_reader.rs index 8ac5ada74d..27e99cdfdd 100644 --- a/bootstrap/tests/run_record_reader.rs +++ b/bootstrap/tests/run_record_reader.rs @@ -4,6 +4,9 @@ //! -- the reader does not need the writer, and #7044 (which writes receipts) //! can land in either order. //! +//! Exit codes: 0 citable, 1 not citable, 2 REFUSED -- the reader never +//! crashes on a malformed receipt dir; it answers. +//! //! The codes pinned here are run_record.t27's constants: //! RUN_MISSING_NONE=0, RUN_TOO_FEW_RECEIPTS=1, RUN_RECEIPT_INCOMPLETE=2, //! RUN_WORDS_DISAGREE=3, RUN_PRODUCER_MISMATCH=4 (placements_needed()=3), with