diff --git a/dag/gunbc/regen_receipt.dag b/dag/gunbc/regen_receipt.dag index c28c183413c..dcdebdad71b 100644 --- a/dag/gunbc/regen_receipt.dag +++ b/dag/gunbc/regen_receipt.dag @@ -2,19 +2,72 @@ module gunbc.regen_receipt import std.types { String, Int, List, Bool } -data regen_receipt_note: String = "Typed receipt for a non-mutating stage0 regen verify run via claim_executor --required-regen. first_generation_equal and fixed_point_equal are DISTINCT questions and must never collapse. The host binary materializes this carrier at runtime; fixed_point_equal is answered by a second invocation (--required-regen-fixed-point), not a second code path inside one run." +// A RECEIPT MAY REFERENCE PRIOR EVIDENCE BUT MAY NOT IMPERSONATE PRIOR EVIDENCE AS SOMETHING IT +// MEASURED ITSELF (operator ruling, 2026-08-20). The two regen passes answer DISTINCT questions — +// first_generation_equal (does a fresh emit match the committed seed) and fixed_point_equal (does +// the emit reproduce itself on a second pass) — and they run as two separate process invocations +// against one receipt file at target/stage0-regen-receipt.json. +// +// WHAT WENT WRONG WITH ONE FLAT RECORD. A single eight-field record forced the second pass to +// populate fields it had not measured, and the only available source was the receipt the first +// pass left on disk. Four of six were therefore copied through verbatim: +// committed_generated_digest, first_generation_equal, changed_paths, candidate_artifact. The +// result is stamped with the SECOND pass commit_sha while carrying the FIRST pass answers, and it +// is internally consistent and schema-valid — which is exactly what makes it invisible. Nothing in +// the artifact says which tree four of its fields describe. +// +// The reachable arm is the ordinary local one rather than CI: the two modes are separate +// invocations, and nothing requires the first to have run in this process, at this commit, or at +// all. A developer iterating on the determinism half alone, over a warm target/ from an earlier +// commit, gets today commit_sha carrying yesterday changed_paths. In CI the arm is currently +// unreachable because actions/checkout default clean removes the ignored target/ each run — +// measured, two consecutive main runs each compiling 105 crates starting at proc-macro2, where a +// warm tree compiles zero — but that is a property of a checkout default nobody declared, and it +// is one cache-reuse change away from live on a required path. +// +// THE SHAPE BELOW MAKES THE FABRICATION UNWRITABLE RATHER THAN DETECTABLE. The two passes are two +// variants, and the second variant HAS NO first_generation_equal FIELD to fill in. There is no +// value to copy and no check to pass, because the invalid state has no constructor — DESIGN 4b +// structural impossibility, not the validation rung one below it. Choosing this over "compute all +// six in pass two" is deliberate: computing all six would make the second pass re-derive +// first_generation_equal against the committed tree, which is the FIRST pass question, fusing two +// authorities into one row (DESIGN 3). +// +// A REFERENCE IS ONLY HONEST IF IT NAMES ITS SUBJECT. PriorReceiptRef therefore carries the +// commit_sha the referenced evidence was measured at, so a consumer READS which tree those facts +// describe instead of inferring it from context. A ref that silently pointed at another tree would +// be the same defect with better vocabulary. -type RegenReceipt { +type PriorReceiptRef { commit_sha: String - authority_digest: String committed_generated_digest: String - candidate_generated_digest: String first_generation_equal: Bool - fixed_point_equal: Bool changed_paths: List candidate_artifact: String } +// FIRST GENERATION measures everything it reports. FIXED POINT measures the pass-2 digest and its +// equality, and references the rest. The commit_sha on each variant is the tree THAT PASS ran +// against; prior.commit_sha is the tree the referenced evidence came from, and the host refuses +// when they differ. +type RegenReceipt + = FirstGeneration { + commit_sha: String + authority_digest: String + committed_generated_digest: String + candidate_generated_digest: String + first_generation_equal: Bool + changed_paths: List + candidate_artifact: String + } + | FixedPoint { + commit_sha: String + authority_digest: String + candidate_generated_digest: String + fixed_point_equal: Bool + prior: PriorReceiptRef + } + data stage0_regen_candidate_dir_rel: String = "target/stage0-regen-candidate" data stage0_regen_receipt_rel: String = "target/stage0-regen-receipt.json" @@ -23,6 +76,12 @@ data stage0_regen_candidate_artifact_name: String = "stage0-regen-candidate" data stage0_regen_receipt_artifact_name: String = "stage0-regen-receipt" -fn stage0_regen_receipt_is_green(receipt: RegenReceipt) -> Bool { - receipt.first_generation_equal && receipt.fixed_point_equal +// STALENESS IS DECIDABLE FROM THE CARRIER ALONE, which is the point of putting commit_sha on the +// ref. The host refuses a cross-tree reference on the required path; this predicate is the same +// question asked of an already-materialized receipt. +fn prior_reference_is_same_tree(receipt: RegenReceipt) -> Bool { + match receipt { + FirstGeneration { commit_sha: _, authority_digest: _, committed_generated_digest: _, candidate_generated_digest: _, first_generation_equal: _, changed_paths: _, candidate_artifact: _ } => true, + FixedPoint { commit_sha, authority_digest: _, candidate_generated_digest: _, fixed_point_equal: _, prior } => commit_sha == prior.commit_sha + } } diff --git a/dag/test/claim/regen_receipt_prior_reference_witness_test.dag b/dag/test/claim/regen_receipt_prior_reference_witness_test.dag new file mode 100644 index 00000000000..f6f411bcaeb --- /dev/null +++ b/dag/test/claim/regen_receipt_prior_reference_witness_test.dag @@ -0,0 +1,82 @@ +module test.claim.regen_receipt_prior_reference_witness + +import std.types { Bool, String, List } +import gunbc.regen_receipt { RegenReceipt, PriorReceiptRef, FirstGeneration, FixedPoint, prior_reference_is_same_tree } + +// WHAT THIS WITNESS COVERS, AND WHAT IT DOES NOT — stated first because the gap is the point. +// +// COVERS: the model predicate. A FixedPoint receipt whose prior reference names a DIFFERENT tree +// is distinguishable from one that names the same tree, decidably, from the carrier alone. +// +// DOES NOT COVER: the host refusal arm in required_regen_host run_required_regen_fixed_point. +// That arm compares the on-disk prior receipt against git HEAD and returns an Err, and reaching it +// requires planting a receipt file at target/stage0-regen-receipt.json with a chosen commit_sha +// before invoking the binary. No witness form available here writes a file before running a +// process, so the host arm has no executing evidence and I am not claiming otherwise. Its +// next-rung trigger is a witness form that can stage a fixture file for a wet run; until then the +// host arm rests on review, which is strictly weaker than this predicate does. +// +// The reason the predicate is worth having anyway is that it is the SAME question the host asks. +// If the model says a cross-tree reference is detectable and the host later stops checking, the +// disagreement is between two things that both exist, rather than a rule that lives only in one +// unexecuted comment. + +data same_tree_sha: String = "bd239370923f0000000000000000000000000000" + +data other_tree_sha: String = "5a10ca7e01891e32f7568c3bac23878f2b3fdc5f" + +fn a_prior_ref(at: String) -> PriorReceiptRef { + PriorReceiptRef { + commit_sha: at, + committed_generated_digest: "digest-committed", + first_generation_equal: true, + changed_paths: [], + candidate_artifact: "target/stage0-regen-candidate" + } +} + +fn a_fixed_point_receipt(ran_at: String, referenced_at: String) -> RegenReceipt { + FixedPoint { + commit_sha: ran_at, + authority_digest: "digest-authority", + candidate_generated_digest: "digest-candidate", + fixed_point_equal: true, + prior: a_prior_ref(at: referenced_at) + } +} + +// POSITIVE CONTROL. Without this, the RED below is satisfied by a predicate that returns false +// unconditionally — which is the failure mode that cost me a broken instrument earlier today, one +// that printed the right answer while being incapable of printing any other. +test fn a_fixed_point_receipt_referencing_its_own_tree_is_same_tree() -> Bool { + prior_reference_is_same_tree( + receipt: a_fixed_point_receipt(ran_at: same_tree_sha, referenced_at: same_tree_sha) + ) +} + +// THE DISCRIMINATING RED. This is the state the flat eight-field receipt could reach silently: +// stamped with the tree the second pass ran against, carrying evidence measured on another. It is +// internally consistent and schema-valid, and the ONLY thing that distinguishes it is that the +// reference names its subject. +test fn w_RED_a_fixed_point_receipt_referencing_another_tree_is_not_same_tree() -> Bool { + !prior_reference_is_same_tree( + receipt: a_fixed_point_receipt(ran_at: same_tree_sha, referenced_at: other_tree_sha) + ) +} + +// A FirstGeneration receipt has no prior reference to be wrong about, so the predicate is +// vacuously true there. Asserted rather than assumed: a predicate that answered false for the +// variant carrying no reference would refuse every first pass. +test fn a_first_generation_receipt_has_no_cross_tree_reference() -> Bool { + prior_reference_is_same_tree( + receipt: FirstGeneration { + commit_sha: same_tree_sha, + authority_digest: "digest-authority", + committed_generated_digest: "digest-committed", + candidate_generated_digest: "digest-candidate", + first_generation_equal: true, + changed_paths: [], + candidate_artifact: "target/stage0-regen-candidate" + } + ) +} diff --git a/src/v1/stage0/src/bin/claim_executor.rs b/src/v1/stage0/src/bin/claim_executor.rs index 64359c4e4e2..615a8e9f5da 100644 --- a/src/v1/stage0/src/bin/claim_executor.rs +++ b/src/v1/stage0/src/bin/claim_executor.rs @@ -10464,9 +10464,28 @@ fn run() -> Result { return match v1_compiler::cli_run::run_required_regen_fixed_point(®en_receipt_path, None) { Ok(outcome) => { + // The provenance is printed, not just carried. This line previously read + // `first_generation_equal={}` off the receipt as though the fixed-point pass had + // measured it; it never does. Labelling it `referenced_` and naming the commit it + // came from means the log itself distinguishes measured from quoted -- and since + // the host refuses a cross-tree reference, `referenced_at` equals HEAD on every + // line that is allowed to print. + let (referenced_fge, referenced_at) = match outcome.receipt.prior() { + Some(prior) => ( + prior.first_generation_equal.to_string(), + prior.commit_sha.clone(), + ), + None => ("unavailable".to_string(), "unavailable".to_string()), + }; eprintln!( - "required-regen-fixed-point: fixed_point_equal={} first_generation_equal={}", - outcome.receipt.fixed_point_equal, outcome.receipt.first_generation_equal + "required-regen-fixed-point: fixed_point_equal={} referenced_first_generation_equal={} referenced_at={}", + outcome + .receipt + .fixed_point_equal() + .map(|v| v.to_string()) + .unwrap_or_else(|| "unmeasured".to_string()), + referenced_fge, + referenced_at ); for failure in &outcome.failures { eprintln!("required-regen-fixed-point: FAIL {failure}"); @@ -10490,10 +10509,24 @@ fn run() -> Result { ®en_receipt_path, ) { Ok(outcome) => { - eprintln!( - "required-regen: first_generation_equal={} candidate={}", - outcome.receipt.first_generation_equal, outcome.receipt.candidate_artifact - ); + // Both values here ARE measured by this pass, so they print unqualified. Read + // through accessors rather than by matching the variant: the + // `required_regen_host` module is private to `cli_run`, so the type is usable here + // but not nameable. The accessors return Option because the sibling variant does + // not measure these fields; a `None` on this path would mean the first pass built + // the wrong variant, so it prints `unmeasured` rather than defaulting to a + // plausible-looking value. + let fge = outcome + .receipt + .first_generation_equal() + .map(|v| v.to_string()) + .unwrap_or_else(|| "unmeasured".to_string()); + let candidate = outcome + .receipt + .candidate_artifact() + .unwrap_or("unmeasured") + .to_string(); + eprintln!("required-regen: first_generation_equal={fge} candidate={candidate}"); for failure in &outcome.failures { eprintln!("required-regen: FAIL {failure}"); } diff --git a/src/v1/stage0/src/required_regen_host.rs b/src/v1/stage0/src/required_regen_host.rs index a10828097b4..86a954ab6b9 100644 --- a/src/v1/stage0/src/required_regen_host.rs +++ b/src/v1/stage0/src/required_regen_host.rs @@ -21,21 +21,128 @@ use bootstrap_stage0_crate_layout_generated::{ HAND_MAINTAINED_STAGE0_DIRS, HAND_MAINTAINED_STAGE0_FILES, }; -const RECEIPT_SCHEMA: &str = "gunbc.regen_receipt.v1"; - -#[derive(Debug, Serialize)] -pub struct RegenReceipt { - pub schema: &'static str, +/// Bumped from `.v1` when the flat eight-field record split into the two-variant carrier below. +/// +/// THE BUMP IS LOAD-BEARING ONLY BECAUSE `read_receipt` COMPARES IT. An earlier revision of this +/// comment claimed a stale `.v1` receipt "fails to deserialize into the new shape". That was +/// FALSE, and review caught it: serde ignores unknown fields by default, and a v1 record carries +/// every field the reader requires plus the removed `fixed_point_equal`, so it parsed cleanly. +/// The version string was written three times and read zero times -- decoration, not a version. +/// Two things now make the claim true rather than aspirational: `deny_unknown_fields` on the +/// carrier, and an explicit equality check in `read_receipt`. Writing a version nobody compares is +/// the same class of defect as the impersonation this module exists to close -- an artifact +/// asserting a property that nothing establishes. +const RECEIPT_SCHEMA: &str = "gunbc.regen_receipt.v2"; + +/// Evidence produced by the FIRST regen pass, referenced by the second. +/// +/// It carries the `commit_sha` it was measured at so that a consumer READS which tree these facts +/// describe instead of inferring it from the receipt that quotes them. A reference that did not +/// name its subject would be the impersonation this type exists to end, wearing better vocabulary. +#[derive(Debug, Serialize, serde::Deserialize)] +pub struct PriorReceiptRef { pub commit_sha: String, - pub authority_digest: String, pub committed_generated_digest: String, - pub candidate_generated_digest: String, pub first_generation_equal: bool, - pub fixed_point_equal: bool, pub changed_paths: Vec, pub candidate_artifact: String, } +/// A RECEIPT MAY REFERENCE PRIOR EVIDENCE BUT MAY NOT IMPERSONATE PRIOR EVIDENCE AS SOMETHING IT +/// MEASURED ITSELF (operator ruling, 2026-08-20). +/// +/// This was one flat eight-field record, which forced the second pass to populate fields it had +/// not measured. The only source available was the receipt the first pass left on disk, so four of +/// six were copied through verbatim: `committed_generated_digest`, `first_generation_equal`, +/// `changed_paths`, `candidate_artifact`. The product is stamped with the SECOND pass `commit_sha` +/// while carrying the FIRST pass answers -- internally consistent, schema-valid, and silent about +/// which tree four of its fields describe. Validating stayed impossible because nothing in the +/// artifact recorded the provenance that would have been validated. +/// +/// The split makes the fabrication UNWRITABLE rather than detectable: `FixedPoint` has no +/// `first_generation_equal` field to fill in, so there is no value to copy and no check to pass +/// (DESIGN 4b structural impossibility, one rung above the validation that would otherwise sit +/// here). Computing all six in the second pass was the alternative and is worse -- it would make +/// pass two re-derive `first_generation_equal` against the committed tree, which is pass one's +/// question, fusing two authorities into one row (DESIGN 3). +/// +/// Authority: `gunbc.regen_receipt`. +#[derive(Debug, Serialize, serde::Deserialize)] +#[serde(tag = "pass", deny_unknown_fields)] +pub enum RegenReceipt { + #[serde(rename = "first_generation")] + FirstGeneration { + schema: String, + commit_sha: String, + authority_digest: String, + committed_generated_digest: String, + candidate_generated_digest: String, + first_generation_equal: bool, + changed_paths: Vec, + candidate_artifact: String, + }, + #[serde(rename = "fixed_point")] + FixedPoint { + schema: String, + commit_sha: String, + authority_digest: String, + candidate_generated_digest: String, + fixed_point_equal: bool, + prior: PriorReceiptRef, + }, +} + +impl RegenReceipt { + /// The tree THIS pass ran against. + pub fn commit_sha(&self) -> &str { + match self { + RegenReceipt::FirstGeneration { commit_sha, .. } => commit_sha, + RegenReceipt::FixedPoint { commit_sha, .. } => commit_sha, + } + } + + /// `Some` only where the pass measured it. `FixedPoint` returns `None` rather than reaching + /// into `prior`, because "the second pass did not measure this" and "the first pass measured + /// it as false" are different states and a `bool` cannot hold both. + pub fn first_generation_equal(&self) -> Option { + match self { + RegenReceipt::FirstGeneration { + first_generation_equal, + .. + } => Some(*first_generation_equal), + RegenReceipt::FixedPoint { .. } => None, + } + } + + /// `Some` only where the pass measured it -- the mirror of the above. + pub fn fixed_point_equal(&self) -> Option { + match self { + RegenReceipt::FirstGeneration { .. } => None, + RegenReceipt::FixedPoint { + fixed_point_equal, .. + } => Some(*fixed_point_equal), + } + } + + /// The candidate artifact path, measured only by the first pass. + pub fn candidate_artifact(&self) -> Option<&str> { + match self { + RegenReceipt::FirstGeneration { + candidate_artifact, .. + } => Some(candidate_artifact), + RegenReceipt::FixedPoint { .. } => None, + } + } + + /// The referenced first-pass evidence, present only on `FixedPoint`. + pub fn prior(&self) -> Option<&PriorReceiptRef> { + match self { + RegenReceipt::FirstGeneration { .. } => None, + RegenReceipt::FixedPoint { prior, .. } => Some(prior), + } + } +} + #[derive(Debug)] pub struct RequiredRegenOutcome { pub receipt: RegenReceipt, @@ -106,14 +213,17 @@ pub fn run_required_regen( copy_hand_maintained_support(&stage0_src, &fresh_src)?; verify_candidate_tree(&fresh_src, &committed_basenames)?; - let receipt = RegenReceipt { - schema: RECEIPT_SCHEMA, + // Every field here was measured by THIS pass against THIS tree. The old shape also carried + // `fixed_point_equal: false`, which was not a measurement at all -- the first pass never asks + // that question, so a literal `false` asserted a negative answer where the honest content was + // "not asked". The variant has no such field, so the placeholder is now unwritable. + let receipt = RegenReceipt::FirstGeneration { + schema: RECEIPT_SCHEMA.to_string(), commit_sha, authority_digest, committed_generated_digest: committed_digest, candidate_generated_digest: candidate_digest, first_generation_equal, - fixed_point_equal: false, changed_paths: changed_paths.clone(), candidate_artifact: candidate_dir_rel.to_string(), }; @@ -147,10 +257,33 @@ pub fn run_required_regen_fixed_point( ) -> Result { let workspace = workspace_root(); let receipt_path = workspace.join(receipt_rel); + let commit_sha = git_head_sha(&workspace)?; let prior = read_receipt(&receipt_path)?; - let pass1 = pass1_digest.unwrap_or(prior.candidate_generated_digest); - let commit_sha = git_head_sha(&workspace)?; + // THE CROSS-TREE REFUSAL. The two passes are separate process invocations sharing one file + // under `target/`, and nothing requires the first to have run in this process, at this commit, + // or at all. Without this arm a developer iterating on the determinism half alone -- the + // ordinary thing to do -- over a `target/` warm from an earlier commit produces a receipt + // stamped with TODAY's `commit_sha` carrying YESTERDAY's `changed_paths` and + // `first_generation_equal`. In CI the arm is currently unreachable because actions/checkout's + // default clean removes the ignored `target/` each run (measured: two consecutive main runs + // each compiled 105 crates starting at proc-macro2, where a warm tree compiles zero) -- but + // that is a property of a checkout default nobody declared, one cache-reuse change from live + // on a required path. + // + // It refuses rather than recomputing: silently re-running the first pass here would fuse the + // two authorities, and silently proceeding is the fabrication. The `PriorReceiptRef` shape + // makes the impersonation unwritable; this makes referencing the WRONG tree loud. + if prior.commit_sha != commit_sha { + return Err(format!( + "refusal: prior regen receipt was measured at commit {} but HEAD is {} -- the \ + fixed-point pass may reference first-generation evidence only from the same tree. \ + Re-run `claim_executor --required-regen` at this commit first.", + prior.commit_sha, commit_sha + )); + } + + let pass1 = pass1_digest.unwrap_or(prior.candidate_generated_digest); let sources = super::regen_input_sources(&workspace)?; let authority_digest = authority_digest_from_sources(&sources)?; let emitted = compile_stage0(&workspace)?; @@ -165,16 +298,24 @@ pub fn run_required_regen_fixed_point( let pass2 = tree_digest_from_map(&emitted, &committed_basenames)?; let fixed_point_equal = pass1 == pass2; - let receipt = RegenReceipt { - schema: RECEIPT_SCHEMA, + // `commit_sha` is what THIS pass ran against; `prior` names the tree its referenced evidence + // came from. They are checked for equality above and a mismatch refuses, so a receipt reaching + // this point never quotes another tree -- but the field is carried regardless, because a + // reference whose subject is only guaranteed by an upstream check is one refactor away from + // being a reference that does not name its subject. + let receipt = RegenReceipt::FixedPoint { + schema: RECEIPT_SCHEMA.to_string(), commit_sha, authority_digest, - committed_generated_digest: prior.committed_generated_digest, candidate_generated_digest: pass2.clone(), - first_generation_equal: prior.first_generation_equal, fixed_point_equal, - changed_paths: prior.changed_paths, - candidate_artifact: prior.candidate_artifact, + prior: PriorReceiptRef { + commit_sha: prior.commit_sha, + committed_generated_digest: prior.committed_generated_digest, + first_generation_equal: prior.first_generation_equal, + changed_paths: prior.changed_paths, + candidate_artifact: prior.candidate_artifact, + }, }; write_receipt(&receipt_path, &receipt)?; @@ -345,14 +486,20 @@ fn regen_refusal_outcome( reason: String, ) -> Result { let receipt_path = workspace.join(receipt_rel); - let receipt = RegenReceipt { - schema: RECEIPT_SCHEMA, + // A population refusal happens BEFORE any content comparison, so the digests are not + // "refused" values of a measurement -- there was no measurement. The sentinel string is + // retained rather than improved because the honest repair is a refusal variant that carries + // no digest fields at all, and that is a wider change than the impersonation this commit + // closes. Named here so it is a known residue rather than something a later reader discovers + // and mistakes for a measured digest: `first_generation_equal: false` below is likewise "not + // asked", not "asked and answered no". + let receipt = RegenReceipt::FirstGeneration { + schema: RECEIPT_SCHEMA.to_string(), commit_sha, authority_digest, committed_generated_digest: "refused:population".to_string(), candidate_generated_digest: "refused:population".to_string(), first_generation_equal: false, - fixed_point_equal: false, changed_paths: Vec::new(), candidate_artifact: candidate_dir_rel.to_string(), }; @@ -649,22 +796,77 @@ fn git_head_sha(workspace: &Path) -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } -#[derive(serde::Deserialize)] -struct RegenReceiptStored { +/// The first-pass measurement the second pass builds on. +/// +/// This was a separate `RegenReceiptStored` struct mirroring the carrier's field list -- a second +/// representation of one fact (DESIGN 3), and the place the false fail-closed claim hid: it +/// silently accepted any JSON containing its fields, so it read a v1 record as happily as a v2 +/// one. It is gone. `read_receipt` now deserializes the REAL carrier and destructures it, so the +/// reader cannot drift from the writer -- there is only one shape. +struct PriorMeasurement { commit_sha: String, - authority_digest: String, committed_generated_digest: String, candidate_generated_digest: String, first_generation_equal: bool, - fixed_point_equal: bool, changed_paths: Vec, candidate_artifact: String, } -fn read_receipt(path: &Path) -> Result { +/// Read the prior receipt, refusing everything that is not a first-generation measurement written +/// by this version of the carrier. +/// +/// THREE REFUSALS, each closing a state the previous shape accepted silently: +/// +/// * `deny_unknown_fields` on the carrier rejects a record carrying a field this shape does not +/// know -- which is exactly a stale `.v1` receipt, whose removed `fixed_point_equal` serde +/// would otherwise ignore; +/// * the schema equality check rejects a record whose version differs, so the version string is +/// compared rather than merely written; +/// * the variant match rejects a `fixed_point` receipt, because the second pass must build on a +/// FIRST-pass measurement and a receipt left by another second pass is not one. +/// +/// The third was already true by construction (a `fixed_point` record lacks four required fields), +/// but it is stated as an explicit arm rather than left to a missing-field parse error, because a +/// parse error would report the symptom -- a missing field name -- instead of the cause. +fn read_receipt(path: &Path) -> Result { let bytes = fs::read_to_string(path).map_err(|e| format!("read receipt {}: {e}", path.display()))?; - serde_json::from_str(&bytes).map_err(|e| format!("parse receipt {}: {e}", path.display())) + let receipt: RegenReceipt = serde_json::from_str(&bytes) + .map_err(|e| format!("parse receipt {}: {e}", path.display()))?; + match receipt { + RegenReceipt::FirstGeneration { + schema, + commit_sha, + authority_digest: _, + committed_generated_digest, + candidate_generated_digest, + first_generation_equal, + changed_paths, + candidate_artifact, + } => { + if schema != RECEIPT_SCHEMA { + return Err(format!( + "refusal: prior receipt {} declares schema {schema} but this reader is \ + {RECEIPT_SCHEMA} -- re-run `claim_executor --required-regen` to rewrite it", + path.display() + )); + } + Ok(PriorMeasurement { + commit_sha, + committed_generated_digest, + candidate_generated_digest, + first_generation_equal, + changed_paths, + candidate_artifact, + }) + } + RegenReceipt::FixedPoint { .. } => Err(format!( + "refusal: prior receipt {} is a fixed-point receipt, not a first-generation \ + measurement -- the fixed-point pass cannot build on another fixed-point pass. \ + Re-run `claim_executor --required-regen` first.", + path.display() + )), + } } fn write_receipt(path: &Path, receipt: &RegenReceipt) -> Result<(), String> {