diff --git a/crates/epiphany-bundle/src/chunk.rs b/crates/epiphany-bundle/src/chunk.rs index 09a0e38..5414f1a 100644 --- a/crates/epiphany-bundle/src/chunk.rs +++ b/crates/epiphany-bundle/src/chunk.rs @@ -314,6 +314,41 @@ mod tests { assert_eq!(ChunkKind::from_discriminant(9), None); } + #[test] + fn chunk_kind_discriminants_are_golden() { + // RATIFIED by Pass 11 (item 1.5, req:format:chunkkind-discriminants): + // the declaration-order discriminant byte is in the chunk hash preimage, + // so the *literal* values are normative. The round-trip test above is + // invariant under a coordinated renumbering; this locks the values + // themselves so a reorder breaks deliberately (and cannot silently + // change every chunk's content address). + assert_eq!(ChunkKind::OperationEnvelopeBlock.discriminant(), 0); + assert_eq!(ChunkKind::OperationIndex.discriminant(), 1); + assert_eq!(ChunkKind::Snapshot.discriminant(), 2); + assert_eq!(ChunkKind::Blob.discriminant(), 3); + assert_eq!(ChunkKind::ExtensionData.discriminant(), 4); + assert_eq!(ChunkKind::TextProjection.discriminant(), 5); + assert_eq!(ChunkKind::LayoutCache.discriminant(), 6); + assert_eq!(ChunkKind::IntegrityIndex.discriminant(), 7); + assert_eq!(ChunkKind::Manifest.discriminant(), 8); + } + + #[test] + fn compression_algorithm_encoding_is_golden() { + // RATIFIED by Pass 11 (item 1.5, req:format:chunkkind-discriminants): + // CompressionAlgorithm encodes as a fixed two bytes — a discriminant + // byte plus an always-present parameter byte. `None` carries a zero + // parameter byte (it is NOT a bare tag), so lock the exact bytes. + let enc = |c: CompressionAlgorithm| { + let mut w = Writer::new(); + c.encode(&mut w); + w.into_bytes() + }; + assert_eq!(enc(CompressionAlgorithm::None), vec![0, 0]); + assert_eq!(enc(CompressionAlgorithm::Zstd { level: 9 }), vec![1, 9]); + assert_eq!(enc(CompressionAlgorithm::Reserved(7)), vec![2, 7]); + } + #[test] fn manifest_and_ordinary_chunks_use_distinct_domains() { // A manifest payload and an operation block with identical bytes must diff --git a/crates/epiphany-bundle/src/superblock.rs b/crates/epiphany-bundle/src/superblock.rs index 2cad7ec..7e1897a 100644 --- a/crates/epiphany-bundle/src/superblock.rs +++ b/crates/epiphany-bundle/src/superblock.rs @@ -406,6 +406,25 @@ mod tests { } } + #[test] + fn profile_id_discriminants_are_golden() { + // RATIFIED by Pass 11 (item 1.5, req:format:profileid-discriminants): + // ProfileId is a load-bearing superblock-selection field, so the literal + // u32 discriminants are normative. Lock the values, not just round-trip. + assert_eq!(ProfileId::Full.discriminant(), 0); + assert_eq!(ProfileId::ReadOnly.discriminant(), 1); + assert_eq!(ProfileId::Lite.discriminant(), 2); + assert_eq!( + ProfileId::Custom(ProfileRegistryId([0; 16])).discriminant(), + 3 + ); + // And the fixed-width encoding: a u32-LE discriminant + 16-byte registry + // id (zero unless Custom) = 20 bytes; Full is twenty zero bytes. + let mut w = Writer::new(); + ProfileId::Full.encode(&mut w); + assert_eq!(w.into_bytes(), vec![0u8; 20]); + } + #[test] fn superblock_round_trips_through_256_bytes() { let original = Superblock { diff --git a/crates/epiphany-ops/src/anomaly.rs b/crates/epiphany-ops/src/anomaly.rs index f6860dc..24e742d 100644 --- a/crates/epiphany-ops/src/anomaly.rs +++ b/crates/epiphany-ops/src/anomaly.rs @@ -303,6 +303,29 @@ mod tests { } } + #[test] + fn integrity_anomaly_id_byte_form_is_locked() { + // Golden: locks the MUSCSANM-derived anomaly id for a fixed kind. + // RATIFIED by Pass 11 (item 1.4, req:graph:integrity-anomaly-id): the + // identity is content-derived so two replicas observing the same failure + // agree on it, which is a conformance property. A change to the domain + // tag, the kind's canonical encoding, or the derivation breaks this + // deliberately. + let kind = IntegrityAnomalyKind::OperationSlotEquivocated { + operation_id: OperationId::new(ReplicaId(7), 42), + }; + let id = IntegrityAnomaly::new(kind).id; + // First 8 bytes are the SYSTEM_DERIVED replica; the id is in that namespace. + assert_eq!( + &id.canonical_bytes()[0..8], + &ReplicaId::SYSTEM_DERIVED.to_be_bytes() + ); + const GOLDEN: [u8; 16] = [ + 255, 255, 255, 255, 255, 255, 255, 255, 81, 178, 18, 20, 252, 222, 201, 215, + ]; + assert_eq!(id.canonical_bytes(), GOLDEN); + } + #[test] fn monotone_stream_has_no_anomaly() { let a = env(1, 0, 10, 0); diff --git a/crates/epiphany-ops/src/conflict.rs b/crates/epiphany-ops/src/conflict.rs index cdb310e..8e84660 100644 --- a/crates/epiphany-ops/src/conflict.rs +++ b/crates/epiphany-ops/src/conflict.rs @@ -400,6 +400,33 @@ mod tests { TypedObjectId::Event(EventId::from_raw(n)) } + #[test] + fn resolution_action_discriminants_are_golden() { + // RATIFIED by Pass 11 (items 2.5 / 1.5): ResolutionAction is canonically + // encoded into operation content, so its discriminants are normative. + // Dismiss = 4 was inserted ahead of Registered (now 5); lock the literal + // values so any future reorder breaks deliberately rather than silently + // shifting the wire form. + assert_eq!(ResolutionAction::AcceptLoser.discriminant(), 0); + assert_eq!(ResolutionAction::KeepWinner.discriminant(), 1); + assert_eq!( + ResolutionAction::Override { + override_operation: op(1, 1) + } + .discriminant(), + 2 + ); + assert_eq!( + ResolutionAction::Reanchor { new_target: obj(7) }.discriminant(), + 3 + ); + assert_eq!(ResolutionAction::Dismiss.discriminant(), 4); + assert_eq!( + ResolutionAction::Registered(ResolutionRegistryId(0)).discriminant(), + 5 + ); + } + #[test] fn conflict_id_is_order_independent_in_its_inputs() { let kind = ConflictKind::TombstonedTarget { diff --git a/crates/epiphany-ops/src/payload.rs b/crates/epiphany-ops/src/payload.rs index a573b95..07b8c1c 100644 --- a/crates/epiphany-ops/src/payload.rs +++ b/crates/epiphany-ops/src/payload.rs @@ -537,6 +537,20 @@ mod tests { use super::*; use epiphany_core::{ReplicaId, SlurId}; + #[test] + fn transaction_category_discriminants_are_golden() { + // RATIFIED by Pass 11 (item 2.4, req:semops:transaction-category): the + // declaration-order discriminants are normative and canonically encoded. + assert_eq!(TransactionCategory::NoteEntry.discriminant(), 0); + assert_eq!(TransactionCategory::Structural.discriminant(), 1); + assert_eq!(TransactionCategory::Layout.discriminant(), 2); + assert_eq!(TransactionCategory::Import.discriminant(), 3); + assert_eq!( + TransactionCategory::Registered(OperationKindRegistryId(0)).discriminant(), + 4 + ); + } + #[test] fn kind_tag_projects_and_round_trips_registered_id() { let reg = OperationKindRegistryId(7); diff --git a/crates/epiphany-ops/src/support.rs b/crates/epiphany-ops/src/support.rs index d05fc10..9f6f718 100644 --- a/crates/epiphany-ops/src/support.rs +++ b/crates/epiphany-ops/src/support.rs @@ -192,6 +192,20 @@ mod tests { use super::*; use epiphany_determinism::sort_canonical; + #[test] + fn object_kind_discriminants_are_golden() { + // RATIFIED by Pass 11 (item 2.4, req:graph:object-kind-vocab): the + // discriminant byte feeds an IntegrityAnomaly's content-derived + // identity, so the literal values are normative and must agree across + // replicas. Lock them. + assert_eq!(ObjectKind::Voice.discriminant(), 0); + assert_eq!(ObjectKind::Pitch.discriminant(), 1); + assert_eq!( + ObjectKind::Registered(OperationKindRegistryId(0)).discriminant(), + 2 + ); + } + #[test] fn registry_ids_order_by_canonical_bytes() { let mut v = vec![ diff --git a/crates/epiphany-testkit/src/generators.rs b/crates/epiphany-testkit/src/generators.rs index a6510e3..26098b3 100644 --- a/crates/epiphany-testkit/src/generators.rs +++ b/crates/epiphany-testkit/src/generators.rs @@ -308,9 +308,10 @@ fn obj_pitch(n: u64) -> PitchId { PitchId::new(OBJ_REPLICA, n) } -/// A conflict-resolution action (every core and registered variant). +/// A conflict-resolution action (every core and registered variant, +/// including `Dismiss`). pub fn resolution_action(rng: &mut Rng) -> ResolutionAction { - match rng.below(5) { + match rng.below(6) { 0 => ResolutionAction::AcceptLoser, 1 => ResolutionAction::KeepWinner, 2 => ResolutionAction::Override { @@ -319,6 +320,7 @@ pub fn resolution_action(rng: &mut Rng) -> ResolutionAction { 3 => ResolutionAction::Reanchor { new_target: typed_object_id(rng), }, + 4 => ResolutionAction::Dismiss, _ => ResolutionAction::Registered(ResolutionRegistryId( ((rng.next_u64() as u128) << 64) | rng.next_u64() as u128, )), diff --git a/spec/core_spec.pdf b/spec/core_spec.pdf index bd1c4c6..ef4d8ed 100644 Binary files a/spec/core_spec.pdf and b/spec/core_spec.pdf differ diff --git a/spec/core_spec.tex b/spec/core_spec.tex index a1b9a69..c3886e8 100644 --- a/spec/core_spec.tex +++ b/spec/core_spec.tex @@ -2210,8 +2210,19 @@ pub struct Tuplet { } pub struct TupletRatio { - pub actual: u32, - pub notated: u32, + // Fields are private: a TupletRatio is constructed only through the + // checked `new`, so a degenerate ratio is never representable (see + // the requirement below). The fields are read via `actual()`/`notated()`. + actual: u32, + notated: u32, +} + +impl TupletRatio { + /// Returns `None` for a degenerate ratio (either term zero, or + /// `actual == notated`); otherwise the well-formed ratio. + pub fn new(actual: u32, notated: u32) -> Option { /* checked */ } + pub fn actual(&self) -> u32 { /* ... */ } + pub fn notated(&self) -> u32 { /* ... */ } } \end{lstlisting} @@ -9642,11 +9653,15 @@ pub struct ChunkId(pub ContentHash); \end{tabular} \end{center} - \texttt{CompressionAlgorithm} likewise encodes as a single - declaration-order discriminant byte followed by any variant payload: - \texttt{None} $= 0$ (no payload); \texttt{Zstd\{level\}} $= 1$ - followed by the \texttt{level} byte; \texttt{Reserved(u8)} $= 2$ - followed by the reserved byte. Compression is \emph{not} part of a + \texttt{CompressionAlgorithm} encodes as a fixed \emph{two} bytes: a + declaration-order discriminant byte followed by a single parameter + byte that is always present. \texttt{None} $= 0$ (the parameter byte + is present but zero, and ignored on read); \texttt{Zstd\{level\}} + $= 1$ with the \texttt{level} in the parameter byte; + \texttt{Reserved(u8)} $= 2$ with the reserved value in the parameter + byte. The parameter byte is written even for \texttt{None}, so the + encoding is fixed-width and a reader always consumes two bytes. + Compression is \emph{not} part of a chunk's content identity (it is metadata on the \texttt{ChunkRef}), so these discriminants are stable but do not affect chunk hashes. @@ -10408,11 +10423,15 @@ pub struct ProfileConstraints { \begin{requirement} \label{req:format:profileid-discriminants} - \texttt{ProfileId} encodes as a single declaration-order - discriminant followed by any variant payload: \texttt{Full} $= 0$, - \texttt{ReadOnly} $= 1$, \texttt{Lite} $= 2$, - \texttt{Custom(ProfileRegistryId)} $= 3$ followed by the registry - id's canonical bytes. This assignment is normative and stable. + \texttt{ProfileId} encodes as a fixed \emph{20} bytes: a + little-endian \texttt{u32} declaration-order discriminant + (\texttt{Full} $= 0$, \texttt{ReadOnly} $= 1$, \texttt{Lite} $= 2$, + \texttt{Custom} $= 3$) followed by a 16-byte + \texttt{ProfileRegistryId}. The trailing 16 bytes carry the registry + id for \texttt{Custom} and are zero for the other three variants; the + field is always present, so the encoding is fixed-width (the + superblock reserves a fixed slot for it). This assignment is + normative and stable. \texttt{ProfileId} participates in superblock selection (Section~\ref{sec:format:bundle}, the load-bearing field set), so its discriminants are part of that comparison.