From 9f688cc5920677fbb3249b9fa2a1e3dd3111a5eb Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 5 Jul 2026 19:21:11 -0400 Subject: [PATCH] Schema major 1 Phase B: the dispatch seam + version infrastructure (no-op) Stands up the schema-evolution machinery as a tested behavioral no-op, the load-bearing one-way-door piece the later phases build on. - SchemaVersion::V1 (bundle/ids.rs) -- infrastructure, an identity today. - The core dispatch seam Score::decode_canonical_versioned(bytes, major) (core/codec.rs), with the decode_v0_score / migrate_v0_score scaffold. It is the identity at major 1's introduction (v0 layout == v1 layout), with explicit "Phase C/D freeze this by value + default-fill the new field" contracts baked into the doc comments so the freeze is a clean edit later. Unit-tested by versioned_decode_is_identity_across_majors. A first-pass review caught that the initial gate widening over-reached: it admitted major 1 for every chunk kind, but the bundle's own op-block decoder (block::decode_block) and manifest decoder are unversioned, so a spec-valid major-1 op block would have passed the gate and then been mis-read rather than migrated / opened read-only. The accept-set ran ahead of the decoders. Corrected: the gates stay EXACT to major 0 in this phase -- the manifest gate to Manifest::SCHEMA.major (the manifest never grows a v1 layout in this bump), the generic-chunk gate to SUPPORTED_SCHEMA_MAJOR = 0. Admission of major 1 is raised PER CHUNK ROLE by the phase that adds that role's versioned decode or discard path (snapshot -> C, op block -> D, layout cache -> E), never as a blanket accept-set ahead of a decoder that can read it. The roundtrip seam-exercise was reverted too (it conflated the canonical-base MaterializedState role with the acceleration-snapshot Score role); the acceleration-snapshot read path + the first usable_* wrapper land in Phase C. So Phase B is version infrastructure + the dispatch seam only; the gate widening, usable_* wrappers, and ops symmetry move to the phases that exercise them. 863 workspace tests pass; clippy -D warnings, fmt --check, rustdoc -D warnings all clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd --- crates/epiphany-bundle/src/bundle.rs | 49 +++++++++++++-- crates/epiphany-bundle/src/ids.rs | 11 +++- crates/epiphany-core/src/codec.rs | 78 ++++++++++++++++++++++++ crates/epiphany-testkit/src/roundtrip.rs | 6 +- 4 files changed, 136 insertions(+), 8 deletions(-) diff --git a/crates/epiphany-bundle/src/bundle.rs b/crates/epiphany-bundle/src/bundle.rs index 74bfc7f..0355054 100644 --- a/crates/epiphany-bundle/src/bundle.rs +++ b/crates/epiphany-bundle/src/bundle.rs @@ -33,9 +33,20 @@ use std::collections::BTreeMap; /// superblock slots): 576 bytes. pub const BODY_START: u64 = SLOT_B_OFFSET + SUPERBLOCK_LEN; -/// The schema major version this format version can parse (Chapter 8 -/// §"Schema Versioning"): a canonical chunk or manifest at a higher major is -/// not interpretable by this reader. +/// The schema major version this reader can parse for a **generic canonical +/// chunk** (Chapter 8 §"Schema Versioning"). A chunk at a higher major is not +/// interpretable by this reader as canonical state. +/// +/// This stays `0` through the schema-major-1 machinery phase: schema major 1's +/// wire form is *defined* (Binary Format companion §"Schema Major 1", and +/// [`SchemaVersion::V1`]) and its dispatch seam exists in `epiphany-core` +/// (`Score::decode_canonical_versioned`), but no chunk is +/// produced at major 1 yet and the reader's per-role decoders (operation +/// blocks, layout caches) are not yet versioned. Admission of major 1 is raised +/// **per chunk role** by the later phases that add each role's versioned decode +/// or discard-and-regenerate path — never as a blanket accept-set ahead of the +/// decoders (which would let a spec-valid major-1 op block reach an unversioned +/// decoder and be mis-read). pub const SUPPORTED_SCHEMA_MAJOR: u16 = 0; /// The conformance-profile major version this implementation understands @@ -239,8 +250,12 @@ impl Bundle { let superblock = selection.superblock; // A manifest at an unsupported schema major cannot be interpreted as - // canonical state (Chapter 8 §"Schema Versioning"). - if superblock.manifest_schema_version.major != SUPPORTED_SCHEMA_MAJOR { + // canonical state (Chapter 8 §"Schema Versioning"). The manifest stays + // at its own schema major across the schema-major-1 bump (its body is + // carried opaquely and never grows a v1 layout here), so this gate is + // exact to `Manifest::SCHEMA.major` — it must not admit a manifest major + // that has no defined wire form. + if superblock.manifest_schema_version.major != Manifest::SCHEMA.major { return Err(BundleError::UnsupportedSchemaVersion { version: superblock.manifest_schema_version, }); @@ -829,7 +844,11 @@ fn read_and_verify_chunk(store: &dyn BlockStore, r: &ChunkRef) -> Result file_len: store.len(), }); } - // Canonical chunks must be parseable: v0 supports schema major 0 only. + // A chunk at a schema major this reader cannot parse. The gate stays exact + // to `SUPPORTED_SCHEMA_MAJOR` (major 0) through the machinery phase; later + // phases raise admission per chunk role as each role's versioned decode or + // discard path lands, so a major-1 chunk is never admitted ahead of a + // decoder that can read it (Binary Format companion §"Schema Major 1"). if r.schema_version.major != SUPPORTED_SCHEMA_MAJOR { return Err(BundleError::UnsupportedSchemaVersion { version: r.schema_version, @@ -1145,6 +1164,24 @@ mod tests { use super::*; use crate::ids::DocumentId; + #[test] + fn schema_major_1_is_defined_but_not_yet_admitted_by_the_gates() { + // The schema-major-1 machinery phase: SchemaVersion::V1 and the dispatch + // seam exist, but no chunk is produced at major 1 and the gates stay + // exact to major 0 — admission is raised per chunk role by the later + // phases that add each role's versioned decoder (Binary Format + // companion §"Schema Major 1"). So the generic-chunk gate and the + // manifest gate both still reject major 1 here. + assert_eq!(SUPPORTED_SCHEMA_MAJOR, 0); + assert_eq!(Manifest::SCHEMA.major, 0); + assert_eq!(SchemaVersion::V1.major, 1, "v1 is defined as an identity"); + assert_ne!( + SchemaVersion::V1.major, + SUPPORTED_SCHEMA_MAJOR, + "major 1 is defined but not yet admitted at the generic gate" + ); + } + fn fresh_bundle() -> Bundle { Bundle::create( MemStore::new(), diff --git a/crates/epiphany-bundle/src/ids.rs b/crates/epiphany-bundle/src/ids.rs index a17eff3..983a54a 100644 --- a/crates/epiphany-bundle/src/ids.rs +++ b/crates/epiphany-bundle/src/ids.rs @@ -168,9 +168,18 @@ pub struct SchemaVersion { } impl SchemaVersion { - /// The current prototype schema version. + /// The baseline prototype schema version (major 0). Every chunk whose + /// layout is unchanged by the schema-major-1 bump keeps this version. pub const V0: SchemaVersion = SchemaVersion { major: 0, minor: 1 }; + /// Schema major 1 — the first data-model expansion major (Binary Format + /// companion §"Schema Major 1"). Stamped on chunks whose payload carries a + /// v1 layout: the acceleration full-`Score` snapshot, the resolved-layout + /// `LayoutCache`, and any operation-envelope block bearing a v1 + /// `CreateRegion`. The canonical-base `MaterializedState`, the manifest, + /// and operation blocks without a changed payload stay at [`Self::V0`]. + pub const V1: SchemaVersion = SchemaVersion { major: 1, minor: 0 }; + /// Constructs a schema version. #[inline] pub const fn new(major: u16, minor: u16) -> Self { diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index 036670a..2c55616 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -2031,12 +2031,65 @@ impl Score { /// Decodes the exact inverse of [`Score::canonical_bytes`], validating every /// tag, length, primitive, and type invariant. Trailing bytes are rejected. + /// + /// This is the **current (schema major 1)** layout. To decode bytes whose + /// schema major is not known to be current, use + /// [`Score::decode_canonical_versioned`]. pub fn decode_canonical(bytes: &[u8]) -> Result { let mut r = Reader::new(bytes); let score = Score::dec(&mut r)?; r.finish()?; Ok(score) } + + /// The **schema-version dispatch seam** (Binary Format companion + /// §"Schema Major 1"): decodes a full-`Score` snapshot whose bytes were + /// written under the given schema `major`, migrating a major-0 encoding up + /// to the current in-memory form on read. Major 1 is the current layout + /// ([`Score::decode_canonical`]); major 0 is decoded through the frozen v0 + /// wire form (`decode_v0_score`) and then migrated (`migrate_v0_score`). + /// + /// At schema major 1's introduction the v0 and v1 layouts are **identical**, + /// so the v0 path is the identity today — the machinery is a behavioral + /// no-op. When a later phase grows a field on `Canvas`, `Instrument`, or + /// `Region`, the frozen pre-field decoder is added here (reading the old + /// layout) and `migrate_v0_score` default-fills the new field. + /// + /// The caller (the bundle read path) only reaches this after the chunk gate + /// has admitted the major into its accept-set, so a major outside `{0, 1}` + /// is a defensive error, not an expected path. + pub fn decode_canonical_versioned(bytes: &[u8], major: u16) -> Result { + match major { + 1 => Score::decode_canonical(bytes), + 0 => { + let v0 = decode_v0_score(bytes)?; + Ok(migrate_v0_score(v0)) + } + _ => Err(ScoreDecodeError::InvalidValue("unsupported schema major")), + } + } +} + +/// Decodes a `Score` from its **frozen schema-major-0** wire bytes. +/// +/// At major 1's introduction the v0 layout equals the current layout, so this +/// delegates to [`Score::decode_canonical`]. **Phase C/D freeze this by value:** +/// when `Canvas`/`Instrument`/`Region` grow a field, this function is replaced +/// by a copy of the pre-field positional decoder (reading the old layout), and a +/// golden v0 byte fixture guards it against drift — so the migrate-on-read path +/// decodes real historical bytes, not the current layout in disguise. +fn decode_v0_score(bytes: &[u8]) -> Result { + Score::decode_canonical(bytes) +} + +/// Migrates a `Score` decoded from schema major 0 up to the current in-memory +/// form. Total and default-filling (no score context needed). +/// +/// Identity today (v0 layout == v1 layout). **Phase C/D fill the new fields +/// here:** `Canvas.layout_defaults` = the A4/8 mm default, `Instrument.range` +/// = `None`, `Region.permits_spanning_slurs` = `false`. +fn migrate_v0_score(score: Score) -> Score { + score } // =========================================================================== @@ -2300,6 +2353,31 @@ mod tests { } } + #[test] + fn versioned_decode_is_identity_across_majors_at_major_1_introduction() { + // The schema-version dispatch seam (Binary Format §"Schema Major 1"): + // at major 1's introduction the v0 and v1 layouts are identical, so + // decoding the same bytes under either major yields the same score as + // the unversioned decoder. This pins the machinery as a behavioral + // no-op; a later phase that grows a field flips the major-0 path to a + // real frozen decode + default-fill, and this test moves with it. + for seed in 0..64u64 { + let score = valid_score(seed.wrapping_mul(0x9E37_79B9).wrapping_add(1)); + let bytes = score.canonical_bytes(); + let via_current = Score::decode_canonical(&bytes).expect("decodes"); + let via_v1 = Score::decode_canonical_versioned(&bytes, 1).expect("major 1 decodes"); + let via_v0 = Score::decode_canonical_versioned(&bytes, 0).expect("major 0 decodes"); + assert_eq!(via_current, via_v1, "major 1 == unversioned"); + assert_eq!( + via_current, via_v0, + "major 0 (identity migration) == unversioned" + ); + } + // A major outside the accept-set is a defensive decode error (the gate + // rejects it upstream in practice). + assert!(Score::decode_canonical_versioned(&valid_score(1).canonical_bytes(), 2).is_err()); + } + #[test] fn distinct_scores_serialize_differently() { assert_ne!( diff --git a/crates/epiphany-testkit/src/roundtrip.rs b/crates/epiphany-testkit/src/roundtrip.rs index 5b1d85b..b3accb5 100644 --- a/crates/epiphany-testkit/src/roundtrip.rs +++ b/crates/epiphany-testkit/src/roundtrip.rs @@ -386,7 +386,11 @@ pub fn assert_score_serialization_stable(score: &Score, frontier: &[u8], seed: u "score bytes were not preserved through content-addressed storage" ); - // deserialize → equal → reserialize byte-identically. + // deserialize → equal → reserialize byte-identically. (The canonical base + // is a major-0 chunk; the schema-version dispatch seam for the acceleration + // full-`Score` snapshot is exercised on its own read path in a later phase, + // where a properly-roled acceleration snapshot exists. This harness's base + // decodes with the current codec.) let decoded = Score::decode_canonical(&loaded).expect("loaded score must decode"); assert_eq!(&decoded, score, "decoded score changed"); assert_eq!(