From 4598f30dddd6080b350d87114320a6db269e2f53 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 6 Jul 2026 12:09:30 -0400 Subject: [PATCH] Schema major 1 Phase D2: CreateRegion op-block major + cross-major read-only The canonical op-block side of Region.permits_spanning_slurs: CreateRegion now encodes at schema major 1, blocks carrying one are stamped major 1, the reader admits them per-role, and a bundle whose op history is beyond this reader's accept-set opens read-only. The migrate-on-read primitive is deferred (op payloads are never reconstructed-to-values from bytes, so it has no consumer). - ops: CreateRegionOp::encode_canonical embeds the region's full (v1) canonical bytes; OperationKind/OperationPayload/OperationEnvelope::schema_major report the payload's binary-format major (CreateRegion => 1, else 0). Removed D1's transitional Region::canonical_bytes_v0 (dec_region_v0 stays for snapshots). - bundle: max_supported_major(kind) raises the OperationEnvelopeBlock role to [0,1] (every other role stays exact-0); the read gate is now major > max_supported_major(r.kind). StagedChunk::operation_block_versioned + SchemaVersion::for_major project a derived block major to a version. - bundle: commit-time canonical-root validation checks structure without the accept-set (a newer writer's higher-major root is publishable); the accept-set is a read concern. Both open and commit consult unsupported_operation_root_major and go read-only (+ the new IntegrityAnomaly::UnsupportedCanonicalChunkMajor) when a canonical op root exceeds the accept-set, so the live bundle refuses further commits at once. - testkit: stage_operation_block derives a block's schema version from its operations (max schema_major); staged_envelope_blocks routes through it so a generated CreateRegion stream is never mis-stamped v0. Tests: CreateRegion payload is v1 and carries the flag; the op reports major 1; a derived CreateRegion block stamps V1 and reopens read-write; a major-2 block opens read-only (open and post-commit); the per-role accept-set shape. Full gate green (workspace tests, clippy -D warnings, fmt, rustdoc -D warnings). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd --- crates/epiphany-bundle/src/bundle.rs | 199 +++++++++++++++--- crates/epiphany-bundle/src/error.rs | 13 ++ crates/epiphany-bundle/src/ids.rs | 14 ++ crates/epiphany-core/src/codec.rs | 48 +---- crates/epiphany-ops/src/envelope.rs | 7 + crates/epiphany-ops/src/payload.rs | 86 +++++--- crates/epiphany-testkit/src/bundle_harness.rs | 26 ++- crates/epiphany-testkit/src/roundtrip.rs | 92 ++++++++ 8 files changed, 387 insertions(+), 98 deletions(-) diff --git a/crates/epiphany-bundle/src/bundle.rs b/crates/epiphany-bundle/src/bundle.rs index 0355054..8a3f6a0 100644 --- a/crates/epiphany-bundle/src/bundle.rs +++ b/crates/epiphany-bundle/src/bundle.rs @@ -37,18 +37,35 @@ pub const BODY_START: u64 = SLOT_B_OFFSET + SUPERBLOCK_LEN; /// 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). +/// This is the baseline for a **generic** chunk role. Admission of major 1 is +/// raised **per chunk role** by `max_supported_major` as each role's versioned +/// path lands — never as a blanket accept-set ahead of the decoders (which would +/// let a spec-valid major-1 chunk reach an unversioned decoder and be mis-read). +/// The operation-envelope-block role is raised to major 1 (schema-major track +/// D2: a block bearing a v1 `CreateRegion`); the manifest and layout-cache roles +/// stay at `0`. pub const SUPPORTED_SCHEMA_MAJOR: u16 = 0; +/// The maximum schema major this reader admits for a chunk of `kind` — the upper +/// bound of its per-role accept-set `[0, max]` (Binary Format companion +/// §"Schema Major 1", "The accept-set gate"). +/// +/// `OperationEnvelopeBlock` admits major 1 (its v1 form embeds a v1 +/// `CreateRegion`; the reader treats the block bytes opaquely, so it parses a +/// v1 block without decoding the payload). Every other role stays at +/// [`SUPPORTED_SCHEMA_MAJOR`] until its own versioned path lands — including the +/// payload-polymorphic `Snapshot` (the acceleration form's migrate-on-read is +/// core-side; the canonical base stays major 0) and the manifest (carried +/// opaquely, never grows a v1 layout). A chunk above its role's max is not +/// admitted; for a **canonical** role that means the bundle opens read-only +/// (a major-0-only reader meeting a v1 op block), not a hard reject. +pub fn max_supported_major(kind: ChunkKind) -> u16 { + match kind { + ChunkKind::OperationEnvelopeBlock => 1, + _ => SUPPORTED_SCHEMA_MAJOR, + } +} + /// The conformance-profile major version this implementation understands /// (Chapter 8 §"Format Profiles"). A profile declared at a higher major is a /// future capability set this reader cannot honor. @@ -82,11 +99,24 @@ pub struct StagedChunk { } impl StagedChunk { - /// A staged operation-envelope block at the current schema version. + /// A staged operation-envelope block at schema major 0 (the baseline: no + /// operation in the block carries a schema-major-1 payload). Use + /// [`StagedChunk::operation_block_versioned`] for a block whose operations + /// may include a v1 `CreateRegion`. pub fn operation_block(payload: Vec) -> Self { + StagedChunk::operation_block_versioned(payload, SchemaVersion::V0) + } + + /// A staged operation-envelope block at the given schema version. The version + /// is the maximum over the block's operations + /// (`OperationEnvelope::schema_major`, projected to `SchemaVersion`): a block + /// bearing a v1 `CreateRegion` is stamped [`SchemaVersion::V1`], so a + /// major-0-only reader opens the bundle read-only rather than + /// mis-parsing v1 op bytes as v0 (Binary Format companion §"Schema Major 1"). + pub fn operation_block_versioned(payload: Vec, schema_version: SchemaVersion) -> Self { StagedChunk { kind: ChunkKind::OperationEnvelopeBlock, - schema_version: SchemaVersion::V0, + schema_version, payload, } } @@ -339,6 +369,19 @@ impl Bundle { anomalies.push(IntegrityAnomaly::UnknownRequiredExtension); } + // A canonical operation root at a schema major above this reader's + // accept-set forces read-only preservation (Binary Format companion + // §"Schema Major 1"): the reader still reads the canonical base and + // manifest (both major 0) but refuses to author against op history it + // cannot interpret. A cheap manifest-metadata scan; the beyond-accept + // blocks are never read (a lazy read would hit the accept-set gate). + if let Some(major) = unsupported_operation_root_major(&manifest) { + read_only = true; + anomalies.push(IntegrityAnomaly::UnsupportedCanonicalChunkMajor { + schema_major: major, + }); + } + let write_cursor = store.len(); Ok(Bundle { store, @@ -698,6 +741,17 @@ impl Bundle { self.superblock = superblock; self.read_only = manifest_forces_read_only(&manifest) || !profile_is_editable(&active_profile); + // A commit that publishes a canonical operation root beyond this reader's + // accept-set (a forward-compat write) makes the *live* bundle read-only + // at once — mirroring the open-time scan — so no further commit runs + // against canonical history this reader can no longer parse. + if let Some(major) = unsupported_operation_root_major(&manifest) { + self.read_only = true; + self.anomalies + .push(IntegrityAnomaly::UnsupportedCanonicalChunkMajor { + schema_major: major, + }); + } self.manifest = manifest; self.write_cursor = cursor; Ok(()) @@ -758,6 +812,22 @@ fn manifest_forces_read_only(manifest: &Manifest) -> bool { manifest.extension_declarations.iter().any(|e| e.required) } +/// The schema major of a declared **canonical operation root** that is beyond +/// this reader's accept-set for the op-block role, if any (Binary Format +/// companion §"Schema Major 1", "Canonical chunks — parse or open read-only"). +/// Such a root forces read-only preservation: this reader cannot parse the newer +/// op bytes, so it must not author against op history it cannot interpret. Both +/// `open` (at load) and `commit` (a builder that publishes such a root, e.g. a +/// forward-compat write) consult this so the in-memory bundle goes read-only +/// immediately, not only on the next reopen. +fn unsupported_operation_root_major(manifest: &Manifest) -> Option { + manifest + .operation_roots + .iter() + .map(|r| r.schema_version.major) + .find(|&m| m > max_supported_major(ChunkKind::OperationEnvelopeBlock)) +} + /// Whether this implementation *understands* a profile (can interpret and honor /// its constraints): a built-in `ProfileId` (not a `Custom` registry profile), /// a supported major version, and a block bound within the reader's hard chunk @@ -827,6 +897,23 @@ fn reduction_version_for(manifest: &Manifest) -> ReductionAlgorithmVersion { /// schema-major support, declared length, the content hash, and the /// `id == hash` redundancy (Chapter 8 §"Chunks"). fn read_and_verify_chunk(store: &dyn BlockStore, r: &ChunkRef) -> Result, BundleError> { + read_and_verify_chunk_impl(store, r, true) +} + +/// [`read_and_verify_chunk`] with the schema-major **accept-set** check made +/// optional. The accept-set is a *reader-capability* gate, not a structural +/// property: a bundle may legitimately carry a canonical root written by a +/// newer writer at a major beyond this reader's accept-set. Commit-time +/// canonical-root validation therefore checks a root's *structure* (resolves, +/// hash-intact, right kind, decodes) with `enforce_accept_set = false` — it must +/// not refuse to publish a root it merely cannot itself parse — while every read +/// path enforces the accept-set (a beyond-accept-set canonical root instead +/// opens the bundle read-only at `open`). +fn read_and_verify_chunk_impl( + store: &dyn BlockStore, + r: &ChunkRef, + enforce_accept_set: bool, +) -> Result, BundleError> { // The manifest chunk is mandatorily uncompressed in this format version // (Chapter 8 §"Manifest Encoding"): a compressed manifest reference is // rejected outright, before any bytes are read. @@ -844,12 +931,16 @@ fn read_and_verify_chunk(store: &dyn BlockStore, r: &ChunkRef) -> Result file_len: store.len(), }); } - // 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 { + // A chunk at a schema major this reader cannot parse for its role. The + // accept-set is `[0, max_supported_major(kind)]`: the op-block role admits + // major 1 (D2), every other role stays exact-0 until its versioned path + // lands (Binary Format companion §"Schema Major 1"). A chunk above its + // role's max reaches this only on a direct read — a canonical root beyond + // the accept-set opens the bundle read-only at `open` instead, before any + // such read (see the operation-root scan there). Commit-time structural + // validation skips this gate (a newer writer's higher-major root is still + // structurally valid). + if enforce_accept_set && r.schema_version.major > max_supported_major(r.kind) { return Err(BundleError::UnsupportedSchemaVersion { version: r.schema_version, }); @@ -1016,7 +1107,11 @@ fn validate_canonical_roots( "operation block exceeds the active profile's maximum size", ))); } - let payload = read_and_verify_chunk(store, r)?; + // Structural verification only (`enforce_accept_set = false`): a commit + // may publish an op block at a major beyond this reader's accept-set (a + // newer writer's v1+ block). The accept-set is enforced on read, and a + // beyond-accept-set canonical root opens the bundle read-only at `open`. + let payload = read_and_verify_chunk_impl(store, r, false)?; // The block payload must be a well-formed envelope sequence. block::decode_block(&payload).map_err(BundleError::Decode)?; } @@ -1165,21 +1260,63 @@ mod tests { 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. + fn schema_major_1_admission_is_raised_per_role_op_blocks_only() { + // Admission of major 1 is raised **per chunk role**, never as a blanket + // accept-set (Binary Format companion §"Schema Major 1"). D2 raised the + // operation-envelope-block role to major 1 (a block bearing a v1 + // CreateRegion); every other role stays exact-0 until its own versioned + // path lands, and the manifest stays major 0 forever. + assert_eq!(SchemaVersion::V1.major, 1); + // The op-block role admits [0, 1]. + assert_eq!(max_supported_major(ChunkKind::OperationEnvelopeBlock), 1); + // Every other role stays at the generic baseline (major 0): the + // payload-polymorphic Snapshot (its migrate-on-read is core-side), the + // layout cache, and the operation index. assert_eq!(SUPPORTED_SCHEMA_MAJOR, 0); + assert_eq!(max_supported_major(ChunkKind::Snapshot), 0); + assert_eq!(max_supported_major(ChunkKind::LayoutCache), 0); + assert_eq!(max_supported_major(ChunkKind::OperationIndex), 0); + // The manifest gate is exact to the manifest's own major (0), independent + // of the per-role chunk accept-set. 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" + } + + #[test] + fn committing_an_unsupported_major_op_root_makes_the_live_bundle_read_only() { + // A commit publishes an op block beyond this reader's accept-set (a + // forward-compat write): structural validation lets it through, but the + // LIVE bundle must go read-only at once — not only on the next reopen — + // so no further commit runs against canonical history it cannot parse. + let mut bundle = fresh_bundle(); + let block = StagedChunk::operation_block_versioned( + crate::block::encode_block(&[vec![1u8, 2, 3]]), + SchemaVersion::new(2, 0), ); + bundle + .commit(&[block], |ctx| { + let mut m = ctx.previous_manifest.clone(); + m.operation_roots.push(ctx.new_chunks[0]); + m + }) + .expect("a structurally-valid future-major root is publishable"); + + assert!( + bundle.is_read_only(), + "the live bundle is read-only immediately after the commit" + ); + assert!(bundle.anomalies().iter().any(|a| matches!( + a, + IntegrityAnomaly::UnsupportedCanonicalChunkMajor { schema_major: 2 } + ))); + // A further commit against the now-read-only bundle is refused. + let more = StagedChunk::operation_block_versioned( + crate::block::encode_block(&[vec![9u8]]), + SchemaVersion::V0, + ); + assert!(matches!( + bundle.commit(&[more], |ctx| ctx.previous_manifest.clone()), + Err(BundleError::ReadOnly) + )); } fn fresh_bundle() -> Bundle { diff --git a/crates/epiphany-bundle/src/error.rs b/crates/epiphany-bundle/src/error.rs index e3db922..c24d623 100644 --- a/crates/epiphany-bundle/src/error.rs +++ b/crates/epiphany-bundle/src/error.rs @@ -249,6 +249,15 @@ pub enum IntegrityAnomaly { /// version, or one demanding a block bound beyond the reader's limit /// (Chapter 8 §"Format Profiles"). The bundle opens read-only. UnsupportedProfile, + + /// A declared **canonical** root — an operation-envelope block — is stamped + /// at a schema major above this reader's accept-set for that role + /// (Binary Format companion §"Schema Major 1", "Canonical chunks — parse or + /// open read-only"). The reader cannot interpret the newer op bytes, so it + /// opens the bundle read-only: it still reads the canonical base and + /// manifest (both stay major 0) but refuses to author against op history it + /// cannot parse. Carries the offending major. + UnsupportedCanonicalChunkMajor { schema_major: u16 }, } impl core::fmt::Display for IntegrityAnomaly { @@ -271,6 +280,10 @@ impl core::fmt::Display for IntegrityAnomaly { IntegrityAnomaly::UnsupportedProfile => { f.write_str("the active profile is unsupported; opened read-only") } + IntegrityAnomaly::UnsupportedCanonicalChunkMajor { schema_major } => write!( + f, + "a canonical operation block is at schema major {schema_major}, above this reader's accept-set; opened read-only" + ), } } } diff --git a/crates/epiphany-bundle/src/ids.rs b/crates/epiphany-bundle/src/ids.rs index 983a54a..86c2005 100644 --- a/crates/epiphany-bundle/src/ids.rs +++ b/crates/epiphany-bundle/src/ids.rs @@ -186,6 +186,20 @@ impl SchemaVersion { SchemaVersion { major, minor } } + /// The current schema version at a given major: [`Self::V0`] for major 0, + /// [`Self::V1`] for major 1, and `{major, 0}` for any higher (future) major. + /// A writer maps a chunk's derived schema major to a version this way — e.g. + /// an operation-envelope block stamps the max over its operations' + /// `schema_major()`. + #[inline] + pub const fn for_major(major: u16) -> Self { + match major { + 0 => SchemaVersion::V0, + 1 => SchemaVersion::V1, + m => SchemaVersion { major: m, minor: 0 }, + } + } + /// Canonical 4 bytes for the hash preimage (Chapter 8 /// §"Domain-Separated Preimages"): major then minor, little-endian. #[inline] diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index 76ece5e..f9a7d32 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -2199,43 +2199,6 @@ fn dec_region_v0(r: &mut Reader<'_>) -> Result { }) } -/// Frozen v0 encoder for a `Region` element — the inverse of [`dec_region_v0`]. -/// Emits the six fields before `permits_spanning_slurs`; the flag is **not** -/// written, so the bytes are byte-identical to schema major 0. -fn enc_region_v0(reg: &Region, out: &mut Vec) { - reg.id.enc(out); - reg.time_model.enc(out); - reg.content.enc(out); - reg.time_extent.enc(out); - reg.staff_extent.enc(out); - reg.local_tempo_map.enc(out); - // v0: permits_spanning_slurs is not carried. -} - -impl Region { - /// The **frozen schema-major-0** canonical bytes of this region: the six - /// fields before `permits_spanning_slurs`, which is *not* carried. - /// - /// The canonical `CreateRegion` operation payload embeds a region, and its - /// op-envelope block is stamped at schema major 0. To keep that block - /// byte-identical to schema major 0 — so a major-0 reader parses it and no - /// op-block migration is owed — the operation payload uses **this** surface, - /// not the full [`canonical_bytes`](CanonicalValue::canonical_bytes) (which - /// now carries `permits_spanning_slurs`, schema major 1). A region minted by - /// a `CreateRegion` therefore reduces with `permits_spanning_slurs = false` - /// regardless of the value passed — the only value any producer sets today. - /// - /// The op payload moves to the schema-major-1 encoding, and the op block to - /// major 1 with migrate-on-read, when the op-block schema-major machinery - /// lands (schema-major track, D2); the crate-private `dec_region_v0` is that - /// migration's frozen decoder. - pub fn canonical_bytes_v0(&self) -> Vec { - let mut out = Vec::new(); - enc_region_v0(self, &mut out); - out - } -} - /// Frozen v0 decoder for `Score.instruments`: v0 `Instrument` was `{ id, name }` /// (no `range`), which is default-filled `None`. Mirrors the `Vec` framing (a /// `u32` count then each element). @@ -2524,7 +2487,16 @@ mod tests { /// *wrong* layout would still round-trip, so the callers also anchor the v0 /// byte length against the production v1 encoder (which this does not touch). fn encode_v0_score(s: &Score) -> Vec { - // Reuses the production frozen v0 region encoder (super::enc_region_v0). + // The inverse of dec_region_v0: the six fields before the schema-major-1 + // permits_spanning_slurs (a full-Score snapshot's v0 region form). + fn enc_region_v0(reg: &Region, out: &mut Vec) { + reg.id.enc(out); + reg.time_model.enc(out); + reg.content.enc(out); + reg.time_extent.enc(out); + reg.staff_extent.enc(out); + reg.local_tempo_map.enc(out); + } let mut out = Vec::new(); s.metadata.enc(&mut out); // Canvas v0: `regions` only (no `layout_defaults`). diff --git a/crates/epiphany-ops/src/envelope.rs b/crates/epiphany-ops/src/envelope.rs index cc905b1..4af1db8 100644 --- a/crates/epiphany-ops/src/envelope.rs +++ b/crates/epiphany-ops/src/envelope.rs @@ -87,6 +87,13 @@ impl OperationEnvelope { p.push_bytes(&self.to_canonical_bytes()); EnvelopeHash(*p.finish().as_bytes()) } + + /// The binary-format schema major this envelope's canonical bytes require + /// ([`OperationPayload::schema_major`]). An op-envelope block's schema major + /// is the maximum over the envelopes it carries. + pub fn schema_major(&self) -> u16 { + self.payload.schema_major() + } } impl CanonicalEncode for OperationEnvelope { diff --git a/crates/epiphany-ops/src/payload.rs b/crates/epiphany-ops/src/payload.rs index b171a85..e97df90 100644 --- a/crates/epiphany-ops/src/payload.rs +++ b/crates/epiphany-ops/src/payload.rs @@ -78,6 +78,19 @@ impl OperationPayload { OperationPayload::ResolveEquivocation(_) => 3, } } + + /// The binary-format schema major this payload's canonical encoding requires + /// ([`OperationKind::schema_major`]). Only a primitive `CreateRegion` is + /// major 1; the meta-operations embed no schema-major-1 value, so they are + /// major 0. + pub fn schema_major(&self) -> u16 { + match self { + OperationPayload::Primitive(kind) => kind.schema_major(), + OperationPayload::ResolveConflict(_) + | OperationPayload::UndoTransaction(_) + | OperationPayload::ResolveEquivocation(_) => 0, + } + } } impl CanonicalEncode for OperationPayload { @@ -173,6 +186,21 @@ pub enum OperationKind { } impl OperationKind { + /// The binary-format schema major this kind's canonical payload encodes at + /// (Binary Format companion §"Schema Major 1"). `CreateRegion` embeds a + /// [`Region`], which grew `permits_spanning_slurs` at schema major 1, so its + /// payload is major 1; every other kind's payload is unchanged from major 0. + /// + /// An op-envelope block's schema major is the maximum over the operations it + /// carries: a block bearing any `CreateRegion` is stamped major 1, and a + /// major-0-only reader opens such a bundle read-only. + pub fn schema_major(&self) -> u16 { + match self { + OperationKind::CreateRegion(_) => 1, + _ => 0, + } + } + fn discriminant(&self) -> u8 { match self { OperationKind::InsertEvent(_) => 0, @@ -1004,11 +1032,11 @@ impl CanonicalEncode for ModifyCrossCuttingOp { // deletes are empty-only delete-wins tombstones (the container must have no live // children — the caller deletes contents first). See `DECISIONS.md`. -/// Mint an empty region into the canvas (Chapter 6 §6.10 InsertRegion). Holds -/// the full [`Region`] value; the reduction preconditions it carries no staff -/// instances (an empty container). Its canonical payload embeds the region's -/// **schema-major-0** form (no `permits_spanning_slurs`) so the op-envelope -/// block stays byte-v0 — see [`CreateRegionOp::encode_canonical`]. +/// Mint an empty region into the canvas (Chapter 6 §6.10 InsertRegion). Carries +/// the full [`Region`] value (schema major 1, including `permits_spanning_slurs`); +/// the reduction preconditions it carries no staff instances (an empty +/// container). Its canonical encoding puts this op at schema major 1 — see +/// [`CreateRegionOp::encode_canonical`] and [`OperationKind::schema_major`]. #[derive(Clone, PartialEq, Eq, Debug)] pub struct CreateRegionOp { pub region: Region, @@ -1023,15 +1051,13 @@ impl CreateRegionOp { impl CanonicalEncode for CreateRegionOp { fn encode_canonical(&self, out: &mut Vec) { - // The op-envelope block is stamped schema major 0, so this payload stays - // byte-identical to schema major 0: it embeds the region's **v0** - // canonical form (no `permits_spanning_slurs`), not the schema-major-1 - // `canonical_bytes`. A region minted here therefore reduces with the - // flag `false` — the only value any producer sets today. The op payload - // moves to the v1 encoding, and the block to major 1 with - // migrate-on-read, when the op-block schema-major machinery lands - // (schema-major track, D2). See `Region::canonical_bytes_v0`. - push_lp_bytes(out, &self.region.canonical_bytes_v0()); + // Schema major 1: the payload embeds the region's full canonical form, + // carrying `permits_spanning_slurs`. Because this encoding changed at + // major 1, a `CreateRegion` operation encodes at schema major 1 + // ([`OperationKind::schema_major`]), and any op-envelope block carrying + // one is stamped major 1 — a major-0-only reader opens such a bundle + // read-only (Binary Format companion §"Schema Major 1"). + push_lp_bytes(out, &self.region.canonical_bytes()); } } @@ -1614,12 +1640,11 @@ mod tests { } #[test] - fn create_region_payload_is_byte_v0_and_omits_the_spanning_flag() { - // The op-envelope block is stamped schema major 0, so the CreateRegion - // payload must stay byte-identical to schema major 0 — it must NOT carry - // Region.permits_spanning_slurs (schema major 1). Encoding a region with - // the flag set produces the same bytes as with it clear, and equals the - // region's frozen v0 canonical form, length-prefixed. + fn create_region_payload_is_v1_and_carries_the_spanning_flag() { + // Schema major 1: the CreateRegion payload embeds the region's full + // canonical form, so permits_spanning_slurs IS carried — a region with + // the flag set encodes differently from one without, and the payload is + // exactly the region's (v1) canonical_bytes, length-prefixed. let rid = RegionId::new(ReplicaId(9), 3); let mut permit = crate::valuegen::region(rid); permit.permits_spanning_slurs = true; @@ -1630,14 +1655,27 @@ mod tests { CreateRegionOp { region }.encode_canonical(&mut out); out }; - // The flag is not carried: both encode identically. - assert_eq!(enc(permit.clone()), enc(forbid.clone())); - // And the payload is exactly the region's v0 canonical form, LP-framed. + // The flag is carried: the two encode differently. + assert_ne!(enc(permit.clone()), enc(forbid.clone())); + // And the payload is exactly the region's v1 canonical form, LP-framed. let mut expected = Vec::new(); - push_lp_bytes(&mut expected, &forbid.canonical_bytes_v0()); + push_lp_bytes(&mut expected, &forbid.canonical_bytes()); assert_eq!(enc(forbid), expected); } + #[test] + fn create_region_op_encodes_at_schema_major_1() { + // CreateRegion's canonical encoding changed at major 1, so the op reports + // schema major 1; a representative major-0 op reports 0. + let rid = RegionId::new(ReplicaId(9), 3); + let create = OperationKind::CreateRegion(CreateRegionOp { + region: crate::valuegen::region(rid), + }); + assert_eq!(create.schema_major(), 1); + let delete = OperationKind::DeleteRegion(DeleteRegionOp { region: rid }); + assert_eq!(delete.schema_major(), 0); + } + #[test] fn transaction_category_discriminants_are_golden() { // RATIFIED by Pass 11 (item 2.4, req:semops:transaction-category): the diff --git a/crates/epiphany-testkit/src/bundle_harness.rs b/crates/epiphany-testkit/src/bundle_harness.rs index d869818..45da9f3 100644 --- a/crates/epiphany-testkit/src/bundle_harness.rs +++ b/crates/epiphany-testkit/src/bundle_harness.rs @@ -16,11 +16,28 @@ use epiphany_bundle::{ encode_block, envelope_offsets, pack_operation_blocks, BlockStore, Bundle, BundleError, CommitContext, CrashPoint, DocumentId, ExtensionDeclaration, ExtensionId, FaultStore, FileUuid, - IndexedBlock, Manifest, MemStore, OperationIndex, SemVer, Slot, StagedChunk, Tear, + IndexedBlock, Manifest, MemStore, OperationIndex, SchemaVersion, SemVer, Slot, StagedChunk, + Tear, }; use epiphany_determinism::CanonicalEncode; use epiphany_ops::{peek_operation_id, OperationEnvelope}; +/// Stages real operation envelopes into a single op-envelope block, **deriving** +/// the block's schema version from its operations: the block major is the max +/// over `OperationEnvelope::schema_major` (a v1 `CreateRegion` → major 1), +/// mapped to a version by [`SchemaVersion::for_major`]. This is the writer-side +/// derivation every real-envelope staging path must use so a block carrying a +/// v1 payload is never mis-stamped major 0. +pub fn stage_operation_block(envelopes: &[OperationEnvelope]) -> StagedChunk { + let payloads: Vec> = envelopes.iter().map(|e| e.to_canonical_bytes()).collect(); + let major = envelopes + .iter() + .map(|e| e.schema_major()) + .max() + .unwrap_or(0); + StagedChunk::operation_block_versioned(encode_block(&payloads), SchemaVersion::for_major(major)) +} + use crate::generators; use crate::rng::Rng; @@ -251,12 +268,11 @@ pub fn run_manifest_selection(seed: u64) { /// `per_block` envelopes each (forcing a multi-block layout regardless of the /// 1 MiB soft target, so index ordinals are actually exercised). fn staged_envelope_blocks(envelopes: &[OperationEnvelope], per_block: usize) -> Vec { + // Each block derives its schema version from its own operations, so a group + // containing a v1 CreateRegion is stamped major 1 (not V0). envelopes .chunks(per_block) - .map(|group| { - let payloads: Vec> = group.iter().map(|e| e.to_canonical_bytes()).collect(); - StagedChunk::operation_block(encode_block(&payloads)) - }) + .map(stage_operation_block) .collect() } diff --git a/crates/epiphany-testkit/src/roundtrip.rs b/crates/epiphany-testkit/src/roundtrip.rs index b3accb5..a5fd9bc 100644 --- a/crates/epiphany-testkit/src/roundtrip.rs +++ b/crates/epiphany-testkit/src/roundtrip.rs @@ -555,6 +555,98 @@ mod tests { } } + /// A real `CreateRegion` operation envelope (schema major 1: its payload + /// carries `Region.permits_spanning_slurs`). + fn create_region_envelope() -> OperationEnvelope { + use epiphany_core::{OperationId, RegionId, ReplicaId, WallClockTime}; + use epiphany_ops::{ + AuthorId, CausalContext, CreateRegionOp, HybridLogicalClock, OperationKind, + OperationPayload, OperationStamp, + }; + let rid = RegionId::new(ReplicaId(9), 3); + let mut region = epiphany_ops::valuegen::region(rid); + region.permits_spanning_slurs = true; + let id = OperationId::new(ReplicaId(9), 1); + OperationEnvelope { + id, + author: AuthorId(0), + stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(1), 0), id), + causal_context: CausalContext::new(), + transaction: None, + payload: OperationPayload::Primitive(OperationKind::CreateRegion(CreateRegionOp { + region, + })), + } + } + + /// Commits a single already-staged operation block into a fresh bundle and + /// returns the reopened bundle. + fn reopen_with_op_block(seed: u64, block: StagedChunk) -> Bundle { + let mut rng = Rng::new(seed); + let mut bundle = Bundle::create( + MemStore::new(), + FileUuid(rng.array16()), + Manifest::empty(DocumentId(rng.array16())), + ) + .expect("create bundle"); + bundle + .commit(&[block], |ctx| { + let mut m = ctx.previous_manifest.clone(); + m.operation_roots.push(ctx.new_chunks[0]); + m + }) + .expect("commit op block"); + let image = bundle.into_store().into_bytes(); + Bundle::open(MemStore::from_bytes(image)).expect("reopen bundle") + } + + #[test] + fn create_region_op_block_is_stamped_major_1_and_reopens_read_write() { + let env = create_region_envelope(); + assert_eq!( + env.schema_major(), + 1, + "CreateRegion encodes at schema major 1" + ); + // The WRITER *derives* the block major from its operations — the same + // `stage_operation_block` the real-envelope harness uses — so this proves + // derivation, not a hand-picked version. A v1 CreateRegion → major 1. + let block = crate::bundle_harness::stage_operation_block(std::slice::from_ref(&env)); + let reopened = reopen_with_op_block(0xD2_0001, block); + // Major 1 is within the op-block accept-set [0,1], so the bundle opens + // read-write and the block reads back opaquely. + assert_eq!( + reopened.manifest().operation_roots[0].schema_version, + SchemaVersion::V1 + ); + assert!(!reopened.is_read_only()); + let blocks = reopened + .read_operation_block(&reopened.manifest().operation_roots[0]) + .expect("major-1 op block is admitted by the accept-set"); + assert_eq!(blocks, vec![env.to_canonical_bytes()]); + } + + #[test] + fn op_block_beyond_the_accept_set_opens_read_only() { + use epiphany_bundle::IntegrityAnomaly; + // A newer writer's op block, stamped schema major 2 — beyond the reader's + // op-block accept-set [0,1]. The bundle opens read-only preservation (the + // canonical base and manifest still read) rather than hard-rejecting. + let block = StagedChunk::operation_block_versioned( + encode_block(&[vec![1u8, 2, 3, 4]]), + SchemaVersion::new(2, 0), + ); + let reopened = reopen_with_op_block(0xD2_0002, block); + assert!( + reopened.is_read_only(), + "a beyond-accept-set canonical root opens read-only" + ); + assert!(reopened.anomalies().iter().any(|a| matches!( + a, + IntegrityAnomaly::UnsupportedCanonicalChunkMajor { schema_major: 2 } + ))); + } + #[test] fn corpus_round_trips() { run_roundtrip_corpus(60_000, 0x00C0_FFEE_1234_5678);