diff --git a/crates/epiphany-bundle/src/bundle.rs b/crates/epiphany-bundle/src/bundle.rs index 6d24c95..d8ba13e 100644 --- a/crates/epiphany-bundle/src/bundle.rs +++ b/crates/epiphany-bundle/src/bundle.rs @@ -172,6 +172,65 @@ pub struct CommitContext<'a> { pub previous_manifest_version: SchemaVersion, } +/// What semantics a caller states this build implements. +/// +/// Required at [`Bundle::open`] **and** [`Bundle::create`], and carried on the +/// [`Bundle`] so `commit` and `commit_versioned` validate against it without +/// their 57 call sites changing — the capability is a property of the session, +/// not of each call. +/// +/// # No `Default`, and no bundle-local constant +/// +/// This type **MUST NOT** implement [`Default`], and this crate **MUST NOT** +/// define a constant to fill it in. Either would let a caller open or create a +/// bundle without stating what semantics it implements, which is precisely the +/// defect P13-S27 exists to remove: before it, `reduction_version_for` sourced +/// a new superblock's version from the canonical base's own self-report, and +/// `open` compared that against the superblock the same value had seeded — both +/// operands descending from one source, so the comparison was a tautology for +/// every conformingly-written document. +/// +/// **No test can catch a `Default` that callers then use**, because a defaulted +/// value is indistinguishable from a correct one at every call site. The +/// prohibition is therefore a **review rule**, and gate 6 of the contract is its +/// only mechanical guard. +/// +/// # A struct, not a bare parameter +/// +/// Later capabilities append as fields without another signature break across +/// 92 call sites. **Do not add speculative fields**; add them when a rung needs +/// them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BundleCapabilities { + /// The reduction semantics this build implements. Production composition + /// paths wrap `epiphany_ops::CURRENT_REDUCTION_ALGORITHM_VERSION`; fixtures + /// deliberately exercising arbitrary wire values use + /// [`BundleCapabilities::synthetic_for_fixture`]. + pub current_reduction_version: ReductionAlgorithmVersion, +} + +impl BundleCapabilities { + /// Capabilities for a **format or container fixture** that deliberately + /// exercises an arbitrary wire value. + /// + /// **MUST NEVER appear in a production composition path.** Production paths + /// wrap `epiphany_ops::CURRENT_REDUCTION_ALGORITHM_VERSION`; this + /// constructor exists so a fixture asserting behaviour at version `7` can + /// say so explicitly instead of silently inheriting whatever the real + /// authority happens to be, which would make the fixture stop testing what + /// it was written to test the moment the authority moved. + /// + /// The name is **pinned** by contract pin 3b, because gate 6a greps for it + /// by that exact literal; renaming it requires amending pin 3b and gate 6a + /// together. + #[must_use] + pub fn synthetic_for_fixture(v: u32) -> Self { + BundleCapabilities { + current_reduction_version: ReductionAlgorithmVersion(v), + } + } +} + /// An open bundle over a block store. pub struct Bundle { store: S, @@ -184,6 +243,8 @@ pub struct Bundle { write_cursor: u64, read_only: bool, anomalies: Vec, + /// The semantics the opener/creator stated this build implements (pin 3). + caps: BundleCapabilities, } impl Bundle { @@ -202,8 +263,13 @@ impl Bundle { /// tag requires a higher epoch — see [`Bundle::create_versioned`] for a /// caller (e.g. a repack seeding a non-empty initial manifest through a /// different path) that must supply the version explicitly. - pub fn create(store: S, file_uuid: FileUuid, manifest: Manifest) -> Result { - Self::create_versioned(store, file_uuid, manifest, Manifest::SCHEMA) + pub fn create( + store: S, + file_uuid: FileUuid, + manifest: Manifest, + caps: BundleCapabilities, + ) -> Result { + Self::create_versioned(store, file_uuid, manifest, Manifest::SCHEMA, caps) } /// As [`Bundle::create`], but the manifest chunk is stamped at the given @@ -218,6 +284,7 @@ impl Bundle { file_uuid: FileUuid, mut manifest: Manifest, manifest_schema_version: SchemaVersion, + caps: BundleCapabilities, ) -> Result { manifest.generation = 0; manifest.manifest_id = manifest.derive_id(); @@ -289,6 +356,7 @@ impl Bundle { write_cursor, read_only, anomalies: Vec::new(), + caps, }) } @@ -298,7 +366,7 @@ impl Bundle { /// manifest hash), and reads + verifies the manifest. A structural anomaly /// (generation gap, divergent same-generation slots, a non-committed slot) /// opens the bundle **read-only** and is recorded in [`Bundle::anomalies`]. - pub fn open(store: S) -> Result { + pub fn open(store: S, caps: BundleCapabilities) -> Result { // 1. Header. let header_bytes = read_vec(&store, 0, crate::header::HEADER_LEN)?; let header = FixedHeader::decode(&header_bytes)?; @@ -413,14 +481,24 @@ impl Bundle { // above): only once the base is structurally sound do we ask // whether this epoch may open it at all. A legacy (major-0) // container can never open with a base — the epoch is not - // retroactive (row 2, permanent). A major-1 container is the - // right epoch, but until P13-S27 lands there is no - // reduction-authority capability to validate it against (row 5i, - // interim — TEMPORARY, removed by P13-S27, pin 3a). - return Err(match header.epoch { - FormatEpoch::Legacy => BundleError::LegacyBundleHasCanonicalBase, - FormatEpoch::Current => BundleError::ReductionAuthorityUnavailable, - }); + // retroactive (row 2, permanent). + // + // Row 5i is no longer a categorical refusal. P13-S27 pin 5 + // replaces the interim `ReductionAuthorityUnavailable` with real + // validation: a major-1 container is the right epoch, and its base + // is now compared against the semantics the *caller* states this + // build implements. A disagreement is + // `CanonicalBaseRequiresRebuild` — not read-only, not an integrity + // anomaly (pin 4). + if header.epoch == FormatEpoch::Legacy { + return Err(BundleError::LegacyBundleHasCanonicalBase); + } + if base.reduction_algorithm_version != caps.current_reduction_version { + return Err(BundleError::CanonicalBaseRequiresRebuild { + base: base.reduction_algorithm_version, + current: caps.current_reduction_version, + }); + } } // An unknown *required* extension forces read-only (Chapter 8 §"Behavior // Under Unknown Extensions"). @@ -460,9 +538,23 @@ impl Bundle { write_cursor, read_only, anomalies, + caps, }) } + /// The semantics this bundle's opener or creator stated the build + /// implements (pin 3). + /// + /// Read-only and borrowing, with **no setter**: a setter would let a caller + /// change the semantics it claims *after* `open` validated against them, + /// reintroducing exactly what pin 3 removes. Exposed because the value + /// **governs rejection behaviour** — a caller diagnosing a + /// [`BundleError::CanonicalBaseRequiresRebuild`] needs to see which + /// authority was in force. + pub fn capabilities(&self) -> &BundleCapabilities { + &self.caps + } + /// The active manifest. pub fn manifest(&self) -> &Manifest { &self.manifest @@ -775,25 +867,49 @@ impl Bundle { let active_profile = validate_emittable_manifest(&manifest)?; // Format-epoch matrix (`CONTRACT_FORMAT_EPOCH_MAJOR1.md` pin 3, rows 3 - // and 6i): a commit may never introduce (or replace) a canonical - // base. `self.manifest.canonical_base` is always `None` here — `open` - // and `create` both already refuse ever producing a live bundle whose - // base is `Some`, so any `Some` reaching this point is necessarily a - // fresh introduction. Row 3 — a legacy (major-0) container can never - // become base-bearing in place, because its header can never say the - // base was validated (permanent; the epoch is not inheritable). Row - // 6i — a major-1 container is the right epoch, but until P13-S27 - // lands there is no reduction-authority capability to validate a - // newly introduced base against (interim — TEMPORARY, removed by - // P13-S27, pin 3a). Placed after `validate_emittable_manifest` (which - // already rejects a base whose profile is undeclared) so that a - // structurally malformed base is still reported as malformed, not - // masked by this categorical epoch refusal. - if manifest.canonical_base.is_some() { - return Err(match self.header.epoch { - FormatEpoch::Legacy => BundleError::LegacyBaseIntroductionRejected, - FormatEpoch::Current => BundleError::ReductionAuthorityUnavailable, - }); + // and 6i), as amended by P13-S27 pin 3a. + // + // Row 3 is permanent: a legacy (major-0) container can never become + // base-bearing in place, because its header can never say the base was + // validated — the epoch is not inheritable. + // + // Row 6i is no longer categorical. `commit`/`commit_versioned` are + // **public API**, so an out-of-tree caller can stage a canonical base + // without going through `epiphany-textproj` and without ever calling + // `open`. This is the writer half of the authority, and it validates + // rather than refuses. + // + // **Scope: newly emitted or replaced only.** A commit that does not + // touch `canonical_base` must NOT be refused merely because an + // inherited base is stale, and refusing here would make an unrelated + // commit the site of the diagnosis. + // + // What holds is narrower than "there is never an inherited base". + // `self.manifest.canonical_base` **can** be `Some` here — an unrelated + // second commit on a base-bearing bundle is exactly that, and it is + // required to succeed. What cannot happen is an inherited base that is + // **stale**: `open` refuses one (pin 5) and `create` refuses a + // base-bearing manifest outright, so any base already on `self` matched + // the session's authority when it arrived. + // + // That is why *narrowing* this check to "any stale inherited base" is + // unobservable — the state it would catch is unreachable — while + // *broadening* it to "any base-bearing commit" is very observable, and + // wrong. The scope is a real choice on one axis and forced on the other. + // + // Placed after `validate_emittable_manifest` (which already rejects a + // base whose profile is undeclared) so a structurally malformed base is + // still reported as malformed, not masked by an authority verdict. + if let Some(base) = &manifest.canonical_base { + if self.header.epoch == FormatEpoch::Legacy { + return Err(BundleError::LegacyBaseIntroductionRejected); + } + if base.reduction_algorithm_version != self.caps.current_reduction_version { + return Err(BundleError::CanonicalBaseRequiresRebuild { + base: base.reduction_algorithm_version, + current: self.caps.current_reduction_version, + }); + } } // Before publishing, validate that every canonical root the new manifest @@ -1021,6 +1137,30 @@ fn active_profile_for_emit(manifest: &Manifest) -> Option { /// The reduction-algorithm version a superblock should carry: the canonical /// base's, if a base is present (only a base records a reduction); otherwise the /// default (no base means no reduced base state at this generation). +/// The reduction version a new superblock carries: the canonical base's own +/// self-report, or the default when there is no base. +/// +/// # Why sourcing from the base is sound — and why it takes BOTH pins +/// +/// This looks like the tautology §0.1 of `CONTRACT_P13S27_REDUCTION_AUTHORITY.md` +/// describes, and before P13-S27 it was one. It is sound now only because the +/// value can no longer be unvalidated by the time it reaches here: +/// +/// * **read-time — pin 5.** An opened bundle has proved +/// `base.reduction_algorithm_version == caps.current_reduction_version`, or +/// it did not open. +/// * **write-time — pin 3a.** A newly emitted or replaced base has proved the +/// same at `commit`/`commit_versioned`. +/// +/// **Only together** do these make propagating the base's self-report equal to +/// propagating the current version. Neither alone suffices: read-time validation +/// leaves a production writer free to mint a stale document without ever calling +/// `open`, and write-time validation leaves an already-stale file readable. +/// +/// **Do not "fix" this to read a constant, and do not trust it in a context +/// where neither check has run.** An earlier draft of this rung reasoned +/// one-sidedly about exactly this function and concluded the writer path was +/// test-only; it was not. fn reduction_version_for(manifest: &Manifest) -> ReductionAlgorithmVersion { manifest .canonical_base @@ -1572,10 +1712,22 @@ mod tests { } fn fresh_bundle() -> Bundle { + fresh_bundle_at(0) + } + + /// A fresh bundle whose session states reduction authority `v`. + /// + /// `synthetic_for_fixture` and not the real constant: these are container + /// fixtures exercising chosen wire values, and pin 3b requires them to say + /// so explicitly rather than inherit whatever the authority happens to be — + /// otherwise a fixture asserting behaviour at `4` silently stops asserting + /// it the moment P13-S16 moves the authority. + fn fresh_bundle_at(v: u32) -> Bundle { Bundle::create( MemStore::new(), FileUuid([1; 16]), Manifest::empty(DocumentId([2; 16])), + BundleCapabilities::synthetic_for_fixture(v), ) .unwrap() } @@ -1705,7 +1857,10 @@ mod tests { /// cannot be used directly on `Bundle::open`'s result; this extracts the /// error by hand. fn open_err(image: Vec, context: &str) -> BundleError { - match Bundle::open(MemStore::from_bytes(image)) { + match Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0), + ) { Err(e) => e, Ok(_) => panic!("{context}"), } @@ -1716,8 +1871,11 @@ mod tests { // Matrix row 1: a legacy container with no base opens cleanly — // nothing unverifiable is exposed. let image = craft_image_epoch(0); - let bundle = Bundle::open(MemStore::from_bytes(image)) - .expect("a legacy bundle without a base opens"); + let bundle = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0), + ) + .expect("a legacy bundle without a base opens"); assert!(!bundle.is_read_only()); assert!(bundle.anomalies().is_empty()); assert_eq!(bundle.header().epoch, FormatEpoch::Legacy); @@ -1737,7 +1895,10 @@ mod tests { let err = open_err(image, "a legacy base-bearing bundle must not open"); assert!(matches!(err, BundleError::LegacyBundleHasCanonicalBase)); assert!(!matches!(err, BundleError::LegacyBaseIntroductionRejected)); - assert!(!matches!(err, BundleError::ReductionAuthorityUnavailable)); + assert!(!matches!( + err, + BundleError::CanonicalBaseRequiresRebuild { .. } + )); } #[test] @@ -1747,8 +1908,11 @@ mod tests { // inheritable, because its header can never say the base was // validated. let image = craft_image_epoch(0); - let mut bundle = Bundle::open(MemStore::from_bytes(image)) - .expect("a legacy bundle without a base opens"); + let mut bundle = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0), + ) + .expect("a legacy bundle without a base opens"); let staged = StagedChunk { kind: ChunkKind::Snapshot, schema_version: SchemaVersion::V0, @@ -1771,7 +1935,10 @@ mod tests { .expect_err("committing a base into a legacy bundle must be refused"); assert!(matches!(err, BundleError::LegacyBaseIntroductionRejected)); assert!(!matches!(err, BundleError::LegacyBundleHasCanonicalBase)); - assert!(!matches!(err, BundleError::ReductionAuthorityUnavailable)); + assert!(!matches!( + err, + BundleError::CanonicalBaseRequiresRebuild { .. } + )); assert!( err.to_string().to_lowercase().contains("repack"), "row-3 error must name repack: {err}" @@ -1783,16 +1950,64 @@ mod tests { ); } + /// P13-S27 test 8 — the **write-side positive** branch, converted from the + /// format rung's interim refusal (matrix row 6i). Obligation 1 warns that + /// converting only one branch "leaves a hole exactly where the format + /// rung's own review found one"; without this, an implementation that + /// converts the read side and leaves `commit` refusing categorically passes + /// every other test in this section. + /// + /// Per §3's state table this test **starts base-free and introduces** the + /// base — that is the whole point — so its pre-commit assertion is + /// `is_none()`, not `is_some()`. #[test] - fn a_major_1_bundle_round_trips_and_refuses_to_introduce_a_base() { - // Matrix row 4 (round-trip half) + row 6i (refusal half). Renamed - // from "..._validates_its_base", which pin 3a forbids claiming until - // P13-S27 lands: this rung does not validate a base, it refuses one - // outright. - let mut bundle = fresh_bundle(); + fn committing_a_canonical_base_succeeds_when_the_authority_matches() { + let mut bundle = fresh_bundle_at(4); assert_eq!(bundle.header().epoch, FormatEpoch::Current); + assert!( + bundle.manifest().canonical_base.is_none(), + "test 8 must start base-free: it introduces the base" + ); - // Round trip: ordinary non-base-bearing history commits and reopens. + let staged = StagedChunk { + kind: ChunkKind::Snapshot, + schema_version: SchemaVersion::V0, + payload: vec![9u8, 9, 9], + }; + bundle + .commit(&[staged], |ctx| { + let mut m = ctx.previous_manifest.clone(); + let root = ctx.new_chunks[0]; + m.canonical_base = Some(SnapshotRef { + snapshot_id: SnapshotId([2; 16]), + covers_causal_frontier: FrontierBytes::empty(), + reduction_algorithm_version: ReductionAlgorithmVersion(4), + profile_id: ProfileId::Full, + hash: root.hash, + root, + }); + m + }) + .expect("committing a base whose version matches the authority succeeds"); + + assert!( + bundle.manifest().canonical_base.is_some(), + "the base must be present after the commit that introduced it" + ); + } + + /// P13-S27 test 5 — the pin-3a writer test, and the one this contract's + /// first draft omitted entirely. + /// + /// Asserts **both** halves: the commit fails with the authority error, and + /// the bundle still reopens at the prior active generation with its earlier + /// content intact. **A writer check that corrupts the document while + /// refusing is worse than no check.** + #[test] + fn committing_a_stale_canonical_base_fails_and_leaves_the_prior_generation_reopenable() { + let mut bundle = fresh_bundle_at(4); + + // A good generation first, so there is a prior state to preserve. let block = StagedChunk::operation_block(block::encode_block(&[vec![1u8, 2, 3]])); bundle .commit(&[block], |ctx| { @@ -1800,51 +2015,67 @@ mod tests { m.operation_roots.push(ctx.new_chunks[0]); m }) - .expect("a major-1 bundle commits ordinary non-base-bearing history"); - let image = bundle.into_store().into_bytes(); - let mut reopened = - Bundle::open(MemStore::from_bytes(image)).expect("a major-1 bundle round trips"); - assert!(!reopened.is_read_only()); - assert_eq!(reopened.manifest().operation_roots.len(), 1); + .expect("an ordinary commit succeeds"); + let good_generation = bundle.generation(); - // Refusal: introducing a base is refused (row 6i), distinctly from - // both legacy errors. let staged = StagedChunk { kind: ChunkKind::Snapshot, schema_version: SchemaVersion::V0, payload: vec![9u8, 9, 9], }; - let err = reopened + let err = bundle .commit(&[staged], |ctx| { let mut m = ctx.previous_manifest.clone(); let root = ctx.new_chunks[0]; m.canonical_base = Some(SnapshotRef { snapshot_id: SnapshotId([2; 16]), covers_causal_frontier: FrontierBytes::empty(), - reduction_algorithm_version: ReductionAlgorithmVersion(0), + // Differs from caps(4) — a newly emitted stale base. + reduction_algorithm_version: ReductionAlgorithmVersion(9), profile_id: ProfileId::Full, hash: root.hash, root, }); m }) - .expect_err( - "a major-1 bundle must refuse to introduce a base during the S28 -> P13-S27 interval", - ); - assert!(matches!(err, BundleError::ReductionAuthorityUnavailable)); - assert!(!matches!(err, BundleError::LegacyBundleHasCanonicalBase)); - assert!(!matches!(err, BundleError::LegacyBaseIntroductionRejected)); + .expect_err("a newly emitted base under a different authority must be refused"); + match err { + BundleError::CanonicalBaseRequiresRebuild { base, current } => { + assert_eq!(base, ReductionAlgorithmVersion(9)); + assert_eq!(current, ReductionAlgorithmVersion(4)); + } + other => panic!("expected CanonicalBaseRequiresRebuild, got {other:?}"), + } + + // The refused commit did not corrupt or advance the document. + let image = bundle.into_store().into_bytes(); + let reopened = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(4), + ) + .expect("the bundle still reopens after a refused commit"); + assert_eq!(reopened.generation(), good_generation); + assert_eq!(reopened.manifest().operation_roots.len(), 1); + assert!(reopened.manifest().canonical_base.is_none()); } + /// P13-S27 test 3 — the pin-6 path. Extended in this rung from + /// `a_corrupt_base_fails_as_malformed_before_any_epoch_error`: the malformed + /// assertion is inherited, and what P13-S27 adds is that the result is + /// **not** `CanonicalBaseRequiresRebuild`. + /// + /// **Paired with test 2 in review**: each asserts the other's error is not + /// produced. Pin 6 keeps tampering and valid staleness distinct — they + /// detect different things, and collapsing them would lose the distinction + /// §0.1 rests on. + /// + /// **The fixture deliberately satisfies BOTH conditions**, which is what + /// makes this a precedence test rather than a coincidence: the base reports + /// `1` while its superblock says `2` (corrupt), *and* `1` differs from the + /// session's authority `0` (stale). Malformed must win. If the fixture only + /// tripped one condition, the ordering would never be exercised. #[test] - fn a_corrupt_base_fails_as_malformed_before_any_epoch_error() { - // Pin 3's precedence rule, in BOTH epochs: a base whose version - // disagrees with its superblock's is corrupt and must fail with the - // existing malformed-bundle error — never row 2's legacy error, and - // never row 5i's `ReductionAuthorityUnavailable`. Collapsing - // tampering into staleness would erase the distinction P13-S27's - // authority check rests on. Renamed from `..._corrupt_legacy_base...`: - // the major-1 half is the one an earlier draft missed. + fn a_corrupt_base_superblock_disagreement_still_fails_as_malformed() { for format_major in [0u16, 1u16] { let image = craft_image_with_base( format_major, @@ -1852,33 +2083,240 @@ mod tests { ReductionAlgorithmVersion(1), ReductionAlgorithmVersion(2), // disagrees with the base's own version ); + // `open_err` opens under `synthetic_for_fixture(0)`, so the + // authority condition is live too — and must lose. let err = open_err(image, "a corrupt base must not open"); assert!( matches!(err, BundleError::Decode(DecodeError::Malformed(_))), "format_major {format_major}: expected the malformed-bundle error, got {err:?}" ); assert!(!matches!(err, BundleError::LegacyBundleHasCanonicalBase)); - assert!(!matches!(err, BundleError::ReductionAuthorityUnavailable)); + assert!( + !matches!(err, BundleError::CanonicalBaseRequiresRebuild { .. }), + "format_major {format_major}: corruption must not be reported as staleness" + ); } } + /// P13-S27 test 1 — the **commit construction route** (routes swapped in + /// review round 18). Test 6 asserts the same outcome from a hand-built + /// image; this one reaches the state by committing, so both construction + /// paths into the guarantee are covered. + /// + /// The scratch probe is why that is coverage rather than redundancy: **how + /// an artifact is constructed changes its bytes**, and a hand-built and a + /// committed bundle are not interchangeable. + /// + /// Shares test 8's setup and asserts a different thing — test 8 asserts the + /// **commit** succeeds, this asserts the **reopen** does. #[test] - fn opening_a_major_1_bundle_that_already_carries_a_base_is_refused() { - // Matrix row 5i, the read-side branch pin 3a adds — an earlier draft - // omitted it entirely. + fn open_succeeds_when_base_and_current_reduction_versions_match() { + let mut bundle = fresh_bundle_at(4); + let staged = StagedChunk { + kind: ChunkKind::Snapshot, + schema_version: SchemaVersion::V0, + payload: vec![9u8, 9, 9], + }; + bundle + .commit(&[staged], |ctx| { + let mut m = ctx.previous_manifest.clone(); + let root = ctx.new_chunks[0]; + m.canonical_base = Some(SnapshotRef { + snapshot_id: SnapshotId([2; 16]), + covers_causal_frontier: FrontierBytes::empty(), + reduction_algorithm_version: ReductionAlgorithmVersion(4), + profile_id: ProfileId::Full, + hash: root.hash, + root, + }); + m + }) + .expect("committing a matching base succeeds"); + let image = bundle.into_store().into_bytes(); + + let reopened = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(4), + ) + .expect("a committed base-bearing bundle reopens when the authority matches"); + + assert!( + reopened.manifest().canonical_base.is_some(), + "test 1 must exercise a base-bearing bundle, or it is test 4" + ); + assert_eq!( + reopened + .manifest() + .canonical_base + .as_ref() + .unwrap() + .reduction_algorithm_version, + ReductionAlgorithmVersion(4) + ); + } + + /// P13-S27 test 4 — the no-base exemption. Pin 5: a bundle with **no** + /// canonical base is openable regardless of the session's authority, because + /// there is no reduced state to be stale. + /// + /// The two capability values are **asserted unequal** (round 17): if they + /// coincided this would prove nothing about *any* authority, and nothing + /// would say so. + #[test] + fn a_bundle_with_no_canonical_base_opens_at_any_reduction_version() { + let bundle = fresh_bundle_at(0); + assert!( + bundle.manifest().canonical_base.is_none(), + "this test's fixture must be base-free — the mirror of §3's rule" + ); + let image = bundle.into_store().into_bytes(); + + let (a, b) = (3u32, 11u32); + assert_ne!( + a, b, + "the two authorities must differ, or 'any' is untested" + ); + for v in [a, b] { + let opened = Bundle::open( + MemStore::from_bytes(image.clone()), + BundleCapabilities::synthetic_for_fixture(v), + ) + .unwrap_or_else(|e| panic!("a base-free bundle must open at authority {v}: {e:?}")); + assert!(opened.manifest().canonical_base.is_none()); + assert_eq!( + opened.capabilities().current_reduction_version, + ReductionAlgorithmVersion(v) + ); + } + } + + /// P13-S27 test 9 — the permanent statement of pin 3a's "newly emitted or + /// replaced" scope. **Added in review round 3**, because M6 only + /// *demonstrates* that scope and a mutation does not pin behaviour: with + /// tests 2, 5, 6 and 8 alone, an implementation rejecting every post-base + /// unrelated commit passed all of them, and M6's broadening would have had + /// nothing to break. + #[test] + fn an_unrelated_commit_on_a_base_bearing_bundle_succeeds() { + // Reach the state test 6 establishes, by the commit route. + let mut bundle = fresh_bundle_at(4); + let staged = StagedChunk { + kind: ChunkKind::Snapshot, + schema_version: SchemaVersion::V0, + payload: vec![9u8, 9, 9], + }; + bundle + .commit(&[staged], |ctx| { + let mut m = ctx.previous_manifest.clone(); + let root = ctx.new_chunks[0]; + m.canonical_base = Some(SnapshotRef { + snapshot_id: SnapshotId([2; 16]), + covers_causal_frontier: FrontierBytes::empty(), + reduction_algorithm_version: ReductionAlgorithmVersion(4), + profile_id: ProfileId::Full, + hash: root.hash, + root, + }); + m + }) + .expect("introducing a matching base succeeds"); + let base_before = bundle.manifest().canonical_base.clone(); + assert!(base_before.is_some(), "test 9 must start base-bearing"); + + // An unrelated commit: it does NOT touch `canonical_base`. + let block = StagedChunk::operation_block(block::encode_block(&[vec![1u8, 2, 3]])); + bundle + .commit(&[block], |ctx| { + let mut m = ctx.previous_manifest.clone(); + m.operation_roots.push(ctx.new_chunks[0]); + m + }) + .expect("an unrelated commit on a base-bearing bundle must succeed"); + + let image = bundle.into_store().into_bytes(); + let reopened = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(4), + ) + .expect("reopens with both the new content and the untouched base"); + assert_eq!(reopened.manifest().operation_roots.len(), 1); + assert!(reopened.manifest().canonical_base.is_some()); + assert_eq!( + reopened.manifest().canonical_base, + base_before, + "the inherited base must be untouched by an unrelated commit" + ); + } + + /// P13-S27 test 6 — the read-side half of the authority, converted from the + /// format rung's interim refusal (`opening_a_major_1_bundle_that_already_ + /// carries_a_base_is_refused`, matrix row 5i) into real validation. Its + /// **construction is inherited from that test**: hand-built via + /// `craft_image_with_base`, so `open` is exercised in isolation from any + /// commit path. Test 1 reaches the same state by the commit route. + /// + /// Sibling of test 2, which is this same path when the authority + /// *disagrees*. Both branches must exist — the format rung's own review + /// found a draft that closed only one. + #[test] + fn a_major_1_bundle_carrying_a_base_opens_when_the_authority_matches() { let image = craft_image_with_base( 1, 0, - ReductionAlgorithmVersion(0), - ReductionAlgorithmVersion(0), + ReductionAlgorithmVersion(4), + ReductionAlgorithmVersion(4), ); - let err = open_err( - image, - "a major-1 bundle already carrying a base must not open during the interval", + let bundle = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(4), + ) + .expect("a major-1 base-bearing bundle opens when the authority matches"); + + // The base-presence assertion §3 requires: without it this test passes + // on a base-free bundle, which opens at *any* authority (pin 5), and so + // asserts nothing about the authority at all. + assert!( + bundle.manifest().canonical_base.is_some(), + "test 6 must exercise a base-bearing bundle, or it is test 4" ); - assert!(matches!(err, BundleError::ReductionAuthorityUnavailable)); - assert!(!matches!(err, BundleError::LegacyBundleHasCanonicalBase)); - assert!(!matches!(err, BundleError::LegacyBaseIntroductionRejected)); + assert_eq!( + bundle.capabilities().current_reduction_version, + ReductionAlgorithmVersion(4) + ); + } + + /// P13-S27 test 2 — the same path as test 6 when the authority disagrees. + /// The base and superblock agree with **each other** (so this is not the + /// corrupt case, which test 3 covers); only `caps.current` differs. + #[test] + fn open_rejects_a_valid_stale_canonical_base() { + let image = craft_image_with_base( + 1, + 0, + ReductionAlgorithmVersion(4), + ReductionAlgorithmVersion(4), + ); + let err = match Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(9), + ) { + Err(e) => e, + Ok(_) => panic!("a base under a different authority must not open"), + }; + // Both fields asserted, not merely the variant. + match err { + BundleError::CanonicalBaseRequiresRebuild { base, current } => { + assert_eq!(base, ReductionAlgorithmVersion(4)); + assert_eq!(current, ReductionAlgorithmVersion(9)); + } + other => panic!("expected CanonicalBaseRequiresRebuild, got {other:?}"), + } + // Paired with test 3 in review: each asserts the other's error is *not* + // produced, which is the whole point of pin 6. + assert!(!matches!( + err, + BundleError::Decode(DecodeError::Malformed(_)) + )); } // ----------------------------------------------------------------- @@ -1897,6 +2335,7 @@ mod tests { FileUuid([9; 16]), Manifest::empty(DocumentId([9; 16])), SchemaVersion::new(0, 8), + BundleCapabilities::synthetic_for_fixture(0), ) .unwrap(); assert_eq!( @@ -1937,14 +2376,18 @@ mod tests { FileUuid([10; 16]), Manifest::empty(DocumentId([10; 16])), SchemaVersion::new(0, 8), // differs from Manifest::SCHEMA's minor (1) + BundleCapabilities::synthetic_for_fixture(0), ) .unwrap(); assert_ne!(SchemaVersion::new(0, 8), Manifest::SCHEMA); assert_eq!(SchemaVersion::new(0, 8).major, Manifest::SCHEMA.major); let image = bundle.into_store().into_bytes(); - let reopened = Bundle::open(MemStore::from_bytes(image)) - .expect("a major-matching minor mismatch opens"); + let reopened = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0), + ) + .expect("a major-matching minor mismatch opens"); assert!(!reopened.is_read_only()); assert_eq!( reopened.superblock().manifest_schema_version, @@ -2002,7 +2445,11 @@ mod tests { fn create_then_open_round_trips() { let bundle = fresh_bundle(); let image = bundle.into_store().into_bytes(); - let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap(); + let reopened = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); assert_eq!(reopened.generation(), 0); assert_eq!(reopened.active_slot(), Slot::A); assert_eq!(reopened.file_uuid(), FileUuid([1; 16])); @@ -2026,7 +2473,11 @@ mod tests { // Reopen from the image; the committed state is visible and verifies. let image = bundle.into_store().into_bytes(); - let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap(); + let reopened = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); assert_eq!(reopened.generation(), 1); assert_eq!(reopened.manifest().operation_roots.len(), 1); reopened.verify_canonical_chunks().unwrap(); @@ -2072,7 +2523,11 @@ mod tests { let mut image = bundle.into_store().into_bytes(); image[op_offset as usize] ^= 0xFF; // corrupt the operation block payload - let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap(); + let reopened = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); // Opening still works (the manifest/superblock are intact)... assert_eq!(reopened.generation(), 1); // ...but verifying the canonical chunk surfaces hard corruption. @@ -2157,7 +2612,13 @@ mod tests { compression: CompressionAlgorithm::None, hash: ContentHash([1; 32]), }]; - assert!(Bundle::create(MemStore::new(), FileUuid([1; 16]), m).is_err()); + assert!(Bundle::create( + MemStore::new(), + FileUuid([1; 16]), + m, + BundleCapabilities::synthetic_for_fixture(0) + ) + .is_err()); } #[test] @@ -2246,7 +2707,11 @@ mod tests { // And a reopen agrees with the in-memory (already normalized) manifest. let image = bundle.into_store().into_bytes(); - let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap(); + let reopened = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); assert_eq!(reopened.manifest().operation_roots.len(), 1); } @@ -2271,7 +2736,11 @@ mod tests { }) .unwrap(); let image = bundle.into_store().into_bytes(); - let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap(); + let reopened = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); assert!(reopened.is_read_only()); assert!(reopened .anomalies() @@ -2343,7 +2812,11 @@ mod tests { // 3) And it survives a reopen (durably preserved). let image = bundle.into_store().into_bytes(); - let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap(); + let reopened = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); assert!(survives(reopened.manifest())); } @@ -2365,10 +2838,20 @@ mod tests { constraints: ProfileConstraints::DEFAULT_FULL, }, ]; - let bundle = Bundle::create(MemStore::new(), FileUuid([1; 16]), m).unwrap(); + let bundle = Bundle::create( + MemStore::new(), + FileUuid([1; 16]), + m, + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); let in_memory_profile = bundle.superblock().profile_id; let image = bundle.into_store().into_bytes(); - let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap(); + let reopened = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); assert_eq!(in_memory_profile, reopened.superblock().profile_id); } @@ -2382,7 +2865,11 @@ mod tests { bundle.verify_canonical_chunks().unwrap(); // intact let mut image = bundle.into_store().into_bytes(); image[blob_offset as usize] ^= 0xFF; - let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap(); + let reopened = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); assert!(matches!( reopened.verify_canonical_chunks(), Err(BundleError::ChunkHashMismatch { .. }) @@ -2394,7 +2881,13 @@ mod tests { // Finding 1: a writer must not emit a manifest its own open would reject. let mut m = Manifest::empty(DocumentId([8; 16])); m.profile_declarations.clear(); - assert!(Bundle::create(MemStore::new(), FileUuid([1; 16]), m).is_err()); + assert!(Bundle::create( + MemStore::new(), + FileUuid([1; 16]), + m, + BundleCapabilities::synthetic_for_fixture(0) + ) + .is_err()); let mut bundle = fresh_bundle(); let err = bundle.commit(&[], |ctx| { @@ -2481,7 +2974,11 @@ mod tests { // Discover the commit's final-flush syscall index via a no-fault run. let base = fresh_bundle().into_store().into_bytes(); let total = { - let mut b = Bundle::open(FaultStore::no_fault(base.clone())).unwrap(); + let mut b = Bundle::open( + FaultStore::no_fault(base.clone()), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); b.commit( &[StagedChunk::operation_block(block::encode_block(&[ b"e".to_vec() @@ -2502,7 +2999,11 @@ mod tests { after_syscalls: total - 1, tear: Tear::TornLastWrite { prefix: 256 }, }; - let mut bundle = Bundle::open(FaultStore::new(base, crash)).unwrap(); + let mut bundle = Bundle::open( + FaultStore::new(base, crash), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); let result = bundle.commit( &[StagedChunk::operation_block(block::encode_block(&[ b"e".to_vec() @@ -2533,7 +3034,13 @@ mod tests { affected_object_kinds: Vec::new(), edit_barriers: Vec::new(), }); - let bundle = Bundle::create(MemStore::new(), FileUuid([1; 16]), m).unwrap(); + let bundle = Bundle::create( + MemStore::new(), + FileUuid([1; 16]), + m, + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); assert!(bundle.is_read_only()); } @@ -2580,7 +3087,12 @@ mod tests { edit_barriers: Vec::new(), }); assert!(matches!( - Bundle::create(MemStore::new(), FileUuid([1; 16]), m), + Bundle::create( + MemStore::new(), + FileUuid([1; 16]), + m, + BundleCapabilities::synthetic_for_fixture(0) + ), Err(BundleError::ResourceLimitExceeded { .. }) )); } @@ -2611,7 +3123,13 @@ mod tests { constraints: small, }, ]; - let mut bundle = Bundle::create(MemStore::new(), FileUuid([1; 16]), m).unwrap(); + let mut bundle = Bundle::create( + MemStore::new(), + FileUuid([1; 16]), + m, + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); // Canonical-first profile is Full (discriminant 0): active = Full limit. assert_eq!(bundle.superblock().profile_id, ProfileId::Full); assert_eq!(bundle.max_block_size(), 64 << 20); @@ -2667,7 +3185,12 @@ mod tests { let make = |decls: Vec| { let mut m = Manifest::empty(DocumentId([1; 16])); m.profile_declarations = decls; - Bundle::create(MemStore::new(), FileUuid([1; 16]), m) + Bundle::create( + MemStore::new(), + FileUuid([1; 16]), + m, + BundleCapabilities::synthetic_for_fixture(0), + ) }; // Custom-only (no understood profile to operate under). assert!(make(vec![profile( @@ -2695,14 +3218,23 @@ mod tests { let v0 = crate::ids::SemVer::new(0, 1, 0); let mut m = Manifest::empty(DocumentId([1; 16])); m.profile_declarations = vec![profile(ProfileId::ReadOnly, v0, 1 << 20)]; - let bundle = Bundle::create(MemStore::new(), FileUuid([1; 16]), m).unwrap(); + let bundle = Bundle::create( + MemStore::new(), + FileUuid([1; 16]), + m, + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); assert_eq!(bundle.superblock().profile_id, ProfileId::ReadOnly); assert!(bundle.is_read_only()); // Round-trips: a reopen agrees it is read-only. let image = bundle.into_store().into_bytes(); - assert!(Bundle::open(MemStore::from_bytes(image)) - .unwrap() - .is_read_only()); + assert!(Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0) + ) + .unwrap() + .is_read_only()); } #[test] @@ -2715,7 +3247,13 @@ mod tests { profile(ProfileId::ReadOnly, v0, 1 << 20), profile(ProfileId::Lite, v0, 1 << 20), ]; - let bundle = Bundle::create(MemStore::new(), FileUuid([1; 16]), m).unwrap(); + let bundle = Bundle::create( + MemStore::new(), + FileUuid([1; 16]), + m, + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); assert_eq!(bundle.superblock().profile_id, ProfileId::Lite); assert!(!bundle.is_read_only()); } @@ -2729,7 +3267,13 @@ mod tests { profile(ProfileId::ReadOnly, v0, read_only_max), profile(ProfileId::Lite, v0, lite_max), ]; - Bundle::create(MemStore::new(), FileUuid([1; 16]), m).unwrap() + Bundle::create( + MemStore::new(), + FileUuid([1; 16]), + m, + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap() }; let staged = || StagedChunk::operation_block(block::encode_block(&[vec![7; 16]])); let append_root = |ctx: &CommitContext| { @@ -2751,9 +3295,10 @@ mod tests { permissive_lite.commit(&[staged()], append_root).unwrap(); let root = permissive_lite.manifest().operation_roots[0]; assert!(permissive_lite.read_operation_block(&root).is_ok()); - let reopened = Bundle::open(MemStore::from_bytes( - permissive_lite.into_store().into_bytes(), - )) + let reopened = Bundle::open( + MemStore::from_bytes(permissive_lite.into_store().into_bytes()), + BundleCapabilities::synthetic_for_fixture(0), + ) .unwrap(); assert!(reopened.read_operation_block(&root).is_ok()); } @@ -2800,7 +3345,11 @@ mod tests { // Finding 6: a bundle whose active profile is ReadOnly opens read-only // (v0 does not auto-upgrade it). let image = craft_image(0, ProfileId::ReadOnly); - let bundle = Bundle::open(MemStore::from_bytes(image)).unwrap(); + let bundle = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); assert!(bundle.is_read_only()); assert!(bundle.anomalies().is_empty()); // a normal read-only bundle } @@ -2810,7 +3359,11 @@ mod tests { // Finding 2: a Custom (registry-defined) active profile is unsupported in // v0 — open read-only and surface it. let image = craft_image(0, ProfileId::Custom(crate::ids::ProfileRegistryId([9; 16]))); - let bundle = Bundle::open(MemStore::from_bytes(image)).unwrap(); + let bundle = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); assert!(bundle.is_read_only()); assert!(bundle .anomalies() @@ -2821,7 +3374,11 @@ mod tests { fn generation_exhaustion_is_an_error_not_a_panic() { // Finding 5: committing a generation-u64::MAX bundle returns an error. let image = craft_image(u64::MAX, ProfileId::Full); - let mut bundle = Bundle::open(MemStore::from_bytes(image)).unwrap(); + let mut bundle = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); assert_eq!(bundle.generation(), u64::MAX); assert!(matches!( bundle.commit(&[], |ctx| ctx.previous_manifest.clone()), @@ -2934,7 +3491,11 @@ mod tests { .unwrap(); let image = bundle.into_store().into_bytes(); - let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap(); + let reopened = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); reopened.verify_canonical_chunks().unwrap(); let stored_root = reopened.manifest().operation_roots[0]; assert_eq!( @@ -3134,7 +3695,10 @@ mod tests { // the stored bytes fail hash verification → no valid superblock. let image = craft_image_with_manifest_bytes(&compressed, manifest_chunk_hash(&payload)); assert!(matches!( - Bundle::open(MemStore::from_bytes(image)), + Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0) + ), Err(BundleError::NoValidSuperblock) )); @@ -3143,7 +3707,10 @@ mod tests { // malformed. let image = craft_image_with_manifest_bytes(&compressed, manifest_chunk_hash(&compressed)); assert!(matches!( - Bundle::open(MemStore::from_bytes(image)), + Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0) + ), Err(BundleError::Decode(_)) )); } diff --git a/crates/epiphany-bundle/src/error.rs b/crates/epiphany-bundle/src/error.rs index 7ad54df..f5d8ea0 100644 --- a/crates/epiphany-bundle/src/error.rs +++ b/crates/epiphany-bundle/src/error.rs @@ -15,7 +15,7 @@ //! bundle, not as errors. use crate::codec::DecodeError; -use crate::ids::SchemaVersion; +use crate::ids::{ReductionAlgorithmVersion, SchemaVersion}; use epiphany_determinism::ContentHash; /// A hard bundle failure: the file is unopenable, or a canonical chunk is @@ -139,17 +139,42 @@ pub enum BundleError { /// error MUST NOT degrade to a read-only open. LegacyBaseIntroductionRejected, - /// A major-1 container's canonical base cannot yet be validated: matrix - /// rows 5i/6i, opening a container that already carries a base, or - /// committing one into it. The container is the **right** epoch — this is - /// not a request to repack — but no reduction-authority capability exists - /// yet to validate the base against. **Temporary**: `P13-S27` supplies - /// that capability and replaces both branches with real validation, not - /// this categorical refusal. Never mentions repack (repacking a - /// already-correct-epoch container would be wrong advice), and MUST NOT - /// degrade to a read-only open — a pre-authority base is not a - /// restricted-but-correct view. - ReductionAuthorityUnavailable, + /// A canonical base was produced under reduction semantics this build does + /// not implement, so the materialized state it carries is **the wrong + /// materialization** and must be rebuilt before use. + /// + /// Raised on both boundaries a base can cross: `Bundle::open`, when a + /// container's base disagrees with the caller's + /// [`BundleCapabilities::current_reduction_version`], and + /// `commit`/`commit_versioned`, when a **newly emitted or replaced** base + /// does. Replaces the format rung's temporary + /// `ReductionAuthorityUnavailable`, which refused both boundaries + /// categorically because no authority existed to validate against; P13-S27 + /// supplies that authority, so refusal becomes validation. + /// + /// # Not read-only, and not an integrity anomaly + /// + /// A stale base is **not a restricted-but-correct view** — it is state + /// computed under different rules. Exposing it read-only would serve + /// incorrect canonical state confidently, which is worse than refusing. + /// + /// # Why `open` cannot recover + /// + /// **`open` cannot rebuild.** Drop-and-replay is unsound once pruning + /// exists (`core_spec.tex:12207`, `:14701` — specified, **not + /// implemented**; no `prune` appears anywhere in this crate), because the + /// operations needed to rebuild may no longer be present. A higher-level + /// rebuild path may be authorized later **only where full pre-base history + /// is demonstrably available**; this rung authorizes none. + /// + /// [`BundleCapabilities::current_reduction_version`]: + /// crate::bundle::BundleCapabilities::current_reduction_version + CanonicalBaseRequiresRebuild { + /// The version the canonical base reports for itself. + base: ReductionAlgorithmVersion, + /// The version the caller stated this build implements. + current: ReductionAlgorithmVersion, + }, } impl core::fmt::Display for BundleError { @@ -229,9 +254,11 @@ impl core::fmt::Display for BundleError { "cannot add or replace a canonical base in a legacy (format major 0) bundle; \ repack into a fresh major-1 bundle", ), - BundleError::ReductionAuthorityUnavailable => f.write_str( - "this major-1 container's canonical base cannot yet be validated: no \ - reduction-authority capability exists until P13-S27 lands", + BundleError::CanonicalBaseRequiresRebuild { base, current } => write!( + f, + "canonical base was produced under reduction algorithm version {} but this \ + build implements {}; the base must be rebuilt before use", + base.0, current.0 ), } } diff --git a/crates/epiphany-bundle/src/fuzz.rs b/crates/epiphany-bundle/src/fuzz.rs index 1deb7d7..22202ce 100644 --- a/crates/epiphany-bundle/src/fuzz.rs +++ b/crates/epiphany-bundle/src/fuzz.rs @@ -35,7 +35,7 @@ //! bundle images by hand and asserts the Chapter 8 §"Superblock Selection" rule //! across every corruption scenario the QUICKSTART enumerates. -use crate::bundle::{Bundle, CommitContext, StagedChunk, BODY_START}; +use crate::bundle::{Bundle, BundleCapabilities, CommitContext, StagedChunk, BODY_START}; use crate::chunk::{ChunkKind, ChunkRef, CompressionAlgorithm}; use crate::error::IntegrityAnomaly; use crate::header::FixedHeader; @@ -101,14 +101,21 @@ fn staged_blocks(envelope_payloads: &[Vec]) -> Vec { fn check_recovery(base_image: &[u8], base_gen: u64, chunks: &[StagedChunk], crash: CrashPoint) { // Open the bundle over a fault store; `open` only reads, so it never // consumes crash budget and always succeeds on a valid base image. - let mut bundle = Bundle::open(FaultStore::new(base_image.to_vec(), crash)) - .expect("base image must open before the commit"); + let mut bundle = Bundle::open( + FaultStore::new(base_image.to_vec(), crash), + BundleCapabilities::synthetic_for_fixture(0), + ) + .expect("base image must open before the commit"); let committed = bundle.commit(chunks, append_roots).is_ok(); let store = bundle.into_store(); // Recover: reopen from exactly the bytes that survived the crash. let durable = store.durable_image(); - let recovered = Bundle::open(MemStore::from_bytes(durable.clone())).unwrap_or_else(|e| { + let recovered = Bundle::open( + MemStore::from_bytes(durable.clone()), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap_or_else(|e| { panic!( "crash at {crash:?} left an UNOPENABLE bundle (base gen {base_gen}): {e}\n\ durable image length {}", @@ -164,7 +171,13 @@ fn check_recovery(base_image: &[u8], base_gen: u64, chunks: &[StagedChunk], cras fn build_base(rng: &mut SplitMix64, commits: u64) -> (Vec, u64) { let doc = DocumentId([(rng.next_u64() & 0xff) as u8; 16]); let uuid = FileUuid([(rng.next_u64() & 0xff) as u8; 16]); - let mut bundle = Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).unwrap(); + let mut bundle = Bundle::create( + MemStore::new(), + uuid, + Manifest::empty(doc), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); for _ in 0..commits { let n = rng.below(3) as usize; // 0..2 envelopes let envelopes: Vec> = (0..n) @@ -289,7 +302,11 @@ pub fn run_wire_decode_fuzz(iters: u64, seed: u64) -> WireFuzzCoverage { let mut manifests: Vec> = Vec::new(); for commits in 0..4u64 { let (image, _) = build_base(&mut rng, commits); - let bundle = Bundle::open(MemStore::from_bytes(image.clone())).expect("valid image opens"); + let bundle = Bundle::open( + MemStore::from_bytes(image.clone()), + BundleCapabilities::synthetic_for_fixture(0), + ) + .expect("valid image opens"); manifests.push(bundle.manifest().encode()); images.push(image); } @@ -347,7 +364,10 @@ pub fn run_wire_decode_fuzz(iters: u64, seed: u64) -> WireFuzzCoverage { // 1. Whole-image open. Must never panic; an Ok manifest must re-encode. let pick = (rng.next_u64() as usize) % images.len(); let image = mutate_image(&mut rng, &images[pick]); - match Bundle::open(MemStore::from_bytes(image)) { + match Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0), + ) { Ok(bundle) => { cov.opens_ok += 1; let encoded = bundle.manifest().encode(); @@ -532,10 +552,18 @@ pub fn exhaustive_crash_check(base_image: &[u8], base_gen: u64, envelope_payload // Learn the commit's total syscall count (and confirm the clean commit // recovers to G+1) via a no-fault run. let total = { - let mut bundle = Bundle::open(FaultStore::no_fault(base_image.to_vec())).unwrap(); + let mut bundle = Bundle::open( + FaultStore::no_fault(base_image.to_vec()), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); bundle.commit(&chunks, append_roots).unwrap(); let store = bundle.into_store(); - let recovered = Bundle::open(store.recover()).unwrap(); + let recovered = Bundle::open( + store.recover(), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); assert_eq!(recovered.generation(), base_gen + 1); store.syscalls_issued() }; @@ -646,7 +674,8 @@ pub fn run_manifest_selection_harness() { b.set_slot(Slot::A, &sb_a); b.set_slot(Slot::B, &sb_b); b.corrupt_slot(Slot::A); - let bundle = Bundle::open(b.store()).expect("slot B is valid; bundle must open"); + let bundle = Bundle::open(b.store(), BundleCapabilities::synthetic_for_fixture(0)) + .expect("slot B is valid; bundle must open"); assert_eq!(bundle.active_slot(), Slot::B); assert_eq!(bundle.generation(), 1); assert!(bundle.anomalies().is_empty()); @@ -661,7 +690,8 @@ pub fn run_manifest_selection_harness() { b.set_slot(Slot::A, &sb_a); b.set_slot(Slot::B, &sb_b); b.corrupt_slot(Slot::B); - let bundle = Bundle::open(b.store()).expect("slot A is valid; bundle must open"); + let bundle = Bundle::open(b.store(), BundleCapabilities::synthetic_for_fixture(0)) + .expect("slot A is valid; bundle must open"); assert_eq!(bundle.active_slot(), Slot::A); assert_eq!(bundle.generation(), 7); assert!(bundle.anomalies().is_empty()); @@ -675,7 +705,7 @@ pub fn run_manifest_selection_harness() { let sb_b = b.add_manifest(5, &m); b.set_slot(Slot::A, &sb_a); b.set_slot(Slot::B, &sb_b); - let bundle = Bundle::open(b.store()).unwrap(); + let bundle = Bundle::open(b.store(), BundleCapabilities::synthetic_for_fixture(0)).unwrap(); assert_eq!(bundle.generation(), 5); assert_eq!(bundle.active_slot(), Slot::B); assert!(bundle.anomalies().is_empty()); @@ -688,7 +718,7 @@ pub fn run_manifest_selection_harness() { let sb = b.add_manifest(9, &m); b.set_slot(Slot::A, &sb); b.set_slot(Slot::B, &sb); - let bundle = Bundle::open(b.store()).unwrap(); + let bundle = Bundle::open(b.store(), BundleCapabilities::synthetic_for_fixture(0)).unwrap(); assert_eq!(bundle.active_slot(), Slot::A); assert_eq!(bundle.generation(), 9); assert!(bundle.anomalies().is_empty()); @@ -707,7 +737,7 @@ pub fn run_manifest_selection_harness() { sb_b.generation = 9; b.set_slot(Slot::A, &sb_a); b.set_slot(Slot::B, &sb_b); - let bundle = Bundle::open(b.store()).unwrap(); + let bundle = Bundle::open(b.store(), BundleCapabilities::synthetic_for_fixture(0)).unwrap(); assert_eq!( bundle.anomalies(), &[IntegrityAnomaly::DivergentSameGeneration { generation: 9 }] @@ -726,7 +756,7 @@ pub fn run_manifest_selection_harness() { sb_b.generation = 9; b.set_slot(Slot::A, &sb_a); b.set_slot(Slot::B, &sb_b); - let bundle = Bundle::open(b.store()).unwrap(); + let bundle = Bundle::open(b.store(), BundleCapabilities::synthetic_for_fixture(0)).unwrap(); assert_eq!(bundle.generation(), 9); assert_eq!( bundle.anomalies(), @@ -749,7 +779,7 @@ pub fn run_manifest_selection_harness() { b.corrupt_slot(Slot::A); b.corrupt_slot(Slot::B); assert!(matches!( - Bundle::open(b.store()), + Bundle::open(b.store(), BundleCapabilities::synthetic_for_fixture(0)), Err(crate::BundleError::NoValidSuperblock) )); } @@ -764,7 +794,7 @@ pub fn run_manifest_selection_harness() { sb_b.commit_state = CommitState::Reserved(1); b.set_slot(Slot::A, &sb_a); b.set_slot(Slot::B, &sb_b); - let bundle = Bundle::open(b.store()).unwrap(); + let bundle = Bundle::open(b.store(), BundleCapabilities::synthetic_for_fixture(0)).unwrap(); // Slot B is not committed -> excluded; A (gen 3) is selected. The // non-committed slot is surfaced as an anomaly, but this is ordinary // fallback (the next commit overwrites the bad slot), so the bundle is @@ -787,7 +817,8 @@ pub fn run_manifest_selection_harness() { sb_b.manifest_hash = manifest_chunk_hash(b"not the manifest"); b.set_slot(Slot::A, &sb_a); b.set_slot(Slot::B, &sb_b); - let bundle = Bundle::open(b.store()).expect("slot A is valid"); + let bundle = Bundle::open(b.store(), BundleCapabilities::synthetic_for_fixture(0)) + .expect("slot A is valid"); assert_eq!(bundle.active_slot(), Slot::A); assert_eq!(bundle.generation(), 5); } diff --git a/crates/epiphany-bundle/src/ids.rs b/crates/epiphany-bundle/src/ids.rs index 723d8f9..c772bb9 100644 --- a/crates/epiphany-bundle/src/ids.rs +++ b/crates/epiphany-bundle/src/ids.rs @@ -285,8 +285,22 @@ impl SchemaVersion { /// The reduction-algorithm version that produced a canonical-base snapshot /// (Chapter 8): a snapshot may serve as a canonical base only if this matches -/// the active superblock's value. Modeled as an opaque monotonically-versioned -/// `u32` (the algorithm catalog itself lives in `epiphany-ops`). +/// **the semantics the running build implements**, which the caller states via +/// `BundleCapabilities` at `open` and `create`. +/// +/// Modeled as an opaque monotonically-versioned `u32`. **The authoritative +/// number lives in `epiphany-ops` as +/// `epiphany_ops::CURRENT_REDUCTION_ALGORITHM_VERSION`, a plain `u32`; this +/// wrapper type is constructed at the composition boundary** by whichever crate +/// depends on both. `epiphany-bundle` deliberately does **not** depend on +/// `epiphany-ops` — its only workspace dependency is `epiphany-determinism` — +/// so it cannot read that constant itself, which is exactly why the capability +/// is injected rather than looked up. +/// +/// This doc comment previously claimed "the algorithm catalog itself lives in +/// `epiphany-ops`" while nothing of the kind existed there — a doc asserting a +/// false fact about another module, and as written **unimplementable from where +/// the check must run**. P13-S27 pin 8 is the rung that made it true. #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)] pub struct ReductionAlgorithmVersion(pub u32); diff --git a/crates/epiphany-bundle/src/lib.rs b/crates/epiphany-bundle/src/lib.rs index 14b5c46..f151b7c 100644 --- a/crates/epiphany-bundle/src/lib.rs +++ b/crates/epiphany-bundle/src/lib.rs @@ -67,8 +67,9 @@ pub use block::{ MAX_BLOCK_DEFAULT, }; pub use bundle::{ - manifest_chunk_hash, manifest_chunk_hash_versioned, Bundle, CommitContext, StagedChunk, - BODY_START, MAX_BLOB_BYTES, MAX_CHUNK_BYTES, MAX_MANIFEST_BYTES, SUPPORTED_SCHEMA_MAJOR, + manifest_chunk_hash, manifest_chunk_hash_versioned, Bundle, BundleCapabilities, CommitContext, + StagedChunk, BODY_START, MAX_BLOB_BYTES, MAX_CHUNK_BYTES, MAX_MANIFEST_BYTES, + SUPPORTED_SCHEMA_MAJOR, }; pub use chunk::{ chunk_content_hash, chunk_id, content_hash_for, ChunkKind, ChunkRef, CompressionAlgorithm, diff --git a/crates/epiphany-bundle/tests/crash_recovery.rs b/crates/epiphany-bundle/tests/crash_recovery.rs index 79c1a42..80ae6f8 100644 --- a/crates/epiphany-bundle/tests/crash_recovery.rs +++ b/crates/epiphany-bundle/tests/crash_recovery.rs @@ -10,6 +10,7 @@ //! against an actual durable-flush primitive, not only the simulator. use epiphany_bundle::fuzz::{exhaustive_crash_check, run_crash_recovery_fuzz, SplitMix64}; +use epiphany_bundle::BundleCapabilities; use epiphany_bundle::{Bundle, DocumentId, FileUuid, Manifest, MemStore, StagedChunk}; /// The headline gate: 10,000 randomized crash scenarios. Every one must recover @@ -51,8 +52,13 @@ fn exhaustive_sweep_across_base_states_and_commit_shapes() { /// its image and generation. (Mirrors the fuzzer's own base builder.) fn build_base(rng: &mut SplitMix64, commits: u64) -> (Vec, u64) { let doc = DocumentId([(rng.next_u64() & 0xff) as u8; 16]); - let mut bundle = - Bundle::create(MemStore::new(), FileUuid([7; 16]), Manifest::empty(doc)).unwrap(); + let mut bundle = Bundle::create( + MemStore::new(), + FileUuid([7; 16]), + Manifest::empty(doc), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); for i in 0..commits { let payload = epiphany_bundle::encode_block(&[vec![i as u8; 16]]); bundle @@ -83,6 +89,7 @@ fn file_store_real_fsync_round_trip() { store, FileUuid([0xAB; 16]), Manifest::empty(DocumentId([1; 16])), + BundleCapabilities::synthetic_for_fixture(0), ) .unwrap(); for i in 1..=2u64 { @@ -99,7 +106,11 @@ fn file_store_real_fsync_round_trip() { } // Reopen from disk in a fresh handle: the committed state is durable. - let reopened = Bundle::open(FileStore::open(&path).unwrap()).unwrap(); + let reopened = Bundle::open( + FileStore::open(&path).unwrap(), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); assert_eq!(reopened.generation(), 2); assert_eq!(reopened.manifest().operation_roots.len(), 2); reopened.verify_canonical_chunks().unwrap(); diff --git a/crates/epiphany-bundle/tests/manifest_selection.rs b/crates/epiphany-bundle/tests/manifest_selection.rs index db41a4c..16beb05 100644 --- a/crates/epiphany-bundle/tests/manifest_selection.rs +++ b/crates/epiphany-bundle/tests/manifest_selection.rs @@ -9,6 +9,7 @@ //! real commit path. use epiphany_bundle::fuzz::run_manifest_selection_harness; +use epiphany_bundle::BundleCapabilities; use epiphany_bundle::{Bundle, DocumentId, FileUuid, Manifest, MemStore, Slot, StagedChunk}; #[test] @@ -25,6 +26,7 @@ fn commit_then_corrupt_active_slot_falls_back() { MemStore::new(), FileUuid([3; 16]), Manifest::empty(DocumentId([4; 16])), + BundleCapabilities::synthetic_for_fixture(0), ) .unwrap(); // Commit once: slot A holds gen 0, slot B holds gen 1 (active). @@ -44,7 +46,11 @@ fn commit_then_corrupt_active_slot_falls_back() { image[320 + 80] ^= 0xFF; // Recovery falls back to slot A (the previous generation), cleanly. - let recovered = Bundle::open(MemStore::from_bytes(image)).unwrap(); + let recovered = Bundle::open( + MemStore::from_bytes(image), + BundleCapabilities::synthetic_for_fixture(0), + ) + .unwrap(); assert_eq!(recovered.active_slot(), Slot::A); assert_eq!(recovered.generation(), 0); assert!(recovered.anomalies().is_empty()); diff --git a/crates/epiphany-ops/src/lib.rs b/crates/epiphany-ops/src/lib.rs index 628bfb4..003bb24 100644 --- a/crates/epiphany-ops/src/lib.rs +++ b/crates/epiphany-ops/src/lib.rs @@ -104,6 +104,53 @@ pub mod valuegen; pub mod fuzz; pub mod vectors; +/// The reduction semantics **this build implements**, as a bare number. +/// +/// `core_spec.tex` §"Canonical Document Identity" is normative: *snapshots +/// produced under an earlier algorithm version cannot be used as canonical +/// bases under a later one without rebuilding*. Enforcing that needs a value +/// naming what the running implementation actually does — and before P13-S27 +/// no such value existed anywhere. `ReductionAlgorithmVersion` +/// (`epiphany-bundle`) was a wire field whose reader compared it only against +/// the superblock that same value had seeded, so the check was a tautology for +/// every conformingly-written document. +/// +/// # The bump discipline — this is the whole guarantee +/// +/// **Any change to a canonical reduction verdict, or to canonical reduced +/// state, MUST bump this constant and record the change in the list below.** +/// +/// **No mechanism can detect a semantics change.** A golden test over reduction +/// outputs can *prompt* the question — outputs moved, did semantics? — but it +/// can never answer it: a deliberate semantics change and an accidental +/// regression look identical from outside. **The discipline is the guarantee; +/// there is no backstop.** +/// +/// # Why `0`, and why that is a decision +/// +/// Bundles written to date carry `0` when they have no canonical base, and +/// bases self-report whatever they were stamped with. Starting anywhere but `0` +/// would make every existing base-bearing document fail to open **without any +/// semantics having changed** — the check would manufacture the breakage it +/// exists to detect. `0` is therefore a decision, **not "unset"**. +/// +/// # Bumps +/// +/// * `0` — the baseline. The semantics `canonical_reduction_order` and +/// `reduce_onto` implement as of P13-S27 (2026-08-08). No earlier version +/// exists; nothing predates this constant. +/// +/// The first real bump belongs to **P13-S16**, which changes +/// `CreateStaffGroup`'s reduction verdict and must move this to `1`. +/// +/// # Layering +/// +/// This is a plain `u32`, and `epiphany-ops` **MUST NOT** gain a dependency on +/// `epiphany-bundle` in order to use that crate's `ReductionAlgorithmVersion` +/// wrapper. The wrapper is constructed at the composition boundary by whoever +/// depends on both (P13-S27 pin 1, §0.3). +pub const CURRENT_REDUCTION_ALGORITHM_VERSION: u32 = 0; + pub use anomaly::{ AnomalousReplicaSegment, IntegrityAnomaly, IntegrityAnomalyKind, ReplicaAnomalyReason, }; diff --git a/crates/epiphany-testkit/benches/bundle.rs b/crates/epiphany-testkit/benches/bundle.rs index d503589..7d43244 100644 --- a/crates/epiphany-testkit/benches/bundle.rs +++ b/crates/epiphany-testkit/benches/bundle.rs @@ -124,8 +124,13 @@ fn build_fixture(dir: &Path) -> Fixture { let uuid = FileUuid(rng.array16()); let doc = DocumentId(rng.array16()); - let mut bundle = - Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create base bundle"); + let mut bundle = Bundle::create( + MemStore::new(), + uuid, + Manifest::empty(doc), + epiphany_testkit::production_caps(), + ) + .expect("create base bundle"); bundle .commit(&staged_blocks(&envelopes), append_roots) .expect("commit base operation blocks"); @@ -160,7 +165,11 @@ fn build_fixture(dir: &Path) -> Fixture { /// Un-timed setup for the commit row: restore the base image and open it. fn restore_and_open(path: &Path, image: &[u8]) -> Bundle { fs::write(path, image).expect("restore base image"); - Bundle::open(FileStore::open(path).expect("open store")).expect("open bundle") + Bundle::open( + FileStore::open(path).expect("open store"), + epiphany_testkit::production_caps(), + ) + .expect("open bundle") } /// The timed commit: block append + manifest rewrite + superblock flip, fsync'd. @@ -176,7 +185,11 @@ fn typical_edit_commit(mut bundle: Bundle, edit: &[StagedChunk]) -> u /// `ChunkRef`; see `Fixture::base_root`'s doc comment for why it is not /// `manifest.canonical_base` right now) and every operation block. fn open_bootstrap_read(path: &Path, base_root: &ChunkRef) -> usize { - let bundle = Bundle::open(FileStore::open(path).expect("open store")).expect("open bundle"); + let bundle = Bundle::open( + FileStore::open(path).expect("open store"), + epiphany_testkit::production_caps(), + ) + .expect("open bundle"); let manifest = bundle.manifest(); let mut bytes = 0usize; bytes += bundle diff --git a/crates/epiphany-testkit/src/bundle_harness.rs b/crates/epiphany-testkit/src/bundle_harness.rs index 9a886df..f1648ec 100644 --- a/crates/epiphany-testkit/src/bundle_harness.rs +++ b/crates/epiphany-testkit/src/bundle_harness.rs @@ -81,8 +81,13 @@ fn staged(payloads: &[Vec]) -> Vec { pub fn build_base(rng: &mut Rng, commits: u64) -> (Vec, u64) { let uuid = FileUuid(rng.array16()); let doc = DocumentId(rng.array16()); - let mut bundle = - Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle"); + let mut bundle = Bundle::create( + MemStore::new(), + uuid, + Manifest::empty(doc), + crate::production_caps(), + ) + .expect("create bundle"); for _ in 0..commits { let n = rng.range_usize(0, 2); let payloads: Vec> = (0..n).map(|_| rng.byte_vec(1, 40)).collect(); @@ -104,16 +109,20 @@ pub fn assert_recovers( crash: CrashPoint, ) { let blocks = staged(envelope_payloads); - let mut bundle = Bundle::open(FaultStore::new(base_image.to_vec(), crash)) - .expect("the base image must open before the commit"); + let mut bundle = Bundle::open( + FaultStore::new(base_image.to_vec(), crash), + crate::production_caps(), + ) + .expect("the base image must open before the commit"); let committed = bundle.commit(&blocks, append_roots).is_ok(); let store = bundle.into_store(); // Recover from exactly the bytes that survived the crash. let durable = store.durable_image(); - let recovered = Bundle::open(MemStore::from_bytes(durable)).unwrap_or_else(|e| { - panic!("crash at {crash:?} left an UNOPENABLE bundle (base gen {base_gen}): {e}") - }); + let recovered = Bundle::open(MemStore::from_bytes(durable), crate::production_caps()) + .unwrap_or_else(|e| { + panic!("crash at {crash:?} left an UNOPENABLE bundle (base gen {base_gen}): {e}") + }); let g = recovered.generation(); assert!( @@ -159,10 +168,14 @@ pub fn exhaustive_crash_sweep(base_image: &[u8], base_gen: u64, envelope_payload // Learn the commit's total syscall count via a no-fault run (and confirm the // clean commit reaches G+1). let total = { - let mut bundle = Bundle::open(FaultStore::no_fault(base_image.to_vec())).unwrap(); + let mut bundle = Bundle::open( + FaultStore::no_fault(base_image.to_vec()), + crate::production_caps(), + ) + .unwrap(); bundle.commit(&blocks, append_roots).unwrap(); let store = bundle.into_store(); - let recovered = Bundle::open(store.recover()).unwrap(); + let recovered = Bundle::open(store.recover(), crate::production_caps()).unwrap(); assert_eq!(recovered.generation(), base_gen + 1); store.syscalls_issued() }; @@ -232,8 +245,13 @@ pub fn assert_selection_through_commits(seed: u64) { let mut rng = Rng::new(seed); let uuid = FileUuid(rng.array16()); let doc = DocumentId(rng.array16()); - let mut bundle = - Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle"); + let mut bundle = Bundle::create( + MemStore::new(), + uuid, + Manifest::empty(doc), + crate::production_caps(), + ) + .expect("create bundle"); // Generation 0 lives in slot A; each commit flips the active slot. assert_eq!(bundle.active_slot(), Slot::A); let commits = 5u64; @@ -249,7 +267,8 @@ pub fn assert_selection_through_commits(seed: u64) { let image = bundle.into_store().into_bytes(); // Reopen: selection picks the highest committed generation, no anomaly. - let reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen"); + let reopened = + Bundle::open(MemStore::from_bytes(image), crate::production_caps()).expect("reopen"); assert_eq!(reopened.generation(), commits); assert!(reopened.anomalies().is_empty()); assert!(!reopened.is_read_only()); @@ -373,8 +392,13 @@ pub fn assert_operation_index_end_to_end(seed: u64) { let envelopes = generators::operation_envelopes(&mut rng, 36, 3, 8, 8); let uuid = FileUuid(rng.array16()); let doc = DocumentId(rng.array16()); - let mut bundle = - Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle"); + let mut bundle = Bundle::create( + MemStore::new(), + uuid, + Manifest::empty(doc), + crate::production_caps(), + ) + .expect("create bundle"); bundle .commit(&staged_envelope_blocks(&envelopes, 12), append_roots) .expect("commit operation blocks"); @@ -392,7 +416,8 @@ pub fn assert_operation_index_end_to_end(seed: u64) { // Reader side: reopen from the durable image; the fresh index is usable // and locates every operation. let image = bundle.into_store().into_bytes(); - let reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen"); + let reopened = + Bundle::open(MemStore::from_bytes(image), crate::production_caps()).expect("reopen"); let usable = reopened .usable_operation_index() .expect("a fresh, covering index is usable"); @@ -413,8 +438,13 @@ pub fn assert_stale_operation_index_rejected_and_rebuilt(seed: u64) { let uuid = FileUuid(rng.array16()); let doc = DocumentId(rng.array16()); - let mut bundle = - Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle"); + let mut bundle = Bundle::create( + MemStore::new(), + uuid, + Manifest::empty(doc), + crate::production_caps(), + ) + .expect("create bundle"); bundle .commit(&staged_envelope_blocks(first, 12), append_roots) .expect("commit first blocks"); @@ -444,7 +474,8 @@ pub fn assert_stale_operation_index_rejected_and_rebuilt(seed: u64) { // The same verdict from a cold reopen; then rebuild from blocks. let image = bundle.into_store().into_bytes(); - let mut reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen"); + let mut reopened = + Bundle::open(MemStore::from_bytes(image), crate::production_caps()).expect("reopen"); assert!(reopened.usable_operation_index().is_none()); let rebuilt = scan_rebuild_operation_index(&reopened); commit_index(&mut reopened, &rebuilt); @@ -466,8 +497,13 @@ pub fn assert_corrupt_operation_index_is_not_bundle_corruption(seed: u64) { let envelopes = generators::operation_envelopes(&mut rng, 24, 3, 8, 8); let uuid = FileUuid(rng.array16()); let doc = DocumentId(rng.array16()); - let mut bundle = - Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle"); + let mut bundle = Bundle::create( + MemStore::new(), + uuid, + Manifest::empty(doc), + crate::production_caps(), + ) + .expect("create bundle"); bundle .commit(&staged_envelope_blocks(&envelopes, 8), append_roots) .expect("commit operation blocks"); @@ -486,7 +522,7 @@ pub fn assert_corrupt_operation_index_is_not_bundle_corruption(seed: u64) { ) .expect("commit garbage index chunk"); let image = bundle.into_store().into_bytes(); - let mut reopened = Bundle::open(MemStore::from_bytes(image)) + let mut reopened = Bundle::open(MemStore::from_bytes(image), crate::production_caps()) .expect("a defective index must not prevent opening"); assert!(reopened.anomalies().is_empty()); assert!(!reopened.is_read_only()); @@ -512,14 +548,18 @@ pub fn assert_corrupt_operation_index_is_not_bundle_corruption(seed: u64) { // (b) On-disk corruption of the now-valid index chunk's payload region. let valid_image = reopened.into_store().into_bytes(); - let probe = Bundle::open(MemStore::from_bytes(valid_image.clone())).expect("reopen"); + let probe = Bundle::open( + MemStore::from_bytes(valid_image.clone()), + crate::production_caps(), + ) + .expect("reopen"); let root = probe .manifest() .operation_index_root .expect("the index is referenced"); let mut corrupt = valid_image; corrupt[(root.offset + 3) as usize] ^= 0xFF; - let reopened = Bundle::open(MemStore::from_bytes(corrupt)) + let reopened = Bundle::open(MemStore::from_bytes(corrupt), crate::production_caps()) .expect("index-region corruption must not prevent opening"); assert!(reopened.anomalies().is_empty()); reopened @@ -595,8 +635,13 @@ pub fn run_barrier_declaration_roundtrip(seed: u64) { // Commit a manifest carrying the declaration; reopen from the raw image. let uuid = FileUuid(rng.array16()); let doc = DocumentId(rng.array16()); - let mut bundle = - Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle"); + let mut bundle = Bundle::create( + MemStore::new(), + uuid, + Manifest::empty(doc), + crate::production_caps(), + ) + .expect("create bundle"); bundle .commit(&staged(&[rng.byte_vec(1, 40)]), |ctx| { let mut m = append_roots(ctx); @@ -605,7 +650,8 @@ pub fn run_barrier_declaration_roundtrip(seed: u64) { }) .expect("commit the declaration"); let image = bundle.into_store().into_bytes(); - let reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen"); + let reopened = + Bundle::open(MemStore::from_bytes(image), crate::production_caps()).expect("reopen"); // The bundle preserved the opaque blobs verbatim ... let decl = reopened diff --git a/crates/epiphany-testkit/src/gminor.rs b/crates/epiphany-testkit/src/gminor.rs index b19dd1f..ffdf3b2 100644 --- a/crates/epiphany-testkit/src/gminor.rs +++ b/crates/epiphany-testkit/src/gminor.rs @@ -87,6 +87,7 @@ fn build_bundle( FileUuid([seed; 16]), manifest, stamped_version, + crate::production_caps(), ) .expect("fixture manifest is emittable") } diff --git a/crates/epiphany-testkit/src/lib.rs b/crates/epiphany-testkit/src/lib.rs index 060b4b4..0995889 100644 --- a/crates/epiphany-testkit/src/lib.rs +++ b/crates/epiphany-testkit/src/lib.rs @@ -130,4 +130,24 @@ pub mod layout_stub; // re-layout → re-render → re-resolve selection, across the ops/layout/render seams. pub mod editloop; +use epiphany_bundle::{BundleCapabilities, ReductionAlgorithmVersion}; + pub use rng::Rng; + +/// The capabilities a **production composition path** supplies: the reduction +/// semantics this build actually implements, wrapped at the composition +/// boundary. +/// +/// This is the "real authority" side of pin 3b's split. Fixtures deliberately +/// exercising arbitrary wire values use +/// [`BundleCapabilities::synthetic_for_fixture`] instead, so that a fixture +/// asserting behaviour at version `7` keeps asserting it when the authority +/// moves. **Never use `synthetic_for_fixture` on a production path.** +#[must_use] +pub fn production_caps() -> BundleCapabilities { + BundleCapabilities { + current_reduction_version: ReductionAlgorithmVersion( + epiphany_ops::CURRENT_REDUCTION_ALGORITHM_VERSION, + ), + } +} diff --git a/crates/epiphany-testkit/src/roundtrip.rs b/crates/epiphany-testkit/src/roundtrip.rs index 9f910c2..c9d5ad1 100644 --- a/crates/epiphany-testkit/src/roundtrip.rs +++ b/crates/epiphany-testkit/src/roundtrip.rs @@ -195,8 +195,13 @@ pub fn committed_manifest(seed: u64) -> Manifest { let mut rng = Rng::new(seed); let uuid = FileUuid(rng.array16()); let doc = DocumentId(rng.array16()); - let mut bundle = - Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle"); + let mut bundle = Bundle::create( + MemStore::new(), + uuid, + Manifest::empty(doc), + crate::production_caps(), + ) + .expect("create bundle"); for _ in 0..3 { let n = rng.range_usize(1, 3); let payloads: Vec> = (0..n).map(|_| rng.byte_vec(1, 80)).collect(); @@ -228,25 +233,21 @@ fn canonical_score_bytes(envelopes: &[OperationEnvelope]) -> Vec { /// stored as a `Snapshot` chunk, hash-verified on reopen and read back /// byte-identically. /// -/// **Canonical-base wiring suspended** (`CONTRACT_FORMAT_EPOCH_MAJOR1.md` pin -/// 3c, amended 2026-08-07). The snapshot's correct semantic home is the -/// manifest's `canonical_base` — that has not changed — but pin 3a's -/// format-epoch matrix refuses every base introduction (`Bundle::commit`) and -/// every base-bearing open (`Bundle::open`) during the S28 → P13-S27 -/// interval, in both format epochs. So this harness cannot wire the snapshot -/// there right now: it stages the snapshot as an ordinary chunk instead and -/// carries the resulting `ChunkRef` across the reopen out of band, reading it -/// back directly via [`Bundle::read_chunk`] (which hash-verifies any -/// `ChunkRef`, not only a canonical one). The -/// serialize → load → decode → reserialize cycle below is unchanged; only the -/// snapshot's manifest placement is suspended. **Not** re-homed to +/// **Canonical-base wiring RESTORED by P13-S27** (test 7 / inherited obligation +/// 3). The format rung's pin 3c suspended it for the S28 → P13-S27 interval, +/// because pin 3a refused every base introduction and every base-bearing open +/// while no reduction authority existed to validate against. P13-S27 supplies +/// that authority, so a base-bearing container is constructible again and the +/// snapshot is wired to its correct semantic home: the manifest's +/// `canonical_base`. +/// +/// The two assertions that lapsed are back, and they are the point of the +/// restoration: **`verify_canonical_chunks` covers the base branch again** +/// (including its `base.hash != base.root.hash` cross-check), and **the +/// reopened manifest actually carries the base**. It was never re-homed to /// `acceleration_snapshots` — that field is verified nowhere in -/// `epiphany-bundle` (not in `open`, not in `verify_canonical_chunks`), so a -/// reference there would verify nothing while looking like preserved -/// coverage. Two assertions lapse until P13-S27 restores the wiring: -/// `verify_canonical_chunks`'s base branch (its `base.hash != base.root.hash` -/// cross-check), and the reopened manifest actually carrying the base. Both -/// are owed back in `spec/CONTRACT_P13S27_REDUCTION_AUTHORITY.md`. +/// `epiphany-bundle`, so a reference there would have verified nothing while +/// looking like preserved coverage. /// /// After reopen, the snapshot payload is decoded through /// [`MaterializedState::decode_canonical`], compared structurally with the @@ -262,18 +263,21 @@ pub fn assert_reduction_serialization_stable(envelopes: &[OperationEnvelope], se "re-reduction changed the canonical score bytes" ); - // serialize: stage the canonical state as a real **Snapshot** chunk. - // P13-S27 / CONTRACT_FORMAT_EPOCH_MAJOR1.md pin 3c: the manifest's - // `canonical_base` is the snapshot's correct semantic home, but that - // wiring is suspended for the S28 -> P13-S27 interval (see the doc - // comment above) — the chunk is staged but not referenced from any - // manifest root, and its `ChunkRef` is captured directly from the commit - // closure instead. + // serialize: stage the canonical state as a real **Snapshot** chunk and + // wire it to its correct semantic home, the manifest's `canonical_base`. + // Restored by P13-S27 (test 7): the base's version is the authority this + // session states, so pin 3a validates the introduction rather than + // refusing it. let mut rng = Rng::new(seed); let uuid = FileUuid(rng.array16()); let doc = DocumentId(rng.array16()); - let mut bundle = - Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle"); + let mut bundle = Bundle::create( + MemStore::new(), + uuid, + Manifest::empty(doc), + crate::production_caps(), + ) + .expect("create bundle"); let snapshot = StagedChunk { kind: ChunkKind::Snapshot, schema_version: SchemaVersion::V0, @@ -282,21 +286,45 @@ pub fn assert_reduction_serialization_stable(envelopes: &[OperationEnvelope], se let mut snapshot_root = None; bundle .commit(&[snapshot], |ctx| { - snapshot_root = Some(ctx.new_chunks[0]); - ctx.previous_manifest.clone() + let root = ctx.new_chunks[0]; + snapshot_root = Some(root); + let mut m = ctx.previous_manifest.clone(); + m.canonical_base = Some(SnapshotRef { + snapshot_id: SnapshotId(rng.array16()), + covers_causal_frontier: FrontierBytes::empty(), + reduction_algorithm_version: ReductionAlgorithmVersion( + epiphany_ops::CURRENT_REDUCTION_ALGORITHM_VERSION, + ), + profile_id: ProfileId::Full, + hash: root.hash, + root, + }); + m }) - .expect("commit snapshot"); + .expect("commit snapshot as the canonical base"); let snapshot_root = snapshot_root.expect("commit ran the build closure"); let image = bundle.into_store().into_bytes(); - // load: reopen from exactly those bytes; the snapshot chunk is read back - // directly by its `ChunkRef` (not through `manifest.canonical_base`, - // suspended above) — `read_chunk` hash-verifies any `ChunkRef`, so the - // cycle's integrity guarantee is unchanged. - let reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen bundle"); + // load: reopen from exactly those bytes. `verify_canonical_chunks` now + // covers the **base branch** again — RESTORED ASSERTION 1 of 2 — including + // its `base.hash != base.root.hash` cross-check. + let reopened = + Bundle::open(MemStore::from_bytes(image), crate::production_caps()).expect("reopen bundle"); reopened .verify_canonical_chunks() .expect("canonical chunks intact"); + // RESTORED ASSERTION 2 of 2: the reopened manifest actually carries the + // base. Without this the harness passes on a base-free bundle and the + // restoration would be cosmetic. + let reopened_base = reopened + .manifest() + .canonical_base + .as_ref() + .expect("the reopened manifest must carry the canonical base"); + assert_eq!( + reopened_base.root, snapshot_root, + "the reopened base must point at the snapshot chunk that was staged" + ); let loaded = reopened .read_chunk(&snapshot_root) .expect("read snapshot chunk back"); @@ -347,8 +375,13 @@ pub fn assert_score_serialization_stable(score: &Score, frontier: &[u8], seed: u let mut rng = Rng::new(seed); let uuid = FileUuid(rng.array16()); let doc = DocumentId(rng.array16()); - let mut bundle = - Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle"); + let mut bundle = Bundle::create( + MemStore::new(), + uuid, + Manifest::empty(doc), + crate::production_caps(), + ) + .expect("create bundle"); let snapshot = StagedChunk { kind: ChunkKind::Snapshot, schema_version: SchemaVersion::for_major(3), @@ -377,7 +410,8 @@ pub fn assert_score_serialization_stable(score: &Score, frontier: &[u8], seed: u // load: reopen (read-write — an acceleration snapshot at the current // major is within the snapshot role's accept-set), hash-verify, read the // referenced chunk back byte-identically. - let reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen bundle"); + let reopened = + Bundle::open(MemStore::from_bytes(image), crate::production_caps()).expect("reopen bundle"); assert!( !reopened.is_read_only(), "a current-major acceleration snapshot must not force read-only" @@ -525,8 +559,13 @@ pub fn assert_operation_block_summary_survives_storage(envelopes: &[OperationEnv let mut rng = Rng::new(seed); let uuid = FileUuid(rng.array16()); let doc = DocumentId(rng.array16()); - let mut bundle = - Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle"); + let mut bundle = Bundle::create( + MemStore::new(), + uuid, + Manifest::empty(doc), + crate::production_caps(), + ) + .expect("create bundle"); // A real operation block (opaque payload bytes) carrying the summary. let blocks: Vec = pack_operation_blocks(&[rng.byte_vec(4, 64)]) .into_iter() @@ -544,7 +583,8 @@ pub fn assert_operation_block_summary_survives_storage(envelopes: &[OperationEnv // Reopen and select the summary by block id — no block payload is decoded. let image = bundle.into_store().into_bytes(); - let reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen bundle"); + let reopened = + Bundle::open(MemStore::from_bytes(image), crate::production_caps()).expect("reopen bundle"); let root_id = reopened.manifest().operation_roots[0].id; assert_eq!( reopened.manifest().operation_block_summary(root_id), @@ -599,6 +639,7 @@ mod tests { MemStore::new(), FileUuid(rng.array16()), Manifest::empty(DocumentId(rng.array16())), + crate::production_caps(), ) .expect("create bundle"); bundle @@ -609,7 +650,7 @@ mod tests { }) .expect("commit op block"); let image = bundle.into_store().into_bytes(); - Bundle::open(MemStore::from_bytes(image)).expect("reopen bundle") + Bundle::open(MemStore::from_bytes(image), crate::production_caps()).expect("reopen bundle") } #[test] @@ -788,8 +829,13 @@ mod tests { // A real committed superblock from a live bundle. let uuid = FileUuid(rng.array16()); let doc = DocumentId(rng.array16()); - let mut bundle = - Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create"); + let mut bundle = Bundle::create( + MemStore::new(), + uuid, + Manifest::empty(doc), + crate::production_caps(), + ) + .expect("create"); bundle .commit( &[StagedChunk::operation_block(encode_block(&[vec![1u8; 8]]))], @@ -816,4 +862,95 @@ mod tests { // Strong sensitivity: same identities, changed content → different bytes. assert_content_mutation_changes_serialization(); } + + /// P13-S27 test 10b — the test M5b breaks, and **the only place in the rung + /// where the real authority meets a canonical base**. In `epiphany-testkit`, + /// which may reach the real constant. + /// + /// # Two provably independent operands + /// + /// The fixture is built with `synthetic_for_fixture(0)` and commits a base + /// carrying the **literal** `ReductionAlgorithmVersion(0)`; the reopen then + /// supplies `production_caps()`, which wraps the real constant. One operand + /// is a literal written into a fixture, the other is the authority read at + /// the reopen — neither derived from the other. + /// + /// **Round 3 caught the alternative**: if both the supplied capability and + /// the base version descended from `CURRENT_REDUCTION_ALGORITHM_VERSION`, + /// both would move together under M5b's mutation and the comparison would + /// pass for every value — §0.1's own tautology, reproduced inside the + /// mutation built to detect it. **Do not tidy either literal into the + /// constant** (§7 item 4b). + /// + /// # Both `Result` arms are written deliberately + /// + /// Round 5 pinned this: "assert it opens" was not enough, because under M5b + /// the reopen returns `Err` and **a `#[test]` returning `Err` asserts + /// nothing about that error's fields**. The `Err` arm below runs only under + /// mutation, and it is what makes M5b's required two-field observation a + /// *verified* one rather than a stack trace. The third arm exists so a + /// *different* error under mutation is reported rather than read as success. + #[test] + fn a_base_bearing_bundle_reopened_under_the_real_authority_validates() { + use epiphany_bundle::{BundleCapabilities, BundleError}; + + let mut bundle = Bundle::create( + MemStore::new(), + FileUuid([0x5E; 16]), + Manifest::empty(DocumentId([0x5E; 16])), + BundleCapabilities::synthetic_for_fixture(0), + ) + .expect("fixture bundle creates"); + + let staged = StagedChunk { + kind: ChunkKind::Snapshot, + schema_version: SchemaVersion::V0, + payload: vec![5u8, 5, 5], + }; + bundle + .commit(&[staged], |ctx| { + let mut m = ctx.previous_manifest.clone(); + let root = ctx.new_chunks[0]; + m.canonical_base = Some(SnapshotRef { + snapshot_id: SnapshotId([0x5E; 16]), + covers_causal_frontier: FrontierBytes::empty(), + // A deliberate LITERAL — not the constant. See above. + reduction_algorithm_version: ReductionAlgorithmVersion(0), + profile_id: ProfileId::Full, + hash: root.hash, + root, + }); + m + }) + .expect("committing the base under the matching synthetic capability succeeds"); + let image = bundle.into_store().into_bytes(); + + match Bundle::open(MemStore::from_bytes(image), crate::production_caps()) { + Ok(reopened) => { + assert!( + reopened.manifest().canonical_base.is_some(), + "the base must survive the reopen, or this asserts nothing" + ); + assert_eq!( + reopened + .manifest() + .canonical_base + .as_ref() + .unwrap() + .reduction_algorithm_version, + ReductionAlgorithmVersion(0) + ); + } + Err(BundleError::CanonicalBaseRequiresRebuild { base, current }) => { + // Reached only under M5b. Assert both fields, then fail loudly + // quoting them — that is the mutation's required observation. + assert_eq!(base, ReductionAlgorithmVersion(0)); + panic!( + "M5b observation: base={} current={} — the authority is load-bearing here", + base.0, current.0 + ); + } + Err(other) => panic!("unexpected error, not the authority verdict: {other:?}"), + } + } } diff --git a/crates/epiphany-testkit/tests/bundle_reopen.rs b/crates/epiphany-testkit/tests/bundle_reopen.rs index 20dda44..365a661 100644 --- a/crates/epiphany-testkit/tests/bundle_reopen.rs +++ b/crates/epiphany-testkit/tests/bundle_reopen.rs @@ -48,13 +48,18 @@ fn a_bundle_round_trips_through_bytes_back_into_a_reduced_score() { MemStore::new(), FileUuid([3; 16]), Manifest::empty(DocumentId([9; 16])), + epiphany_testkit::production_caps(), ) .expect("create"); bundle.commit(&staged, append_roots).expect("commit"); let image = bundle.into_store().into_bytes(); // Close it, reopen it from nothing but the bytes. - let reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen"); + let reopened = Bundle::open( + MemStore::from_bytes(image), + epiphany_testkit::production_caps(), + ) + .expect("reopen"); let mut recovered = Vec::new(); for chunk in reopened.manifest().operation_roots.clone() { diff --git a/crates/epiphany-testkit/tests/requirement_labels.rs b/crates/epiphany-testkit/tests/requirement_labels.rs index b42f742..fc8d8b4 100644 --- a/crates/epiphany-testkit/tests/requirement_labels.rs +++ b/crates/epiphany-testkit/tests/requirement_labels.rs @@ -12,11 +12,11 @@ use std::path::{Path, PathBuf}; // +1 for req:format:container-epoch (the format-epoch rung, pin 7: // spec/CONTRACT_FORMAT_EPOCH_MAJOR1.md) — the container major becomes an epoch, // and Chapter 8 states the classification and the epoch matrix normatively. -const CORE_REQUIREMENT_COUNT: usize = 213; +const CORE_REQUIREMENT_COUNT: usize = 214; // +1 for req:textproj:manifest-schema-carried (G-minor, pins 8/11: // spec/PLAN_GMINOR_SCHEMA_MINOR.md); +1 for req:format:container-epoch (above). -const SUITE_REQUIREMENT_COUNT: usize = 284; -const SUITE_LABEL_COUNT: usize = 284; +const SUITE_REQUIREMENT_COUNT: usize = 285; +const SUITE_LABEL_COUNT: usize = 285; /// The normative chapter-to-area assignment. Keeping this as data makes adding a /// requirement under the wrong chapter fail without encoding chapter names in diff --git a/crates/epiphany-textproj/src/lib.rs b/crates/epiphany-textproj/src/lib.rs index c0c2345..4456500 100644 --- a/crates/epiphany-textproj/src/lib.rs +++ b/crates/epiphany-textproj/src/lib.rs @@ -9,6 +9,8 @@ pub mod project; pub mod serialize; pub mod vectors; +use epiphany_bundle::BundleCapabilities; + use epiphany_bundle::{ ChunkKind, DocumentId, ExtensionId, FrontierBytes, LineageId, ProfileDeclaration, ProfileId, ReductionAlgorithmVersion, SchemaVersion, SemVer, SnapshotId, @@ -172,3 +174,21 @@ pub struct TextBlob { /// Uncompressed blob payload carried inline. pub payload: Vec, } + +/// The capabilities a **production composition path** supplies: the reduction +/// semantics this build actually implements, wrapped at the composition +/// boundary. +/// +/// This is the "real authority" side of pin 3b's split. Fixtures deliberately +/// exercising arbitrary wire values use +/// [`BundleCapabilities::synthetic_for_fixture`] instead, so that a fixture +/// asserting behaviour at version `7` keeps asserting it when the authority +/// moves. **Never use `synthetic_for_fixture` on a production path.** +#[must_use] +pub(crate) fn production_caps() -> BundleCapabilities { + BundleCapabilities { + current_reduction_version: ReductionAlgorithmVersion( + epiphany_ops::CURRENT_REDUCTION_ALGORITHM_VERSION, + ), + } +} diff --git a/crates/epiphany-textproj/src/project.rs b/crates/epiphany-textproj/src/project.rs index ffb458b..80265ae 100644 --- a/crates/epiphany-textproj/src/project.rs +++ b/crates/epiphany-textproj/src/project.rs @@ -980,8 +980,13 @@ mod tests { let mut initial = Manifest::empty(DocumentId([3; 16])); initial.lineage_id = Some(LineageId([4; 16])); - let mut bundle = Bundle::create(MemStore::new(), FileUuid([1; 16]), initial) - .expect("a freshly created bundle with no canonical roots is valid"); + let mut bundle = Bundle::create( + MemStore::new(), + FileUuid([1; 16]), + initial, + crate::production_caps(), + ) + .expect("a freshly created bundle with no canonical roots is valid"); bundle .commit( @@ -1119,7 +1124,13 @@ mod tests { fn a_corrupt_operation_envelope_is_a_typed_error_not_a_panic() { let mut initial = Manifest::empty(DocumentId([5; 16])); initial.lineage_id = None; - let mut bundle = Bundle::create(MemStore::new(), FileUuid([2; 16]), initial).unwrap(); + let mut bundle = Bundle::create( + MemStore::new(), + FileUuid([2; 16]), + initial, + crate::production_caps(), + ) + .unwrap(); let garbage_block = StagedChunk::operation_block(encode_block(&[vec![0xFF; 4]])); bundle .commit(&[garbage_block], |ctx| { @@ -1144,7 +1155,7 @@ mod tests { let corrupt_at = extension_root.offset as usize; image[corrupt_at] ^= 0xFF; - let corrupted = Bundle::open(MemStore::from_bytes(image)) + let corrupted = Bundle::open(MemStore::from_bytes(image), crate::production_caps()) .expect("corrupting a non-canonical chunk's payload does not stop the bundle opening"); match document_from_bundle(&corrupted) { Err(ProjectError::Bundle(_)) => {} diff --git a/crates/epiphany-textproj/src/serialize.rs b/crates/epiphany-textproj/src/serialize.rs index a5f97f6..1c64497 100644 --- a/crates/epiphany-textproj/src/serialize.rs +++ b/crates/epiphany-textproj/src/serialize.rs @@ -152,7 +152,12 @@ pub fn serialize_document( return Err(SerializeError::CanonicalBaseUnsupported); } - let mut bundle = Bundle::create(store, file_uuid, empty_manifest(document))?; + let mut bundle = Bundle::create( + store, + file_uuid, + empty_manifest(document), + crate::production_caps(), + )?; let mut staged = Vec::new(); if let Some(base) = &document.canonical_base { @@ -380,7 +385,8 @@ mod tests { let bundle = serialize_document(document, MemStore::new(), FileUuid([1; 16])) .expect("a well-formed document serializes"); let image = bundle.into_store().into_bytes(); - Bundle::open(MemStore::from_bytes(image)).expect("the serialized bundle reopens") + Bundle::open(MemStore::from_bytes(image), crate::production_caps()) + .expect("the serialized bundle reopens") } #[test] @@ -632,4 +638,32 @@ mod tests { assert_eq!(reopened.manifest().document_id, document.document_id); } } + + /// P13-S27 test 10a — the test M5a breaks. **In `epiphany-textproj`**, + /// because `epiphany-bundle` must not depend on `epiphany-ops` (pin 1, §0.3) + /// and so no test there can reach the real authority. + /// + /// # The `0` is a deliberate LITERAL, and that is load-bearing + /// + /// Comparing against `CURRENT_REDUCTION_ALGORITHM_VERSION` would compare the + /// constant with itself laundered through one function call: mutate the + /// constant and **both sides move**, so the assertion would hold for every + /// value and M5a could not break it. **Do not "tidy" this into the + /// constant** — doing so makes M5a vacuous while leaving every test green, + /// a failure invisible to the suite (contract §7 item 4b exists to catch it). + /// + /// **This test is expected to fail when P13-S16 bumps the authority**, and + /// that is correct: the literal is a tripwire on the production wiring, and + /// S16 updating it is S16 stating that the authority moved. + #[test] + fn serialize_document_supplies_the_real_reduction_authority() { + let document = minimal_document(42); + let bundle = serialize_document(&document, MemStore::new(), FileUuid([1; 16])) + .expect("a base-free document serializes"); + assert_eq!( + bundle.capabilities().current_reduction_version, + ReductionAlgorithmVersion(0), + "the production writer must supply the real authority, not a literal of its own" + ); + } } diff --git a/spec/CONTRACT_P13S27_REDUCTION_AUTHORITY.md b/spec/CONTRACT_P13S27_REDUCTION_AUTHORITY.md index 974d83d..3a1a3f6 100644 --- a/spec/CONTRACT_P13S27_REDUCTION_AUTHORITY.md +++ b/spec/CONTRACT_P13S27_REDUCTION_AUTHORITY.md @@ -10,8 +10,9 @@ them, having gone stale in two consecutive rounds by doing so. execution is **reported, not patched in place** — if it needs a pin change, that is its own amendment with its own review round. -**IMPLEMENTED 2026-08-09, STAGED, and NOT YET ACCEPTED.** The implementation remains -staged and uncommitted; only the amendments are committed. +**IMPLEMENTED AND ACCEPTED 2026-08-09, by the repository owner after execution review +8.** The implementation was staged for the required independent review and is committed +with this acceptance record. **How many post-ratification reviews have closed, which amendment each produced, and what each found are THE HISTORY TABLE'S ROWS. This block does not restate them — @@ -1046,9 +1047,10 @@ history table, and it shows that every independent round before this one found s - **Every finding since execution has been in this contract, not in the 21 staged files** — across all eight reviews. -**What remains is the owner's acceptance decision.** This document does not make it. -Round 1's ratification was claimed by the author after a single round and withdrawn; -**that precedent is why this block records the evidence and stops.** +**The repository owner accepted the implementation on 2026-08-09, after execution +review 8.** That decision is recorded here; it does not recast the evidence as proof of +correctness. Round 1's author-claimed ratification was withdrawn, which is why the +evidence and the owner's decision remain distinct. **Review round 19 — 2026-08-08, independent, against the round-17/18 working tree. ZERO FINDINGS. The first clean round in nineteen.** @@ -3059,7 +3061,8 @@ begin it. No `create_staff`, `create_staff_group`, or invariant change. **Do not bump `CURRENT_REDUCTION_ALGORITHM_VERSION` past 0** — pin 2. The bump to 1 belongs to S16. -**The executing agent MUST NOT commit.** Leave the work staged. +**Execution boundary (SATISFIED):** the executing agent MUST NOT commit and left the +work staged for independent review. **Execution is AUTHORISED as of ratification, 2026-08-08.** *(This read "no execution work may begin at all until this contract is ratified"; ratification has @@ -3067,9 +3070,10 @@ happened.)* The boundaries above are unchanged and remain binding — **stage on §2's files by explicit path, never `git add -A`, re-check `HEAD` before staging and before committing, and never `git reset`/`restore`/`checkout`/`stash`.** -**Leave the work STAGED. Do not commit.** The execution report is then subject to -**independent review before completion is accepted**, covering in particular -**M7's three observations and its control**. +**The executing agent left the work STAGED and did not commit.** The execution report +was then subject to **independent review before completion was accepted**, covering in +particular **M7's three observations and its control**. The repository owner accepted +the reviewed staged implementation on 2026-08-09. --- diff --git a/spec/PASS13_CANDIDATES.md b/spec/PASS13_CANDIDATES.md index 12155a8..7084960 100644 --- a/spec/PASS13_CANDIDATES.md +++ b/spec/PASS13_CANDIDATES.md @@ -122,5 +122,5 @@ evidence in isolation. | P13-S23 | **No filed candidate owns "place any anchor pair on a common timeline and measure musical distance along it" — P13-S18 previously mis-cited a narrower capability as its gate.** Two disjoint deficiencies, both owned by this candidate. (1) **No ordering.** The pair is not comparable under any of `measure20_comparable_order`'s five shapes c1-c5 (`invariants.rs:2457`) at all — whether the failure is in the **referent** (distinct `Event` ids; distinct `Measure` ids outside c3's `Start`+`Zero` restriction), the **variant or selector** (`Event` against `Measure`, `Measure` against `Region`, differing `pos`/`edge`), or the **clock** (`Musical` against `WallClock`, including inside `measure20_offset_order`, `:2419`) — this is what invariant 20's A4 and B4 are made of. (2) **Ordering without a usable delta.** The pair IS comparable and still yields no musical distance: c3 supplies a vector index (an order, never a distance), and c5 compares two `WallClock`s, and `measure20_musical_delta` (`:2522`) never returns a `WallClock` delta (`:2527`) — this is what invariant 20's B5 is made of. Scoping this as merely "anchors of differing shapes" or "not directly comparable under c1-c5" would exclude B5 entirely — S5 (distinct-id `Measure` `Start`/`Zero`) is c3-comparable and S1 (`WallClock` measures, `WallClock` meter changes) is c5-comparable, and both still reach B5 — an earlier draft of this filing made exactly that narrower mistake. **Explicitly broader than P11-C5**: P11-C5 (`PASS11_WORKLIST.md:159`) is a re-anchoring proximity metric that resolves "when the graph-mutation phase tracks resolved positions", and covers narrowly the two-distinct-`Event`s case (`CONTRACT_GENESIS_G3B_MEASURE.md:223`, `effect.rs:139`-`:142`'s `PositionOutsideRegion` Reserved note); P13-S23 is the timeline itself, whatever positions get placed on it. Names its dependents: invariant 20's A4, B4 and B5, and `PositionOutsideRegion`'s Reserved status | `spec/CONTRACT_P13S18_MATRIX.md` pin 10 (filed 2026-07-31 during the same rung that corrected P13-S18's over-narrow P11-C5 citation) | **open.** No code owed by this rung. Closing it needs the deferred common-timeline/duration machinery — once a `Measure` end, a distinct-id `Measure`/`Event` referent, or an `Event` position on a wall-clock-placed region can be placed on a common timeline with a musical distance, invariant 20's A4/B4/B5 residue and `PositionOutsideRegion`'s Reserved status shrink together | | P13-S25 | **The committed decode corpus's numbered tag rows lock byte→byte, not variant→byte — one row already has the property the other thirty-nine lack.** `ops/src/vectors.rs:206`–`:209` emits one row per tag as `format!("tag_{:02}", tag.discriminant())` carrying `[discriminant]`: **both the name and the payload derive from the value alone**, so `tag_32` asserts that `0x20` round-trips and never that `SetCanvasLayoutDefaults` is 32. The `Registered` row (`:210`–`:217`) is different — its name is the hard-coded string `"registered"` while its bytes are computed from the variant, so the frozen literal at `spec/vectors/decode_vectors.txt:80` binds the association. **Disposition B of P13-S22:** give the numbered rows the same property. It **does** catch the coordinated permutation — by exactly the `Registered` mechanism, with the committed text serving as the independent statement — and it propagates the property to every implementation that reads the cross-impl corpus, which an in-crate Rust test cannot do | `spec/CONTRACT_P13S22_TAGLOCK.md` (disposition B, considered and deferred during the 2026-07-31 ruling; filed rather than left as a closing remark, per the same discipline that moved P13-S22 out of P13-S15's resolved row) | **open. Complementary to P13-S22, not a replacement for it, and not a re-litigation of it.** P13-S22 landed disposition A (`tag_wire_discriminants_are_golden`, `payload.rs:2730`), which fails **by variant name inside the crate**. B cannot supply that: its failure is still *"spec/vectors/decode_vectors.txt is stale. Regenerate: …"* (`testkit/src/vectors.rs:224`) — the misleading diagnosis P13-S22 was filed about — even though the diff text would now name variants. **What B buys is cross-implementation reach; what it costs is churn in a committed artifact other implementations pin.** Both are wanted; neither substitutes for the other. Sequencing note: run B's own signing mutation as the coordinated permutation (literals *and* declaration lines), since the literal-only form is caught today by row ordering and proves nothing | | P13-S26 | **A doc comment in shipped code claims a specification repair that never landed, and the claim is guarded on the code side and nowhere on the specification side.** `crates/epiphany-core/src/invariants.rs:69`–`:71` enumerates invariant 10's four reference classes and states that *“genesis tranche G3a repairs this prose to name what the check body already enforced”*. **It did not.** `core_spec.tex:6570`–`:6572`, the normative enumeration item 10, still reads only *“Every cross-cutting structure's references resolve to extant objects in the graph, except where explicit re-anchoring rules permit transient dangling states during edits”* — naming neither a staff's declared instrument, a staff's group, a staff group's members, a part's staves, a view's active layers, nor any of the meter/time-signature references the Rust doc lists and the check body enforces. The repair landed in the Rust doc comment only. **The asymmetry is the defect's sharp edge:** the Rust doc block is protected by a grep-assert, `t12_invariant_10_doc_comment_names_the_four_reference_classes` (`invariants.rs:4554`, needles at `:4562`–`:4566`), so the side that is *wrong about the other* is the side that is **locked**, while the side that is actually stale is unguarded | this file (found 2026-07-31 during P13-S16 reconnaissance, while verifying that row's invariant-10 citations; no ledger entry covered it) | **open.** **Not a live incorrectness** — the check body is correct and enforces every class; only the normative prose under-describes it, and only the doc comment lies about that. **A P13-S9 instance**, and filed deliberately as one: the loud form (a dangling citation) is caught by `requirement_labels.rs`, and this quiet form — a *true-sounding claim about another document's state* — is caught by nothing. **`invariants.rs:69`–`:71` MUST NOT be “corrected” on its own.** It is currently the only artifact in the tree pointing at the `core_spec.tex` gap; softening the Rust claim in isolation would make the specification defect invisible and convert a caught defect into an uncaught one — which is P13-S9's stated failure mode verbatim. **Repair both sides in one rung**, and consider whether the LaTeX enumeration deserves the grep-assert its Rust mirror already has | -| P13-S27 | **The reduction-algorithm-version machinery is self-referential, so the one check that would detect a canonical-semantics change necessarily passes.** `core_spec.tex:11614`–`:11617` is normative — *"Snapshots produced under an earlier algorithm version cannot be used as canonical bases under a later one without rebuilding"* — and `:14369`–`:14372` states that replicas at differing versions *"may produce different canonical states from the same operation set."* The machinery to enforce it appears to exist: `ReductionAlgorithmVersion` (`bundle/src/ids.rs:291`) is a superblock wire field (bytes `68..72`, `superblock.rs:20`); `reduction_version_for` (`bundle.rs:989`) sets a new superblock's value; and `open` (`bundle.rs:396`–`:399`) rejects a mismatch. **But the writer sources the value from the canonical base's own self-report** (mapping the base's `reduction_algorithm_version` through `unwrap_or_default()`), **and the reader compares it only against the superblock that value seeded.** Nothing compares either against the semantics the running implementation actually implements. **The check is not vacuous** — it catches a corrupt or tampered base whose version disagrees with its superblock — but it **necessarily passes for a conformingly propagated stale base**, which is precisely the case the requirement exists to prevent. Supporting: **no constant or accessor anywhere names the implementation's current reduction semantics**, and `ids.rs:288`–`:289` states that *"the algorithm catalog itself lives in `epiphany-ops`"* while nothing of the kind exists in that crate — **a second instance of P13-S26's pattern**, a doc comment asserting a false fact about another module | `spec/CONTRACT_P13S16_PROJECTION.md` pin 0 (found 2026-07-31 while scoping P13-S16, which is a canonical reduction-semantics change and therefore the first rung to need this guarantee; filed in the same ledger edit as the row it blocks) | **UNBLOCKED 2026-08-07 — the format-epoch rung landed; dispatchable, and still blocking P13-S16.** (Was: open, BLOCKED on P13-S28.) **Scoped 2026-07-31 as `spec/CONTRACT_P13S27_REDUCTION_AUTHORITY.md` (DRAFT, not dispatchable).** Rulings taken: a typed `BundleCapabilities` required at both `Bundle::open` and `Bundle::create` and carried on the `Bundle` — no default, so every caller states the semantics it implements — and outright rejection on mismatch via a new `CanonicalBaseRequiresRebuild` error, not read-only and not an integrity anomaly. Storing the capability keeps all 57 `commit` sites unchanged; only `open` (57 sites) and `create` (32) move. **The scoping also falsified this row's first reading that the writer path was test-only:** `epiphany-textproj`'s `serialize_document` (`serialize.rs:119`) and `project.rs:936` are production paths that copy a base's `reduction_algorithm_version` verbatim into a fresh `SnapshotRef`, which `commit_versioned` then stamps into the superblock (`bundle.rs:798`) — so production mints self-consistent stale documents **without ever calling `open`**, and the capability must govern writers too. **What blocks it:** contract pin 2a. Baseline authority `0` does not preserve the corpus (`serialize.rs:327` stamps `1` and round-trips it; `vectors.rs:353`/`:363` likewise), and once P13-S16 moves the authority to `1`, a pre-S27 base that happens to carry `1` is **indistinguishable from a legitimately rebuilt one** — a raw `u32` carries no provenance. Four dispositions are recorded there; `FORMAT_MINOR` as a provenance carrier was proposed and **rejected** (the header never changes after creation, `core_spec.tex:10799`, so a legacy bundle committing a freshly validated base keeps its old minor forever; and a minor change may only append append-safe discriminants, `:12258`, not alter acceptance semantics). The surviving requirement — provenance must ride a container property **old readers cannot silently accept** and **a later commit cannot inherit unchanged** — is a format-epoch design, filed as **P13-S28**. **Scope of the claim, deliberately narrow:** this establishes that the **current implementation** has no detection mechanism. It does **not** establish that no reduction-semantics change in the project's history was ever detectable — that needs a history audit not yet done, and the stronger sentence is deliberately not written here. **What closing it requires:** an authority naming the semantics this build implements, and a rejection-or-rebuild path when a base disagrees with it. Until then any rung changing canonical reduction semantics can record its break in prose but cannot make stale bases unusable — which is why P13-S16's contract is complete, ratifiable as a plan, and **not dispatchable**. **Method note:** an earlier draft of S16's pin 0 claimed no writer path existed at all. That was false, and the way it was false is the point — the search behind it looked for `ReductionAlgorithmVersion(` constructor calls, which cannot find a path that propagates an existing value without constructing one. The instrument could not observe the thing it was used to rule out. **UNBLOCKED 2026-08-07:** the format-epoch rung landed and its pin 8 **resolves pin 2a** — reduction-version authority is meaningful only in major-1 containers, so legacy bases are refused by container epoch and never by version arithmetic. The collision pin 2a identified never has to be adjudicated: a pre-S27 base carrying `1` and a rebuilt S16 base carrying `1` are indistinguishable as numbers but can never meet, because the former exists only in a major-0 container, refused at the epoch boundary before any version is compared. The `u32` never has to carry provenance because the container does. **S27 now additionally owes three inherited items** (both interim refusals converted to validation, M8's deferred laundering demonstration, pin 3c's two suspended conformance assertions), recorded in its contract as required tests. **~~RATIFIED 2026-08-07 after review round 1~~ — RATIFICATION WITHDRAWN 2026-08-07, see round 2 below. **RATIFIED 2026-08-08 on the repository owner's authority, after NINETEEN adversarial review rounds the last of which returned zero findings. PINS FROZEN — executed, not edited; a defect found during execution is reported, not patched in place. DISPATCHED for execution, with the work to be left STAGED and the execution report subject to INDEPENDENT REVIEW before completion is accepted, covering in particular M7's three observations and its control. Not settled by ratification: M7's authority/base leg is unverifiable until this rung is implemented, and every gate, test and mutation is specified while none has been run.** Prior status through the review: NOT RATIFIED, NOT DISPATCHABLE, pins NOT frozen, **awaiting the next independent review round. Which rounds have closed and the running tally live in the contract's own history table and are deliberately NOT restated here — this clause carried a count through two rounds and went stale in both, and the round list went stale the same way**; no execution work may begin.** Round 1 was run because this contract had reached "dispatchable" with **zero** ratification rounds on record, against the standing rule that contracts go through adversarial review before dispatch. Round 1 returned **nine findings, four blocking**, all now carried in the contract. **Correcting this row's own preceding clause:** the three inherited items were *not* all "recorded as required tests" — obligation 2, M8's laundering demonstration, appeared in **neither** the contract's test section nor its mutation plan, while that section's preamble claimed all of them were tests. It is now **M7**, and ruled a **mutation** rather than a capability restoration: the format rung's text refusal is permanent, `COMPANION_VERSION` stays 0.14.0, and the text-projection corpus keeps `canonical_bases` reach 0. The other blocking three: §0.4's `commit`-site count counted a same-named method in `epiphany-editor-core`, **a crate with no `epiphany-bundle` dependency at all** — the third instrument failure recorded in that one section; three independent stale list-counts (the test-section header, gate 1, and three report items) all naming figures the document had outgrown; and `testkit/tests/requirement_labels.rs` missing from the touch table while pin 9 may move `CORE_REQUIREMENT_COUNT` 213 → 214 — **the escapee `CLAUDE.md` names by name**, which also escaped the format-epoch rung. Non-blocking: locator drift since `381c498` (`bc06706` grew `bundle.rs` by 338 lines; pin 5's own `:396`–`:399` confirmed unmoved), pin 2a's corpus evidence superseded by the 2 → 0 rebuild, `Bundle::open(` 57 → **60**, gate 6a's scope widened to `epiphany-testkit`, and a missing **commit-side positive** test now added as test 8. **REVIEW ROUND 2, 2026-08-07, against the frozen contract: six further findings, four blocking — and round 1's ratification was therefore premature.** (1) The call-site correction had been applied to §0.4 only, leaving the "Rung type" paragraph at **57** and touch row 2 claiming `bundle.rs` has **35** opens — a figure that was never `bundle.rs` alone and is stale besides — which made the required reconciliation impossible. (2) §0.4 called `project.rs:936` a **production** bundle writer; `#[cfg(test)]` starts at `:630` and every `Bundle` call in that file is below it, so the writer-path correction stands on `serialize.rs` alone. (3) **M5 was unexecutable**: `serialize_document` refuses bases at `serialize.rs:151`, so its output is base-free, and pin 5 with test 4 require base-free bundles to open at *any* authority — split into **M5a**/**M5b**. (4) **M6's second half was unexecutable**: `open` rejects a stale base, `create` rejects a base-bearing manifest (`bundle.rs:234`), and `commit` validates what it emits, so no caller can hold an open `Bundle` with a stale *inherited* base — replaced by broadening rather than narrowing, with the unreachability itself reported as the stronger result. Non-blocking: pin 3a's justification (*"production code mints a self-consistent stale document"*) is **false in-tree** — zero production paths stage a base — so it now rests on guarding the public `commit_versioned` API; and `serialize.rs:157` is dead code orphaned by the `:151` guard, recorded and explicitly **not** repaired here. **Two of round 2's findings were introduced by round 1** — ruling M7's refusal permanent is what made M5 unexecutable, and test 8 was added without re-deriving M6 against the same reachability. **Method note: an amendment is a change to the system, not a patch to a line**; a round must re-derive every mutation against every ruling the previous round made. **Round 3 is warranted before dispatch — the defect rate has not fallen (9, then 6), and "dispatchable" is a claim requiring evidence of convergence rather than a status reached by running out of findings.** **REVIEW ROUND 3, 2026-08-07, INDEPENDENT, against `b842975`: six findings, four blocking — and every blocking finding was a defect in text rounds 1 and 2 wrote.** (1) **Pin 3a still carried the rationale round 2 retracted** — §0.4 states there is no in-tree production base writer while pin 3a still said "§0.4 shows production code minting a stale document", so the contract asserted a claim and its negation; **the third occurrence of fix-one-site-leave-the-others**. Rewritten onto the footing that survives: `commit`/`commit_versioned` are **public API** and guard out-of-tree callers, not an in-tree path. (2) **M5a had no observation mechanism** — pin 3 required the capability be *stored* and nothing exposed it; `Bundle` carries 17 public accessors and none for capabilities, so no `epiphany-textproj` test could inspect it. **`Bundle::capabilities()` is now pinned** — new scope, flagged for round 4. (3) **M5b could not fail**: if the supplied capability and the base version both derive from `CURRENT_REDUCTION_ALGORITHM_VERSION` — the natural implementation, since `roundtrip.rs:367` currently hardcodes `ReductionAlgorithmVersion(0)` — both operands move together and the comparison passes for every value. **This is §0.1's own tautology reproduced inside the mutation built to detect it.** The base version must now come from a source that does not track the authority (persisted artifact or deliberate literal), with both operands' provenance reported. (4) **M6's replacement named a scenario with no test** — test 6 stops at opening, so nothing asserted that an unrelated commit *succeeds*, and an implementation rejecting every post-base commit passed tests 2/5/6/8 while the broadening had nothing to break; **test 9 added**. Cleanup: touch row 7 listed `generators.rs` as "call sites, real authority" though it has **zero** `Bundle::open`/`create` calls and its `rng.range(0, 8)` versions are precisely the arbitrary wire values pin 3b assigns to *synthetic* capabilities — split to **row 7a**; and §7's call-site attribution credited round 1 alone where rounds 1 and 2 are both load-bearing. **The pattern is now legible and it is not about counts: three separate mutations were unrunnable in three different ways — M5a could not observe, M5b could not fail, M6 had nothing to break. §7 item 4a now requires, for every mutation, the named test it breaks and the provenance of each operand.** **Defect rate across three rounds: 9, 6, 6 — not converging.** The newest text (pin 3's accessor, M5a, M5b, test 9, row 7a) has had **zero** adversarial passes and was written by the same agent whose previous two attempts round 3 falsified. **REVIEW ROUND 4, 2026-08-07, INDEPENDENT, against `53292f6`: five findings, four blocking.** It accepted pin 3's `capabilities()` accessor as **bounded** — the first new text any round has passed — and found the M5 pair defective a third time. (1) **M5b cited the wrong value**: `roundtrip.rs:367` sits in `assert_score_serialization_stable` (`:332`) and versions an **acceleration snapshot**, not a canonical base, while `assert_reduction_serialization_stable` has **no base at all** because pin 3c suspended it — so the value round 3 warned the implementer not to touch was irrelevant to the authority check. **The tautology diagnosis stands; only its evidence was wrong.** (2) **The instrument was left unchosen** — round 3 said "the rung picks one" and offered two, one of which does not exist for the nominated crate, since `craft_image_with_base` is a private `fn` inside `epiphany-bundle`'s `#[cfg(test)]` module (`:1648`). **Now chosen: commit-then-reopen through public API only** — build with `synthetic_for_fixture(0)`, commit a base carrying the literal `0`, reopen those bytes under the real constant. (3) **No test could assert the error fields**: `assert_reduction_serialization_stable` returns `()` and reopens with `.expect` (`:292`), so a mismatch panics rather than yielding a matchable `CanonicalBaseRequiresRebuild { base, current }`. **Test 10b added.** (4) **M5a violated §7 item 4a, the rule round 3 added in the same edit** — it named no test, and its natural assertion compares the constant with itself and cannot fail. **Test 10a added, asserting against a deliberate literal.** **Round 3's error is the one to carry: it grepped `ReductionAlgorithmVersion`, saw a `roundtrip.rs` hit, and attributed it without resolving the enclosing item — the same shape as §0.4's `.commit(` miscount that round 1 had already recorded as a lesson. Recording a defect is not the same as not committing it.** Both literals in tests 10a/10b are **load-bearing as literals**; §7 item 4b now requires confirming neither was tidied into the constant, a failure mode invisible to the suite. **Defect rate: 9, 6, 6, 5 — still not converging after four rounds, and every blocking finding in rounds 3 and 4 was in text written to fix the previous round.** **REVIEW ROUND 5, 2026-08-08, INDEPENDENT, against `df9e528`: four findings, two blocking — the first round in which blocking findings fell below four.** (1) **The status history was numerically stale again** — "amended three times … fifteen findings so far, eight blocking" were the round-2 figures, left standing through rounds 3 and 4 **while the tables recording those very rounds sat directly below them**. This is the **fifth** count-staleness defect in five rounds, and it was in the one block the author edited every round. Replaced with a **table**, so a round appends a row rather than requiring a number to be found and re-derived. (2) **Test 10b could not make the two-field assertion M5b requires**: §3 said only "assert it opens", and under mutation that yields a bare `Err` or a panic — **a `#[test] -> Result` that returns `Err` asserts nothing about that error's fields**, so M5b's required observation had no home in the test M5b names. Both `Result` arms are now pinned, plus a third for the wrong-error case, so the mutation run produces a *verified* observation rather than a stack trace. Smaller: **M5b's "cannot be tidied" claim was false** — keeping `synthetic_for_fixture` while passing `CURRENT_REDUCTION_ALGORITHM_VERSION` as both its argument and the base version preserves the fixture and fully restores the tautology, so the structure does not protect itself and the real protection is §7 item 4b; round 4 asserted a structural guarantee that undercut the procedural check actually doing the work, **which is the same error as reasoning that a mutation would fail instead of running it**. And §3's preamble still said the tests were "in `epiphany-bundle`" after round 4 added two that **cannot** be, since `epiphany-bundle` must not depend on `epiphany-ops` and reaching the real authority is their entire purpose — corrected, with each test's touch-table home named. **Blocking findings by round: 4, 4, 4, 4, 2 — the first movement in four rounds and the first weak evidence of convergence, set against the fact that every round since the third has found blocking defects in text written to fix its predecessor.** **REVIEW ROUND 6, 2026-08-08, INDEPENDENT, against `03c85dd`: three findings, ALL THREE BLOCKING, and all three in text round 5 wrote.** (1) **The amendment tally went stale inside the block round 5 restructured to prevent exactly that** — round 5 turned the review totals into a table and left "amended five times … rounds 1–4" as prose immediately above it. The amendment count is now **the number of rows**, with no separate figure to go stale. (2) **§3's test-home correction was itself false**: round 5 wrote "tests 1–9 in `epiphany-bundle`", but **test 7 *is* `assert_reduction_serialization_stable`**, which the same section names as `testkit/src/roundtrip.rs`. Two wrong versions of that sentence, both written while fixing it; replaced with a per-crate table (1–6/8/9 bundle, 7 and 10b testkit, 10a textproj). (3) **§7 item 4b protected one operand where test 10b has two** — replacing **both** `synthetic_for_fixture(0)` **and** the committed base's `ReductionAlgorithmVersion(0)` with the constant **keeps the synthetic call in place** and fully restores the tautology, and **test 10b's `Err` arm never executes in the unmutated run**, so its literal cannot detect it. Item 4b now enumerates all **three** fixture operands individually and requires each quoted verbatim. **All three findings are one defect in different clothes: a fix applied to the site named rather than to every site the claim covers** — the sixth count-staleness defect in six rounds and the third range-correction that did not check its own range. **The mechanism that works is structural, not vigilant: the review totals stopped going stale when they became a table, the amendment count did not because it stayed prose, and item 4b stopped being under-specified when it became a table.** **Demonstrated a seventh time inside round 6's own amendment**, where the new table's Total row was first written "6 amendments" — a free-standing count, three paragraphs after the sentence declaring no such count exists, and already wrong at seven rows; caught before commit and replaced with "one amendment per row". **Prose invites a number and a table does not; the defence must be the shape of the artifact, not the attention of the editor.** **REVIEW ROUND 7, 2026-08-08, INDEPENDENT, against `c0d896c`: three findings, all blocking, and all three the same defect — a claim living in two places and fixed in one.** (1) **§7 item 4a was unsatisfiable**: it required every mutation to name "the test it breaks", while item 1 four paragraphs above states that **M4 is observed to *compile*** (no test is possible — that is why pin 3's prohibition is a review rule) and **M7's expected outcome is *success***. A report obeying 4a literally could not be written, and the honest response would have been to invent a test. 4a is now a table of what each of the eight mutations owes, with M4 and M7 carved out explicitly. (2) **Round 6's three-literal correction reached §7 and not §3** — §3 still said "**both** literals … tidying **either**", so the contract carried the fixed and the broken version of the same claim, reopening exactly the narrow-scope ambiguity round 6 existed to close. §3 no longer states the count at all; it points at item 4b. (3) **"Rounds 3, 4 and 5 were independent"** went stale the instant round 6 closed, sitting in prose beside the table whose own column records it. Deleted. **Three rounds, one lesson: round 5 fixed the review totals and not the amendment tally beside them, round 6 fixed item 4b and not §3's copy of the same rule, round 7 found the classification sentence duplicating the table's column. The defect is duplication, and every previous remedy was vigilance — "check the other sites too" — which has now failed three rounds running. The remedy adopted here is deletion, not diligence: where a claim had two homes, one is removed and replaced with a pointer. A copy that cannot drift is one that does not exist.** **Findings by round: 9, 6, 6, 5, 4, 3, 3 — flattened rather than still falling. Blocking: 4, 4, 4, 4, 2, 3, 3 — rounds 6 and 7 were both 100% blocking and 100% in the previous round's text. Seven consecutive rounds, no clean round yet. The deduplication is the first structural remedy for this particular defect and therefore the first with a reason to work, but it is untested.** **REVIEW ROUND 8, 2026-08-08, INDEPENDENT, against `9829ae3`: two findings, both blocking — and the first round to reach into a mutation's mechanics rather than its bookkeeping.** (1) **M7 did not describe a runnable observation.** It instructed execution to *construct* a base-bearing `TextDocument`, which **bypasses `parse_document` entirely**, so the parser refusal it ordered removed was irrelevant and the demonstration was not the **import** laundering it is named for; `project_text_document` is the **export** direction (`&TextDocument -> Result`) and is not on the path at all, so "all three sides, since removing one leaves the others refusing and the document never reaches the writer" was simply false for it; and "byte-indistinguishable from one whose base was genuinely validated" named **no comparison artifact and no comparison method**, leaving the central claim a conclusion rather than an observation. Now: the input must be **text and must be parsed**; only the parser (`parse.rs:138`–`:147`) and serializer (`serialize.rs:151`) refusals are removed and restored; the comparison artifact is **test 10b's construction** with the same `FileUuid` and base bytes; and the comparison is a **field-by-field enumeration** of the `canonical_base` `SnapshotRef`, the superblock's reduction version and the header's major/epoch, **reported rather than concluded** — informative in both directions, since a field that *does* differ is a provenance signal nobody knew existed. (2) The round-7 deduplication was incomplete: the status block still carried "rounds 3 and 4 are closed" while declaring the history table the sole authority. **Finding 1 is the most substantive of any round, because every earlier one was about text agreeing with other text — this one is about whether the experiment runs at all, and it did not. M7 had been in the contract since round 1 and survived seven reviews, three of which specifically re-derived mutations, because reading it never required tracing what calls what. An observation stated in the right register can look complete for a long time; "indistinguishable" was a conclusion sitting inside the rung's own demonstration, which is the exact failure mode this rung exists to eliminate.** **Findings by round: 9, 6, 6, 5, 4, 3, 3, 2. Blocking: 4, 4, 4, 4, 2, 3, 3, 2. Eight rounds, none clean. While amending, the author caught a third instance unaided — §7 item 4a's M7 row still said "all three refusals" — which is weak evidence the deduplication rule is being applied rather than merely stated. The M7 rewrite is now the newest and least-reviewed material in the contract, and its predecessor survived seven rounds while being unrunnable.** **REVIEW ROUND 9, 2026-08-08, INDEPENDENT, against `01e76d1`: two findings, both blocking, both in M7's comparator — the text round 8 had just rewritten.** (1) **Test 10b is not the "genuinely validated" reference M7 nominated**: its write-side capability is `synthetic_for_fixture(0)` and only its *reopen* uses the real authority, so M7 would have compared one synthetic fixture against another with the validated half of the claim simply absent. **This is a collision between two of the contract's own designs, not a typo** — round 4 made 10b synthetic-on-write *deliberately* so M5b's operands would be provably independent, and that is exactly what disqualifies it here. **One artifact cannot be both independent of the real authority and committed under it.** M7 now builds its own reference in `epiphany-testkit`, committing a base under `caps` derived from the real constant so pin 3a validates it on the way in. (2) **The field enumeration could not support its conclusion**: it claimed "everything that could carry provenance" while omitting `FixedHeader.file_uuid` — **the field it required to match** — plus the superblock's `generation`, `manifest_offset`, `manifest_length` and `manifest_hash`, and the whole manifest outside `canonical_base`. Replaced with **whole-`image()` byte comparison**, any difference enumerated and classified as justified nondeterminism (normalize, stating why) or as a **provenance signal** (a finding, since the refusal may then be stronger than needed). **Finding 2 retires a technique rather than an instance: a hand-written list of "every field" is a claim about a struct's contents that is wrong the moment the struct changes, and this one was wrong the day it was written. Comparing the whole artifact cannot be incomplete — the tables-over-numbers lesson applied to the experiment instead of the prose.** Three further sites were caught by the author while amending: §7 item 6 still said "M7's three text refusals", surviving round 8's correction of that exact count in two other places; §7 item 4a's M7 row still named the superseded method; and round 8's own disposition cell stated it as current. **Findings by round: 9, 6, 6, 5, 4, 3, 3, 2, 2. Blocking: 4, 4, 4, 4, 2, 3, 3, 2, 2. Nine rounds, none clean. Rounds 8 and 9 both found defects in the immediately preceding round's rewrite of the same paragraph, so M7 has now been wrong in three distinct ways across three consecutive rounds — unrunnable, then wrong-artifact, then wrong-method. The comparator is on its third design and has never been executed.** **REVIEW ROUND 10, 2026-08-08, INDEPENDENT, against `0efd543`: one finding, blocking — the smallest round yet, and again in M7.** **The whole-image comparison had no complete construction alignment.** Round 9 named four things to align, but `serialize_document` also fixes `document_id`, `lineage_id`, `profile_declarations`, every extension's fields and preserved chunks, the envelope payloads, the **staging order** (base root → extension chunks → operation-envelope block), the manifest schema `major` and `epoch_max`, and every chunk ref, hash and offset derived from those. **So a byte difference would have had a third possible cause — "the reference was built differently" — which is neither permitted classification; the result would have been unclassifiable and the comparison meaningless. A result that cannot be classified is not an observation.** M7 is now a **round trip**: build `B` validated under the real authority, export it to text via `document_from_bundle` + the crate-private `render_text_document`, parse that text back, re-serialize as `A` with `B`'s `FileUuid`, and compare whole images. **Alignment is inherited rather than enumerated** — every input `serialize_document` reads is already `B`'s own, so no list can be incomplete and the setup-mismatch category is eliminated by construction rather than by care. It is also the realistic form of the threat: export a validated document to text, re-import it, and observe the re-imported container is indistinguishable from the original having validated only the base's number, never its provenance. **This was the third hand-enumerated "complete set" in this contract and the third wrong on the day it was written — "every field that could carry provenance" (round 8), "every field to align" (round 9), and round 9's list again. The single rule earned across rounds 5–10: where a claim requires completeness, do not enumerate, derive. Tables instead of counts, whole artifacts instead of field lists, one shared origin instead of an alignment list.** No refusal count is stated anywhere in M7 any more; three successive wordings each had a wrong one. **Findings by round: 9, 6, 6, 5, 4, 3, 3, 2, 2, 1. Blocking: 4, 4, 4, 4, 2, 3, 3, 2, 2, 1. Ten rounds, none clean, and three consecutive rounds have found one paragraph — M7 — defective in a new way each time: unrunnable, wrong artifact, wrong method, incomplete alignment. Findings are falling steadily and each of the last three has been narrower than the last, the first sustained convergence signal here. Against that: M7 has never been executed and each of its four designs looked correct when written — the open question for round 11 is whether the next defect is findable by reading at all, or whether M7 must be run against a scratch branch before further paper review can add anything.** **BOUNDED SCRATCH PROBE, 2026-08-08, authorised as an explicit narrow exception in the contract's status block and run on a discarded branch: it FALSIFIED round 10.** First, recorded as evidence in its own right: **M7 cannot be executed at all until S27 lands** — `BundleCapabilities` and `CURRENT_REDUCTION_ALGORITHM_VERSION` do not exist in the tree, being S27's own deliverables, and M7 step 1 needs a base committed under the real authority. M7 is a mutation *of this rung's implementation*, so it runs after the rung. The probe therefore tested the round-trip machinery M7 depends on, base-free — which removes **no** refusal, since both `project_text_document` and `serialize_document` gate on `canonical_base.is_some()`, leaving §1.2 untouched. **Result: the round trip is byte-preserving, but only from a fixed point, and round 10's comparison did not compare from one.** Round 10 compared `A` against a `B` built from the *input* document, which is valid only when that document is already a fixed point of `document_from_bundle ∘ serialize_document`. `minimal_document(42)` happens to be one — so the first probe **passed**, and would have been reported as success — while `minimal_document(99)` was not, and the **one-extension case diverged by 295 bytes from offset 352**. Rebuilt from the fixed point, all three cases are byte-identical (1641 / 1800 / 1894). **The non-idempotent field is `envelopes`, not extensions:** diagnosed field-by-field, `document_id`, `manifest_schema_version`, `lineage_id`, `profiles`, `canonical_base`, `blobs` and `extensions` — including every `TextChunk` payload — survive exactly, while `document_from_bundle` applies a **canonical envelope ordering** (its own test says so), so any other arrival order is not a fixed point and its operation-block bytes differ. **`project_text_document` → `parse_document` proved LOSSLESS** (`b_doc == d` in every case) — the text leg was never the problem; the defect was entirely in which artifact round 10 chose as reference. **What M7 must add, for round 11 to ratify rather than for the probe to assume: an explicit fixed-point normalisation and assertion before any byte comparison, because otherwise a mismatch is round 10's own unclassifiable "third category".** Probe hygiene: the comparison was **mutation-verified** — a different `FileUuid` for `A` produced 20 differing bytes at offsets 32–47 and 60–63, observed, then restored by hand-editing, incidentally confirming round 9's point that `FixedHeader.file_uuid` is byte-visible and round 8's enumeration had omitted it; one file touched, 142 insertions, all inside `#[cfg(test)]`; no refusal removed; no canonical base carried; diff captured, branch deleted. **The methodological result: four paper rounds refined this comparison and none found that it silently depended on an unstated precondition. One execution found it in minutes, via the case a reviewer would least likely hand-pick — a document with an extension. Had the probe stopped at the case round 10 implied, the contract would have been ratified on a comparison that fails for most documents.** **REVIEW ROUND 11, 2026-08-08, INDEPENDENT, against `39f2617` (post-probe): three findings, two blocking. It confirmed the probe contained and its fixed-point result decisive, and kept M7 BLOCKED.** (1) **M7 still lacked a distinct normalised reference.** Round 10 named one artifact where the comparison needs two: build **`B_raw`** under the real authority, then **iterate derive-and-reserialize until `B_fixed` is a byte-level fixed point**, **assert that property explicitly as a hard failure**, and **compare the imported artifact only with `B_fixed`, never with `B_raw`** — otherwise an envelope-order normalisation difference remains **indistinguishable from a provenance result**, and a comparison whose failure mode cannot be told from its success condition decides nothing. Steps 1a–1c added, including a bounded convergence loop (the probe saw one pass suffice for three documents, which is not proof that one pass always suffices) and a required report of the iteration count and whether `B_raw` was already fixed. (2) **The claim was stated more broadly than any observation supports.** M7 read as though every direct bundle is byte-identical to its re-imported form; it is not, and the probe measured 295 differing bytes proving so. The contract now scopes it: M7 proves **the text path carries no provenance marker *after normalisation***, and explicitly **not** that every direct bundle is byte-identical before it — the pre-normalisation differences are `document_from_bundle`'s canonical envelope ordering and have nothing to do with provenance. **Both sentences must appear in the rung's report.** **This finding has consequences beyond M7: its conclusion is the sole evidence for a permanent capability loss — the text refusal that moved `COMPANION_VERSION` to 0.14.0 and took the corpus's `canonical_bases` from 2 to 0 — so justifying a permanent refusal from a claim broader than the result obtained is the same error as concluding instead of observing, one level up: not a false observation, but a true one asked to carry more than it can.** (3) Clarification rather than defect: **the probe cannot pre-verify M7's authority/base leg**, which needs `BundleCapabilities`, `capabilities()` and pin 3a's validation — S27's own deliverables — so it remains an **execution requirement after S27 implementation**, with the probe standing as evidence for the prerequisite and explicitly **not** as a demonstration of laundering, since it carried no base. Recorded as a standing prerequisite table: the round-trip leg is settled, the authority leg is not pre-verifiable by any review or probe. **Findings by round: 9, 6, 6, 5, 4, 3, 3, 2, 2, 1, 3. Blocking: 4, 4, 4, 4, 2, 3, 3, 2, 2, 1, 2. Eleven rounds, none clean. Round 11 broke the falling trend, and did so because the probe supplied evidence that made a previously invisible defect findable — a reason to expect the next round to find more rather than less. The M7 comparator is on its fifth design: four were falsified by reading, the fifth by execution and then rebuilt on that evidence. It is the first with a measured result behind it and the first whose precondition is asserted rather than assumed, and it still cannot be executed end to end until S27 is implemented.** **REVIEW ROUND 12, 2026-08-08, INDEPENDENT, against `74dc994`: two findings, both blocking, and both the same defect — a requirement stated without the decision it requires, leaving execution to make a design choice silently.** (1) **The convergence loop was not actually bounded**: it demanded a bound and named no limit, so execution would have chosen when non-convergence becomes failure, changing what the experiment means. **Now pinned at one normalising step** — with `B₀ = B_raw` and `B₍ₙ₊₁₎ = serialize_document(document_from_bundle(Bₙ), uuid)`, compute at most `B₁` and `B₂`, permitted maximum `n = 1`, with a three-row outcome table (`B₁ == B₀` → already fixed; `B₁ != B₀` and `B₂ == B₁` → `n = 1`, the expected case; **`B₂ != B₁` → HARD FAILURE**, reporting all three image lengths and the first differing offset). **The bound is one step because it is a property, not a tolerance:** `document_from_bundle` canonicalises, so `serialize_document ∘ document_from_bundle` must reach its canonical form in a single application, and if it does not there is **no canonical form**, no principled reference artifact, and **M7 is invalid as a whole** — a finding about the projection rather than a signal to iterate further. A loop that runs until it happens to settle tests nothing; it reports how long it took. Raising the bound needs its own amendment and review round. (2) **M7's location was unchosen**: "in a crate that can reach the real constant" is true of two crates and decisive for neither, and `render_text_document` is **`pub(crate)` to `epiphany-textproj`** (`project.rs:595`), so `epiphany-testkit` could host M7 only via **an unpinned visibility change to another crate's public API**. **The harness is now pinned to `epiphany-textproj`**, which alone has both the renderer and (via its `epiphany-ops` dependency) the real constant — under **existing touch row 9**, no new row. **`render_text_document` stays `pub(crate)`:** handoff §1.3 records it as *the one intentional hole* in the text refusal, existing solely so a negative vector can carry the spelling it asserts is refused, and widening it to host a mutation that gets reverted would leave a permanently widened public surface behind — which is how a temporary harness becomes an API change nobody ratified. **Findings by round: 9, 6, 6, 5, 4, 3, 3, 2, 2, 1, 3, 2. Blocking: 4, 4, 4, 4, 2, 3, 3, 2, 2, 1, 2, 2. Twelve rounds, none clean. The last two rounds found the same kind of defect — a requirement that reads as a decision but is not one — so the next scan should hunt remaining instructions that name a constraint without naming its value. Everything M7 now specifies is pinned to a number, a crate or a named artifact, which is a checkable property a round can test directly.** **REVIEW ROUND 13, 2026-08-08, INDEPENDENT, against `bff9c9a`: one finding, blocking — and it inverted M7's result.** **M7 claimed the capability check "does not fire".** Pin 3a requires `commit`/`commit_versioned` to validate a **newly emitted** canonical base, which is exactly what **both** `B_raw` and the parsed `A` commit — so **the check fires on both paths and ACCEPTS**, because the raw version equals the real authority. **That acceptance is the laundering result:** the base is not slipped past an absent check, it is admitted by a check working correctly that cannot tell a coincidence from a rebuild. **As written, M7 was satisfiable by deleting pin 3a's writer check entirely** — yielding a passing M7 that demonstrated the exact opposite of its purpose. M7 now requires **three observations** (`A.image()` equals `B_fixed.image()`; pin 3a's validation ran and accepted on both commits; and the control) plus a **required control**: in the same run, same harness, repeat the import with a base version deliberately **not** equal to the real authority and observe the commit **REJECTED** with `CanonicalBaseRequiresRebuild`. **M7's removals are now explicitly limited to the text refusals — pin 3a is not among them and may not be weakened, being the thing under observation rather than an obstacle to it.** **This is a new failure shape worth naming: an observation satisfiable by the absence of the thing it observes.** M7's earlier defects were about being unrunnable or comparing the wrong artifacts; this one would have run, passed and reported success on a tree with the writer check removed. **"The check does not fire" cannot distinguish a check that accepts from a check that is not there, and only one of those is the finding.** **Findings by round: 9, 6, 6, 5, 4, 3, 3, 2, 2, 1, 3, 2, 1. Blocking: 4, 4, 4, 4, 2, 3, 3, 2, 2, 1, 2, 2, 1. Thirteen rounds, none clean. Round 13 is the narrowest since the probe, but it found a defect of a kind no earlier round had looked for — not "can this run?" or "does this compare the right things?" but "could this pass for the wrong reason?" — and that question has NOT been asked of M1–M6, M5a or M5b. Every mutation in §4 deserves the same check: what else, besides the intended defect, would make it pass?** **REVIEW ROUND 14, 2026-08-08, INDEPENDENT, against `f579172`: one finding, blocking — a contradiction round 13 created.** **The comparison method still said equal images "complete the observation and require nothing further"** — written in round 9, when byte equality *was* the whole of M7, and not swept when round 13 added the writer-check control. **The contract therefore simultaneously required the control and licensed omitting it, with the permissive sentence sitting earlier and reading as the summary.** Equality is now **necessary but not sufficient**: observation 1 of three, with the control still required, and that paragraph now specifies *how to compare*, never *what suffices*. **A second instance was found while amending, and round 14 reported none:** the "informative in both directions" note read *"if **every field matches**, the refusal is justified"* — the same sufficiency claim in different words, still carrying round 8's *"every field"* vocabulary that round 9 had replaced with whole-image comparison. **A search for "nothing further" or "sufficient" cannot reach a sentence that says "matches"** — the defect `CLAUDE.md` names, *searching one spelling and concluding about all sites*, met inside the fix for a sweep failure; neither the reviewer's search nor the author's first search found it, and a third pass on different terms did. **The round-13 lesson generalises further than round 13 stated: it is not only that a requirement must be swept to every site, but that the permissive statement usually reads *earlier* than the restrictive one, because requirements accumulate downward as a document is amended. A reader following the document in order stops at the first sentence that says "done". Where a later round narrows what suffices, the earlier summary is the site most likely to contradict it and least likely to be searched.** **Findings by round: 9, 6, 6, 5, 4, 3, 3, 2, 2, 1, 3, 2, 1, 1. Blocking: 4, 4, 4, 4, 2, 3, 3, 2, 2, 1, 2, 2, 1, 1. Fourteen rounds, none clean — but the last two are single-finding rounds and round 14's was created by round 13 rather than pre-existing, the narrowest the defect stream has been. Against that, round 13's question — what else, besides the intended defect, would make this pass? — has still not been asked of M1–M6, M5a or M5b, and round 14 did not ask it either. That scan remains outstanding and is the largest known unexamined surface.** **REVIEW ROUND 15, 2026-08-08, INDEPENDENT, against `fa483cf`: one finding, blocking — and it ran the scan rounds 13 and 14 left outstanding.** **M6 accepted "test 5 fails" and "test 9 fails" as its observations.** A test fails for **every** reason, not only the one under test, so an unrelated writer rejection satisfies both exactly as well as the intended cause — M6 could have reported success while demonstrating nothing about pin 3a's scope. Both halves now require the **mutated outcome itself**: after removing pin 3a, test 5's stale commit must be observed to **SUCCEED** and the bundle to reopen at the new generation with the stale base present; after broadening pin 3a, test 9's otherwise-unchanged commit — one that does not touch `canonical_base` — must be observed **rejected specifically by the broadened writer rule**, named in the report, not merely erroring. **The scan is now complete: M1–M5b survive it, M6 did not.** That the one remaining instance was in M6 — the mutation twice rewritten for unexecutability — is worth noting: **a mutation can be made runnable and still not be evidential.** **The principle, stated once so it need not be rediscovered: the evidence a mutation owes is the behaviour it changed, not the assertion it broke. A broken assertion is a symptom with many possible causes; the changed behaviour has one. Every mutation in §4 now names an outcome, not a failure.** **Findings by round: 9, 6, 6, 5, 4, 3, 3, 2, 2, 1, 3, 2, 1, 1, 1. Blocking: 4, 4, 4, 4, 2, 3, 3, 2, 2, 1, 2, 2, 1, 1, 1. Fifteen rounds, none returning zero — but what changed in the last three is the character of the findings, not only the count: round 13 found a defect of a kind never looked for, round 14 found a contradiction round 13 created, and round 15 found the last instance of round 13's kind with the scan reported complete across every mutation. The known unexamined surfaces are now enumerable, which they were not before: §4 is scanned and clean, and M7's authority/base leg remains unverifiable until S27 is implemented by construction, carried as an execution requirement rather than a gap in the document.** **ROUNDS 16–19, 2026-08-08, recorded together.** **Round 16 (independent, 4 findings, all blocking):** round 15 stated a rule covering every mutation and applied it only to M6 — **M1, M2, M3 and M5a still took a broken assertion as evidence**, and each now requires the mutated behaviour itself: the stale base observed *opening*; the corrupt fixture observed returning `CanonicalBaseRequiresRebuild`; the base-free fixture observed rejected by the wrongly widened check, with `base` named as the superblock's no-base default and **that synthetic source prohibited from shipped validation**; and `serialize_document`'s stored capability observed equal to the changed authority. Round 15's completeness claim is marked **FALSIFIED IN ROUND 16** at its original site. **Round 17 (authored-side sweep of §3 and §5, 10 findings, 5 blocking):** round 13's *could-this-pass-for-the-wrong-reason* question had never been asked of the tests or the gates, and both yielded immediately. **Gate 6's derive alternative could never match** — `grep` is line-oriented, so `[[:space:]]*` cannot cross the newline rustfmt puts between `#[derive(…, Default)]` and `pub struct BundleCapabilities`; the likelier violation returned 0 matches and the gate **passed**, while being the sole mechanical guard on the pin-3 prohibition M4 exists for because no test can catch it. **Gate 6a was vacuous under a rename** — pin 3b offered `synthetic_for_fixture` as an example, and the name is now pinned. **Gates 2 and 3 named no toolchain** in a repo whose CI records 1.95/1.97 lint divergence and whose default is 1.97.1; both are now `cargo +1.95.0`. **Gate 4's "staged list exactly §2" was unsatisfiable** with a conditional touch row, now subset-both-ways. **Tests 1, 6, 7, 8 and 9 could all pass on a base-free bundle**, since pin 5 makes base-free the permissive case and base-bearing fixtures are the awkward ones to build — test 1 degenerated into test 4. Also: tests 1/6 given distinct construction routes, test 4's caps asserted unequal, gate 1 requiring **0 ignored**, gate 7 given a method, gate 5 quoting all three dependency tables. **The unifying defect: a gate proving absence is only as strong as the string it searches for — a regex that cannot match, a name that was an example, a clause with no method, all reporting success while checking nothing. The remedy throughout is §4's: require an artifact quoted and read, not a pattern matched.** **Round 18 (independent, 2 findings, both blocking, both created by round 17):** the base-presence rule **demanded the opposite of what test 8 is for** — it grouped tests 8 and 9 as "the ones that commit", but test 8 *introduces* the base and must start `is_none()`, so the rule was either unsatisfiable or satisfiable by a fixture that made the test assert nothing; and **test 6's construction was self-contradictory**, assigned the commit path while required to arrive as its hand-built ancestor did (`bundle.rs:1866` calls `craft_image_with_base` at `:1869`). Fixed by a per-test state table and by **swapping the routes**, which makes the attribution true rather than deleting it. **Round 19 (independent): ZERO FINDINGS — the first clean round in nineteen**, confirming the per-test table, the route swap and the revised gate mechanics. **Running total: 65 findings, 47 blocking, across 19 rounds. A clean round is the criterion named at round 11 and the first evidence of convergence this contract has produced; it is not proof of correctness, and no round has re-derived the whole document. Still open after any ratification: M7's authority/base leg is unverifiable until S27 is implemented, those being S27's own deliverables, and every gate, test and mutation is specified but none has been run** | +| P13-S27 | **The reduction-algorithm-version machinery is self-referential, so the one check that would detect a canonical-semantics change necessarily passes.** `core_spec.tex:11614`–`:11617` is normative — *"Snapshots produced under an earlier algorithm version cannot be used as canonical bases under a later one without rebuilding"* — and `:14369`–`:14372` states that replicas at differing versions *"may produce different canonical states from the same operation set."* The machinery to enforce it appears to exist: `ReductionAlgorithmVersion` (`bundle/src/ids.rs:291`) is a superblock wire field (bytes `68..72`, `superblock.rs:20`); `reduction_version_for` (`bundle.rs:989`) sets a new superblock's value; and `open` (`bundle.rs:396`–`:399`) rejects a mismatch. **But the writer sources the value from the canonical base's own self-report** (mapping the base's `reduction_algorithm_version` through `unwrap_or_default()`), **and the reader compares it only against the superblock that value seeded.** Nothing compares either against the semantics the running implementation actually implements. **The check is not vacuous** — it catches a corrupt or tampered base whose version disagrees with its superblock — but it **necessarily passes for a conformingly propagated stale base**, which is precisely the case the requirement exists to prevent. Supporting: **no constant or accessor anywhere names the implementation's current reduction semantics**, and `ids.rs:288`–`:289` states that *"the algorithm catalog itself lives in `epiphany-ops`"* while nothing of the kind exists in that crate — **a second instance of P13-S26's pattern**, a doc comment asserting a false fact about another module | `spec/CONTRACT_P13S16_PROJECTION.md` pin 0 (found 2026-07-31 while scoping P13-S16, which is a canonical reduction-semantics change and therefore the first rung to need this guarantee; filed in the same ledger edit as the row it blocks) | **UNBLOCKED 2026-08-07 — the format-epoch rung landed; dispatchable, and still blocking P13-S16.** (Was: open, BLOCKED on P13-S28.) **Scoped 2026-07-31 as `spec/CONTRACT_P13S27_REDUCTION_AUTHORITY.md` (DRAFT, not dispatchable).** Rulings taken: a typed `BundleCapabilities` required at both `Bundle::open` and `Bundle::create` and carried on the `Bundle` — no default, so every caller states the semantics it implements — and outright rejection on mismatch via a new `CanonicalBaseRequiresRebuild` error, not read-only and not an integrity anomaly. Storing the capability keeps all 57 `commit` sites unchanged; only `open` (57 sites) and `create` (32) move. **The scoping also falsified this row's first reading that the writer path was test-only:** `epiphany-textproj`'s `serialize_document` (`serialize.rs:119`) and `project.rs:936` are production paths that copy a base's `reduction_algorithm_version` verbatim into a fresh `SnapshotRef`, which `commit_versioned` then stamps into the superblock (`bundle.rs:798`) — so production mints self-consistent stale documents **without ever calling `open`**, and the capability must govern writers too. **What blocks it:** contract pin 2a. Baseline authority `0` does not preserve the corpus (`serialize.rs:327` stamps `1` and round-trips it; `vectors.rs:353`/`:363` likewise), and once P13-S16 moves the authority to `1`, a pre-S27 base that happens to carry `1` is **indistinguishable from a legitimately rebuilt one** — a raw `u32` carries no provenance. Four dispositions are recorded there; `FORMAT_MINOR` as a provenance carrier was proposed and **rejected** (the header never changes after creation, `core_spec.tex:10799`, so a legacy bundle committing a freshly validated base keeps its old minor forever; and a minor change may only append append-safe discriminants, `:12258`, not alter acceptance semantics). The surviving requirement — provenance must ride a container property **old readers cannot silently accept** and **a later commit cannot inherit unchanged** — is a format-epoch design, filed as **P13-S28**. **Scope of the claim, deliberately narrow:** this establishes that the **current implementation** has no detection mechanism. It does **not** establish that no reduction-semantics change in the project's history was ever detectable — that needs a history audit not yet done, and the stronger sentence is deliberately not written here. **What closing it requires:** an authority naming the semantics this build implements, and a rejection-or-rebuild path when a base disagrees with it. Until then any rung changing canonical reduction semantics can record its break in prose but cannot make stale bases unusable — which is why P13-S16's contract is complete, ratifiable as a plan, and **not dispatchable**. **Method note:** an earlier draft of S16's pin 0 claimed no writer path existed at all. That was false, and the way it was false is the point — the search behind it looked for `ReductionAlgorithmVersion(` constructor calls, which cannot find a path that propagates an existing value without constructing one. The instrument could not observe the thing it was used to rule out. **UNBLOCKED 2026-08-07:** the format-epoch rung landed and its pin 8 **resolves pin 2a** — reduction-version authority is meaningful only in major-1 containers, so legacy bases are refused by container epoch and never by version arithmetic. The collision pin 2a identified never has to be adjudicated: a pre-S27 base carrying `1` and a rebuilt S16 base carrying `1` are indistinguishable as numbers but can never meet, because the former exists only in a major-0 container, refused at the epoch boundary before any version is compared. The `u32` never has to carry provenance because the container does. **S27 now additionally owes three inherited items** (both interim refusals converted to validation, M8's deferred laundering demonstration, pin 3c's two suspended conformance assertions), recorded in its contract as required tests. **~~RATIFIED 2026-08-07 after review round 1~~ — RATIFICATION WITHDRAWN 2026-08-07, see round 2 below. **RATIFIED 2026-08-08 on the repository owner's authority, after NINETEEN adversarial review rounds the last of which returned zero findings. PINS FROZEN — executed, not edited; a defect found during execution is reported, not patched in place. DISPATCHED for execution, with the work to be left STAGED and the execution report subject to INDEPENDENT REVIEW before completion is accepted, covering in particular M7's three observations and its control. Not settled by ratification: M7's authority/base leg is unverifiable until this rung is implemented, and every gate, test and mutation is specified while none has been run.** Prior status through the review: NOT RATIFIED, NOT DISPATCHABLE, pins NOT frozen, **awaiting the next independent review round. Which rounds have closed and the running tally live in the contract's own history table and are deliberately NOT restated here — this clause carried a count through two rounds and went stale in both, and the round list went stale the same way**; no execution work may begin.** Round 1 was run because this contract had reached "dispatchable" with **zero** ratification rounds on record, against the standing rule that contracts go through adversarial review before dispatch. Round 1 returned **nine findings, four blocking**, all now carried in the contract. **Correcting this row's own preceding clause:** the three inherited items were *not* all "recorded as required tests" — obligation 2, M8's laundering demonstration, appeared in **neither** the contract's test section nor its mutation plan, while that section's preamble claimed all of them were tests. It is now **M7**, and ruled a **mutation** rather than a capability restoration: the format rung's text refusal is permanent, `COMPANION_VERSION` stays 0.14.0, and the text-projection corpus keeps `canonical_bases` reach 0. The other blocking three: §0.4's `commit`-site count counted a same-named method in `epiphany-editor-core`, **a crate with no `epiphany-bundle` dependency at all** — the third instrument failure recorded in that one section; three independent stale list-counts (the test-section header, gate 1, and three report items) all naming figures the document had outgrown; and `testkit/tests/requirement_labels.rs` missing from the touch table while pin 9 may move `CORE_REQUIREMENT_COUNT` 213 → 214 — **the escapee `CLAUDE.md` names by name**, which also escaped the format-epoch rung. Non-blocking: locator drift since `381c498` (`bc06706` grew `bundle.rs` by 338 lines; pin 5's own `:396`–`:399` confirmed unmoved), pin 2a's corpus evidence superseded by the 2 → 0 rebuild, `Bundle::open(` 57 → **60**, gate 6a's scope widened to `epiphany-testkit`, and a missing **commit-side positive** test now added as test 8. **REVIEW ROUND 2, 2026-08-07, against the frozen contract: six further findings, four blocking — and round 1's ratification was therefore premature.** (1) The call-site correction had been applied to §0.4 only, leaving the "Rung type" paragraph at **57** and touch row 2 claiming `bundle.rs` has **35** opens — a figure that was never `bundle.rs` alone and is stale besides — which made the required reconciliation impossible. (2) §0.4 called `project.rs:936` a **production** bundle writer; `#[cfg(test)]` starts at `:630` and every `Bundle` call in that file is below it, so the writer-path correction stands on `serialize.rs` alone. (3) **M5 was unexecutable**: `serialize_document` refuses bases at `serialize.rs:151`, so its output is base-free, and pin 5 with test 4 require base-free bundles to open at *any* authority — split into **M5a**/**M5b**. (4) **M6's second half was unexecutable**: `open` rejects a stale base, `create` rejects a base-bearing manifest (`bundle.rs:234`), and `commit` validates what it emits, so no caller can hold an open `Bundle` with a stale *inherited* base — replaced by broadening rather than narrowing, with the unreachability itself reported as the stronger result. Non-blocking: pin 3a's justification (*"production code mints a self-consistent stale document"*) is **false in-tree** — zero production paths stage a base — so it now rests on guarding the public `commit_versioned` API; and `serialize.rs:157` is dead code orphaned by the `:151` guard, recorded and explicitly **not** repaired here. **Two of round 2's findings were introduced by round 1** — ruling M7's refusal permanent is what made M5 unexecutable, and test 8 was added without re-deriving M6 against the same reachability. **Method note: an amendment is a change to the system, not a patch to a line**; a round must re-derive every mutation against every ruling the previous round made. **Round 3 is warranted before dispatch — the defect rate has not fallen (9, then 6), and "dispatchable" is a claim requiring evidence of convergence rather than a status reached by running out of findings.** **REVIEW ROUND 3, 2026-08-07, INDEPENDENT, against `b842975`: six findings, four blocking — and every blocking finding was a defect in text rounds 1 and 2 wrote.** (1) **Pin 3a still carried the rationale round 2 retracted** — §0.4 states there is no in-tree production base writer while pin 3a still said "§0.4 shows production code minting a stale document", so the contract asserted a claim and its negation; **the third occurrence of fix-one-site-leave-the-others**. Rewritten onto the footing that survives: `commit`/`commit_versioned` are **public API** and guard out-of-tree callers, not an in-tree path. (2) **M5a had no observation mechanism** — pin 3 required the capability be *stored* and nothing exposed it; `Bundle` carries 17 public accessors and none for capabilities, so no `epiphany-textproj` test could inspect it. **`Bundle::capabilities()` is now pinned** — new scope, flagged for round 4. (3) **M5b could not fail**: if the supplied capability and the base version both derive from `CURRENT_REDUCTION_ALGORITHM_VERSION` — the natural implementation, since `roundtrip.rs:367` currently hardcodes `ReductionAlgorithmVersion(0)` — both operands move together and the comparison passes for every value. **This is §0.1's own tautology reproduced inside the mutation built to detect it.** The base version must now come from a source that does not track the authority (persisted artifact or deliberate literal), with both operands' provenance reported. (4) **M6's replacement named a scenario with no test** — test 6 stops at opening, so nothing asserted that an unrelated commit *succeeds*, and an implementation rejecting every post-base commit passed tests 2/5/6/8 while the broadening had nothing to break; **test 9 added**. Cleanup: touch row 7 listed `generators.rs` as "call sites, real authority" though it has **zero** `Bundle::open`/`create` calls and its `rng.range(0, 8)` versions are precisely the arbitrary wire values pin 3b assigns to *synthetic* capabilities — split to **row 7a**; and §7's call-site attribution credited round 1 alone where rounds 1 and 2 are both load-bearing. **The pattern is now legible and it is not about counts: three separate mutations were unrunnable in three different ways — M5a could not observe, M5b could not fail, M6 had nothing to break. §7 item 4a now requires, for every mutation, the named test it breaks and the provenance of each operand.** **Defect rate across three rounds: 9, 6, 6 — not converging.** The newest text (pin 3's accessor, M5a, M5b, test 9, row 7a) has had **zero** adversarial passes and was written by the same agent whose previous two attempts round 3 falsified. **REVIEW ROUND 4, 2026-08-07, INDEPENDENT, against `53292f6`: five findings, four blocking.** It accepted pin 3's `capabilities()` accessor as **bounded** — the first new text any round has passed — and found the M5 pair defective a third time. (1) **M5b cited the wrong value**: `roundtrip.rs:367` sits in `assert_score_serialization_stable` (`:332`) and versions an **acceleration snapshot**, not a canonical base, while `assert_reduction_serialization_stable` has **no base at all** because pin 3c suspended it — so the value round 3 warned the implementer not to touch was irrelevant to the authority check. **The tautology diagnosis stands; only its evidence was wrong.** (2) **The instrument was left unchosen** — round 3 said "the rung picks one" and offered two, one of which does not exist for the nominated crate, since `craft_image_with_base` is a private `fn` inside `epiphany-bundle`'s `#[cfg(test)]` module (`:1648`). **Now chosen: commit-then-reopen through public API only** — build with `synthetic_for_fixture(0)`, commit a base carrying the literal `0`, reopen those bytes under the real constant. (3) **No test could assert the error fields**: `assert_reduction_serialization_stable` returns `()` and reopens with `.expect` (`:292`), so a mismatch panics rather than yielding a matchable `CanonicalBaseRequiresRebuild { base, current }`. **Test 10b added.** (4) **M5a violated §7 item 4a, the rule round 3 added in the same edit** — it named no test, and its natural assertion compares the constant with itself and cannot fail. **Test 10a added, asserting against a deliberate literal.** **Round 3's error is the one to carry: it grepped `ReductionAlgorithmVersion`, saw a `roundtrip.rs` hit, and attributed it without resolving the enclosing item — the same shape as §0.4's `.commit(` miscount that round 1 had already recorded as a lesson. Recording a defect is not the same as not committing it.** Both literals in tests 10a/10b are **load-bearing as literals**; §7 item 4b now requires confirming neither was tidied into the constant, a failure mode invisible to the suite. **Defect rate: 9, 6, 6, 5 — still not converging after four rounds, and every blocking finding in rounds 3 and 4 was in text written to fix the previous round.** **REVIEW ROUND 5, 2026-08-08, INDEPENDENT, against `df9e528`: four findings, two blocking — the first round in which blocking findings fell below four.** (1) **The status history was numerically stale again** — "amended three times … fifteen findings so far, eight blocking" were the round-2 figures, left standing through rounds 3 and 4 **while the tables recording those very rounds sat directly below them**. This is the **fifth** count-staleness defect in five rounds, and it was in the one block the author edited every round. Replaced with a **table**, so a round appends a row rather than requiring a number to be found and re-derived. (2) **Test 10b could not make the two-field assertion M5b requires**: §3 said only "assert it opens", and under mutation that yields a bare `Err` or a panic — **a `#[test] -> Result` that returns `Err` asserts nothing about that error's fields**, so M5b's required observation had no home in the test M5b names. Both `Result` arms are now pinned, plus a third for the wrong-error case, so the mutation run produces a *verified* observation rather than a stack trace. Smaller: **M5b's "cannot be tidied" claim was false** — keeping `synthetic_for_fixture` while passing `CURRENT_REDUCTION_ALGORITHM_VERSION` as both its argument and the base version preserves the fixture and fully restores the tautology, so the structure does not protect itself and the real protection is §7 item 4b; round 4 asserted a structural guarantee that undercut the procedural check actually doing the work, **which is the same error as reasoning that a mutation would fail instead of running it**. And §3's preamble still said the tests were "in `epiphany-bundle`" after round 4 added two that **cannot** be, since `epiphany-bundle` must not depend on `epiphany-ops` and reaching the real authority is their entire purpose — corrected, with each test's touch-table home named. **Blocking findings by round: 4, 4, 4, 4, 2 — the first movement in four rounds and the first weak evidence of convergence, set against the fact that every round since the third has found blocking defects in text written to fix its predecessor.** **REVIEW ROUND 6, 2026-08-08, INDEPENDENT, against `03c85dd`: three findings, ALL THREE BLOCKING, and all three in text round 5 wrote.** (1) **The amendment tally went stale inside the block round 5 restructured to prevent exactly that** — round 5 turned the review totals into a table and left "amended five times … rounds 1–4" as prose immediately above it. The amendment count is now **the number of rows**, with no separate figure to go stale. (2) **§3's test-home correction was itself false**: round 5 wrote "tests 1–9 in `epiphany-bundle`", but **test 7 *is* `assert_reduction_serialization_stable`**, which the same section names as `testkit/src/roundtrip.rs`. Two wrong versions of that sentence, both written while fixing it; replaced with a per-crate table (1–6/8/9 bundle, 7 and 10b testkit, 10a textproj). (3) **§7 item 4b protected one operand where test 10b has two** — replacing **both** `synthetic_for_fixture(0)` **and** the committed base's `ReductionAlgorithmVersion(0)` with the constant **keeps the synthetic call in place** and fully restores the tautology, and **test 10b's `Err` arm never executes in the unmutated run**, so its literal cannot detect it. Item 4b now enumerates all **three** fixture operands individually and requires each quoted verbatim. **All three findings are one defect in different clothes: a fix applied to the site named rather than to every site the claim covers** — the sixth count-staleness defect in six rounds and the third range-correction that did not check its own range. **The mechanism that works is structural, not vigilant: the review totals stopped going stale when they became a table, the amendment count did not because it stayed prose, and item 4b stopped being under-specified when it became a table.** **Demonstrated a seventh time inside round 6's own amendment**, where the new table's Total row was first written "6 amendments" — a free-standing count, three paragraphs after the sentence declaring no such count exists, and already wrong at seven rows; caught before commit and replaced with "one amendment per row". **Prose invites a number and a table does not; the defence must be the shape of the artifact, not the attention of the editor.** **REVIEW ROUND 7, 2026-08-08, INDEPENDENT, against `c0d896c`: three findings, all blocking, and all three the same defect — a claim living in two places and fixed in one.** (1) **§7 item 4a was unsatisfiable**: it required every mutation to name "the test it breaks", while item 1 four paragraphs above states that **M4 is observed to *compile*** (no test is possible — that is why pin 3's prohibition is a review rule) and **M7's expected outcome is *success***. A report obeying 4a literally could not be written, and the honest response would have been to invent a test. 4a is now a table of what each of the eight mutations owes, with M4 and M7 carved out explicitly. (2) **Round 6's three-literal correction reached §7 and not §3** — §3 still said "**both** literals … tidying **either**", so the contract carried the fixed and the broken version of the same claim, reopening exactly the narrow-scope ambiguity round 6 existed to close. §3 no longer states the count at all; it points at item 4b. (3) **"Rounds 3, 4 and 5 were independent"** went stale the instant round 6 closed, sitting in prose beside the table whose own column records it. Deleted. **Three rounds, one lesson: round 5 fixed the review totals and not the amendment tally beside them, round 6 fixed item 4b and not §3's copy of the same rule, round 7 found the classification sentence duplicating the table's column. The defect is duplication, and every previous remedy was vigilance — "check the other sites too" — which has now failed three rounds running. The remedy adopted here is deletion, not diligence: where a claim had two homes, one is removed and replaced with a pointer. A copy that cannot drift is one that does not exist.** **Findings by round: 9, 6, 6, 5, 4, 3, 3 — flattened rather than still falling. Blocking: 4, 4, 4, 4, 2, 3, 3 — rounds 6 and 7 were both 100% blocking and 100% in the previous round's text. Seven consecutive rounds, no clean round yet. The deduplication is the first structural remedy for this particular defect and therefore the first with a reason to work, but it is untested.** **REVIEW ROUND 8, 2026-08-08, INDEPENDENT, against `9829ae3`: two findings, both blocking — and the first round to reach into a mutation's mechanics rather than its bookkeeping.** (1) **M7 did not describe a runnable observation.** It instructed execution to *construct* a base-bearing `TextDocument`, which **bypasses `parse_document` entirely**, so the parser refusal it ordered removed was irrelevant and the demonstration was not the **import** laundering it is named for; `project_text_document` is the **export** direction (`&TextDocument -> Result`) and is not on the path at all, so "all three sides, since removing one leaves the others refusing and the document never reaches the writer" was simply false for it; and "byte-indistinguishable from one whose base was genuinely validated" named **no comparison artifact and no comparison method**, leaving the central claim a conclusion rather than an observation. Now: the input must be **text and must be parsed**; only the parser (`parse.rs:138`–`:147`) and serializer (`serialize.rs:151`) refusals are removed and restored; the comparison artifact is **test 10b's construction** with the same `FileUuid` and base bytes; and the comparison is a **field-by-field enumeration** of the `canonical_base` `SnapshotRef`, the superblock's reduction version and the header's major/epoch, **reported rather than concluded** — informative in both directions, since a field that *does* differ is a provenance signal nobody knew existed. (2) The round-7 deduplication was incomplete: the status block still carried "rounds 3 and 4 are closed" while declaring the history table the sole authority. **Finding 1 is the most substantive of any round, because every earlier one was about text agreeing with other text — this one is about whether the experiment runs at all, and it did not. M7 had been in the contract since round 1 and survived seven reviews, three of which specifically re-derived mutations, because reading it never required tracing what calls what. An observation stated in the right register can look complete for a long time; "indistinguishable" was a conclusion sitting inside the rung's own demonstration, which is the exact failure mode this rung exists to eliminate.** **Findings by round: 9, 6, 6, 5, 4, 3, 3, 2. Blocking: 4, 4, 4, 4, 2, 3, 3, 2. Eight rounds, none clean. While amending, the author caught a third instance unaided — §7 item 4a's M7 row still said "all three refusals" — which is weak evidence the deduplication rule is being applied rather than merely stated. The M7 rewrite is now the newest and least-reviewed material in the contract, and its predecessor survived seven rounds while being unrunnable.** **REVIEW ROUND 9, 2026-08-08, INDEPENDENT, against `01e76d1`: two findings, both blocking, both in M7's comparator — the text round 8 had just rewritten.** (1) **Test 10b is not the "genuinely validated" reference M7 nominated**: its write-side capability is `synthetic_for_fixture(0)` and only its *reopen* uses the real authority, so M7 would have compared one synthetic fixture against another with the validated half of the claim simply absent. **This is a collision between two of the contract's own designs, not a typo** — round 4 made 10b synthetic-on-write *deliberately* so M5b's operands would be provably independent, and that is exactly what disqualifies it here. **One artifact cannot be both independent of the real authority and committed under it.** M7 now builds its own reference in `epiphany-testkit`, committing a base under `caps` derived from the real constant so pin 3a validates it on the way in. (2) **The field enumeration could not support its conclusion**: it claimed "everything that could carry provenance" while omitting `FixedHeader.file_uuid` — **the field it required to match** — plus the superblock's `generation`, `manifest_offset`, `manifest_length` and `manifest_hash`, and the whole manifest outside `canonical_base`. Replaced with **whole-`image()` byte comparison**, any difference enumerated and classified as justified nondeterminism (normalize, stating why) or as a **provenance signal** (a finding, since the refusal may then be stronger than needed). **Finding 2 retires a technique rather than an instance: a hand-written list of "every field" is a claim about a struct's contents that is wrong the moment the struct changes, and this one was wrong the day it was written. Comparing the whole artifact cannot be incomplete — the tables-over-numbers lesson applied to the experiment instead of the prose.** Three further sites were caught by the author while amending: §7 item 6 still said "M7's three text refusals", surviving round 8's correction of that exact count in two other places; §7 item 4a's M7 row still named the superseded method; and round 8's own disposition cell stated it as current. **Findings by round: 9, 6, 6, 5, 4, 3, 3, 2, 2. Blocking: 4, 4, 4, 4, 2, 3, 3, 2, 2. Nine rounds, none clean. Rounds 8 and 9 both found defects in the immediately preceding round's rewrite of the same paragraph, so M7 has now been wrong in three distinct ways across three consecutive rounds — unrunnable, then wrong-artifact, then wrong-method. The comparator is on its third design and has never been executed.** **REVIEW ROUND 10, 2026-08-08, INDEPENDENT, against `0efd543`: one finding, blocking — the smallest round yet, and again in M7.** **The whole-image comparison had no complete construction alignment.** Round 9 named four things to align, but `serialize_document` also fixes `document_id`, `lineage_id`, `profile_declarations`, every extension's fields and preserved chunks, the envelope payloads, the **staging order** (base root → extension chunks → operation-envelope block), the manifest schema `major` and `epoch_max`, and every chunk ref, hash and offset derived from those. **So a byte difference would have had a third possible cause — "the reference was built differently" — which is neither permitted classification; the result would have been unclassifiable and the comparison meaningless. A result that cannot be classified is not an observation.** M7 is now a **round trip**: build `B` validated under the real authority, export it to text via `document_from_bundle` + the crate-private `render_text_document`, parse that text back, re-serialize as `A` with `B`'s `FileUuid`, and compare whole images. **Alignment is inherited rather than enumerated** — every input `serialize_document` reads is already `B`'s own, so no list can be incomplete and the setup-mismatch category is eliminated by construction rather than by care. It is also the realistic form of the threat: export a validated document to text, re-import it, and observe the re-imported container is indistinguishable from the original having validated only the base's number, never its provenance. **This was the third hand-enumerated "complete set" in this contract and the third wrong on the day it was written — "every field that could carry provenance" (round 8), "every field to align" (round 9), and round 9's list again. The single rule earned across rounds 5–10: where a claim requires completeness, do not enumerate, derive. Tables instead of counts, whole artifacts instead of field lists, one shared origin instead of an alignment list.** No refusal count is stated anywhere in M7 any more; three successive wordings each had a wrong one. **Findings by round: 9, 6, 6, 5, 4, 3, 3, 2, 2, 1. Blocking: 4, 4, 4, 4, 2, 3, 3, 2, 2, 1. Ten rounds, none clean, and three consecutive rounds have found one paragraph — M7 — defective in a new way each time: unrunnable, wrong artifact, wrong method, incomplete alignment. Findings are falling steadily and each of the last three has been narrower than the last, the first sustained convergence signal here. Against that: M7 has never been executed and each of its four designs looked correct when written — the open question for round 11 is whether the next defect is findable by reading at all, or whether M7 must be run against a scratch branch before further paper review can add anything.** **BOUNDED SCRATCH PROBE, 2026-08-08, authorised as an explicit narrow exception in the contract's status block and run on a discarded branch: it FALSIFIED round 10.** First, recorded as evidence in its own right: **M7 cannot be executed at all until S27 lands** — `BundleCapabilities` and `CURRENT_REDUCTION_ALGORITHM_VERSION` do not exist in the tree, being S27's own deliverables, and M7 step 1 needs a base committed under the real authority. M7 is a mutation *of this rung's implementation*, so it runs after the rung. The probe therefore tested the round-trip machinery M7 depends on, base-free — which removes **no** refusal, since both `project_text_document` and `serialize_document` gate on `canonical_base.is_some()`, leaving §1.2 untouched. **Result: the round trip is byte-preserving, but only from a fixed point, and round 10's comparison did not compare from one.** Round 10 compared `A` against a `B` built from the *input* document, which is valid only when that document is already a fixed point of `document_from_bundle ∘ serialize_document`. `minimal_document(42)` happens to be one — so the first probe **passed**, and would have been reported as success — while `minimal_document(99)` was not, and the **one-extension case diverged by 295 bytes from offset 352**. Rebuilt from the fixed point, all three cases are byte-identical (1641 / 1800 / 1894). **The non-idempotent field is `envelopes`, not extensions:** diagnosed field-by-field, `document_id`, `manifest_schema_version`, `lineage_id`, `profiles`, `canonical_base`, `blobs` and `extensions` — including every `TextChunk` payload — survive exactly, while `document_from_bundle` applies a **canonical envelope ordering** (its own test says so), so any other arrival order is not a fixed point and its operation-block bytes differ. **`project_text_document` → `parse_document` proved LOSSLESS** (`b_doc == d` in every case) — the text leg was never the problem; the defect was entirely in which artifact round 10 chose as reference. **What M7 must add, for round 11 to ratify rather than for the probe to assume: an explicit fixed-point normalisation and assertion before any byte comparison, because otherwise a mismatch is round 10's own unclassifiable "third category".** Probe hygiene: the comparison was **mutation-verified** — a different `FileUuid` for `A` produced 20 differing bytes at offsets 32–47 and 60–63, observed, then restored by hand-editing, incidentally confirming round 9's point that `FixedHeader.file_uuid` is byte-visible and round 8's enumeration had omitted it; one file touched, 142 insertions, all inside `#[cfg(test)]`; no refusal removed; no canonical base carried; diff captured, branch deleted. **The methodological result: four paper rounds refined this comparison and none found that it silently depended on an unstated precondition. One execution found it in minutes, via the case a reviewer would least likely hand-pick — a document with an extension. Had the probe stopped at the case round 10 implied, the contract would have been ratified on a comparison that fails for most documents.** **REVIEW ROUND 11, 2026-08-08, INDEPENDENT, against `39f2617` (post-probe): three findings, two blocking. It confirmed the probe contained and its fixed-point result decisive, and kept M7 BLOCKED.** (1) **M7 still lacked a distinct normalised reference.** Round 10 named one artifact where the comparison needs two: build **`B_raw`** under the real authority, then **iterate derive-and-reserialize until `B_fixed` is a byte-level fixed point**, **assert that property explicitly as a hard failure**, and **compare the imported artifact only with `B_fixed`, never with `B_raw`** — otherwise an envelope-order normalisation difference remains **indistinguishable from a provenance result**, and a comparison whose failure mode cannot be told from its success condition decides nothing. Steps 1a–1c added, including a bounded convergence loop (the probe saw one pass suffice for three documents, which is not proof that one pass always suffices) and a required report of the iteration count and whether `B_raw` was already fixed. (2) **The claim was stated more broadly than any observation supports.** M7 read as though every direct bundle is byte-identical to its re-imported form; it is not, and the probe measured 295 differing bytes proving so. The contract now scopes it: M7 proves **the text path carries no provenance marker *after normalisation***, and explicitly **not** that every direct bundle is byte-identical before it — the pre-normalisation differences are `document_from_bundle`'s canonical envelope ordering and have nothing to do with provenance. **Both sentences must appear in the rung's report.** **This finding has consequences beyond M7: its conclusion is the sole evidence for a permanent capability loss — the text refusal that moved `COMPANION_VERSION` to 0.14.0 and took the corpus's `canonical_bases` from 2 to 0 — so justifying a permanent refusal from a claim broader than the result obtained is the same error as concluding instead of observing, one level up: not a false observation, but a true one asked to carry more than it can.** (3) Clarification rather than defect: **the probe cannot pre-verify M7's authority/base leg**, which needs `BundleCapabilities`, `capabilities()` and pin 3a's validation — S27's own deliverables — so it remains an **execution requirement after S27 implementation**, with the probe standing as evidence for the prerequisite and explicitly **not** as a demonstration of laundering, since it carried no base. Recorded as a standing prerequisite table: the round-trip leg is settled, the authority leg is not pre-verifiable by any review or probe. **Findings by round: 9, 6, 6, 5, 4, 3, 3, 2, 2, 1, 3. Blocking: 4, 4, 4, 4, 2, 3, 3, 2, 2, 1, 2. Eleven rounds, none clean. Round 11 broke the falling trend, and did so because the probe supplied evidence that made a previously invisible defect findable — a reason to expect the next round to find more rather than less. The M7 comparator is on its fifth design: four were falsified by reading, the fifth by execution and then rebuilt on that evidence. It is the first with a measured result behind it and the first whose precondition is asserted rather than assumed, and it still cannot be executed end to end until S27 is implemented.** **REVIEW ROUND 12, 2026-08-08, INDEPENDENT, against `74dc994`: two findings, both blocking, and both the same defect — a requirement stated without the decision it requires, leaving execution to make a design choice silently.** (1) **The convergence loop was not actually bounded**: it demanded a bound and named no limit, so execution would have chosen when non-convergence becomes failure, changing what the experiment means. **Now pinned at one normalising step** — with `B₀ = B_raw` and `B₍ₙ₊₁₎ = serialize_document(document_from_bundle(Bₙ), uuid)`, compute at most `B₁` and `B₂`, permitted maximum `n = 1`, with a three-row outcome table (`B₁ == B₀` → already fixed; `B₁ != B₀` and `B₂ == B₁` → `n = 1`, the expected case; **`B₂ != B₁` → HARD FAILURE**, reporting all three image lengths and the first differing offset). **The bound is one step because it is a property, not a tolerance:** `document_from_bundle` canonicalises, so `serialize_document ∘ document_from_bundle` must reach its canonical form in a single application, and if it does not there is **no canonical form**, no principled reference artifact, and **M7 is invalid as a whole** — a finding about the projection rather than a signal to iterate further. A loop that runs until it happens to settle tests nothing; it reports how long it took. Raising the bound needs its own amendment and review round. (2) **M7's location was unchosen**: "in a crate that can reach the real constant" is true of two crates and decisive for neither, and `render_text_document` is **`pub(crate)` to `epiphany-textproj`** (`project.rs:595`), so `epiphany-testkit` could host M7 only via **an unpinned visibility change to another crate's public API**. **The harness is now pinned to `epiphany-textproj`**, which alone has both the renderer and (via its `epiphany-ops` dependency) the real constant — under **existing touch row 9**, no new row. **`render_text_document` stays `pub(crate)`:** handoff §1.3 records it as *the one intentional hole* in the text refusal, existing solely so a negative vector can carry the spelling it asserts is refused, and widening it to host a mutation that gets reverted would leave a permanently widened public surface behind — which is how a temporary harness becomes an API change nobody ratified. **Findings by round: 9, 6, 6, 5, 4, 3, 3, 2, 2, 1, 3, 2. Blocking: 4, 4, 4, 4, 2, 3, 3, 2, 2, 1, 2, 2. Twelve rounds, none clean. The last two rounds found the same kind of defect — a requirement that reads as a decision but is not one — so the next scan should hunt remaining instructions that name a constraint without naming its value. Everything M7 now specifies is pinned to a number, a crate or a named artifact, which is a checkable property a round can test directly.** **REVIEW ROUND 13, 2026-08-08, INDEPENDENT, against `bff9c9a`: one finding, blocking — and it inverted M7's result.** **M7 claimed the capability check "does not fire".** Pin 3a requires `commit`/`commit_versioned` to validate a **newly emitted** canonical base, which is exactly what **both** `B_raw` and the parsed `A` commit — so **the check fires on both paths and ACCEPTS**, because the raw version equals the real authority. **That acceptance is the laundering result:** the base is not slipped past an absent check, it is admitted by a check working correctly that cannot tell a coincidence from a rebuild. **As written, M7 was satisfiable by deleting pin 3a's writer check entirely** — yielding a passing M7 that demonstrated the exact opposite of its purpose. M7 now requires **three observations** (`A.image()` equals `B_fixed.image()`; pin 3a's validation ran and accepted on both commits; and the control) plus a **required control**: in the same run, same harness, repeat the import with a base version deliberately **not** equal to the real authority and observe the commit **REJECTED** with `CanonicalBaseRequiresRebuild`. **M7's removals are now explicitly limited to the text refusals — pin 3a is not among them and may not be weakened, being the thing under observation rather than an obstacle to it.** **This is a new failure shape worth naming: an observation satisfiable by the absence of the thing it observes.** M7's earlier defects were about being unrunnable or comparing the wrong artifacts; this one would have run, passed and reported success on a tree with the writer check removed. **"The check does not fire" cannot distinguish a check that accepts from a check that is not there, and only one of those is the finding.** **Findings by round: 9, 6, 6, 5, 4, 3, 3, 2, 2, 1, 3, 2, 1. Blocking: 4, 4, 4, 4, 2, 3, 3, 2, 2, 1, 2, 2, 1. Thirteen rounds, none clean. Round 13 is the narrowest since the probe, but it found a defect of a kind no earlier round had looked for — not "can this run?" or "does this compare the right things?" but "could this pass for the wrong reason?" — and that question has NOT been asked of M1–M6, M5a or M5b. Every mutation in §4 deserves the same check: what else, besides the intended defect, would make it pass?** **REVIEW ROUND 14, 2026-08-08, INDEPENDENT, against `f579172`: one finding, blocking — a contradiction round 13 created.** **The comparison method still said equal images "complete the observation and require nothing further"** — written in round 9, when byte equality *was* the whole of M7, and not swept when round 13 added the writer-check control. **The contract therefore simultaneously required the control and licensed omitting it, with the permissive sentence sitting earlier and reading as the summary.** Equality is now **necessary but not sufficient**: observation 1 of three, with the control still required, and that paragraph now specifies *how to compare*, never *what suffices*. **A second instance was found while amending, and round 14 reported none:** the "informative in both directions" note read *"if **every field matches**, the refusal is justified"* — the same sufficiency claim in different words, still carrying round 8's *"every field"* vocabulary that round 9 had replaced with whole-image comparison. **A search for "nothing further" or "sufficient" cannot reach a sentence that says "matches"** — the defect `CLAUDE.md` names, *searching one spelling and concluding about all sites*, met inside the fix for a sweep failure; neither the reviewer's search nor the author's first search found it, and a third pass on different terms did. **The round-13 lesson generalises further than round 13 stated: it is not only that a requirement must be swept to every site, but that the permissive statement usually reads *earlier* than the restrictive one, because requirements accumulate downward as a document is amended. A reader following the document in order stops at the first sentence that says "done". Where a later round narrows what suffices, the earlier summary is the site most likely to contradict it and least likely to be searched.** **Findings by round: 9, 6, 6, 5, 4, 3, 3, 2, 2, 1, 3, 2, 1, 1. Blocking: 4, 4, 4, 4, 2, 3, 3, 2, 2, 1, 2, 2, 1, 1. Fourteen rounds, none clean — but the last two are single-finding rounds and round 14's was created by round 13 rather than pre-existing, the narrowest the defect stream has been. Against that, round 13's question — what else, besides the intended defect, would make this pass? — has still not been asked of M1–M6, M5a or M5b, and round 14 did not ask it either. That scan remains outstanding and is the largest known unexamined surface.** **REVIEW ROUND 15, 2026-08-08, INDEPENDENT, against `fa483cf`: one finding, blocking — and it ran the scan rounds 13 and 14 left outstanding.** **M6 accepted "test 5 fails" and "test 9 fails" as its observations.** A test fails for **every** reason, not only the one under test, so an unrelated writer rejection satisfies both exactly as well as the intended cause — M6 could have reported success while demonstrating nothing about pin 3a's scope. Both halves now require the **mutated outcome itself**: after removing pin 3a, test 5's stale commit must be observed to **SUCCEED** and the bundle to reopen at the new generation with the stale base present; after broadening pin 3a, test 9's otherwise-unchanged commit — one that does not touch `canonical_base` — must be observed **rejected specifically by the broadened writer rule**, named in the report, not merely erroring. **The scan is now complete: M1–M5b survive it, M6 did not.** That the one remaining instance was in M6 — the mutation twice rewritten for unexecutability — is worth noting: **a mutation can be made runnable and still not be evidential.** **The principle, stated once so it need not be rediscovered: the evidence a mutation owes is the behaviour it changed, not the assertion it broke. A broken assertion is a symptom with many possible causes; the changed behaviour has one. Every mutation in §4 now names an outcome, not a failure.** **Findings by round: 9, 6, 6, 5, 4, 3, 3, 2, 2, 1, 3, 2, 1, 1, 1. Blocking: 4, 4, 4, 4, 2, 3, 3, 2, 2, 1, 2, 2, 1, 1, 1. Fifteen rounds, none returning zero — but what changed in the last three is the character of the findings, not only the count: round 13 found a defect of a kind never looked for, round 14 found a contradiction round 13 created, and round 15 found the last instance of round 13's kind with the scan reported complete across every mutation. The known unexamined surfaces are now enumerable, which they were not before: §4 is scanned and clean, and M7's authority/base leg remains unverifiable until S27 is implemented by construction, carried as an execution requirement rather than a gap in the document.** **ROUNDS 16–19, 2026-08-08, recorded together.** **Round 16 (independent, 4 findings, all blocking):** round 15 stated a rule covering every mutation and applied it only to M6 — **M1, M2, M3 and M5a still took a broken assertion as evidence**, and each now requires the mutated behaviour itself: the stale base observed *opening*; the corrupt fixture observed returning `CanonicalBaseRequiresRebuild`; the base-free fixture observed rejected by the wrongly widened check, with `base` named as the superblock's no-base default and **that synthetic source prohibited from shipped validation**; and `serialize_document`'s stored capability observed equal to the changed authority. Round 15's completeness claim is marked **FALSIFIED IN ROUND 16** at its original site. **Round 17 (authored-side sweep of §3 and §5, 10 findings, 5 blocking):** round 13's *could-this-pass-for-the-wrong-reason* question had never been asked of the tests or the gates, and both yielded immediately. **Gate 6's derive alternative could never match** — `grep` is line-oriented, so `[[:space:]]*` cannot cross the newline rustfmt puts between `#[derive(…, Default)]` and `pub struct BundleCapabilities`; the likelier violation returned 0 matches and the gate **passed**, while being the sole mechanical guard on the pin-3 prohibition M4 exists for because no test can catch it. **Gate 6a was vacuous under a rename** — pin 3b offered `synthetic_for_fixture` as an example, and the name is now pinned. **Gates 2 and 3 named no toolchain** in a repo whose CI records 1.95/1.97 lint divergence and whose default is 1.97.1; both are now `cargo +1.95.0`. **Gate 4's "staged list exactly §2" was unsatisfiable** with a conditional touch row, now subset-both-ways. **Tests 1, 6, 7, 8 and 9 could all pass on a base-free bundle**, since pin 5 makes base-free the permissive case and base-bearing fixtures are the awkward ones to build — test 1 degenerated into test 4. Also: tests 1/6 given distinct construction routes, test 4's caps asserted unequal, gate 1 requiring **0 ignored**, gate 7 given a method, gate 5 quoting all three dependency tables. **The unifying defect: a gate proving absence is only as strong as the string it searches for — a regex that cannot match, a name that was an example, a clause with no method, all reporting success while checking nothing. The remedy throughout is §4's: require an artifact quoted and read, not a pattern matched.** **Round 18 (independent, 2 findings, both blocking, both created by round 17):** the base-presence rule **demanded the opposite of what test 8 is for** — it grouped tests 8 and 9 as "the ones that commit", but test 8 *introduces* the base and must start `is_none()`, so the rule was either unsatisfiable or satisfiable by a fixture that made the test assert nothing; and **test 6's construction was self-contradictory**, assigned the commit path while required to arrive as its hand-built ancestor did (`bundle.rs:1866` calls `craft_image_with_base` at `:1869`). Fixed by a per-test state table and by **swapping the routes**, which makes the attribution true rather than deleting it. **Round 19 (independent): ZERO FINDINGS — the first clean round in nineteen**, confirming the per-test table, the route swap and the revised gate mechanics. **Running total: 65 findings, 47 blocking, across 19 rounds. A clean round is the criterion named at round 11 and the first evidence of convergence this contract has produced; it is not proof of correctness, and no round has re-derived the whole document. Still open after any ratification: M7's authority/base leg is unverifiable until S27 is implemented, those being S27's own deliverables, and every gate, test and mutation is specified but none has been run.** **RESOLVED — IMPLEMENTED 2026-08-09 (pin 10).** The authority now exists: `epiphany_ops::CURRENT_REDUCTION_ALGORITHM_VERSION` (a plain `u32`, baseline **0**, with its bump discipline beside it and the standing note that **no mechanism can detect a semantics change** — the discipline is the guarantee), wrapped at the composition boundary into a required `BundleCapabilities` that **has no `Default`** and is carried on the `Bundle` so all `commit` sites stay unchanged. Both boundaries validate: `Bundle::open` refuses a base disagreeing with the session's authority (pin 5), and `commit`/`commit_versioned` refuse a **newly emitted or replaced** base that does (pin 3a) — a scope that turns out to be **forced rather than chosen**, since no caller can hold an open `Bundle` whose *inherited* base is stale. Mismatch is `BundleError::CanonicalBaseRequiresRebuild { base, current }`: not read-only, not an integrity anomaly, and kept distinct from the malformed-document failure a base disagreeing with its own superblock produces. The format rung's temporary `ReductionAuthorityUnavailable` is **deleted**; its three negative assertions were re-pointed rather than dropped. **Both rulings taken as scoped:** the baseline stays `0` (starting anywhere else would manufacture the breakage the rung exists to detect), and the writer-path correction of §0.4 holds — though execution found §0.4 **overstated** it: `serialize_document` is a production writer, but `project.rs` is entirely `#[cfg(test)]`, and **no in-tree production path stages a base at all** now that the format rung's pin 3b closed the only one, so pin 3a guards the **public API** against out-of-tree callers rather than an internal path. **Pin 2a's disposition is the container epoch**, settled from outside by the format rung. **Signature change: `open` 60 sites, `create` 32**, reconciling exactly to §0.4's corrected table; `epiphany-bundle` sites take `synthetic_for_fixture`, `epiphany-testkit` and `epiphany-textproj` a named `production_caps()` wrapping the real constant. **`ids.rs`'s claim that "the algorithm catalog lives in `epiphany-ops`" is now true** — pin 8 is the rung that earned the sentence. **Inherited obligations all discharged:** both interim refusals converted to validation (not one); pin 3c's two suspended conformance assertions restored in `assert_reduction_serialization_stable` with the **suspension marker deleted**; and M8's laundering demonstration **performed for the first time** — see below. **Normative:** `core_spec.tex` gains `req:format:reduction-authority` (213 → **214** requirements; suite 284 → **285**, so touch row 12 was used and needed **three** constants, not one) plus a Revision History row, and `core_spec.pdf` is rebuilt. **P13-S16 becomes dispatchable when this lands** — pin 10 as amended in review round 12, its "ratified and tested within this rung" clause having been unsatisfiable by the only route pin 2a permitted | | P13-S28 | **No container property distinguishes a document produced under a validated reduction authority from one produced before any authority existed — and the two candidates that look like they would, cannot.** P13-S27 installs an authority and validates it at read and write time, but cannot state what to do with a canonical base that predates the authority: a raw `ReductionAlgorithmVersion` is a bare `u32` (`bundle/src/ids.rs:291`) carrying no provenance, and the text-projection parser accepts an unbounded one from a document (`textproj/src/parse.rs:591`), so no numeric convention — including a deliberately high epoch — is safe from a hand-authored or third-party document declaring it. **`FORMAT_MINOR` does not work either, for two independent reasons:** the header *"never changes after the file is created"* (`core_spec.tex:10799`–`:10800`) and `commit_versioned` publishes only a superblock (`bundle.rs:791`), so a legacy bundle that commits a base S27 just validated keeps its old minor **permanently** — rejecting minor-≤1 bases would then reject a base the authority itself accepted, and accepting them leaves S16's `1` ambiguous; and `core_spec.tex:12258`–`:12262` limits a minor change to appending append-safe discriminants and calls it backward-compatible, whereas making a previously-valid base newly rejectable is a **semantic acceptance change**, with current readers ignoring minor entirely (`header.rs:119` gates on major alone) so the boundary would bind only readers that already comply. **The requirement that survives:** provenance MUST ride a container property that **old readers cannot silently accept** and that **a later commit cannot inherit unchanged** | `spec/CONTRACT_P13S27_REDUCTION_AUTHORITY.md` pin 2a (filed 2026-07-31; the disposition S27 cannot make from inside itself) | **IMPLEMENTED 2026-08-07 (`bc06706`, fix `be244df`). Was the critical path; both P13-S27 and P13-S16 were blocked on it.** **This rung must own all five, and none may be deferred into S27:** (1) an **old-reader rejection boundary** — pre-boundary readers must fail closed rather than silently open a document whose safety check they do not run; (2) **provenance that survives commits correctly**, i.e. is not inherited unchanged by a later generation and is not lost by one; (3) **legacy-base rebuild/repack behaviour**, stated for real artifacts rather than assumed away; (4) **every writer path, including text projection** — `serialize_document`, `project.rs`, and the committed `.txt` vectors, since a text document can declare any version; (5) **the exact format-version and compatibility consequences**, most plausibly a **major**-version boundary or a generation-scoped attestation paired with an incompatibility boundary. **Not a sub-pin of S27 and must not drift into it** — S27's pin 2a carries an explicit prohibition against being amended into a disposition without its own ratification round. **Scoped and RATIFIED 2026-07-31 as `spec/CONTRACT_FORMAT_EPOCH_MAJOR1.md`** after four adversarial review rounds — 11 pins, 11 tests, 11 mutations, 15 touch rows, 7 gate items. **This row is now a dependency record only; the work lives there and P13-S28 does not execute as a Pass 13 rung.** Rulings taken: the carrier is the **format major** (`FORMAT_MAJOR` 0 → 1, `FORMAT_MINOR` 1 → 0), decoded three ways through a named `FormatEpoch` rather than a bool, with **no** generation-scoped attestation in this epoch; legacy resolves to **hard rejection, not read-only**; and an eight-row epoch matrix in which a major-0 bundle with no base may open, one carrying a base is rejected, and one attempting to *add* a base is rejected and told to repack — **the non-inheritance rule that `FORMAT_MINOR` could not express**. All five things this row required the rung to own are pinned: old-reader boundary (pin 2), commit-surviving provenance (pin 3), legacy repack (pins 4, 5), every writer path including text projection (pins 3b, 6), and the exact format/compatibility consequences (pins 1, 7). **Three findings from the review rounds that changed the rung's shape**, none of them visible at filing: (1) **it cannot stamp major 1 before S27's writer enforcement exists**, so pin 3a temporarily refuses *both* boundaries — opening a major-1 bundle already carrying a base, and committing one into it — through a third, temporary `ReductionAuthorityUnavailable` error that must name P13-S27 and must **not** name repack; (2) **text projection launders provenance straight through the boundary** (`serialize_document` stages a carried base into a fresh bundle and `build_manifest` writes it), resolved as **symmetric document-level refusal** — projection, parsing and a new dedicated `SerializeError` variant, none of which existed to be "retained" — which forces `COMPANION_VERSION` 0.13.0 → **0.14.0** and rebuilds the committed corpus to **20 vectors, ten rejection classes, `canonical_bases` reach 2 → 0**, a real and stated capability loss; (3) **corruption precedence binds in both epochs** — a corrupt major-1 base must still fail as malformed, never as the *temporary* authority error a user would reasonably retry. **IMPLEMENTED 2026-08-07** — amended once before dispatch (pin 3c, touch rows 10/11, gate 8) after reconnaissance found pin 3a's refusals reaching a conformance criterion through a file the touch table did not carry. All 11 tests landed under their contract names, all 11 mutations run and observed, workspace green at 1569. **P13-S27 is unblocked and P13-S16 remains blocked on S27** — pin 8 resolved S27's open pin 2a (legacy bases are refused by container epoch, never by version arithmetic), and S27 additionally inherits three obligations recorded in its own contract: converting **both** interim refusals to validation, M8's deferred laundering demonstration, and pin 3c's two suspended conformance assertions. **Two touch-table gaps found during execution, both of the same shape** — a `.tex` requirement addition moves hardcoded counts in `testkit/tests/requirement_labels.rs`, and a companion-version bump moves a second normative version literal spelled `version~0.13.0` rather than `(0 13 0)`; neither file was in any touch table, and the second was caught only because `requirements_name_only_this_companion_version` exists. **A third gap was caught in review, after the rung was committed:** pin 3b's projection refusal had been implemented only on the **bundle** side (`document_from_bundle`), leaving the public `project_text_document` free to emit a `(canonical-base ...)` line for a directly constructed `TextDocument` — text the parser then rejects. A projector that can produce what the parser refuses is exactly the asymmetry pin 3b exists to close, and the refusal is unreachable through a `Bundle` during the interval anyway, so the *only* reachable half was the unguarded one. The public projector now returns `Result` and refuses; a crate-private `render_text_document` retains the base spelling for the one legitimate caller, the `canonical_base_present` negative vector. **The lesson is the rung's own recurring one:** a guard placed on the path that happened to be named, rather than on every path a caller can reach | diff --git a/spec/core_spec.pdf b/spec/core_spec.pdf index b1ea413..9d6cda4 100644 Binary files a/spec/core_spec.pdf and b/spec/core_spec.pdf differ diff --git a/spec/core_spec.tex b/spec/core_spec.tex index 28fd1ab..e96f9b7 100644 --- a/spec/core_spec.tex +++ b/spec/core_spec.tex @@ -11733,6 +11733,61 @@ pub struct SnapshotRef { ignored or rebuilt if they disagree with the canonical document. \end{requirement} +\begin{requirement} + \label{req:format:reduction-authority} + An implementation \MUST{} name the reduction semantics it + implements, and \MUST{} validate a canonical base against that + value rather than against a value the base itself supplied. + + Comparing a base's \texttt{reduction\_algorithm\_version} only + against the active superblock's, as + Requirement~\ref{req:format:canonical-document-reduction} + requires, is necessary but \emph{not} sufficient: a writer that + seeds the superblock from the base's own self-report makes that + comparison an identity, so it holds for every conformingly + written document and detects only tampering. The semantics the + running implementation actually implements is a third value, and + it \MUST{} participate. + + Accordingly: + + \begin{itemize} + \item An implementation \MUST{} expose the reduction-algorithm + version it implements, and every reader and writer of a + canonical base \MUST{} state which semantics it implements + rather than infer it from the document. + \item A reader \MUST{} refuse to open a document whose + canonical base's \texttt{reduction\_algorithm\_version} + differs from the semantics the reader implements. A document + with no canonical base \MUST{} open regardless, since it + carries no reduced state that could be stale. + \item A writer \MUST{} refuse to emit or replace a canonical + base whose \texttt{reduction\_algorithm\_version} differs + from the semantics the writer implements. A commit that does + not emit or replace the base \MUSTNOT{} be refused on this + ground. + \item The refusal \MUSTNOT{} degrade to a read-only open and + \MUSTNOT{} be reported as an integrity anomaly. A stale base + is not a restricted-but-correct view of the document; it is + state computed under different rules, and serving it + read-only would present incorrect canonical state as + authoritative. + \item A refusal on this ground \MUST{} remain distinguishable + from a malformed-document failure. A base whose version + disagrees with its own superblock is corrupt, and corruption + \MUST{} be reported as such: the two conditions detect + different faults, and collapsing them loses the distinction + this requirement rests on. + \end{itemize} + + Rebuilding is out of scope for opening. An implementation + \MUSTNOT{} silently rebuild a stale base during open, because + the envelopes required to rebuild may have been pruned + (Requirement~\ref{req:format:pruning-state-preservation}); a rebuild + path is sound only + where the full pre-base history is demonstrably present. +\end{requirement} + \subsection{Pruning} \begin{requirement} @@ -16820,6 +16875,28 @@ layouts they own versus inherit: staleness. \sectionsc{Schema Versioning} gains the corresponding distinction between the schema axis and the container axis. \\ + \today & Ch.~\ref{ch:format} & \sectionsc{The Canonical Document Identity} + gains Requirement~\ref{req:format:reduction-authority}: an implementation + \MUST{} name the reduction semantics it implements and validate a canonical + base against \emph{that}, not against a value the base itself supplied. + Comparing the base's \texttt{reduction\_algorithm\_version} only against + the active superblock's is necessary but not sufficient, because a writer + that seeds the superblock from the base's own self-report makes the + comparison an identity --- so it holds for every conformingly written + document and detects only tampering. The requirement therefore introduces + the implementation's own semantics as a third participant, required at both + boundaries: a reader refuses a base that disagrees with the semantics it + implements, and a writer refuses to emit or replace one. A document with no + canonical base opens regardless, carrying no reduced state that could be + stale, and a commit that does not touch the base is not refused on this + ground. The refusal may not degrade to a read-only open nor be reported as + an integrity anomaly --- a stale base is not a restricted-but-correct view + but state computed under different rules --- and it stays distinguishable + from the malformed-document failure a base disagreeing with its own + superblock produces, since the two detect different faults. Rebuilding + during open is prohibited outright, because the envelopes a rebuild would + need may have been pruned. + \\ \bottomrule \end{longtable}