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:
parent
f4a2f1fdf0
commit
9f688cc592
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
Loading…
Reference in New Issue