diff --git a/crates/epiphany-bundle/src/fuzz.rs b/crates/epiphany-bundle/src/fuzz.rs index b65598a..1deb7d7 100644 --- a/crates/epiphany-bundle/src/fuzz.rs +++ b/crates/epiphany-bundle/src/fuzz.rs @@ -327,6 +327,22 @@ pub fn run_wire_decode_fuzz(iters: u64, seed: u64) -> WireFuzzCoverage { .collect() }; + // The corpus must decode. An *unmutated* valid byte string that a decoder + // rejects would otherwise be tallied as a rejection, like any garbage input + // — which is exactly how a broken `OperationKindTag` decoder hid inside the + // ops fuzzer for two commits (Push 5 / P4). + for bytes in &manifests { + let m = Manifest::decode(bytes).expect("a valid manifest must decode"); + assert_eq!(m.encode(), *bytes); + } + for bytes in &valid_indices { + let i = OperationIndex::decode(bytes).expect("a valid operation index must decode"); + assert_eq!(i.encode(), *bytes); + } + for bytes in &valid_blocks { + block::decode_block(bytes).expect("a valid block payload must decode"); + } + for _ in 0..iters { // 1. Whole-image open. Must never panic; an Ok manifest must re-encode. let pick = (rng.next_u64() as usize) % images.len(); diff --git a/crates/epiphany-bundle/src/lib.rs b/crates/epiphany-bundle/src/lib.rs index 9c3e9bd..bd67edc 100644 --- a/crates/epiphany-bundle/src/lib.rs +++ b/crates/epiphany-bundle/src/lib.rs @@ -60,6 +60,7 @@ mod store; mod superblock; pub mod fuzz; +pub mod vectors; pub use block::{ decode_block, encode_block, envelope_offsets, pack_operation_blocks, BLOCK_SOFT_LIMIT, diff --git a/crates/epiphany-bundle/src/vectors.rs b/crates/epiphany-bundle/src/vectors.rs new file mode 100644 index 0000000..c99c5cf --- /dev/null +++ b/crates/epiphany-bundle/src/vectors.rs @@ -0,0 +1,322 @@ +//! Decode conformance vectors for the bundle wire (P4 of the decode-hardening +//! track). See `epiphany_ops::vectors` for the corpus's purpose and columns. +//! +//! These live inside the crate because several rejection classes can only be +//! *constructed* with internal access: a manifest whose vectors are out of order +//! but whose `manifest_id` is correctly derived over the reordered body isolates +//! the ordering rule from the id rule, and `ManifestId::derive` is crate-private. + +use crate::chunk::{ChunkKind, ChunkRef, CompressionAlgorithm}; +use crate::ids::{DocumentId, ManifestId, SchemaVersion}; +use crate::manifest::Manifest; +use crate::opindex::OperationIndex; +use crate::{block, DecodeError}; +use epiphany_determinism::{ChunkId, ContentHash}; + +/// `(surface, verdict, class, name, bytes)` — see `epiphany_ops::vectors`. +pub type DecodeVector = ( + &'static str, + &'static str, + &'static str, + &'static str, + Vec, +); + +/// A `ChunkRef` encodes as 95 bytes: id 32, kind 1, schema 4, offset 8, +/// compressed_length 8, uncompressed_length 8, compression 2, hash 32. +const CHUNK_REF_LEN: usize = 32 + 1 + 4 + 8 + 8 + 8 + 2 + 32; +/// Offset of the compression *parameter* byte within a `ChunkRef`. +const CHUNK_REF_COMPRESSION_PARAM: usize = 32 + 1 + 4 + 8 + 8 + 8 + 1; + +fn block_ref(hash_byte: u8, offset: u64) -> ChunkRef { + ChunkRef { + id: ChunkId(ContentHash([hash_byte; 32])), + kind: ChunkKind::OperationEnvelopeBlock, + schema_version: SchemaVersion::V0, + offset, + compressed_length: 64, + uncompressed_length: 64, + compression: CompressionAlgorithm::None, + hash: ContentHash([hash_byte; 32]), + } +} + +fn swap(bytes: &[u8], first: usize, width: usize) -> Vec { + let second = first + width; + let mut out = bytes.to_vec(); + out[first..second].copy_from_slice(&bytes[second..second + width]); + out[second..second + width].copy_from_slice(&bytes[first..second]); + out +} + +/// Every bundle-wire vector. +pub fn decode_vectors() -> Vec { + let mut v: Vec = Vec::new(); + + // --- Manifest ---------------------------------------------------------- + const MAN: &str = "bundle.manifest"; + let doc = DocumentId([5; 16]); + let empty = Manifest::empty(doc); + v.push((MAN, "accept", "-", "empty_manifest", empty.encode())); + + let mut one = Manifest::empty(doc); + one.operation_roots.push(block_ref(0x11, 576)); + let one_bytes = one.encode(); + v.push((MAN, "accept", "-", "one_operation_root", one_bytes.clone())); + + // Body edit: `manifest_id` is derived from the body, so it no longer matches. + let mut id_mismatch = one_bytes.clone(); + let last = id_mismatch.len() - 1; + id_mismatch[last] ^= 0xFF; + v.push(( + MAN, + "reject", + "manifest-id-mismatch", + "one_root_body_edited", + id_mismatch, + )); + + // Two roots, encoded in canonical (sorted) order. Swapping them and then + // *re-deriving the id over the reordered body* isolates the ordering rule: + // the id is right, the order is wrong, and only the re-encode guard rejects + // it — `encode_body` sorts, so the round trip cannot reproduce these bytes. + let mut two = Manifest::empty(doc); + two.operation_roots.push(block_ref(0x11, 576)); + two.operation_roots.push(block_ref(0x22, 1024)); + let two_bytes = two.encode(); + v.push((MAN, "accept", "-", "two_operation_roots", two_bytes.clone())); + + // Locate the first root: id(16) + document_id(16) + lineage option tag(1) + // + generation(8) + roots count(4). + const ROOTS_AT: usize = 16 + 16 + 1 + 8 + 4; + let mut reordered = swap(&two_bytes, ROOTS_AT, CHUNK_REF_LEN); + let body = &reordered[16..]; + let redone = ManifestId::derive(doc, 0, body); + reordered[0..16].copy_from_slice(&redone.0.to_be_bytes()); + v.push(( + MAN, + "reject", + "non-canonical-vec-order", + "two_roots_out_of_order_valid_id", + reordered, + )); + + let mut trailing = one_bytes.clone(); + trailing.push(0); + v.push(( + MAN, + "reject", + "trailing-bytes", + "one_root_trailing", + trailing, + )); + + // --- OperationIndex ---------------------------------------------------- + const IDX: &str = "bundle.operation_index"; + let empty_index = OperationIndex::build(&[]).expect("empty").encode(); + v.push((IDX, "accept", "-", "empty_index", empty_index.clone())); + + let one_index = OperationIndex::build(&[(block_ref(0x11, 576), vec![([2; 16], 8)])]) + .expect("one block") + .encode(); + v.push((IDX, "accept", "-", "one_block_one_entry", one_index.clone())); + + let two_index = OperationIndex::build(&[ + (block_ref(0x11, 576), vec![([1; 16], 8), ([3; 16], 40)]), + (block_ref(0x22, 1024), vec![([2; 16], 8)]), + ]) + .expect("two blocks") + .encode(); + v.push((IDX, "accept", "-", "two_blocks", two_index.clone())); + + // The lenient-compression class. `CompressionAlgorithm::None` used to ignore + // this byte, so `[0, 0xFF]` and `[0, 0]` decoded alike: two byte strings, one + // value. The index has no whole-value re-encode guard, so it accepted both. + // (Push 5 / P3; `req:binfmt:compression-none-parameter`.) + let mut lenient = one_index.clone(); + lenient[4 + CHUNK_REF_COMPRESSION_PARAM] = 0xFF; + v.push(( + IDX, + "reject", + "lenient-sub-codec", + "compression_none_non_zero_parameter", + lenient, + )); + + // Strict ascent, checked per-site (there is no guard here to catch it). + v.push(( + IDX, + "reject", + "non-canonical-vec-order", + "blocks_out_of_order", + swap(&two_index, 4, CHUNK_REF_LEN), + )); + + // Entries are 24 bytes: id(16) + block(4) + offset(4). They begin after the + // blocks and their count. + let entries_at = 4 + 2 * CHUNK_REF_LEN + 4; + v.push(( + IDX, + "reject", + "non-canonical-vec-order", + "entries_out_of_order", + swap(&two_index, entries_at, 24), + )); + + // An entry naming a block ordinal that does not exist. + let mut bad_ordinal = one_index.clone(); + let ordinal_at = 4 + CHUNK_REF_LEN + 4 + 16; + bad_ordinal[ordinal_at..ordinal_at + 4].copy_from_slice(&7u32.to_le_bytes()); + v.push(( + IDX, + "reject", + "block-ordinal-out-of-range", + "entry_names_missing_block", + bad_ordinal, + )); + + // A block reference whose kind is not an operation-envelope block. + let mut wrong_kind = one_index.clone(); + wrong_kind[4 + 32] = ChunkKind::Snapshot.discriminant(); + v.push(( + IDX, + "reject", + "wrong-chunk-kind", + "block_is_not_an_envelope_block", + wrong_kind, + )); + + let mut idx_trailing = empty_index.clone(); + idx_trailing.push(0); + v.push(( + IDX, + "reject", + "trailing-bytes", + "empty_index_trailing", + idx_trailing, + )); + + // --- Block payload framing --------------------------------------------- + const BLK: &str = "bundle.block"; + let payloads: Vec> = vec![vec![0xAA; 8], vec![0xBB; 13]]; + let packed = block::pack_operation_blocks(&payloads); + let good = packed.first().expect("one block").clone(); + v.push((BLK, "accept", "-", "two_envelopes", good.clone())); + + let mut blk_trailing = good.clone(); + blk_trailing.push(0); + v.push(( + BLK, + "reject", + "trailing-bytes", + "two_envelopes_trailing", + blk_trailing, + )); + + let mut blk_truncated = good.clone(); + blk_truncated.pop(); + v.push(( + BLK, + "reject", + "truncated", + "two_envelopes_truncated", + blk_truncated, + )); + + // A declared envelope count far past the bytes remaining: the decoder must + // reject on the count, not pre-allocate for it. + let mut huge = good.clone(); + huge[0..4].copy_from_slice(&u32::MAX.to_le_bytes()); + v.push(( + BLK, + "reject", + "count-exceeds-remaining", + "envelope_count_u32_max", + huge, + )); + + v +} + +/// Applies `surface`'s decoder to `bytes`. +/// +/// `Ok(injective)` means the decoder **accepted**, and `injective` says whether +/// the value re-encodes to exactly these bytes; `Err` means it **rejected**. See +/// `epiphany_ops::vectors::check` for why those must not be collapsed. +/// +/// `None` for surfaces this crate does not own. +pub fn check(surface: &str, bytes: &[u8]) -> Option> { + fn report(r: Result) -> Result { + r.map_err(|e| format!("{e:?}")) + } + match surface { + "bundle.manifest" => Some(report(Manifest::decode(bytes)).map(|m| m.encode() == bytes)), + "bundle.operation_index" => { + Some(report(OperationIndex::decode(bytes)).map(|i| i.encode() == bytes)) + } + // A block payload is a framing, not a canonical value: `decode_block` + // returns the envelopes, and re-framing them reproduces the payload. + "bundle.block" => Some(report(block::decode_block(bytes)).map(|envelopes| { + block::pack_operation_blocks(&envelopes) + .first() + .is_some_and(|packed| packed == bytes) + })), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_vector_gets_its_declared_verdict() { + for (surface, verdict, class, name, bytes) in decode_vectors() { + let result = check(surface, &bytes).expect("a surface this crate owns"); + match (verdict, &result) { + ("accept", Ok(true)) => {} + ("accept", Ok(false)) => { + panic!("{surface}/{name}: accepted but does not re-encode to its bytes") + } + ("reject", Err(_)) => {} + _ => panic!("{surface}/{name} ({class}): declared {verdict}, got {result:?}"), + } + } + } + + #[test] + fn every_surface_carries_both_verdicts() { + for surface in ["bundle.manifest", "bundle.operation_index", "bundle.block"] { + let rows: Vec<_> = decode_vectors() + .into_iter() + .filter(|(s, ..)| *s == surface) + .collect(); + assert!( + rows.iter().any(|(_, v, ..)| *v == "accept"), + "{surface} has no accept vector" + ); + assert!( + rows.iter().any(|(_, v, ..)| *v == "reject"), + "{surface} has no reject vector" + ); + } + } + + /// The reordered-manifest vector must isolate the *ordering* rule: its + /// `manifest_id` is correctly derived over the reordered body, so it is not + /// rejected merely for a stale id. If this stopped holding, the vector would + /// be pinning the wrong rejection. + #[test] + fn the_reordered_manifest_vector_carries_a_valid_id() { + let (.., bytes) = decode_vectors() + .into_iter() + .find(|(.., name, _)| *name == "two_roots_out_of_order_valid_id") + .expect("the vector exists"); + let derived = ManifestId::derive(DocumentId([5; 16]), 0, &bytes[16..]); + let stored = ManifestId(u128::from_be_bytes( + bytes[0..16].try_into().expect("16 bytes"), + )); + assert_eq!(stored, derived, "the id matches its (reordered) body"); + assert!(Manifest::decode(&bytes).is_err(), "the order still rejects"); + } +} diff --git a/crates/epiphany-layout-ir/src/barrier.rs b/crates/epiphany-layout-ir/src/barrier.rs index b59060b..6ae46f9 100644 --- a/crates/epiphany-layout-ir/src/barrier.rs +++ b/crates/epiphany-layout-ir/src/barrier.rs @@ -1100,22 +1100,61 @@ mod tests { tag: 7 }) ); - // Operation-kind tag 30 is one past the vocabulary (the Phase-3 ops - // tranche appended 24..=27, the repeat pair 28/29; encodings are - // append-only). + // Operation-kind tag 31 is one past the vocabulary (the Phase-3 ops + // tranche appended 24..=27, the repeat pair 28/29, `TransposeInterval` + // 30; encodings are append-only). + // + // This assertion named 30 until Push 5 / P4 — by which time 30 was + // `TransposeInterval`, so the test was pinning a bug: a barrier that + // prohibited the new operation encoded fine and would not read back. let mut bytes = vec![0u8]; bytes.extend(set_blob(&[])); - bytes.extend(set_blob(&[vec![30u8]])); + bytes.extend(set_blob(&[vec![31u8]])); bytes.push(0); assert_eq!( EditBarrier::decode_canonical_bytes(&bytes), Err(BarrierDecodeError::InvalidTag { kind: "OperationKindTag", - tag: 30 + tag: 31 }) ); } + /// A barrier naming *every* operation tag must survive a round trip. Edit + /// barriers are where `OperationKindTag` is persisted, so a tag that encodes + /// and will not decode is silent data loss on reopen — which is exactly what + /// `TransposeInterval` did between Push 4a and Push 5 / P4. + #[test] + fn a_barrier_prohibiting_every_operation_tag_round_trips() { + use epiphany_ops::OperationKindRegistryId; + let mut tags: Vec = (0u8..=30) + .filter(|d| *d != 16) + .map(|d| { + epiphany_determinism::CanonicalDecode::decode_canonical(&[d][..]) + .unwrap_or_else(|e| panic!("tag {d} must decode: {e:?}")) + }) + .collect(); + tags.push(OperationKindTag::Registered(OperationKindRegistryId(7))); + + let barrier = EditBarrier { + scope: BarrierScope::WholeScore, + affected_object_kinds: Vec::new(), + prohibited_operation_kinds: tags, + condition: BarrierCondition::Always, + }; + let bytes = barrier.to_canonical_bytes(); + let decoded = EditBarrier::decode_canonical_bytes(&bytes).expect("round-trips"); + // `prohibited_operation_kinds` is encoded as a canonical *set*, so the + // decoded order is the canonical one, not the order it was built in. + // Compare as sets, and pin injectivity on the bytes. + let mut want = barrier.prohibited_operation_kinds.clone(); + let mut got = decoded.prohibited_operation_kinds.clone(); + want.sort_by_key(epiphany_determinism::CanonicalEncode::to_canonical_bytes); + got.sort_by_key(epiphany_determinism::CanonicalEncode::to_canonical_bytes); + assert_eq!(got, want, "every tag survives the round trip"); + assert_eq!(decoded.to_canonical_bytes(), bytes); + } + #[test] fn decode_rejects_non_nfc_pitch_space_text() { // scope: PitchSpace(tag 5) carrying a decomposed "café" — canonically diff --git a/crates/epiphany-ops/DECISIONS.md b/crates/epiphany-ops/DECISIONS.md index f477f1e..9f88352 100644 --- a/crates/epiphany-ops/DECISIONS.md +++ b/crates/epiphany-ops/DECISIONS.md @@ -1517,3 +1517,55 @@ Each was mutation-verified against the exact check it locks. reduces to states with no conflicts, anomalies, pending, or spellings — the very branches that hold every canonical-order check. Measured: 6 of 12 seeds failed to produce all four. `build_decode_corpus` now draws until covered and asserts it. + +## Push 5 / P4 — the cross-implementation decode corpus, and what it caught + +`spec/vectors/decode_vectors.txt`: 37 committed byte strings across five +surfaces (`ops.materialized_state`, `ops.operation_kind_tag`, `bundle.manifest`, +`bundle.operation_index`, `bundle.block`), each with its normative accept/reject +verdict. The reference implementation's fuzzers prove its own decoders +self-consistent, which says nothing about whether a *foreign* decoder agrees +with the format. This is what one is checked against. + +**It found a real defect on its first run.** `OperationKindTag::TransposeInterval` +encoded to `[30]` and **its own decoder rejected it**: Push 4a added the variant +to `discriminant()` and never to `decode_canonical`. `OperationKindTag` is what +edit barriers persist, so a barrier prohibiting `TransposeInterval` could be +written and never read back — silent data loss on reopen. + +**Four things should have caught it and did not.** + +1. `operation_kind_tag_decode_mirrors_encode_exactly` enumerated *discriminants* + (`(0u8..30).map(decode_canonical)`), so it started from bytes the decoder + already knew and structurally could not notice a variant the decoder was + missing. It now enumerates **variants**, from a single `all_tags()` list, with + a completeness check in both directions. +2. `every_normative_operation_tag_has_a_distinct_canonical_discriminant`'s + hand-written variant list also omitted it. Same list now. +3. `operation_kind_tag_decode_rejects_malformed_bytes` asserted **tag 30 is + rejected**, and `epiphany-layout-ir`'s `decode_rejects_unknown_discriminants` + asserted the same at the barrier surface. Both were *locking the bug in + place*, and made it look deliberate. Both now name 31. +4. The P2 decode fuzzer fed valid corpus bytes to the tag decoder and tallied the + failure as a **rejection**, like any garbage input. It never asserted that an + *unmutated* corpus entry decodes. Both fuzzers now do, as a pre-pass. + +**The harness had the same disease as the code.** `check` originally collapsed +"rejected" with "accepted but does not re-encode", so a decoder that silently +normalizes non-canonical bytes *passed* the `reject` vectors it was written to +catch — verified: removing the whole-state guard and restoring the lenient +compression codec both left the corpus green. `check` now returns +`Ok(injective)` for accept and `Err` for reject, and the two are never conflated: +**silently normalizing non-canonical bytes IS accepting them.** With that fixed, +all four defect mutations fail the corpus, each naming its class. + +The corpus pins one vector per class this repository has shipped a bug in: +`non-canonical-map-order` (a re-encode guard catches it; no per-site check +exists), `non-canonical-vec-order` (only a per-site check catches it; a guard is +blind), `lenient-sub-codec` (a guard *masked* it in the manifest; the index had +none), plus `trailing-bytes`, `truncated`, `unknown-discriminant`, +`count-exceeds-remaining`. `the_corpus_pins_every_class_we_have_shipped_a_bug_in` +fails if one goes missing. + +Gated in the conformance suite as `[7d]`, and drift-locked: the committed file +must equal `vectors::render()`, so a wire-format change lands in the diff. diff --git a/crates/epiphany-ops/src/fuzz.rs b/crates/epiphany-ops/src/fuzz.rs index 27d42f7..96e83ac 100644 --- a/crates/epiphany-ops/src/fuzz.rs +++ b/crates/epiphany-ops/src/fuzz.rs @@ -830,6 +830,22 @@ pub fn run_decode_fuzz(iters: u64, seed: u64) -> DecodeFuzzCoverage { let corpus = build_decode_corpus(&mut rng); let mut cov = DecodeFuzzCoverage::default(); + // The corpus must decode. Obvious, and it was not checked: an *unmutated* + // valid byte string that a decoder rejects was silently tallied as a + // rejection, like any garbage input. `OperationKindTag::TransposeInterval` + // encoded to `[30]` and would not read back for two commits, right here in + // the corpus, and this fuzzer reported green (Push 5 / P4). + for bytes in &corpus.states { + let state = crate::MaterializedState::decode_canonical(bytes) + .expect("a valid materialized state must decode"); + assert_eq!(state.canonical_bytes(), *bytes); + } + for bytes in &corpus.tags { + let tag = OperationKindTag::decode_canonical(bytes) + .expect("a valid operation-kind tag must decode"); + assert_eq!(tag.to_canonical_bytes(), *bytes); + } + for _ in 0..iters { let bytes = gen_state_input(&mut rng, &corpus); match crate::MaterializedState::decode_canonical(&bytes) { diff --git a/crates/epiphany-ops/src/lib.rs b/crates/epiphany-ops/src/lib.rs index 49e2ea4..36f25f8 100644 --- a/crates/epiphany-ops/src/lib.rs +++ b/crates/epiphany-ops/src/lib.rs @@ -96,6 +96,7 @@ mod validate; pub mod valuegen; pub mod fuzz; +pub mod vectors; pub use anomaly::{ AnomalousReplicaSegment, IntegrityAnomaly, IntegrityAnomalyKind, ReplicaAnomalyReason, diff --git a/crates/epiphany-ops/src/payload.rs b/crates/epiphany-ops/src/payload.rs index ab743e2..e73e257 100644 --- a/crates/epiphany-ops/src/payload.rs +++ b/crates/epiphany-ops/src/payload.rs @@ -526,6 +526,11 @@ impl CanonicalDecode for OperationKindTag { 27 => OperationKindTag::SetStaffLayout, 28 => OperationKindTag::CreateRepeatStructure, 29 => OperationKindTag::DeleteRepeatStructure, + // Push 4a. Omitting this made `to_canonical_bytes` and + // `decode_canonical` asymmetric: the tag encoded to `[30]` and + // would not read back, so an edit barrier that named + // `TransposeInterval` could be persisted and never reopened. + 30 => OperationKindTag::TransposeInterval, _ => return Err(DecodeError::MalformedDomainTag), }) } @@ -1897,9 +1902,18 @@ mod tests { assert_eq!(prim.tag(), OperationKindTag::RespellPitch); } - #[test] - fn every_normative_operation_tag_has_a_distinct_canonical_discriminant() { - let tags = [ + /// **Every** `OperationKindTag` variant, listed by name. + /// + /// Enumerating *variants* is the point. The round-trip test used to + /// enumerate *discriminants* — `(0u8..30).map(decode_canonical)` — which + /// starts from bytes the decoder already knows and therefore cannot notice a + /// variant the decoder is missing. Push 4a added `TransposeInterval` to + /// `discriminant()` and not to `decode_canonical`, and that test stayed + /// green while the tag encoded to `[30]` and would not read back. + /// + /// Adding a variant without adding it here fails `the_tag_vocabulary_is_complete`. + fn all_tags() -> Vec { + let mut tags = vec![ OperationKindTag::InsertEvent, OperationKindTag::DeleteEvent, OperationKindTag::ModifyEvent, @@ -1929,7 +1943,35 @@ mod tests { OperationKindTag::SetStaffLayout, OperationKindTag::CreateRepeatStructure, OperationKindTag::DeleteRepeatStructure, + OperationKindTag::TransposeInterval, ]; + tags.push(OperationKindTag::Registered(OperationKindRegistryId( + 0x0102_0304_0506_0708_090A_0B0C_0D0E_0F10, + ))); + tags + } + + /// A compile-time-ish completeness check: `all_tags` must match the + /// vocabulary the decoder accepts, in both directions. + #[test] + fn the_tag_vocabulary_is_complete() { + let tags = all_tags(); + assert_eq!(tags.len(), 31, "30 payload-less tags plus `Registered`"); + + // Every payload-less discriminant 0..=30 except 16 decodes to a variant + // that is in the list. + for d in (0u8..=30).filter(|d| *d != 16) { + let decoded = OperationKindTag::decode_canonical(&[d]) + .unwrap_or_else(|e| panic!("discriminant {d} must decode: {e:?}")); + assert!(tags.contains(&decoded), "{decoded:?} missing from all_tags"); + } + // And 31 is one past the vocabulary. + assert!(OperationKindTag::decode_canonical(&[31]).is_err()); + } + + #[test] + fn every_normative_operation_tag_has_a_distinct_canonical_discriminant() { + let tags = all_tags(); let encoded: std::collections::BTreeSet<_> = tags .iter() .map(CanonicalEncode::to_canonical_bytes) @@ -1939,17 +1981,13 @@ mod tests { #[test] fn operation_kind_tag_decode_mirrors_encode_exactly() { - // Every non-registered variant round-trips through its 1-byte form, and - // the registered variant through its 17-byte (tag + registry id) form. - let mut tags: Vec = (0u8..30) - .filter(|d| *d != 16) - .map(|d| OperationKindTag::decode_canonical(&[d]).expect("known discriminant")) - .collect(); - tags.push(OperationKindTag::Registered(OperationKindRegistryId( - 0x0102_0304_0506_0708_090A_0B0C_0D0E_0F10, - ))); - assert_eq!(tags.len(), 30, "the full tag vocabulary"); - for tag in tags { + // Every variant round-trips: the payload-less ones through their 1-byte + // form, `Registered` through its 17-byte (tag + registry id) form. + // + // Driven from `all_tags()`, i.e. from the VARIANTS. Driving it from the + // discriminants the decoder already knows is what let a variant the + // decoder was missing pass unnoticed. + for tag in all_tags() { let bytes = tag.to_canonical_bytes(); let decoded = OperationKindTag::decode_canonical(&bytes).expect("round-trips"); assert_eq!(decoded, tag); @@ -1964,10 +2002,12 @@ mod tests { #[test] fn operation_kind_tag_decode_rejects_malformed_bytes() { use epiphany_determinism::DecodeError; - // Unknown discriminant (30 is one past the vocabulary): rejected, - // never normalized. + // Unknown discriminant (31 is one past the vocabulary): rejected, + // never normalized. This assertion named 30 until Push 5 / P4, by which + // time 30 was `TransposeInterval` — so the test was actively locking a + // bug in place rather than guarding against one. assert_eq!( - OperationKindTag::decode_canonical(&[30]), + OperationKindTag::decode_canonical(&[31]), Err(DecodeError::MalformedDomainTag) ); // Empty input. diff --git a/crates/epiphany-ops/src/vectors.rs b/crates/epiphany-ops/src/vectors.rs new file mode 100644 index 0000000..53477b8 --- /dev/null +++ b/crates/epiphany-ops/src/vectors.rs @@ -0,0 +1,304 @@ +//! Decode conformance vectors for the operation layer (P4 of the +//! decode-hardening track). +//! +//! A curated, committed corpus of byte strings with their normative accept / +//! reject verdict. The reference implementation's own fuzzer proves *its* +//! decoders self-consistent; these vectors say what any decoder must do, so a +//! second implementation can be checked against the format rather than against +//! this code. +//! +//! Each rejection class here is one this repository actually shipped a bug in, +//! or one whose check is invisible to an injectivity fuzzer (see +//! `DECISIONS.md` §"Push 5 / P2"): the whole-state re-encode guard catches +//! fields the decoder *normalizes*, and is blind to order-preserving `Vec` +//! fields, which need per-site order checks. A conforming decoder needs both. +//! +//! The `class` string is informative, not normative: implementations need not +//! agree on error taxonomy, only on the accept/reject verdict. + +use epiphany_core::{EventId, OperationId, ReplicaId, TypedObjectId}; +use epiphany_determinism::CanonicalEncode; + +use crate::{ + IntegrityAnomaly, IntegrityAnomalyKind, MaterializedState, ObjectState, + OperationKindRegistryId, OperationKindTag, PendingReason, +}; + +/// One vector: `(surface, verdict, class, name, bytes)`. +/// +/// `verdict` is `"accept"` or `"reject"`. An `accept` vector additionally +/// asserts **injectivity**: the decoded value must re-encode to exactly these +/// bytes. +pub type DecodeVector = ( + &'static str, + &'static str, + &'static str, + &'static str, + Vec, +); + +/// Swaps the two equal-length records of `entry` bytes that begin at `first`. +fn swap_records(bytes: &[u8], first: usize, entry: usize) -> Vec { + let second = first + entry; + let mut out = bytes.to_vec(); + out[first..second].copy_from_slice(&bytes[second..second + entry]); + out[second..second + entry].copy_from_slice(&bytes[first..second]); + out +} + +/// The offset of the count that first differs between an empty encoding and a +/// two-element one, and the per-record width. Both encodings agree up to the +/// count, and differ in total length by exactly the two records. +fn count_and_entry(empty: &[u8], two: &[u8]) -> (usize, usize) { + let count_at = empty + .iter() + .zip(two.iter()) + .position(|(a, b)| a != b) + .expect("the counts differ"); + (count_at, (two.len() - empty.len()) / 2) +} + +fn object(counter: u64) -> TypedObjectId { + TypedObjectId::Event(EventId::new(ReplicaId(1), counter)) +} + +fn anomaly(counter: u64) -> IntegrityAnomaly { + IntegrityAnomaly::new(IntegrityAnomalyKind::OperationSlotEquivocated { + operation_id: OperationId::new(ReplicaId(1), counter), + }) +} + +/// Every operation-layer vector. +pub fn decode_vectors() -> Vec { + let mut v: Vec = Vec::new(); + + // --- MaterializedState ------------------------------------------------- + const MS: &str = "ops.materialized_state"; + let empty = MaterializedState::default().canonical_bytes(); + v.push((MS, "accept", "-", "empty_state", empty.clone())); + + // Two objects, canonically ordered. Swapping them is caught only by the + // whole-state re-encode guard: `objects` is a BTreeMap, so the decoder + // silently re-sorts it and no per-site check exists. + let two_objects = MaterializedState { + objects: [ + (object(1), ObjectState::Live), + (object(2), ObjectState::Live), + ] + .into_iter() + .collect(), + ..Default::default() + } + .canonical_bytes(); + let (at, entry) = count_and_entry(&empty, &two_objects); + v.push((MS, "accept", "-", "two_objects", two_objects.clone())); + v.push(( + MS, + "reject", + "non-canonical-map-order", + "objects_out_of_order", + swap_records(&two_objects, at + 4, entry), + )); + + // Two anomalies, canonically ordered. `anomalies` is a Vec whose order the + // decoder PRESERVES, so a swap re-encodes to itself and the whole-state + // guard is blind: only a per-site order check rejects it. + let (lo, hi) = { + let (a, b) = (anomaly(1), anomaly(2)); + if a.id < b.id { + (a, b) + } else { + (b, a) + } + }; + let two_anomalies = MaterializedState { + anomalies: vec![lo, hi], + ..Default::default() + } + .canonical_bytes(); + let (at, entry) = count_and_entry(&empty, &two_anomalies); + v.push((MS, "accept", "-", "two_anomalies", two_anomalies.clone())); + v.push(( + MS, + "reject", + "non-canonical-vec-order", + "anomalies_out_of_order", + swap_records(&two_anomalies, at + 4, entry), + )); + + // Same for `pending`, whose entries are (OperationId, PendingReason) pairs. + let (p1, p2) = ( + OperationId::new(ReplicaId(1), 1), + OperationId::new(ReplicaId(1), 2), + ); + let two_pending = MaterializedState { + pending: vec![ + (p1, PendingReason::MissingCausalPredecessor { missing: p1 }), + (p2, PendingReason::MissingCausalPredecessor { missing: p1 }), + ], + ..Default::default() + } + .canonical_bytes(); + let (at, entry) = count_and_entry(&empty, &two_pending); + v.push((MS, "accept", "-", "two_pending", two_pending.clone())); + v.push(( + MS, + "reject", + "non-canonical-vec-order", + "pending_out_of_order", + swap_records(&two_pending, at + 4, entry), + )); + + let mut trailing = empty.clone(); + trailing.push(0); + v.push(( + MS, + "reject", + "trailing-bytes", + "empty_state_trailing", + trailing, + )); + + let mut truncated = empty.clone(); + truncated.pop(); + v.push(( + MS, + "reject", + "truncated", + "empty_state_truncated", + truncated, + )); + + // A count prefix far past the bytes remaining. The decoder must not + // pre-allocate on it, and must not loop toward EOF for a measurable time. + let mut huge_count = empty.clone(); + huge_count[0..4].copy_from_slice(&u32::MAX.to_le_bytes()); + v.push(( + MS, + "reject", + "count-exceeds-remaining", + "effects_count_u32_max", + huge_count, + )); + + // --- OperationKindTag -------------------------------------------------- + const TAG: &str = "ops.operation_kind_tag"; + for (name, tag) in [ + ("insert_event", OperationKindTag::InsertEvent), + ("transpose_frozen", OperationKindTag::Transpose), + ("transpose_interval", OperationKindTag::TransposeInterval), + ( + "registered", + OperationKindTag::Registered(OperationKindRegistryId(0x0123_4567_89AB_CDEF)), + ), + ] { + v.push((TAG, "accept", "-", name, tag.to_canonical_bytes())); + } + + v.push((TAG, "reject", "unknown-discriminant", "tag_200", vec![200])); + v.push((TAG, "reject", "truncated", "tag_empty", Vec::new())); + v.push(( + TAG, + "reject", + "trailing-bytes", + "insert_event_trailing", + vec![0, 0], + )); + // `Registered` is 1 + 16 bytes; one short must not read past the end. + let mut short_registered = + OperationKindTag::Registered(OperationKindRegistryId(1)).to_canonical_bytes(); + short_registered.pop(); + v.push(( + TAG, + "reject", + "truncated", + "registered_one_byte_short", + short_registered, + )); + + v +} + +/// Applies `surface`'s decoder to `bytes`. +/// +/// `Ok(injective)` means the decoder **accepted**, and `injective` says whether +/// the value re-encodes to exactly these bytes. `Err` means it **rejected**. +/// +/// The two are deliberately not collapsed. A decoder that accepts non-canonical +/// bytes and silently normalizes them is *not* rejecting them — that is the +/// whole defect class (`non-canonical-map-order`, `lenient-sub-codec`), and an +/// earlier version of this function reported it as a rejection, so the corpus +/// passed against decoders it was written to catch. +/// +/// `None` when the surface is not owned by this crate. +pub fn check(surface: &str, bytes: &[u8]) -> Option> { + match surface { + "ops.materialized_state" => Some(match MaterializedState::decode_canonical(bytes) { + Ok(state) => Ok(state.canonical_bytes() == bytes), + Err(e) => Err(format!("{e}")), + }), + "ops.operation_kind_tag" => Some(decode_tag(bytes)), + _ => None, + } +} + +fn decode_tag(bytes: &[u8]) -> Result { + use epiphany_determinism::CanonicalDecode; + match OperationKindTag::decode_canonical(bytes) { + Ok(tag) => Ok(tag.to_canonical_bytes() == bytes), + Err(e) => Err(format!("{e:?}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Each vector must get the verdict it declares. This is the property a + /// second implementation is being asked to satisfy; if the reference cannot, + /// the corpus is wrong. + #[test] + fn every_vector_gets_its_declared_verdict() { + for (surface, verdict, class, name, bytes) in decode_vectors() { + let result = check(surface, &bytes).expect("a surface this crate owns"); + match (verdict, &result) { + ("accept", Ok(true)) => {} + ("accept", Ok(false)) => { + panic!("{surface}/{name}: accepted but does not re-encode to its bytes") + } + ("reject", Err(_)) => {} + _ => panic!("{surface}/{name} ({class}): declared {verdict}, got {result:?}"), + } + } + } + + /// The corpus must actually contain both verdicts on every surface, or it is + /// pinning half a contract. + #[test] + fn every_surface_carries_both_verdicts() { + for surface in ["ops.materialized_state", "ops.operation_kind_tag"] { + let rows: Vec<_> = decode_vectors() + .into_iter() + .filter(|(s, ..)| *s == surface) + .collect(); + assert!( + rows.iter().any(|(_, v, ..)| *v == "accept"), + "{surface} has no accept vector" + ); + assert!( + rows.iter().any(|(_, v, ..)| *v == "reject"), + "{surface} has no reject vector" + ); + } + } + + /// The two rejection classes that need *different* machinery: a map order a + /// re-encode guard catches, and a `Vec` order only a per-site check catches. + /// If either vector went missing the corpus would stop pinning the lesson. + #[test] + fn the_corpus_pins_both_non_canonical_classes() { + let classes: Vec<&str> = decode_vectors().iter().map(|(_, _, c, ..)| *c).collect(); + assert!(classes.contains(&"non-canonical-map-order")); + assert!(classes.contains(&"non-canonical-vec-order")); + } +} diff --git a/crates/epiphany-testkit/examples/conformance_suite.rs b/crates/epiphany-testkit/examples/conformance_suite.rs index 53dce4c..fb1cb4e 100644 --- a/crates/epiphany-testkit/examples/conformance_suite.rs +++ b/crates/epiphany-testkit/examples/conformance_suite.rs @@ -153,5 +153,25 @@ fn main() { } } + eprintln!("[7d ] cross-implementation decode conformance vectors"); + { + use epiphany_testkit::vectors; + assert_eq!( + vectors::COMMITTED, + vectors::render(), + "{} is stale; regenerate with `cargo run -q -p epiphany-testkit \ + --example generate_vectors`", + vectors::PATH + ); + match vectors::verify(vectors::COMMITTED) { + Ok(n) => eprintln!(" {n} vectors, every verdict agreed"), + Err(failures) => panic!( + "{} decode-vector disagreement(s):\n{}", + failures.len(), + failures.join("\n") + ), + } + } + eprintln!("[8/8] ok: full conformance suite passed (scale {scale})"); } diff --git a/crates/epiphany-testkit/examples/generate_vectors.rs b/crates/epiphany-testkit/examples/generate_vectors.rs new file mode 100644 index 0000000..70785f8 --- /dev/null +++ b/crates/epiphany-testkit/examples/generate_vectors.rs @@ -0,0 +1,9 @@ +//! Regenerates `spec/vectors/decode_vectors.txt`, the cross-implementation +//! decode conformance corpus. Run from the workspace root. +fn main() { + let text = epiphany_testkit::vectors::render(); + let path = epiphany_testkit::vectors::PATH; + std::fs::write(path, &text).unwrap_or_else(|e| panic!("writing {path}: {e}")); + let rows = epiphany_testkit::vectors::parse(&text).expect("parses"); + eprintln!("wrote {} vectors to {path}", rows.len()); +} diff --git a/crates/epiphany-testkit/src/lib.rs b/crates/epiphany-testkit/src/lib.rs index 083ea0a..a576cc3 100644 --- a/crates/epiphany-testkit/src/lib.rs +++ b/crates/epiphany-testkit/src/lib.rs @@ -91,6 +91,7 @@ //! materialization through a bundle snapshot. pub mod rng; +pub mod vectors; // Phase 2, Agent F (worklist F1): the Chapter 10 performance-budget gate the // `benches/` targets assert through (Pass / Xfail rows with thresholds written diff --git a/crates/epiphany-testkit/src/vectors.rs b/crates/epiphany-testkit/src/vectors.rs new file mode 100644 index 0000000..ad3354b --- /dev/null +++ b/crates/epiphany-testkit/src/vectors.rs @@ -0,0 +1,268 @@ +//! The cross-implementation decode conformance corpus (P4 of the +//! decode-hardening track). +//! +//! `spec/vectors/decode_vectors.txt` is a committed, human-diffable list of byte +//! strings and their normative accept/reject verdict. It is what a *second* +//! implementation is checked against — the reference implementation's fuzzers +//! prove its own decoders self-consistent, which says nothing about whether a +//! foreign decoder agrees with the format. +//! +//! Two properties are enforced here: +//! +//! 1. **The committed file is what the generator produces.** A wire-format +//! change that moves a vector's bytes must move the file too, deliberately, +//! in the diff. +//! 2. **Every vector gets its declared verdict** from the owning crate's +//! decoder. An `accept` vector additionally must re-encode to its own bytes. +//! +//! The `class` column is informative: implementations need not agree on error +//! taxonomy, only on accept versus reject. + +/// The committed corpus. +pub const COMMITTED: &str = include_str!("../../../spec/vectors/decode_vectors.txt"); + +/// The path a regeneration writes to, relative to the workspace root. +pub const PATH: &str = "spec/vectors/decode_vectors.txt"; + +const HEADER: &str = "\ +# Epiphany decode conformance vectors — format version 1 +# +# Generated. Regenerate with: +# cargo run -q -p epiphany-testkit --example generate_vectors +# `epiphany_testkit::vectors::the_committed_corpus_matches_the_generator` fails +# on drift, so a wire-format change must land here deliberately. +# +# One vector per line, space-separated: +# +# +# +# `verdict` is `accept` or `reject`, and is the ONLY normative column besides +# the bytes. A conforming decoder must accept every `accept` vector and reject +# every `reject` vector. An accepted value must additionally re-encode to +# exactly the vector's bytes: canonical decode is injective, which is what +# content-addressing rests on. +# +# `class` names why a `reject` vector is rejected. It is informative only — +# implementations need not agree on error taxonomy. `-` where not applicable. +# +# `` is lowercase, no separators; `-` denotes the empty byte string. +"; + +fn to_hex(bytes: &[u8]) -> String { + if bytes.is_empty() { + return "-".to_string(); + } + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn from_hex(s: &str) -> Option> { + if s == "-" { + return Some(Vec::new()); + } + if s.len() % 2 != 0 { + return None; + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()) + .collect() +} + +/// Every vector, in a stable order: the operation layer, then the bundle wire. +fn all() -> Vec<(String, String, String, String, Vec)> { + let ops = epiphany_ops::vectors::decode_vectors() + .into_iter() + .map(|(s, v, c, n, b)| { + ( + s.to_string(), + v.to_string(), + c.to_string(), + n.to_string(), + b, + ) + }); + let bundle = epiphany_bundle::vectors::decode_vectors() + .into_iter() + .map(|(s, v, c, n, b)| { + ( + s.to_string(), + v.to_string(), + c.to_string(), + n.to_string(), + b, + ) + }); + ops.chain(bundle).collect() +} + +/// Renders the corpus file. +pub fn render() -> String { + let mut out = String::from(HEADER); + let mut surface = String::new(); + for (s, v, c, n, b) in all() { + if s != surface { + out.push_str("\n# "); + out.push_str(&s); + out.push('\n'); + surface = s.clone(); + } + out.push_str(&format!("{s} {v} {c} {n} {}\n", to_hex(&b))); + } + out +} + +/// One parsed row. +pub struct Row { + pub surface: String, + pub verdict: String, + pub class: String, + pub name: String, + pub bytes: Vec, +} + +/// Parses the corpus file, skipping comments and blank lines. +pub fn parse(text: &str) -> Result, String> { + let mut rows = Vec::new(); + for (i, line) in text.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let f: Vec<&str> = line.split_whitespace().collect(); + if f.len() != 5 { + return Err(format!( + "line {}: expected 5 columns, got {}", + i + 1, + f.len() + )); + } + rows.push(Row { + surface: f[0].to_string(), + verdict: f[1].to_string(), + class: f[2].to_string(), + name: f[3].to_string(), + bytes: from_hex(f[4]).ok_or_else(|| format!("line {}: bad hex", i + 1))?, + }); + } + Ok(rows) +} + +/// Runs every vector against the owning crate's decoder, returning the number +/// checked or the disagreements. +pub fn verify(text: &str) -> Result> { + let rows = match parse(text) { + Ok(r) => r, + Err(e) => return Err(vec![e]), + }; + let mut failures = Vec::new(); + for row in &rows { + let result = epiphany_ops::vectors::check(&row.surface, &row.bytes) + .or_else(|| epiphany_bundle::vectors::check(&row.surface, &row.bytes)); + let Some(result) = result else { + failures.push(format!("{}: no decoder owns this surface", row.surface)); + continue; + }; + // `Ok(injective)` = accepted; `Err` = rejected. A decoder that accepts + // a `reject` vector fails even if it then re-encodes it faithfully, and + // one that accepts an `accept` vector non-injectively fails too. The two + // must not be collapsed: silently normalizing non-canonical bytes IS + // accepting them, and is the defect the corpus exists to catch. + match (row.verdict.as_str(), &result) { + ("accept", Ok(true)) | ("reject", Err(_)) => {} + ("accept", Ok(false)) => failures.push(format!( + "{}/{}: accepted, but the value does not re-encode to its bytes", + row.surface, row.name + )), + ("reject", Ok(injective)) => failures.push(format!( + "{}/{} ({}): declared reject, but was ACCEPTED (injective={injective})", + row.surface, row.name, row.class + )), + _ => failures.push(format!( + "{}/{} ({}): declared {}, got {:?}", + row.surface, row.name, row.class, row.verdict, result + )), + } + } + if failures.is_empty() { + Ok(rows.len()) + } else { + Err(failures) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The file in the tree is exactly what the generator emits. A wire-format + /// change must land in this diff. + #[test] + fn the_committed_corpus_matches_the_generator() { + assert_eq!( + COMMITTED, + render(), + "\n{PATH} is stale. Regenerate:\n \ + cargo run -q -p epiphany-testkit --example generate_vectors\n" + ); + } + + /// The reference implementation satisfies the contract it is publishing. If + /// it cannot, the corpus is wrong, not the decoder. + #[test] + fn the_reference_implementation_agrees_with_every_vector() { + match verify(COMMITTED) { + Ok(n) => assert!(n >= 25, "only {n} vectors — the corpus has thinned"), + Err(failures) => panic!( + "{} disagreement(s):\n{}", + failures.len(), + failures.join("\n") + ), + } + } + + /// The corpus must pin the rejection classes this repository learned the + /// hard way. Losing one would quietly stop testing it. + #[test] + fn the_corpus_pins_every_class_we_have_shipped_a_bug_in() { + let rows = parse(COMMITTED).expect("parses"); + let classes: Vec<&str> = rows.iter().map(|r| r.class.as_str()).collect(); + for required in [ + // A guard catches this; no per-site check exists (P2). + "non-canonical-map-order", + // Only a per-site check catches this; a guard is blind (P2). + "non-canonical-vec-order", + // A guard *masked* this in the manifest; the index had none (P3). + "lenient-sub-codec", + "trailing-bytes", + "truncated", + "unknown-discriminant", + "count-exceeds-remaining", + ] { + assert!( + classes.contains(&required), + "the corpus no longer pins `{required}`" + ); + } + } + + /// Both verdicts on every surface, or the corpus pins half a contract. + #[test] + fn every_surface_carries_both_verdicts() { + use std::collections::BTreeMap; + let rows = parse(COMMITTED).expect("parses"); + let mut seen: BTreeMap<&str, (bool, bool)> = BTreeMap::new(); + for r in &rows { + let e = seen.entry(r.surface.as_str()).or_default(); + match r.verdict.as_str() { + "accept" => e.0 = true, + "reject" => e.1 = true, + other => panic!("unknown verdict {other}"), + } + } + assert!(seen.len() >= 5, "surfaces: {:?}", seen.keys()); + for (surface, (accept, reject)) in seen { + assert!(accept, "{surface} has no accept vector"); + assert!(reject, "{surface} has no reject vector"); + } + } +} diff --git a/spec/vectors/decode_vectors.txt b/spec/vectors/decode_vectors.txt new file mode 100644 index 0000000..c31d4ab --- /dev/null +++ b/spec/vectors/decode_vectors.txt @@ -0,0 +1,68 @@ +# Epiphany decode conformance vectors — format version 1 +# +# Generated. Regenerate with: +# cargo run -q -p epiphany-testkit --example generate_vectors +# `epiphany_testkit::vectors::the_committed_corpus_matches_the_generator` fails +# on drift, so a wire-format change must land here deliberately. +# +# One vector per line, space-separated: +# +# +# +# `verdict` is `accept` or `reject`, and is the ONLY normative column besides +# the bytes. A conforming decoder must accept every `accept` vector and reject +# every `reject` vector. An accepted value must additionally re-encode to +# exactly the vector's bytes: canonical decode is injective, which is what +# content-addressing rests on. +# +# `class` names why a `reject` vector is rejected. It is informative only — +# implementations need not agree on error taxonomy. `-` where not applicable. +# +# `` is lowercase, no separators; `-` denotes the empty byte string. + +# ops.materialized_state +ops.materialized_state accept - empty_state 0000000000000000000000000000000000000000000000000000000000000000 +ops.materialized_state accept - two_objects 00000000000000000000000002000000000000000000000000010000000000000001000000000000000000000100000000000000020000000000000000000000000000000000 +ops.materialized_state reject non-canonical-map-order objects_out_of_order 00000000000000000000000002000000000000000000000000010000000000000002000000000000000000000100000000000000010000000000000000000000000000000000 +ops.materialized_state accept - two_anomalies 00000000000000000200000021000000ffffffffffffffff68eefa3a8f8dfa49010000000000000001000000000000000121000000ffffffffffffffffbf0e1228971141c501000000000000000100000000000000020000000000000000000000000000000000000000 +ops.materialized_state reject non-canonical-vec-order anomalies_out_of_order 00000000000000000200000021000000ffffffffffffffffbf0e1228971141c5010000000000000001000000000000000221000000ffffffffffffffff68eefa3a8f8dfa4901000000000000000100000000000000010000000000000000000000000000000000000000 +ops.materialized_state accept - two_pending 0000000000000000000000000000000000000000000000000000000002000000000000000000000100000000000000010000000000000000010000000000000001000000000000000100000000000000020000000000000000010000000000000001 +ops.materialized_state reject non-canonical-vec-order pending_out_of_order 0000000000000000000000000000000000000000000000000000000002000000000000000000000100000000000000020000000000000000010000000000000001000000000000000100000000000000010000000000000000010000000000000001 +ops.materialized_state reject trailing-bytes empty_state_trailing 000000000000000000000000000000000000000000000000000000000000000000 +ops.materialized_state reject truncated empty_state_truncated 00000000000000000000000000000000000000000000000000000000000000 +ops.materialized_state reject count-exceeds-remaining effects_count_u32_max ffffffff00000000000000000000000000000000000000000000000000000000 + +# ops.operation_kind_tag +ops.operation_kind_tag accept - insert_event 00 +ops.operation_kind_tag accept - transpose_frozen 04 +ops.operation_kind_tag accept - transpose_interval 1e +ops.operation_kind_tag accept - registered 1000000000000000000123456789abcdef +ops.operation_kind_tag reject unknown-discriminant tag_200 c8 +ops.operation_kind_tag reject truncated tag_empty - +ops.operation_kind_tag reject trailing-bytes insert_event_trailing 0000 +ops.operation_kind_tag reject truncated registered_one_byte_short 10000000000000000000000000000000 + +# bundle.manifest +bundle.manifest accept - empty_manifest 6f9e7d11689ab113c4a1f05faf60fe60050505050505050505050505050505050000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000001000000000000000000000400000000010000000001000000000000 +bundle.manifest accept - one_operation_root ae38d9cd408df9b59917c43a82c9d1880505050505050505050505050505050500000000000000000001000000111111111111111111111111111111111111111111111111111111111111111100000001004002000000000000400000000000000040000000000000000000111111111111111111111111111111111111111111111111111111111111111100000000000000000000000000000100000000000000000000000000000000000000000000000000000001000000000000000000000400000000010000000001000000000000 +bundle.manifest reject manifest-id-mismatch one_root_body_edited ae38d9cd408df9b59917c43a82c9d18805050505050505050505050505050505000000000000000000010000001111111111111111111111111111111111111111111111111111111111111111000000010040020000000000004000000000000000400000000000000000001111111111111111111111111111111111111111111111111111111111111111000000000000000000000000000001000000000000000000000000000000000000000000000000000000010000000000000000000004000000000100000000010000000000ff +bundle.manifest accept - two_operation_roots 423da9a59202adc10cdd0985146bb1d805050505050505050505050505050505000000000000000000020000001111111111111111111111111111111111111111111111111111111111111111000000010040020000000000004000000000000000400000000000000000001111111111111111111111111111111111111111111111111111111111111111222222222222222222222222222222222222222222222222222222222222222200000001000004000000000000400000000000000040000000000000000000222222222222222222222222222222222222222222222222222222222222222200000000000000000000000000000100000000000000000000000000000000000000000000000000000001000000000000000000000400000000010000000001000000000000 +bundle.manifest reject non-canonical-vec-order two_roots_out_of_order_valid_id c643c490d7705661b435a5e3efe0c5fa05050505050505050505050505050505000000000000000000020000002222222222222222222222222222222222222222222222222222222222222222000000010000040000000000004000000000000000400000000000000000002222222222222222222222222222222222222222222222222222222222222222111111111111111111111111111111111111111111111111111111111111111100000001004002000000000000400000000000000040000000000000000000111111111111111111111111111111111111111111111111111111111111111100000000000000000000000000000100000000000000000000000000000000000000000000000000000001000000000000000000000400000000010000000001000000000000 +bundle.manifest reject trailing-bytes one_root_trailing ae38d9cd408df9b59917c43a82c9d188050505050505050505050505050505050000000000000000000100000011111111111111111111111111111111111111111111111111111111111111110000000100400200000000000040000000000000004000000000000000000011111111111111111111111111111111111111111111111111111111111111110000000000000000000000000000010000000000000000000000000000000000000000000000000000000100000000000000000000040000000001000000000100000000000000 + +# bundle.operation_index +bundle.operation_index accept - empty_index 0000000000000000 +bundle.operation_index accept - one_block_one_entry 01000000111111111111111111111111111111111111111111111111111111111111111100000001004002000000000000400000000000000040000000000000000000111111111111111111111111111111111111111111111111111111111111111101000000020202020202020202020202020202020000000008000000 +bundle.operation_index accept - two_blocks 020000001111111111111111111111111111111111111111111111111111111111111111000000010040020000000000004000000000000000400000000000000000001111111111111111111111111111111111111111111111111111111111111111222222222222222222222222222222222222222222222222222222222222222200000001000004000000000000400000000000000040000000000000000000222222222222222222222222222222222222222222222222222222222222222203000000010101010101010101010101010101010000000008000000020202020202020202020202020202020100000008000000030303030303030303030303030303030000000028000000 +bundle.operation_index reject lenient-sub-codec compression_none_non_zero_parameter 010000001111111111111111111111111111111111111111111111111111111111111111000000010040020000000000004000000000000000400000000000000000ff111111111111111111111111111111111111111111111111111111111111111101000000020202020202020202020202020202020000000008000000 +bundle.operation_index reject non-canonical-vec-order blocks_out_of_order 020000002222222222222222222222222222222222222222222222222222222222222222000000010000040000000000004000000000000000400000000000000000002222222222222222222222222222222222222222222222222222222222222222111111111111111111111111111111111111111111111111111111111111111100000001004002000000000000400000000000000040000000000000000000111111111111111111111111111111111111111111111111111111111111111103000000010101010101010101010101010101010000000008000000020202020202020202020202020202020100000008000000030303030303030303030303030303030000000028000000 +bundle.operation_index reject non-canonical-vec-order entries_out_of_order 020000001111111111111111111111111111111111111111111111111111111111111111000000010040020000000000004000000000000000400000000000000000001111111111111111111111111111111111111111111111111111111111111111222222222222222222222222222222222222222222222222222222222222222200000001000004000000000000400000000000000040000000000000000000222222222222222222222222222222222222222222222222222222222222222203000000020202020202020202020202020202020100000008000000010101010101010101010101010101010000000008000000030303030303030303030303030303030000000028000000 +bundle.operation_index reject block-ordinal-out-of-range entry_names_missing_block 01000000111111111111111111111111111111111111111111111111111111111111111100000001004002000000000000400000000000000040000000000000000000111111111111111111111111111111111111111111111111111111111111111101000000020202020202020202020202020202020700000008000000 +bundle.operation_index reject wrong-chunk-kind block_is_not_an_envelope_block 01000000111111111111111111111111111111111111111111111111111111111111111102000001004002000000000000400000000000000040000000000000000000111111111111111111111111111111111111111111111111111111111111111101000000020202020202020202020202020202020000000008000000 +bundle.operation_index reject trailing-bytes empty_index_trailing 000000000000000000 + +# bundle.block +bundle.block accept - two_envelopes 0200000008000000aaaaaaaaaaaaaaaa0d000000bbbbbbbbbbbbbbbbbbbbbbbbbb +bundle.block reject trailing-bytes two_envelopes_trailing 0200000008000000aaaaaaaaaaaaaaaa0d000000bbbbbbbbbbbbbbbbbbbbbbbbbb00 +bundle.block reject truncated two_envelopes_truncated 0200000008000000aaaaaaaaaaaaaaaa0d000000bbbbbbbbbbbbbbbbbbbbbbbb +bundle.block reject count-exceeds-remaining envelope_count_u32_max ffffffff08000000aaaaaaaaaaaaaaaa0d000000bbbbbbbbbbbbbbbbbbbbbbbbbb