Skip to main content

arkhe_kernel/persist/
wal.rs

1//! WAL header + records + BLAKE3-keyed chain.
2//!
3//! Each record's `this_chain_hash` is computed as
4//! `blake3::keyed(chain_key, prev_chain_hash || canonical(body))`
5//! where `chain_key = blake3::derive_key(WAL domain context, world_id)`.
6//! Tampering any record's body or reordering records breaks the chain
7//! at `verify_chain` time.
8
9use serde::{Deserialize, Serialize};
10
11use crate::abi::{EntityId, InstanceId, Principal, Tick, TypeCode};
12use crate::runtime::stage::StepStage;
13
14use super::signature::{SignatureClass, VerifierClass};
15
16/// Pinned `(TypeCode, schema_hash)` registered for this world. v0.14 ships
17/// the slot empty; the snapshot integration will populate it from
18/// `ActionRegistry` (cross-restart pin set).
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20pub struct TypeRegistryPin {
21    /// Pinned type code.
22    pub type_code: TypeCode,
23    /// BLAKE3 hash of the canonical schema bytes for `type_code`.
24    pub schema_hash: [u8; 32],
25}
26
27/// WAL header — pinned at construction, frozen for the lifetime of
28/// the WAL. Replay against an incompatible header is a structural
29/// error (A14).
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub struct WalHeader {
32    /// Magic bytes for format identification.
33    pub magic: [u8; 8],
34    /// Kernel semver `(major, minor, patch)`.
35    pub kernel_semver: (u16, u16, u16),
36    /// Postcard major version pinned at write time.
37    pub postcard_version: u32,
38    /// BLAKE3 major version pinned at write time.
39    pub blake3_version: u32,
40    /// Raw bytes of `WalHeader::DOMAIN_CTX`. Stored as `Vec<u8>` because
41    /// serde's stock array deserializer caps at 32 bytes; this slot is
42    /// used only as build-time constant pinning (the chain key is
43    /// derived from `DOMAIN_CTX` directly via `build_chain_key`).
44    pub domain_separation_context: Vec<u8>,
45    /// World identifier — fed into `blake3::derive_key` along with
46    /// `DOMAIN_CTX` to produce this WAL's chain key.
47    pub world_id: [u8; 32],
48    /// ABI semver `(major, minor)`.
49    pub abi_version: (u16, u16),
50    /// BLAKE3 hash of the `ModuleManifest` that was active at write time.
51    pub manifest_digest: [u8; 32],
52    /// Reserved slot for snapshot-integrated TypeCode pinning.
53    /// Empty (snapshot-integrated TypeCode pinning is deferred).
54    pub type_registry_pins: Vec<TypeRegistryPin>,
55    /// Ed25519 verifying-key bytes when the WAL was constructed with a
56    /// signing class. `None` means Tier 1 (chain-only). Pinning
57    /// the public key in the header makes verification self-contained.
58    pub verifying_key: Option<[u8; 32]>,
59    /// PQC verifying-key bytes when the WAL was constructed with a
60    /// Hybrid signing class (envelope slot for ML-DSA 65 or other PQC
61    /// algorithms). `None` for non-Hybrid configurations. Stored as
62    /// `Vec<u8>` because PQC public keys exceed the serde 32-byte
63    /// fixed-array limit (ML-DSA 65 verifying key = 1952 bytes).
64    pub verifying_key_pqc: Option<Vec<u8>>,
65}
66
67impl WalHeader {
68    /// Magic bytes used at the head of the encoded WAL.
69    pub const MAGIC: [u8; 8] = *b"ARKHEWAL";
70    /// Kernel semver pinned by [`WalWriter::new`].
71    pub const CURRENT_KERNEL_SEMVER: (u16, u16, u16) = (0, 14, 0);
72    /// ABI semver pinned by [`WalWriter::new`].
73    pub const ABI_VERSION: (u16, u16) = (0, 14);
74    /// Postcard major version pinned by [`WalWriter::new`].
75    pub const POSTCARD_MAJOR: u32 = 1;
76    /// BLAKE3 major version pinned by [`WalWriter::new`].
77    pub const BLAKE3_MAJOR: u32 = 1;
78    /// Domain-separation byte string fed into `blake3::derive_key` to
79    /// produce the WAL chain key. The "v0.14" anchor pins the chain
80    /// epoch; it advances with a release whose persisted wire format
81    /// changes (the v0.13 → v0.14 ML-DSA stabilization being the first
82    /// such advance). Any change rederives every chain key and
83    /// invalidates all prior-epoch WAL chains (Layer A item 1
84    /// byte-identity invariant — A1/A14).
85    pub const DOMAIN_CTX: &'static [u8] = b"arkhe-kernel v0.14 WAL chain domain separation context";
86}
87
88/// One-byte annotation summarizing whether every Op in the record's
89/// stage authorized cleanly. Belt-and-suspenders companion to the
90/// chain hash (belt-and-suspenders companion).
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92#[repr(u8)]
93pub enum AuthDecisionAnnotation {
94    /// Every Op authorized.
95    AllAuthorized = 0,
96    /// At least one Op was denied.
97    SomeDenied = 1,
98}
99
100/// Single committed step recorded in the WAL.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct WalRecord {
103    /// Monotonic record sequence within this WAL.
104    pub seq: u64,
105    /// Tick at which the producing `step()` ran.
106    pub at: Tick,
107    /// Instance the action ran against.
108    pub instance: InstanceId,
109    /// Principal under which the action was submitted.
110    pub principal: Principal,
111    /// Submitting entity (the `actor` passed to `submit`), if any. Part of
112    /// the canonical input — it feeds `ActionContext::actor` during
113    /// `compute()` — so it MUST be persisted AND chain-hashed (it is a
114    /// `WalRecordBody` field) to keep replay bit-identical (A1) for any
115    /// module that branches on `ctx.actor`.
116    pub actor: Option<EntityId>,
117    /// Type code of the executed action.
118    pub action_type_code: TypeCode,
119    /// Canonical action bytes (replay deserializes from these).
120    pub action_bytes: Vec<u8>,
121    /// `CapabilityMask` bits in effect during `step()`.
122    pub caps_bits: u64,
123    pub(crate) stage: StepStage,
124    /// Auth-decision summary for this step's Ops.
125    pub auth_decision: AuthDecisionAnnotation,
126    /// Previous record's `this_chain_hash` (or zero for record 0).
127    pub prev_chain_hash: [u8; 32],
128    /// `blake3::keyed(chain_key, prev_chain_hash || canonical(body))`.
129    pub this_chain_hash: [u8; 32],
130    /// Ed25519 signature over the canonical `WalRecordBody` bytes
131    /// (the same bytes hashed into `this_chain_hash`). `None` when the
132    /// owning WAL was created with `SignatureClass::None`. Stored as
133    /// `Vec<u8>` (always exactly 64 bytes when present) because serde's
134    /// array deserializer caps at 32 — same workaround as the header's
135    /// `domain_separation_context`.
136    pub signature: Option<Vec<u8>>,
137    /// PQC signature bytes for Hybrid signing modes (envelope slot for
138    /// ML-DSA 65 or other PQC algorithms). Paired with `signature` for
139    /// dual-sign verification under Hybrid policy. `None` for non-Hybrid
140    /// configurations. Stored as `Vec<u8>` because PQC signatures exceed
141    /// the serde 32-byte fixed-array limit (ML-DSA 65 signature = 3309
142    /// bytes).
143    pub signature_pqc: Option<Vec<u8>>,
144}
145
146#[derive(Serialize)]
147struct WalRecordBody<'a> {
148    seq: u64,
149    at: Tick,
150    instance: InstanceId,
151    principal: &'a Principal,
152    actor: Option<EntityId>,
153    action_type_code: TypeCode,
154    action_bytes: &'a [u8],
155    caps_bits: u64,
156    stage: &'a StepStage,
157    auth_decision: AuthDecisionAnnotation,
158    prev_chain_hash: [u8; 32],
159}
160
161impl<'a> WalRecordBody<'a> {
162    /// Reconstruct the canonical body view from a stored `WalRecord`
163    /// plus the running `prev_chain_hash` (used by `verify_chain`).
164    /// `append` constructs the body inline because its fields are
165    /// per-field locals at that point — sharing a helper there costs
166    /// readability for no LOC saved.
167    fn from_record(rec: &'a WalRecord, prev: [u8; 32]) -> Self {
168        Self {
169            seq: rec.seq,
170            at: rec.at,
171            instance: rec.instance,
172            principal: &rec.principal,
173            actor: rec.actor,
174            action_type_code: rec.action_type_code,
175            action_bytes: &rec.action_bytes,
176            caps_bits: rec.caps_bits,
177            stage: &rec.stage,
178            auth_decision: rec.auth_decision,
179            prev_chain_hash: prev,
180        }
181    }
182}
183
184/// Sealed WAL — the durable read-side counterpart to [`WalWriter`].
185/// Produced by [`Wal::from_writer`] or [`Wal::deserialize`]; consumed
186/// by [`Wal::verify_chain`] / [`replay_into`](super::replay::replay_into).
187#[derive(Debug, Serialize, Deserialize)]
188pub struct Wal {
189    /// Header pinned at writer construction.
190    pub header: WalHeader,
191    /// Records in append order.
192    pub records: Vec<WalRecord>,
193}
194
195/// Signature strength tier advertised by a WAL header, ordered
196/// `None < Ed25519 < Hybrid`. Compared against [`TrustAnchor::min_tier`]
197/// to reject a downgrade.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
199pub enum SignatureTier {
200    /// No signatures (chain integrity only).
201    None,
202    /// RFC 8032 Ed25519.
203    Ed25519,
204    /// Hybrid Ed25519 + ML-DSA 65.
205    Hybrid,
206}
207
208/// Operator-supplied trust anchor for authenticated WAL verification
209/// ([`Wal::verify_chain_anchored`]).
210///
211/// [`Wal::verify_chain`] derives its verification policy and keys from the
212/// WAL header. Under the threat model where WAL bytes are attacker-
213/// controlled (a tampered on-disk log or a malicious peer's snapshot),
214/// that lets an attacker downgrade the tier to `None` (skipping all
215/// signature checks) or substitute their own keys and re-sign the whole
216/// chain. A `TrustAnchor` closes that gap: the caller pins — out-of-band —
217/// the minimum tier and the exact verifying key(s) it trusts. The kernel
218/// provides the MECHANISM (compare the header against the anchor; reject
219/// downgrade / substitution / truncation); the caller owns the POLICY
220/// (which key and tier to require). Unset (`None`) fields impose no check.
221#[derive(Debug, Clone, Default)]
222pub struct TrustAnchor {
223    /// Minimum acceptable signature tier. A header weaker than this is
224    /// rejected with [`WalError::TierDowngrade`].
225    pub min_tier: Option<SignatureTier>,
226    /// Expected Ed25519 verifying-key bytes; the header's pinned key must
227    /// equal this (else [`WalError::VerifyingKeyMismatch`]).
228    pub ed25519_verifying_key: Option<[u8; 32]>,
229    /// Expected ML-DSA 65 verifying-key bytes (Hybrid); the header's
230    /// pinned PQC key must equal this.
231    pub mldsa_verifying_key: Option<Vec<u8>>,
232    /// Expected `manifest_digest` (A14); rejects a WAL written under a
233    /// different `ModuleManifest`.
234    pub expected_manifest_digest: Option<[u8; 32]>,
235    /// Expected final chain tip; pins the record count so a tail
236    /// truncation (a chain-consistent prefix) is detected.
237    pub expected_chain_tip: Option<[u8; 32]>,
238}
239
240/// Append-only WAL writer. Each successful `Kernel::step` writes one
241/// [`WalRecord`] via the kernel's internal append path.
242pub struct WalWriter {
243    header: WalHeader,
244    records: Vec<WalRecord>,
245    next_seq: u64,
246    prev_hash: [u8; 32],
247    chain_key: [u8; 32],
248    sig_class: SignatureClass,
249}
250
251fn build_chain_key(world_id: &[u8; 32]) -> [u8; 32] {
252    let ctx = core::str::from_utf8(WalHeader::DOMAIN_CTX).expect("DOMAIN_CTX is valid UTF-8 ASCII");
253    blake3::derive_key(ctx, world_id)
254}
255
256fn build_dsc() -> Vec<u8> {
257    WalHeader::DOMAIN_CTX.to_vec()
258}
259
260impl WalWriter {
261    /// Construct a chain-only writer (Tier 1 — no signature).
262    pub fn new(world_id: [u8; 32], manifest_digest: [u8; 32]) -> Self {
263        Self::with_signature(world_id, manifest_digest, SignatureClass::None)
264    }
265
266    /// Construct a writer that signs each record under `sig_class`. The
267    /// verifying key is pinned in the header so post-hoc verification
268    /// works against the WAL bytes alone.
269    pub fn with_signature(
270        world_id: [u8; 32],
271        manifest_digest: [u8; 32],
272        sig_class: SignatureClass,
273    ) -> Self {
274        let chain_key = build_chain_key(&world_id);
275        let header = WalHeader {
276            magic: WalHeader::MAGIC,
277            kernel_semver: WalHeader::CURRENT_KERNEL_SEMVER,
278            postcard_version: WalHeader::POSTCARD_MAJOR,
279            blake3_version: WalHeader::BLAKE3_MAJOR,
280            domain_separation_context: build_dsc(),
281            world_id,
282            abi_version: WalHeader::ABI_VERSION,
283            manifest_digest,
284            type_registry_pins: Vec::new(),
285            verifying_key: sig_class.verifying_key_bytes(),
286            verifying_key_pqc: sig_class.verifying_key_pqc_bytes(),
287        };
288        Self {
289            header,
290            records: Vec::new(),
291            next_seq: 0,
292            prev_hash: [0u8; 32],
293            chain_key,
294            sig_class,
295        }
296    }
297
298    #[allow(clippy::too_many_arguments)]
299    pub(crate) fn append(
300        &mut self,
301        at: Tick,
302        instance: InstanceId,
303        principal: Principal,
304        actor: Option<EntityId>,
305        action_type_code: TypeCode,
306        action_bytes: Vec<u8>,
307        caps_bits: u64,
308        stage: StepStage,
309        auth_decision: AuthDecisionAnnotation,
310    ) -> Result<&WalRecord, WalError> {
311        self.next_seq = self.next_seq.saturating_add(1);
312        let body = WalRecordBody {
313            seq: self.next_seq,
314            at,
315            instance,
316            principal: &principal,
317            actor,
318            action_type_code,
319            action_bytes: &action_bytes,
320            caps_bits,
321            stage: &stage,
322            auth_decision,
323            prev_chain_hash: self.prev_hash,
324        };
325        let body_bytes = postcard::to_allocvec(&body)
326            .map_err(|e| WalError::SerializeFailed(format!("{}", e)))?;
327        let mut hasher = blake3::Hasher::new_keyed(&self.chain_key);
328        hasher.update(&self.prev_hash);
329        hasher.update(&body_bytes);
330        let this_hash: [u8; 32] = *hasher.finalize().as_bytes();
331
332        // Signatures are over the same body bytes that feed the chain
333        // hash. Tier 1 (None) leaves both `None`. Hybrid emits paired
334        // Ed25519 + ML-DSA 65 signatures via `sign_hybrid`.
335        let (signature, signature_pqc) = match self.sig_class.sign_hybrid(&body_bytes) {
336            Some(hyb) => (Some(hyb.ed25519.to_vec()), Some(hyb.pqc)),
337            None => (self.sig_class.sign(&body_bytes).map(|s| s.to_vec()), None),
338        };
339
340        let record = WalRecord {
341            seq: self.next_seq,
342            at,
343            instance,
344            principal,
345            actor,
346            action_type_code,
347            action_bytes,
348            caps_bits,
349            stage,
350            auth_decision,
351            prev_chain_hash: self.prev_hash,
352            this_chain_hash: this_hash,
353            signature,
354            signature_pqc,
355        };
356        self.records.push(record);
357        self.prev_hash = this_hash;
358        Ok(self.records.last().expect("just pushed"))
359    }
360
361    /// Pinned WAL header.
362    pub fn header(&self) -> &WalHeader {
363        &self.header
364    }
365    /// All records appended so far, in append order.
366    pub fn records(&self) -> &[WalRecord] {
367        &self.records
368    }
369    /// Most recent record's `this_chain_hash`, or zero if empty.
370    pub fn chain_tip(&self) -> [u8; 32] {
371        self.prev_hash
372    }
373    /// Number of records currently buffered.
374    pub fn record_count(&self) -> usize {
375        self.records.len()
376    }
377}
378
379impl Wal {
380    /// Seal a [`WalWriter`] into a read-only [`Wal`].
381    pub fn from_writer(w: WalWriter) -> Self {
382        Self {
383            header: w.header,
384            records: w.records,
385        }
386    }
387
388    /// Encode the entire WAL (header + records) as canonical postcard
389    /// bytes.
390    pub fn serialize(&self) -> Result<Vec<u8>, WalError> {
391        postcard::to_allocvec(self).map_err(|e| WalError::SerializeFailed(format!("{}", e)))
392    }
393
394    /// Decode bytes produced by [`serialize`](Wal::serialize).
395    pub fn deserialize(bytes: &[u8]) -> Result<Self, WalError> {
396        postcard::from_bytes(bytes).map_err(|e| WalError::DeserializeFailed(format!("{}", e)))
397    }
398
399    /// Most recent record's `this_chain_hash`, or zero if empty.
400    pub fn chain_tip(&self) -> [u8; 32] {
401        self.records
402            .last()
403            .map(|r| r.this_chain_hash)
404            .unwrap_or([0u8; 32])
405    }
406
407    /// Verify every record's chain hash against the keyed BLAKE3 over
408    /// (prev_chain_hash || canonical body). When the header pins a
409    /// `verifying_key` (Tier 2 — Ed25519), each record's signature
410    /// is also checked against the same body bytes. Returns `Ok` if
411    /// every check passes.
412    pub fn verify_chain(&self, world_id: [u8; 32]) -> Result<(), WalError> {
413        let chain_key = build_chain_key(&world_id);
414        let verifier = VerifierClass::from_header_bytes(
415            self.header.verifying_key.as_ref(),
416            self.header.verifying_key_pqc.as_deref(),
417        )
418        .map_err(|e| match e {
419            crate::persist::signature::VerifierInitError::InvalidEd25519Key
420            | crate::persist::signature::VerifierInitError::InvalidPqcKey => {
421                WalError::InvalidVerifyingKey
422            }
423            crate::persist::signature::VerifierInitError::PqcWithoutEd25519 => {
424                WalError::PqcWithoutEd25519
425            }
426        })?;
427        let mut prev = [0u8; 32];
428        for (i, rec) in self.records.iter().enumerate() {
429            if blake3::Hash::from(rec.prev_chain_hash) != blake3::Hash::from(prev) {
430                return Err(WalError::ChainBroken { at_record: i });
431            }
432            let body = WalRecordBody::from_record(rec, prev);
433            let body_bytes = postcard::to_allocvec(&body)
434                .map_err(|e| WalError::SerializeFailed(format!("{}", e)))?;
435            let mut hasher = blake3::Hasher::new_keyed(&chain_key);
436            hasher.update(&prev);
437            hasher.update(&body_bytes);
438            let computed: [u8; 32] = *hasher.finalize().as_bytes();
439            if blake3::Hash::from(computed) != blake3::Hash::from(rec.this_chain_hash) {
440                return Err(WalError::HashMismatch { at_record: i });
441            }
442
443            match &verifier {
444                VerifierClass::None => {}
445                VerifierClass::Ed25519(_) => {
446                    let sig_vec = rec
447                        .signature
448                        .as_ref()
449                        .ok_or(WalError::MissingSignature { at_record: i })?;
450                    verifier
451                        .verify(&body_bytes, sig_vec)
452                        .map_err(|_| WalError::SignatureMismatch { at_record: i })?;
453                }
454                VerifierClass::Hybrid { .. } => {
455                    let sig_vec = rec
456                        .signature
457                        .as_ref()
458                        .ok_or(WalError::MissingSignature { at_record: i })?;
459                    let sig_pqc = rec
460                        .signature_pqc
461                        .as_ref()
462                        .ok_or(WalError::MissingPqcSignature { at_record: i })?;
463                    verifier
464                        .verify_hybrid(&body_bytes, sig_vec, sig_pqc)
465                        .map_err(|_| WalError::PqcSignatureMismatch { at_record: i })?;
466                }
467            }
468
469            prev = computed;
470        }
471        Ok(())
472    }
473
474    /// The signature tier the header advertises, derived from which
475    /// verifying-key slots are populated. `(None, Some)` is the invalid
476    /// PQC-without-Ed25519 envelope.
477    fn header_tier(&self) -> Result<SignatureTier, WalError> {
478        match (
479            self.header.verifying_key.is_some(),
480            self.header.verifying_key_pqc.is_some(),
481        ) {
482            (false, false) => Ok(SignatureTier::None),
483            (true, false) => Ok(SignatureTier::Ed25519),
484            (true, true) => Ok(SignatureTier::Hybrid),
485            (false, true) => Err(WalError::PqcWithoutEd25519),
486        }
487    }
488
489    /// Verify the chain AND authenticate it against a caller-supplied
490    /// [`TrustAnchor`]. Use this when the WAL bytes are untrusted (a
491    /// tampered log or a peer's snapshot): beyond [`Self::verify_chain`]'s
492    /// integrity checks it rejects a tier downgrade below `anchor
493    /// .min_tier`, a verifying-key substitution (header key != the
494    /// anchored key), a `manifest_digest` mismatch, and a tail truncation
495    /// (tip != the anchored `expected_chain_tip`). `world_id` is supplied
496    /// by the caller out-of-band — NOT read from the (untrusted) header.
497    pub fn verify_chain_anchored(
498        &self,
499        world_id: [u8; 32],
500        anchor: &TrustAnchor,
501    ) -> Result<(), WalError> {
502        // (1) Tier floor — reject a downgrade (e.g. header stripped to None).
503        let tier = self.header_tier()?;
504        if let Some(floor) = anchor.min_tier {
505            if tier < floor {
506                return Err(WalError::TierDowngrade);
507            }
508        }
509        // (2) Key pinning — the header's keys must equal the anchored keys,
510        // so an attacker cannot substitute their own keypair and re-sign.
511        if let Some(expected) = anchor.ed25519_verifying_key {
512            match self.header.verifying_key {
513                Some(k) if k == expected => {}
514                _ => return Err(WalError::VerifyingKeyMismatch),
515            }
516        }
517        if let Some(expected) = anchor.mldsa_verifying_key.as_deref() {
518            match self.header.verifying_key_pqc.as_deref() {
519                Some(k) if k == expected => {}
520                _ => return Err(WalError::VerifyingKeyMismatch),
521            }
522        }
523        // (3) Manifest pinning (A14).
524        if let Some(expected) = anchor.expected_manifest_digest {
525            if self.header.manifest_digest != expected {
526                return Err(WalError::ManifestDigestMismatch);
527            }
528        }
529        // (4) Integrity + signature verification over the caller's world_id.
530        self.verify_chain(world_id)?;
531        // (5) Tail-truncation — the verified tip must equal the anchored tip.
532        if let Some(expected_tip) = anchor.expected_chain_tip {
533            if self.chain_tip() != expected_tip {
534                return Err(WalError::ChainTipMismatch);
535            }
536        }
537        Ok(())
538    }
539}
540
541/// WAL operation failures. `#[non_exhaustive]` — adding variants is
542/// not a breaking change for external matchers.
543#[derive(Debug, Clone)]
544#[non_exhaustive]
545pub enum WalError {
546    /// Postcard refused to encode (carries the upstream message).
547    SerializeFailed(String),
548    /// Postcard refused to decode (carries the upstream message).
549    DeserializeFailed(String),
550    /// Record `at_record`'s `prev_chain_hash` doesn't match the running
551    /// expected hash from the previous record.
552    ChainBroken {
553        /// Index of the offending record.
554        at_record: usize,
555    },
556    /// Record `at_record`'s `this_chain_hash` doesn't match the
557    /// recomputed BLAKE3 keyed hash.
558    HashMismatch {
559        /// Index of the offending record.
560        at_record: usize,
561    },
562    /// Header pinning rejected (semver / abi / world / manifest mismatch).
563    HeaderIncompatible(String),
564    /// Header pins a `verifying_key` (Ed25519 or PQC) that fails to parse.
565    InvalidVerifyingKey,
566    /// Header pins a `verifying_key` but a record carries no signature.
567    MissingSignature {
568        /// Index of the offending record.
569        at_record: usize,
570    },
571    /// Signature does not validate against the header's verifying key.
572    SignatureMismatch {
573        /// Index of the offending record.
574        at_record: usize,
575    },
576    /// Header pins a Hybrid envelope (`verifying_key_pqc=Some`) but a
577    /// record carries no PQC signature (`signature_pqc=None`).
578    MissingPqcSignature {
579        /// Index of the offending record.
580        at_record: usize,
581    },
582    /// PQC signature does not validate against the header's PQC
583    /// verifying key (Hybrid AND-mode failure).
584    PqcSignatureMismatch {
585        /// Index of the offending record.
586        at_record: usize,
587    },
588    /// Invalid Hybrid envelope — `verifying_key_pqc=Some` without
589    /// `verifying_key=Some`. Ed25519 is the chain-anchor companion;
590    /// PQC-only envelope is rejected.
591    PqcWithoutEd25519,
592    /// The header advertises a signature tier weaker than the caller's
593    /// [`TrustAnchor::min_tier`] — a downgrade attempt (e.g. a header
594    /// stripped to `None` to skip all signature verification).
595    TierDowngrade,
596    /// The header's pinned verifying key does not equal the key the
597    /// caller's [`TrustAnchor`] expects — a key-substitution attempt.
598    VerifyingKeyMismatch,
599    /// The header's `manifest_digest` does not equal the caller's expected
600    /// digest (A14 manifest pinning under a [`TrustAnchor`]).
601    ManifestDigestMismatch,
602    /// The verified chain tip does not equal the caller's expected tip —
603    /// a tail truncation (a chain-consistent prefix) was detected.
604    ChainTipMismatch,
605}
606
607impl core::fmt::Display for WalError {
608    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
609        match self {
610            Self::SerializeFailed(m) => write!(f, "wal serialize failed: {}", m),
611            Self::DeserializeFailed(m) => write!(f, "wal deserialize failed: {}", m),
612            Self::ChainBroken { at_record } => {
613                write!(f, "wal chain broken at record {}", at_record)
614            }
615            Self::HashMismatch { at_record } => {
616                write!(f, "wal hash mismatch at record {}", at_record)
617            }
618            Self::HeaderIncompatible(m) => write!(f, "wal header incompatible: {}", m),
619            Self::InvalidVerifyingKey => write!(
620                f,
621                "wal verifying_key invalid (not a valid Ed25519 public key)"
622            ),
623            Self::MissingSignature { at_record } => {
624                write!(f, "wal signature missing at record {}", at_record)
625            }
626            Self::SignatureMismatch { at_record } => {
627                write!(f, "wal signature mismatch at record {}", at_record)
628            }
629            Self::MissingPqcSignature { at_record } => {
630                write!(f, "wal PQC signature missing at record {}", at_record)
631            }
632            Self::PqcSignatureMismatch { at_record } => {
633                write!(f, "wal PQC signature mismatch at record {}", at_record)
634            }
635            Self::PqcWithoutEd25519 => write!(
636                f,
637                "wal envelope invalid (verifying_key_pqc set without verifying_key)"
638            ),
639            Self::TierDowngrade => write!(
640                f,
641                "wal signature tier downgraded below the trust anchor's minimum"
642            ),
643            Self::VerifyingKeyMismatch => write!(
644                f,
645                "wal verifying key does not match the trust anchor's expected key"
646            ),
647            Self::ManifestDigestMismatch => {
648                write!(f, "wal manifest_digest does not match the expected digest")
649            }
650            Self::ChainTipMismatch => {
651                write!(f, "wal chain tip does not match the expected tip (tail truncation?)")
652            }
653        }
654    }
655}
656
657impl std::error::Error for WalError {}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662    use crate::abi::{EntityId, ExternalId, RouteId};
663    use crate::runtime::stage::{LedgerOp, StagedStateDelta};
664    use crate::state::EntityMeta;
665
666    fn world() -> [u8; 32] {
667        [7u8; 32]
668    }
669    fn manifest() -> [u8; 32] {
670        [3u8; 32]
671    }
672
673    fn sample_stage() -> StepStage {
674        let mut s = StepStage::default();
675        s.state_ops.push(StagedStateDelta::SpawnEntity {
676            id: EntityId::new(1).unwrap(),
677            meta: EntityMeta {
678                owner: Principal::System,
679                created: Tick(0),
680            },
681        });
682        s.ledger_delta
683            .ops
684            .push(LedgerOp::AddEntity(EntityId::new(1).unwrap()));
685        s.id_counters.next_entity_advance = 1;
686        s
687    }
688
689    #[test]
690    fn empty_writer_serializes_and_deserializes() {
691        let w = WalWriter::new(world(), manifest());
692        let wal = Wal::from_writer(w);
693        let bytes = wal.serialize().unwrap();
694        let back = Wal::deserialize(&bytes).unwrap();
695        assert_eq!(back.header, wal.header);
696        assert_eq!(back.records.len(), 0);
697        assert_eq!(back.chain_tip(), [0u8; 32]);
698    }
699
700    #[test]
701    fn single_append_produces_nonzero_chain_tip() {
702        let mut w = WalWriter::new(world(), manifest());
703        w.append(
704            Tick(5),
705            InstanceId::new(1).unwrap(),
706            Principal::System,
707            None,
708            TypeCode(100),
709            vec![1, 2, 3],
710            0,
711            sample_stage(),
712            AuthDecisionAnnotation::AllAuthorized,
713        )
714        .unwrap();
715        let tip = w.chain_tip();
716        assert_ne!(tip, [0u8; 32]);
717        assert_eq!(w.record_count(), 1);
718    }
719
720    #[test]
721    fn multi_record_chain_links_each_record() {
722        let mut w = WalWriter::new(world(), manifest());
723        for i in 0..5 {
724            w.append(
725                Tick(i),
726                InstanceId::new(1).unwrap(),
727                Principal::System,
728                None,
729                TypeCode(100),
730                vec![i as u8],
731                0,
732                StepStage::default(),
733                AuthDecisionAnnotation::AllAuthorized,
734            )
735            .unwrap();
736        }
737        let wal = Wal::from_writer(w);
738        assert_eq!(wal.records.len(), 5);
739        // Each record's prev_chain_hash equals previous record's this_chain_hash.
740        let mut prev = [0u8; 32];
741        for rec in &wal.records {
742            assert_eq!(rec.prev_chain_hash, prev);
743            prev = rec.this_chain_hash;
744        }
745        wal.verify_chain(world()).expect("clean chain");
746    }
747
748    #[test]
749    fn tampered_record_breaks_verify_chain() {
750        let mut w = WalWriter::new(world(), manifest());
751        for i in 0..3 {
752            w.append(
753                Tick(i),
754                InstanceId::new(1).unwrap(),
755                Principal::System,
756                None,
757                TypeCode(100),
758                vec![i as u8],
759                0,
760                StepStage::default(),
761                AuthDecisionAnnotation::AllAuthorized,
762            )
763            .unwrap();
764        }
765        let mut wal = Wal::from_writer(w);
766        // Tamper: overwrite middle record's caps_bits.
767        wal.records[1].caps_bits = 0xDEAD_BEEF;
768        let result = wal.verify_chain(world());
769        assert!(matches!(result, Err(WalError::HashMismatch { .. })));
770    }
771
772    #[test]
773    fn verify_chain_detects_broken_prev_link() {
774        let mut w = WalWriter::new(world(), manifest());
775        for i in 0..3 {
776            w.append(
777                Tick(i),
778                InstanceId::new(1).unwrap(),
779                Principal::System,
780                None,
781                TypeCode(100),
782                vec![i as u8],
783                0,
784                StepStage::default(),
785                AuthDecisionAnnotation::AllAuthorized,
786            )
787            .unwrap();
788        }
789        let mut wal = Wal::from_writer(w);
790        // Tamper: break the prev_chain_hash link of record 1 without
791        // touching its body. verify_chain must detect chain discontinuity
792        // (ChainBroken) before reaching the body-derived hash check
793        // (HashMismatch).
794        wal.records[1].prev_chain_hash[0] ^= 1;
795        let result = wal.verify_chain(world());
796        assert!(matches!(
797            result,
798            Err(WalError::ChainBroken { at_record: 1 })
799        ));
800    }
801
802    #[test]
803    fn different_world_id_produces_different_chain() {
804        let mut w1 = WalWriter::new([1u8; 32], manifest());
805        let mut w2 = WalWriter::new([2u8; 32], manifest());
806        for w in [&mut w1, &mut w2] {
807            w.append(
808                Tick(0),
809                InstanceId::new(1).unwrap(),
810                Principal::System,
811                None,
812                TypeCode(100),
813                vec![],
814                0,
815                StepStage::default(),
816                AuthDecisionAnnotation::AllAuthorized,
817            )
818            .unwrap();
819        }
820        // Domain-separation: different world_id → different keyed-hash output.
821        assert_ne!(w1.chain_tip(), w2.chain_tip());
822    }
823
824    #[test]
825    fn verify_chain_against_wrong_world_id_fails() {
826        let mut w = WalWriter::new(world(), manifest());
827        w.append(
828            Tick(0),
829            InstanceId::new(1).unwrap(),
830            Principal::System,
831            None,
832            TypeCode(100),
833            vec![],
834            0,
835            StepStage::default(),
836            AuthDecisionAnnotation::AllAuthorized,
837        )
838        .unwrap();
839        let wal = Wal::from_writer(w);
840        let result = wal.verify_chain([99u8; 32]);
841        assert!(matches!(result, Err(WalError::HashMismatch { .. })));
842    }
843
844    #[test]
845    fn auth_decision_annotation_round_trips() {
846        let mut w = WalWriter::new(world(), manifest());
847        w.append(
848            Tick(0),
849            InstanceId::new(1).unwrap(),
850            Principal::External(ExternalId(7)),
851            None,
852            TypeCode(101),
853            vec![],
854            0,
855            StepStage::default(),
856            AuthDecisionAnnotation::SomeDenied,
857        )
858        .unwrap();
859        let wal = Wal::from_writer(w);
860        let bytes = wal.serialize().unwrap();
861        let back = Wal::deserialize(&bytes).unwrap();
862        assert_eq!(
863            back.records[0].auth_decision,
864            AuthDecisionAnnotation::SomeDenied
865        );
866    }
867
868    #[test]
869    fn header_carries_magic_and_versions() {
870        let h = WalWriter::new(world(), manifest()).header().clone();
871        assert_eq!(h.magic, *b"ARKHEWAL");
872        assert_eq!(h.kernel_semver, (0, 14, 0));
873        assert_eq!(h.world_id, world());
874        assert_eq!(h.manifest_digest, manifest());
875        assert!(h.type_registry_pins.is_empty());
876        assert!(h.verifying_key.is_none());
877        let _ = RouteId(1);
878    }
879
880    // ---- Ed25519 SignatureClass (Tier 2, A16) ----
881
882    fn append_one(w: &mut WalWriter) {
883        w.append(
884            Tick(0),
885            InstanceId::new(1).unwrap(),
886            Principal::System,
887            None,
888            TypeCode(100),
889            vec![1, 2, 3],
890            0,
891            sample_stage(),
892            AuthDecisionAnnotation::AllAuthorized,
893        )
894        .unwrap();
895    }
896
897    #[test]
898    fn signature_class_none_produces_no_signature() {
899        let mut w = WalWriter::new(world(), manifest());
900        append_one(&mut w);
901        let wal = Wal::from_writer(w);
902        assert!(wal.header.verifying_key.is_none());
903        assert!(wal.records[0].signature.is_none());
904        wal.verify_chain(world())
905            .expect("Tier 1 chain still verifies");
906    }
907
908    #[test]
909    fn signature_class_ed25519_signs_each_record() {
910        let sig_class = SignatureClass::new_ed25519_from_secret([7u8; 32]);
911        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
912        for _ in 0..3 {
913            append_one(&mut w);
914        }
915        let wal = Wal::from_writer(w);
916        assert!(wal.header.verifying_key.is_some());
917        assert_eq!(wal.records.len(), 3);
918        for rec in &wal.records {
919            let sig = rec.signature.as_ref().expect("Ed25519 signs every record");
920            assert_eq!(sig.len(), 64);
921        }
922    }
923
924    #[test]
925    fn verify_chain_validates_signatures() {
926        let sig_class = SignatureClass::new_ed25519_from_secret([11u8; 32]);
927        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
928        for _ in 0..3 {
929            append_one(&mut w);
930        }
931        let wal = Wal::from_writer(w);
932        // Round-trip through serialize to confirm the on-disk shape verifies.
933        let bytes = wal.serialize().unwrap();
934        let back = Wal::deserialize(&bytes).unwrap();
935        back.verify_chain(world()).expect("signed chain verifies");
936    }
937
938    #[test]
939    fn tampered_signature_fails_verify() {
940        // Hash check would catch body tampering first; isolate the signature
941        // path by tampering ONLY the signature field.
942        let sig_class = SignatureClass::new_ed25519_from_secret([13u8; 32]);
943        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
944        append_one(&mut w);
945        append_one(&mut w);
946        let mut wal = Wal::from_writer(w);
947        // Flip a byte inside record[1]'s signature.
948        if let Some(sig) = wal.records[1].signature.as_mut() {
949            sig[0] ^= 0xFF;
950        }
951        let result = wal.verify_chain(world());
952        assert!(matches!(
953            result,
954            Err(WalError::SignatureMismatch { at_record: 1 })
955        ));
956    }
957
958    #[test]
959    fn missing_signature_fails_verify_when_header_has_key() {
960        let sig_class = SignatureClass::new_ed25519_from_secret([17u8; 32]);
961        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
962        append_one(&mut w);
963        let mut wal = Wal::from_writer(w);
964        // Header still pins a verifying_key, but the record claims no sig.
965        wal.records[0].signature = None;
966        let result = wal.verify_chain(world());
967        assert!(matches!(
968            result,
969            Err(WalError::MissingSignature { at_record: 0 })
970        ));
971    }
972
973    #[test]
974    fn wrong_key_fails_verify() {
975        let sig_class = SignatureClass::new_ed25519_from_secret([19u8; 32]);
976        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
977        append_one(&mut w);
978        let mut wal = Wal::from_writer(w);
979        // Replace the pinned verifying key with a different one — the records'
980        // signatures stop verifying.
981        let other = SignatureClass::new_ed25519_from_secret([23u8; 32])
982            .verifying_key_bytes()
983            .unwrap();
984        wal.header.verifying_key = Some(other);
985        let result = wal.verify_chain(world());
986        assert!(matches!(
987            result,
988            Err(WalError::SignatureMismatch { at_record: 0 })
989        ));
990    }
991
992    #[test]
993    fn signature_deterministic_across_runs() {
994        // RFC 8032 Ed25519 is deterministic — building two WALs with the
995        // same key and the same append sequence yields byte-identical
996        // record signatures.
997        let mk = |secret: [u8; 32]| -> Vec<Vec<u8>> {
998            let mut w = WalWriter::with_signature(
999                world(),
1000                manifest(),
1001                SignatureClass::new_ed25519_from_secret(secret),
1002            );
1003            append_one(&mut w);
1004            append_one(&mut w);
1005            let wal = Wal::from_writer(w);
1006            wal.records
1007                .iter()
1008                .map(|r| r.signature.clone().unwrap())
1009                .collect()
1010        };
1011        let sigs1 = mk([29u8; 32]);
1012        let sigs2 = mk([29u8; 32]);
1013        assert_eq!(sigs1, sigs2);
1014        assert_eq!(sigs1[0].len(), 64);
1015    }
1016
1017    #[test]
1018    fn domain_ctx_byte_identity_blake3() {
1019        // Layer A item 1 (DOMAIN_CTX literal) byte-level formal anchor.
1020        // The literal must remain frozen across kernel semver bumps —
1021        // every WAL chain ever produced is keyed via
1022        // `blake3::derive_key(DOMAIN_CTX, world_id)`; one byte change
1023        // rederives every chain key (A1/A14 byte-identity invariant).
1024        //
1025        // Two complementary witnesses pin the literal:
1026        //   1. Byte-identity vs the canonical literal (rewrite catch).
1027        //   2. BLAKE3 hash regression vs a frozen hex (silent edit
1028        //      catch — the byte-level formal anchor of E14 / A14).
1029        //
1030        // Update procedure: if the literal must change (semver-bump
1031        // escalation), regenerate the hex via
1032        //   `printf '%s' "<new bytes>" | b3sum --no-names`
1033        // and update both `EXPECTED` and `FROZEN_HEX` together.
1034        // Layer A item 1 escalation review required.
1035        const EXPECTED: &[u8] = b"arkhe-kernel v0.14 WAL chain domain separation context";
1036        assert_eq!(WalHeader::DOMAIN_CTX, EXPECTED);
1037        assert_eq!(WalHeader::DOMAIN_CTX.len(), 54);
1038
1039        // Frozen BLAKE3 hex of the canonical bytes (regression pin).
1040        const FROZEN_HEX: &str = "4c20c496b74ba868f2669cfcc1a8b6f4c2e0f122bdba5e0337eaeebe599a1fda";
1041        let actual_hex = blake3::hash(WalHeader::DOMAIN_CTX).to_hex();
1042        assert_eq!(
1043            actual_hex.as_str(),
1044            FROZEN_HEX,
1045            "DOMAIN_CTX BLAKE3 hash regression — byte-level edit detected",
1046        );
1047    }
1048
1049    #[test]
1050    fn wal_record_persists_actor_for_replay_determinism() {
1051        // Regression (#1): the submit-time actor is canonical input (it
1052        // feeds `ctx.actor`) and must survive into the record AND the
1053        // chain hash so replay is bit-identical for modules that read it.
1054        let mut w = WalWriter::new(world(), manifest());
1055        let actor = Some(EntityId::new(42).unwrap());
1056        w.append(
1057            Tick(0),
1058            InstanceId::new(1).unwrap(),
1059            Principal::System,
1060            actor,
1061            TypeCode(100),
1062            vec![1, 2, 3],
1063            0,
1064            sample_stage(),
1065            AuthDecisionAnnotation::AllAuthorized,
1066        )
1067        .unwrap();
1068        let wal = Wal::from_writer(w);
1069        assert_eq!(wal.records[0].actor, actor);
1070        assert!(wal.verify_chain(world()).is_ok());
1071    }
1072
1073    #[test]
1074    fn verify_chain_anchored_rejects_downgrade_and_substitution() {
1075        // Regression (#1/#2 security): an anchored verify must reject a
1076        // tier downgrade and a verifying-key substitution.
1077        let sig = SignatureClass::new_ed25519_from_secret([7u8; 32]);
1078        let vk = sig.verifying_key_bytes().unwrap();
1079        let mut w = WalWriter::with_signature(world(), manifest(), sig);
1080        append_one(&mut w);
1081        let wal = Wal::from_writer(w);
1082
1083        // Correct anchor (Ed25519 floor + matching key) → Ok.
1084        let good = TrustAnchor {
1085            min_tier: Some(SignatureTier::Ed25519),
1086            ed25519_verifying_key: Some(vk),
1087            ..Default::default()
1088        };
1089        assert!(wal.verify_chain_anchored(world(), &good).is_ok());
1090
1091        // Downgrade: require Hybrid, WAL is only Ed25519 → reject.
1092        let downgrade = TrustAnchor {
1093            min_tier: Some(SignatureTier::Hybrid),
1094            ..Default::default()
1095        };
1096        assert!(matches!(
1097            wal.verify_chain_anchored(world(), &downgrade),
1098            Err(WalError::TierDowngrade)
1099        ));
1100
1101        // Substitution: anchor expects a different key → reject.
1102        let wrong_key = TrustAnchor {
1103            min_tier: Some(SignatureTier::Ed25519),
1104            ed25519_verifying_key: Some([0xAB; 32]),
1105            ..Default::default()
1106        };
1107        assert!(matches!(
1108            wal.verify_chain_anchored(world(), &wrong_key),
1109            Err(WalError::VerifyingKeyMismatch)
1110        ));
1111    }
1112
1113    // ---- PQC envelope wire format (Layer A item 7 post-extension) ----
1114
1115    #[test]
1116    fn wal_record_postcard_layout_byte_identity() {
1117        // Pin the postcard wire-format byte sequence for an Ed25519-signed
1118        // WalRecord via BLAKE3 frozen-hash regression. Any silent reorder
1119        // of WalRecord fields (or insertion of new fields without updating
1120        // this pin) breaks this assertion.
1121        let sig_class = SignatureClass::new_ed25519_from_secret([7u8; 32]);
1122        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1123        append_one(&mut w);
1124        let wal = Wal::from_writer(w);
1125        let encoded = postcard::to_allocvec(&wal.records[0]).expect("postcard encode");
1126        const FROZEN_HEX: &str = "170607201a4352833ebfe81a1546b4937cdafd587a3e266408b6c1a6bea16709";
1127        let actual = blake3::hash(&encoded);
1128        assert_eq!(
1129            actual.to_hex().as_str(),
1130            FROZEN_HEX,
1131            "WalRecord postcard byte sequence regression",
1132        );
1133    }
1134
1135    #[test]
1136    fn wal_record_hybrid_layout_byte_identity() {
1137        // Pin the postcard wire-format growth for a Hybrid record's PQC
1138        // signature slot (envelope sized for ML-DSA 65 signature = 3309
1139        // bytes). Verifies the wire format slot accommodates PQC signature
1140        // sizes exceeding Ed25519's 64-byte fixed length.
1141        let sig_class = SignatureClass::new_ed25519_from_secret([19u8; 32]);
1142        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1143        append_one(&mut w);
1144        let mut wal = Wal::from_writer(w);
1145        // Baseline: signature_pqc=None.
1146        let baseline_encoded = postcard::to_allocvec(&wal.records[0]).expect("baseline encode");
1147        // Inject ML-DSA 65-sized placeholder signature (3309 bytes).
1148        wal.records[0].signature_pqc = Some(vec![0xAB; 3309]);
1149        let with_pqc_encoded = postcard::to_allocvec(&wal.records[0]).expect("with_pqc encode");
1150        // Postcard Option<Vec<u8>>: None = 0x00 (1 byte).
1151        // Some(vec[3309]) = 0x01 + varint(3309) + 3309 bytes.
1152        // varint(3309) = 0xED 0x19 (2 bytes).
1153        // Net growth: 1 + 2 + 3309 - 1 = 3311 bytes.
1154        assert_eq!(
1155            with_pqc_encoded.len() - baseline_encoded.len(),
1156            3311,
1157            "PQC signature envelope size mismatch — ML-DSA 65 must fit",
1158        );
1159    }
1160
1161    #[test]
1162    fn wal_header_verifying_key_pqc_slot_pinned() {
1163        // Pin the WalHeader PQC verifying-key envelope slot. None for
1164        // non-Hybrid; Some(1952B) for Hybrid (ML-DSA 65 verifying key
1165        // size). Verifies the wire format slot accommodates PQC public
1166        // key sizes exceeding Ed25519's 32-byte fixed length.
1167        let h = WalWriter::new(world(), manifest()).header().clone();
1168        // Tier 1: both verifying keys absent.
1169        assert!(h.verifying_key.is_none());
1170        assert!(h.verifying_key_pqc.is_none());
1171
1172        let mut h_pqc = h.clone();
1173        // Inject ML-DSA 65-sized placeholder verifying key (1952 bytes).
1174        h_pqc.verifying_key_pqc = Some(vec![0xCD; 1952]);
1175        let baseline = postcard::to_allocvec(&h).expect("encode baseline");
1176        let with_pqc = postcard::to_allocvec(&h_pqc).expect("encode with pqc key");
1177        // Postcard Option<Vec<u8>>: None = 0x00 (1 byte).
1178        // Some(vec[1952]) = 0x01 + varint(1952) + 1952 bytes.
1179        // varint(1952) = 0xA0 0x0F (2 bytes).
1180        // Net growth: 1 + 2 + 1952 - 1 = 1954 bytes.
1181        assert_eq!(
1182            with_pqc.len() - baseline.len(),
1183            1954,
1184            "PQC verifying-key envelope size mismatch — ML-DSA 65 must fit",
1185        );
1186    }
1187
1188    #[test]
1189    fn chain_hash_unchanged_for_ed25519_records() {
1190        // Layer A item 1 (DOMAIN_CTX) byte-identity preservation under
1191        // the WalRecord wire format extension. WalRecordBody (10-field
1192        // chain hash input) UNCHANGED — chain hash for the same body
1193        // input is identical pre/post extension. Pins the resulting
1194        // this_chain_hash via BLAKE3 frozen-hex regression.
1195        let mut w = WalWriter::new([7u8; 32], [3u8; 32]);
1196        w.append(
1197            Tick(0),
1198            InstanceId::new(1).unwrap(),
1199            Principal::System,
1200            None,
1201            TypeCode(100),
1202            vec![1, 2, 3],
1203            0,
1204            sample_stage(),
1205            AuthDecisionAnnotation::AllAuthorized,
1206        )
1207        .unwrap();
1208        let wal = Wal::from_writer(w);
1209        const FROZEN_HEX: &str = "4bb26e665e904314600b52b0d3bef34ee476de1c155f701fa95c8f5438140319";
1210        let actual_hex = blake3::Hash::from(wal.records[0].this_chain_hash).to_hex();
1211        assert_eq!(
1212            actual_hex.as_str(),
1213            FROZEN_HEX,
1214            "chain hash regression — DOMAIN_CTX or WalRecordBody field order changed",
1215        );
1216    }
1217
1218    #[test]
1219    fn wal_record_postcard_field_order_baseline() {
1220        // Layer A item 7 post-extension baseline pin. Pins the WalRecord
1221        // postcard byte sequence for a Tier 1 (no signature) record with
1222        // distinctive inputs. Any silent reorder or addition of fields
1223        // breaks this BLAKE3 hash regression pin.
1224        let mut w = WalWriter::new([7u8; 32], [3u8; 32]);
1225        w.append(
1226            Tick(42),
1227            InstanceId::new(99).unwrap(),
1228            Principal::System,
1229            None,
1230            TypeCode(0xCAFE),
1231            vec![0xAA, 0xBB, 0xCC],
1232            0xFF,
1233            sample_stage(),
1234            AuthDecisionAnnotation::AllAuthorized,
1235        )
1236        .unwrap();
1237        let wal = Wal::from_writer(w);
1238        let encoded = postcard::to_allocvec(&wal.records[0]).expect("postcard encode");
1239        const FROZEN_HEX: &str = "ca468fac6ac1c0742c3b48e6398290630015411da040cf22c0228012b7e71cfa";
1240        let actual = blake3::hash(&encoded);
1241        assert_eq!(
1242            actual.to_hex().as_str(),
1243            FROZEN_HEX,
1244            "WalRecord postcard field order regression",
1245        );
1246    }
1247
1248    // ---- PQC Hybrid (Ed25519 + ML-DSA 65) wal-side wiring ----
1249
1250    #[test]
1251    fn hybrid_writer_emits_both_signatures() {
1252        // Hybrid sig_class populates both signature (Ed25519 64 bytes)
1253        // and signature_pqc (ML-DSA 65 3309 bytes) on every record.
1254        // Header pins both verifying_key (Ed25519 32 bytes) and
1255        // verifying_key_pqc (ML-DSA 65 1952 bytes).
1256        let sig_class = SignatureClass::new_hybrid_from_secrets([7u8; 32], [11u8; 32]);
1257        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1258        for _ in 0..3 {
1259            append_one(&mut w);
1260        }
1261        let wal = Wal::from_writer(w);
1262        assert_eq!(
1263            wal.header
1264                .verifying_key
1265                .expect("Hybrid pins Ed25519 vk")
1266                .len(),
1267            32
1268        );
1269        assert_eq!(
1270            wal.header
1271                .verifying_key_pqc
1272                .as_ref()
1273                .expect("Hybrid pins PQC vk")
1274                .len(),
1275            1952
1276        );
1277        assert_eq!(wal.records.len(), 3);
1278        for rec in &wal.records {
1279            assert_eq!(
1280                rec.signature
1281                    .as_ref()
1282                    .expect("Hybrid signs Ed25519 every record")
1283                    .len(),
1284                64
1285            );
1286            assert_eq!(
1287                rec.signature_pqc
1288                    .as_ref()
1289                    .expect("Hybrid signs PQC every record")
1290                    .len(),
1291                3309
1292            );
1293        }
1294    }
1295
1296    #[test]
1297    fn hybrid_verify_chain_and_mode_passes_with_both_valid() {
1298        // AND-mode positive: both Ed25519 and ML-DSA 65 signatures
1299        // valid → verify_chain succeeds. Round-trip through serialize
1300        // confirms the on-disk shape verifies.
1301        let sig_class = SignatureClass::new_hybrid_from_secrets([13u8; 32], [17u8; 32]);
1302        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1303        for _ in 0..3 {
1304            append_one(&mut w);
1305        }
1306        let wal = Wal::from_writer(w);
1307        let bytes = wal.serialize().unwrap();
1308        let back = Wal::deserialize(&bytes).unwrap();
1309        back.verify_chain(world())
1310            .expect("Hybrid signed chain verifies (AND-mode pass)");
1311    }
1312
1313    #[test]
1314    fn hybrid_verify_chain_rejects_missing_pqc() {
1315        // Strict (write-side): Hybrid envelope (header pins PQC vk) +
1316        // record without signature_pqc → MissingPqcSignature.
1317        let sig_class = SignatureClass::new_hybrid_from_secrets([19u8; 32], [23u8; 32]);
1318        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1319        append_one(&mut w);
1320        let mut wal = Wal::from_writer(w);
1321        // Header still pins PQC vk, but the record claims no PQC sig.
1322        wal.records[0].signature_pqc = None;
1323        let result = wal.verify_chain(world());
1324        assert!(matches!(
1325            result,
1326            Err(WalError::MissingPqcSignature { at_record: 0 })
1327        ));
1328    }
1329
1330    #[test]
1331    fn hybrid_verify_chain_rejects_corrupt_pqc_signature() {
1332        // Hybrid with valid Ed25519 + corrupt PQC signature →
1333        // verify_hybrid Ed25519 phase passes, PQC phase fails →
1334        // PqcSignatureMismatch.
1335        let sig_class = SignatureClass::new_hybrid_from_secrets([29u8; 32], [31u8; 32]);
1336        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1337        append_one(&mut w);
1338        append_one(&mut w);
1339        let mut wal = Wal::from_writer(w);
1340        // Flip a byte inside record[1]'s PQC signature.
1341        if let Some(sig_pqc) = wal.records[1].signature_pqc.as_mut() {
1342            sig_pqc[0] ^= 0xFF;
1343        }
1344        let result = wal.verify_chain(world());
1345        assert!(matches!(
1346            result,
1347            Err(WalError::PqcSignatureMismatch { at_record: 1 })
1348        ));
1349    }
1350
1351    #[test]
1352    fn hybrid_verify_chain_rejects_corrupt_ed25519_when_pqc_valid() {
1353        // AND-mode short-circuit: corrupt Ed25519 signature with valid
1354        // PQC signature → verify_hybrid Ed25519 phase fails first →
1355        // PqcSignatureMismatch (uniform AND-mode failure error — any
1356        // Hybrid signature failure maps to PqcSignatureMismatch at the
1357        // WalError surface).
1358        let sig_class = SignatureClass::new_hybrid_from_secrets([37u8; 32], [41u8; 32]);
1359        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1360        append_one(&mut w);
1361        append_one(&mut w);
1362        let mut wal = Wal::from_writer(w);
1363        // Flip a byte inside record[0]'s Ed25519 signature; PQC stays valid.
1364        if let Some(sig) = wal.records[0].signature.as_mut() {
1365            sig[0] ^= 0xFF;
1366        }
1367        let result = wal.verify_chain(world());
1368        assert!(matches!(
1369            result,
1370            Err(WalError::PqcSignatureMismatch { at_record: 0 })
1371        ));
1372    }
1373
1374    #[test]
1375    fn ed25519_only_wal_replays_under_hybrid_kernel() {
1376        // Backward-compat (read-side): Ed25519-only WAL bytes
1377        // (verifying_key_pqc=None, signature_pqc=None per record)
1378        // replay under a PQC-Hybrid-capable kernel via the
1379        // (Some, None) → VerifierClass::Ed25519 envelope-derived
1380        // dispatch arm. Strict mode applies write-side only.
1381        let sig_class = SignatureClass::new_ed25519_from_secret([43u8; 32]);
1382        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1383        for _ in 0..3 {
1384            append_one(&mut w);
1385        }
1386        let wal = Wal::from_writer(w);
1387        // Ed25519-only envelope: Ed25519 vk pinned, no PQC vk; records
1388        // have signature but no signature_pqc.
1389        assert!(wal.header.verifying_key.is_some());
1390        assert!(wal.header.verifying_key_pqc.is_none());
1391        for rec in &wal.records {
1392            assert!(rec.signature.is_some());
1393            assert!(rec.signature_pqc.is_none());
1394        }
1395        let bytes = wal.serialize().unwrap();
1396        let back = Wal::deserialize(&bytes).unwrap();
1397        back.verify_chain(world())
1398            .expect("Ed25519-only WAL replays under Hybrid-capable kernel");
1399    }
1400
1401    #[test]
1402    fn pqc_without_ed25519_envelope_rejected() {
1403        // Envelope-level invariant: verifying_key=None +
1404        // verifying_key_pqc=Some → invalid envelope. Ed25519 is the
1405        // chain-anchor companion; PQC-only envelope is rejected.
1406        // VerifierClass::from_header_bytes returns
1407        // VerifierInitError::PqcWithoutEd25519 → wal.rs caller maps to
1408        // WalError::PqcWithoutEd25519.
1409        let sig_class = SignatureClass::new_hybrid_from_secrets([47u8; 32], [53u8; 32]);
1410        let w = WalWriter::with_signature(world(), manifest(), sig_class);
1411        let mut wal = Wal::from_writer(w);
1412        // Strip the Ed25519 verifying-key while leaving PQC vk in place
1413        // → (None, Some) invalid envelope.
1414        wal.header.verifying_key = None;
1415        let result = wal.verify_chain(world());
1416        assert!(matches!(result, Err(WalError::PqcWithoutEd25519)));
1417    }
1418}