Phase B follow-up: real acceleration-snapshot path + base role bound

Review findings on 794400c. The criterion-4 harness stored current
(v2) Score bytes stamped V0 in the canonical_base slot and decoded
them with the unversioned decoder — bypassing the schema-major
snapshot contract Phase B built, and misusing the base role. Fixed
substantively: the harness stages a properly-roled ACCELERATION
snapshot (ChunkKind::Snapshot stamped for_major(2), referenced from
Manifest::acceleration_snapshots) and decodes through
Score::decode_canonical_versioned keyed by the stamped major — the
versioned seam now exercised end-to-end through the bundle, giving
the snapshot role its first real producer/consumer.

Consequences: max_supported_major(Snapshot) -> 2; and since the
per-kind gate no longer implies it, the canonical-base-stays-major-0
rule is enforced per ROLE (mis_stamped_canonical_base, consulted at
open and commit -> read-only + UnsupportedCanonicalChunkMajor;
regression test covers commit and reopen).

Also: three stale-doc fixes (decode_canonical now "current (schema
major 2)"; CreateRegionOp + StagedChunk::operation_block +
stage_operation_block docs describe minimal stamping, not just "v1
CreateRegion").

Full gate green: fmt, clippy -D warnings, rustdoc -D warnings, 30
workspace suites, conformance scale 1 (8/8).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
This commit is contained in:
Levi Neuwirth 2026-07-07 16:54:06 -04:00
parent 794400c4c7
commit 437e0758a0
6 changed files with 169 additions and 46 deletions

View File

@ -411,3 +411,20 @@ acceleration full-`Score` snapshot yet, so its role gate waits for a real
producer (the core-side seam `decode_canonical_versioned` already handles
{0,1,2}). `SchemaVersion::V2` added; beyond-accept-set tests moved to
major 3.
### Follow-up (review): the snapshot role gets its producer + the base gets a role bound
A post-commit review caught the criterion-4 harness bypassing the versioned
snapshot contract (current-major `Score` bytes stamped V0 in the
`canonical_base` slot, decoded with the unversioned decoder). Fixed the
substantive way: the harness now stages a **properly-roled acceleration
snapshot** — `ChunkKind::Snapshot` stamped `SchemaVersion::for_major(2)`,
referenced from `Manifest::acceleration_snapshots`, decoded through
`Score::decode_canonical_versioned(bytes, root.schema_version.major)` — so
the schema-major snapshot contract is exercised end-to-end through the
bundle. Consequences: `max_supported_major(Snapshot)` → 2 (superseding the
"waits for a producer" note above), and because the per-kind gate no longer
implies it, the canonical-base-stays-major-0 rule is now enforced per ROLE
(`mis_stamped_canonical_base`, consulted at open and commit → read-only +
`UnsupportedCanonicalChunkMajor`, regression-locked). The `SnapshotId` in
the harness remains a hash-truncation stand-in (companion open question).

View File

@ -53,17 +53,25 @@ pub const SUPPORTED_SCHEMA_MAJOR: u16 = 0;
/// `OperationEnvelopeBlock` admits major 2 (schema major 2 fills the
/// cross-cutting/staff/metadata bodies its payloads embed; major 1 embedded a
/// v1 `CreateRegion`; the reader treats the block bytes opaquely, so it
/// parses a higher-major 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
/// parses a higher-major block without decoding the payload). `Snapshot`
/// admits major 2 for the acceleration full-`Score` form (decoded through
/// the core versioned seam); the canonical BASE carried under the same kind
/// must stay major 0, enforced per role. Every other role stays at
/// [`SUPPORTED_SCHEMA_MAJOR`] until its own versioned path lands — the
/// layout cache, the operation index, and the manifest (carried opaquely,
/// never grows a versioned 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.
/// (a lower-major-only reader meeting a newer op block), not a hard reject.
pub fn max_supported_major(kind: ChunkKind) -> u16 {
match kind {
ChunkKind::OperationEnvelopeBlock => 2,
// The payload-polymorphic Snapshot role: the acceleration
// full-`Score` form is decoded through the core versioned seam
// (`Score::decode_canonical_versioned`, majors {0,1,2}). The
// *canonical base* must stay major 0 regardless — that is enforced
// per ROLE (`mis_stamped_canonical_base`, consulted at open and
// commit), not by this per-kind bound.
ChunkKind::Snapshot => 2,
_ => SUPPORTED_SCHEMA_MAJOR,
}
}
@ -102,9 +110,10 @@ pub struct StagedChunk {
impl StagedChunk {
/// A staged operation-envelope block at schema major 0 (the baseline: no
/// operation in the block carries a schema-major-1 payload). Use
/// operation in the block carries a versioned payload). Use
/// [`StagedChunk::operation_block_versioned`] for a block whose operations
/// may include a v1 `CreateRegion`.
/// may carry a higher-major payload (a v1 `CreateRegion`; a v2
/// cross-cutting/staff/metadata value under minimal stamping).
pub fn operation_block(payload: Vec<u8>) -> Self {
StagedChunk::operation_block_versioned(payload, SchemaVersion::V0)
}
@ -383,6 +392,14 @@ impl<S: BlockStore> Bundle<S> {
schema_major: major,
});
}
// The canonical base is role-bound to major 0 (see
// `mis_stamped_canonical_base`).
if let Some(major) = mis_stamped_canonical_base(&manifest) {
read_only = true;
anomalies.push(IntegrityAnomaly::UnsupportedCanonicalChunkMajor {
schema_major: major,
});
}
let write_cursor = store.len();
Ok(Bundle {
@ -754,6 +771,13 @@ impl<S: BlockStore> Bundle<S> {
schema_major: major,
});
}
if let Some(major) = mis_stamped_canonical_base(&manifest) {
self.read_only = true;
self.anomalies
.push(IntegrityAnomaly::UnsupportedCanonicalChunkMajor {
schema_major: major,
});
}
self.manifest = manifest;
self.write_cursor = cursor;
Ok(())
@ -830,6 +854,21 @@ fn unsupported_operation_root_major(manifest: &Manifest) -> Option<u16> {
.find(|&m| m > max_supported_major(ChunkKind::OperationEnvelopeBlock))
}
/// The canonical base MUST stay schema major 0 across the data-model majors
/// (Binary Format §Schema Major 1 / §Schema Major 2: the `MaterializedState`
/// embeds none of the filled values, and re-stamping byte-identical content
/// churns its content address). With the Snapshot *kind* now admitting the
/// major-2 acceleration form, this per-ROLE check keeps the base constraint:
/// a base ref stamped above major 0 forces read-only preservation, exactly
/// like a beyond-accept-set op root.
fn mis_stamped_canonical_base(manifest: &Manifest) -> Option<u16> {
manifest
.canonical_base
.as_ref()
.map(|b| b.root.schema_version.major)
.filter(|&m| m > 0)
}
/// 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
@ -934,14 +973,15 @@ fn read_and_verify_chunk_impl(
});
}
// 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).
// accept-set is `[0, max_supported_major(kind)]`: the op-block and
// snapshot roles admit major 2, every other role stays exact-0 until its
// versioned path lands (Binary Format companion §"Schema Major 2"). 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 and
// canonical-base scans 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,
@ -1259,7 +1299,8 @@ pub fn manifest_chunk_hash(payload: &[u8]) -> ContentHash {
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::DocumentId;
use crate::ids::{DocumentId, FrontierBytes, ReductionAlgorithmVersion, SnapshotId};
use crate::manifest::SnapshotRef;
#[test]
fn schema_major_1_admission_is_raised_per_role_op_blocks_only() {
@ -1274,11 +1315,12 @@ mod tests {
assert_eq!(SchemaVersion::V2.major, 2);
// The op-block role admits [0, 2].
assert_eq!(max_supported_major(ChunkKind::OperationEnvelopeBlock), 2);
// 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.
// The snapshot role admits the major-2 acceleration form (decoded
// through the core versioned seam); the canonical BASE stays major 0
// per role (`mis_stamped_canonical_base`). The remaining roles stay
// at the generic baseline: 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::Snapshot), 2);
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
@ -1324,6 +1366,57 @@ mod tests {
));
}
#[test]
fn a_canonical_base_stamped_above_major_0_opens_read_only() {
// The canonical base is role-bound to major 0 (Binary Format §Schema
// Major 2: the MaterializedState embeds no data-model-major values,
// and re-stamping byte-identical content churns its address). With
// the Snapshot KIND now admitting the major-2 acceleration form, the
// per-role check must catch a mis-stamped base: read-only + anomaly,
// at commit and again on reopen.
let mut bundle = fresh_bundle();
let base = StagedChunk {
kind: ChunkKind::Snapshot,
schema_version: SchemaVersion::V1,
payload: vec![7u8, 7, 7],
};
bundle
.commit(&[base], |ctx| {
let mut m = ctx.previous_manifest.clone();
let root = ctx.new_chunks[0];
let mut sid = [0u8; 16];
sid.copy_from_slice(&root.hash.as_bytes()[..16]);
m.canonical_base = Some(SnapshotRef {
snapshot_id: SnapshotId(sid),
covers_causal_frontier: FrontierBytes::from_bytes(vec![]),
reduction_algorithm_version: ReductionAlgorithmVersion(0),
profile_id: ProfileId::Full,
hash: root.hash,
root,
});
m
})
.expect("a structurally-valid mis-stamped base is publishable");
assert!(
bundle.is_read_only(),
"a mis-stamped canonical base forces read-only at commit"
);
assert!(bundle.anomalies().iter().any(|a| matches!(
a,
IntegrityAnomaly::UnsupportedCanonicalChunkMajor { schema_major: 1 }
)));
let image = bundle.into_store().into_bytes();
let reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen");
assert!(
reopened.is_read_only(),
"a mis-stamped canonical base forces read-only at open"
);
assert!(reopened.anomalies().iter().any(|a| matches!(
a,
IntegrityAnomaly::UnsupportedCanonicalChunkMajor { schema_major: 1 }
)));
}
fn fresh_bundle() -> Bundle<MemStore> {
Bundle::create(
MemStore::new(),

View File

@ -2334,7 +2334,7 @@ impl Score {
/// Decodes the exact inverse of [`Score::canonical_bytes`], validating every
/// tag, length, primitive, and type invariant. Trailing bytes are rejected.
///
/// This is the **current (schema major 1)** layout. To decode bytes whose
/// This is the **current (schema major 2)** layout. To decode bytes whose
/// schema major is not known to be current, use
/// [`Score::decode_canonical_versioned`].
///

View File

@ -1062,10 +1062,12 @@ impl CanonicalEncode for ModifyCrossCuttingOp {
// children — the caller deletes contents first). See `DECISIONS.md`.
/// 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 full [`Region`] value (`permits_spanning_slurs` since schema major 1);
/// 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`].
/// container). Minimal stamping ([`OperationKind::schema_major`]): the payload
/// is major 1, or major 2 iff a carried staff instance bears
/// `Some(staff_lines_override)` (wire-representable though reduction refuses
/// non-empty regions).
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct CreateRegionOp {
pub region: Region,

View File

@ -24,10 +24,11 @@ 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.
/// over `OperationEnvelope::schema_major` under minimal stamping (a v1
/// `CreateRegion` → major 1; a v2 cross-cutting/staff/metadata value →
/// major 2), 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 versioned 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

View File

@ -336,8 +336,12 @@ pub fn assert_score_serialization_stable(score: &Score, frontier: &[u8], seed: u
"re-encoding the same score changed its bytes"
);
// serialize: stage the canonical score as a real Snapshot chunk referenced
// from the manifest's canonical_base.
// serialize: stage the score as a properly-roled ACCELERATION snapshot
// (Binary Format §Schema Major 2): a `ChunkKind::Snapshot` stamped with
// the current schema major and referenced from the manifest's
// `acceleration_snapshots` — NOT the canonical base, which is the
// MaterializedState's role and stays major 0. (The `SnapshotId` here is a
// hash-truncation stand-in; its derivation is a companion open question.)
let mut rng = Rng::new(seed);
let uuid = FileUuid(rng.array16());
let doc = DocumentId(rng.array16());
@ -345,7 +349,7 @@ pub fn assert_score_serialization_stable(score: &Score, frontier: &[u8], seed: u
Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle");
let snapshot = StagedChunk {
kind: ChunkKind::Snapshot,
schema_version: SchemaVersion::V0,
schema_version: SchemaVersion::for_major(2),
payload: canonical.clone(),
};
let frontier = frontier.to_vec();
@ -355,7 +359,7 @@ pub fn assert_score_serialization_stable(score: &Score, frontier: &[u8], seed: u
let root = ctx.new_chunks[0];
let mut sid = [0u8; 16];
sid.copy_from_slice(&root.hash.as_bytes()[..16]);
m.canonical_base = Some(SnapshotRef {
m.acceleration_snapshots.push(SnapshotRef {
snapshot_id: SnapshotId(sid),
covers_causal_frontier: FrontierBytes::from_bytes(frontier.clone()),
reduction_algorithm_version: ReductionAlgorithmVersion(0),
@ -368,30 +372,36 @@ pub fn assert_score_serialization_stable(score: &Score, frontier: &[u8], seed: u
.expect("commit snapshot");
let image = bundle.into_store().into_bytes();
// load: reopen, hash-verify, read back byte-identically.
// load: reopen (read-write — an acceleration snapshot at the current
// major is within the snapshot role's accept-set), hash-verify, read the
// referenced chunk back byte-identically.
let reopened = Bundle::open(MemStore::from_bytes(image)).expect("reopen bundle");
assert!(
!reopened.is_read_only(),
"a current-major acceleration snapshot must not force read-only"
);
reopened
.verify_canonical_chunks()
.expect("canonical chunks intact");
let base = reopened
let accel = reopened
.manifest()
.canonical_base
.as_ref()
.expect("a canonical base");
.acceleration_snapshots
.first()
.expect("an acceleration snapshot");
assert_eq!(accel.root.schema_version, SchemaVersion::for_major(2));
let loaded = reopened
.read_chunk(&base.root)
.read_chunk(&accel.root)
.expect("read snapshot chunk back");
assert_eq!(
loaded, canonical,
"score bytes were not preserved through content-addressed storage"
);
// deserialize → equal → reserialize byte-identically. (The canonical base
// is a major-0 chunk; the schema-version dispatch seam for the acceleration
// full-`Score` snapshot is exercised on its own read path in a later phase,
// where a properly-roled acceleration snapshot exists. This harness's base
// decodes with the current codec.)
let decoded = Score::decode_canonical(&loaded).expect("loaded score must decode");
// deserialize through the SCHEMA-VERSION DISPATCH SEAM, keyed by the
// chunk's stamped major — the read path a real acceleration-snapshot
// consumer uses — then re-serialize byte-identically.
let decoded = Score::decode_canonical_versioned(&loaded, accel.root.schema_version.major)
.expect("loaded score must decode at its stamped major");
assert_eq!(&decoded, score, "decoded score changed");
assert_eq!(
decoded.canonical_bytes(),