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:
parent
03758bbae0
commit
4598f30ddd
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue