Pass 11 follow-up: golden-lock the ratified discriminant tables; fix three spec-text inaccuracies
Audit follow-up to b2f2e20. The ratification was byte-faithful, but the
audit found a gap between the protection the worklist claims ("a
golden-bytes test already locks every byte-layout item") and the
protection actually in place for several newly-normative tables, plus
three small inaccuracies in the ratified spec text.
Golden locks (close the round-trip-vs-golden gap):
- ChunkKind: chunk_kind_discriminants_are_golden pins the literal 0..=8.
ChunkKind::canonical_bytes() is in the chunk hash preimage, so the
prior round-trip-only test would let a coordinated renumbering silently
change every chunk content address while passing.
- CompressionAlgorithm: compression_algorithm_encoding_is_golden pins the
exact bytes (None -> [0,0], not a bare tag).
- ProfileId (load-bearing superblock field): profile_id_discriminants_are_golden
pins the u32 discriminants and the fixed 20-byte encoding.
- ResolutionAction / TransactionCategory / ObjectKind: *_discriminants_are_golden
pin the canonical discriminants (ObjectKind feeds the anomaly id;
ResolutionAction/TransactionCategory feed operation content hashes).
- IntegrityAnomalyId: integrity_anomaly_id_byte_form_is_locked golden-locks
the MUSCSANM-derived id (cross-replica agreement is a conformance
property; it previously had no byte-form golden).
Spec-text fixes (core_spec.tex):
- CompressionAlgorithm: "None = 0 (no payload)" was wrong; the code writes
a fixed two bytes (discriminant + always-present parameter byte). Text
now states the fixed-width framing.
- ProfileId: "a single discriminant followed by any variant payload" was
wrong; it is a u32-LE discriminant + a fixed 16-byte registry id (zero
unless Custom), 20 bytes total. Text now matches the only encoding.
- TupletRatio listing showed `pub` fields (freely constructible by struct
literal), contradicting req:time:tuplet-ratio-construction. Listing now
shows private fields + the checked `new`/`actual()`/`notated()`, matching
the code.
Test honesty:
- testkit resolution_action generator now emits Dismiss (rng.below(6)); it
previously skipped the variant, leaving the Dismiss path unfuzzed.
Verification: cargo test --workspace (433 pass, +7), clippy -D warnings
clean, fmt clean; spec rebuilds (lualatex/latexmk, 0 undefined refs, 254pp).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b2f2e204a7
commit
a7adbdc5a1
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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![
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)),
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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<TupletRatio> { /* 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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue