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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
This commit is contained in:
Levi Neuwirth 2026-07-06 12:09:30 -04:00
parent 03758bbae0
commit 4598f30ddd
8 changed files with 387 additions and 98 deletions

View File

@ -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<u8>) -> 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<u8>, schema_version: SchemaVersion) -> Self {
StagedChunk {
kind: ChunkKind::OperationEnvelopeBlock,
schema_version: SchemaVersion::V0,
schema_version,
payload,
}
}
@ -339,6 +369,19 @@ impl<S: BlockStore> Bundle<S> {
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<S: BlockStore> Bundle<S> {
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<u16> {
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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>
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<MemStore> {

View File

@ -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"
),
}
}
}

View File

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

View File

@ -2199,43 +2199,6 @@ fn dec_region_v0(r: &mut Reader<'_>) -> Result<Region> {
})
}
/// 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<u8>) {
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<u8> {
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<u8> {
// 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<u8>) {
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`).

View File

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

View File

@ -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<u8>) {
// 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

View File

@ -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<Vec<u8>> = 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<StagedChunk> {
// 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<Vec<u8>> = group.iter().map(|e| e.to_canonical_bytes()).collect();
StagedChunk::operation_block(encode_block(&payloads))
})
.map(stage_operation_block)
.collect()
}

View File

@ -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<MemStore> {
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);