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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
This commit is contained in:
Levi Neuwirth 2026-07-05 19:21:11 -04:00
parent f4a2f1fdf0
commit 9f688cc592
4 changed files with 136 additions and 8 deletions

View File

@ -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<S: BlockStore> Bundle<S> {
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<Vec<u8>
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<MemStore> {
Bundle::create(
MemStore::new(),

View File

@ -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 {

View File

@ -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<Score> {
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<Score> {
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> {
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!(

View File

@ -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!(