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::{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.13 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, 13, 0);
72    /// ABI semver pinned by [`WalWriter::new`].
73    pub const ABI_VERSION: (u16, u16) = (0, 13);
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. "v0.13" inside this literal is the
80    /// public release version anchor — pre-public single fix per user
81    /// directive 2026-05-03, no further version bumps. Any change
82    /// invalidates every WAL chain ever produced (Layer A item 1
83    /// byte-identity invariant — A1/A14).
84    pub const DOMAIN_CTX: &'static [u8] = b"arkhe-kernel v0.13 WAL chain domain separation context";
85}
86
87/// One-byte annotation summarizing whether every Op in the record's
88/// stage authorized cleanly. Belt-and-suspenders companion to the
89/// chain hash (belt-and-suspenders companion).
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[repr(u8)]
92pub enum AuthDecisionAnnotation {
93    /// Every Op authorized.
94    AllAuthorized = 0,
95    /// At least one Op was denied.
96    SomeDenied = 1,
97}
98
99/// Single committed step recorded in the WAL.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct WalRecord {
102    /// Monotonic record sequence within this WAL.
103    pub seq: u64,
104    /// Tick at which the producing `step()` ran.
105    pub at: Tick,
106    /// Instance the action ran against.
107    pub instance: InstanceId,
108    /// Principal under which the action was submitted.
109    pub principal: Principal,
110    /// Type code of the executed action.
111    pub action_type_code: TypeCode,
112    /// Canonical action bytes (replay deserializes from these).
113    pub action_bytes: Vec<u8>,
114    /// `CapabilityMask` bits in effect during `step()`.
115    pub caps_bits: u64,
116    pub(crate) stage: StepStage,
117    /// Auth-decision summary for this step's Ops.
118    pub auth_decision: AuthDecisionAnnotation,
119    /// Previous record's `this_chain_hash` (or zero for record 0).
120    pub prev_chain_hash: [u8; 32],
121    /// `blake3::keyed(chain_key, prev_chain_hash || canonical(body))`.
122    pub this_chain_hash: [u8; 32],
123    /// Ed25519 signature over the canonical `WalRecordBody` bytes
124    /// (the same bytes hashed into `this_chain_hash`). `None` when the
125    /// owning WAL was created with `SignatureClass::None`. Stored as
126    /// `Vec<u8>` (always exactly 64 bytes when present) because serde's
127    /// array deserializer caps at 32 — same workaround as the header's
128    /// `domain_separation_context`.
129    pub signature: Option<Vec<u8>>,
130    /// PQC signature bytes for Hybrid signing modes (envelope slot for
131    /// ML-DSA 65 or other PQC algorithms). Paired with `signature` for
132    /// dual-sign verification under Hybrid policy. `None` for non-Hybrid
133    /// configurations. Stored as `Vec<u8>` because PQC signatures exceed
134    /// the serde 32-byte fixed-array limit (ML-DSA 65 signature = 3309
135    /// bytes).
136    pub signature_pqc: Option<Vec<u8>>,
137}
138
139#[derive(Serialize)]
140struct WalRecordBody<'a> {
141    seq: u64,
142    at: Tick,
143    instance: InstanceId,
144    principal: &'a Principal,
145    action_type_code: TypeCode,
146    action_bytes: &'a [u8],
147    caps_bits: u64,
148    stage: &'a StepStage,
149    auth_decision: AuthDecisionAnnotation,
150    prev_chain_hash: [u8; 32],
151}
152
153impl<'a> WalRecordBody<'a> {
154    /// Reconstruct the canonical body view from a stored `WalRecord`
155    /// plus the running `prev_chain_hash` (used by `verify_chain`).
156    /// `append` constructs the body inline because its fields are
157    /// per-field locals at that point — sharing a helper there costs
158    /// readability for no LOC saved.
159    fn from_record(rec: &'a WalRecord, prev: [u8; 32]) -> Self {
160        Self {
161            seq: rec.seq,
162            at: rec.at,
163            instance: rec.instance,
164            principal: &rec.principal,
165            action_type_code: rec.action_type_code,
166            action_bytes: &rec.action_bytes,
167            caps_bits: rec.caps_bits,
168            stage: &rec.stage,
169            auth_decision: rec.auth_decision,
170            prev_chain_hash: prev,
171        }
172    }
173}
174
175/// Sealed WAL — the durable read-side counterpart to [`WalWriter`].
176/// Produced by [`Wal::from_writer`] or [`Wal::deserialize`]; consumed
177/// by [`Wal::verify_chain`] / [`replay_into`](super::replay::replay_into).
178#[derive(Debug, Serialize, Deserialize)]
179pub struct Wal {
180    /// Header pinned at writer construction.
181    pub header: WalHeader,
182    /// Records in append order.
183    pub records: Vec<WalRecord>,
184}
185
186/// Append-only WAL writer. Each successful `Kernel::step` writes one
187/// [`WalRecord`] via the kernel's internal append path.
188pub struct WalWriter {
189    header: WalHeader,
190    records: Vec<WalRecord>,
191    next_seq: u64,
192    prev_hash: [u8; 32],
193    chain_key: [u8; 32],
194    sig_class: SignatureClass,
195}
196
197fn build_chain_key(world_id: &[u8; 32]) -> [u8; 32] {
198    let ctx = core::str::from_utf8(WalHeader::DOMAIN_CTX).expect("DOMAIN_CTX is valid UTF-8 ASCII");
199    blake3::derive_key(ctx, world_id)
200}
201
202fn build_dsc() -> Vec<u8> {
203    WalHeader::DOMAIN_CTX.to_vec()
204}
205
206impl WalWriter {
207    /// Construct a chain-only writer (Tier 1 — no signature).
208    pub fn new(world_id: [u8; 32], manifest_digest: [u8; 32]) -> Self {
209        Self::with_signature(world_id, manifest_digest, SignatureClass::None)
210    }
211
212    /// Construct a writer that signs each record under `sig_class`. The
213    /// verifying key is pinned in the header so post-hoc verification
214    /// works against the WAL bytes alone.
215    pub fn with_signature(
216        world_id: [u8; 32],
217        manifest_digest: [u8; 32],
218        sig_class: SignatureClass,
219    ) -> Self {
220        let chain_key = build_chain_key(&world_id);
221        let header = WalHeader {
222            magic: WalHeader::MAGIC,
223            kernel_semver: WalHeader::CURRENT_KERNEL_SEMVER,
224            postcard_version: WalHeader::POSTCARD_MAJOR,
225            blake3_version: WalHeader::BLAKE3_MAJOR,
226            domain_separation_context: build_dsc(),
227            world_id,
228            abi_version: WalHeader::ABI_VERSION,
229            manifest_digest,
230            type_registry_pins: Vec::new(),
231            verifying_key: sig_class.verifying_key_bytes(),
232            verifying_key_pqc: sig_class.verifying_key_pqc_bytes(),
233        };
234        Self {
235            header,
236            records: Vec::new(),
237            next_seq: 0,
238            prev_hash: [0u8; 32],
239            chain_key,
240            sig_class,
241        }
242    }
243
244    #[allow(clippy::too_many_arguments)]
245    pub(crate) fn append(
246        &mut self,
247        at: Tick,
248        instance: InstanceId,
249        principal: Principal,
250        action_type_code: TypeCode,
251        action_bytes: Vec<u8>,
252        caps_bits: u64,
253        stage: StepStage,
254        auth_decision: AuthDecisionAnnotation,
255    ) -> Result<&WalRecord, WalError> {
256        self.next_seq = self.next_seq.saturating_add(1);
257        let body = WalRecordBody {
258            seq: self.next_seq,
259            at,
260            instance,
261            principal: &principal,
262            action_type_code,
263            action_bytes: &action_bytes,
264            caps_bits,
265            stage: &stage,
266            auth_decision,
267            prev_chain_hash: self.prev_hash,
268        };
269        let body_bytes = postcard::to_allocvec(&body)
270            .map_err(|e| WalError::SerializeFailed(format!("{}", e)))?;
271        let mut hasher = blake3::Hasher::new_keyed(&self.chain_key);
272        hasher.update(&self.prev_hash);
273        hasher.update(&body_bytes);
274        let this_hash: [u8; 32] = *hasher.finalize().as_bytes();
275
276        // Signatures are over the same body bytes that feed the chain
277        // hash. Tier 1 (None) leaves both `None`. Hybrid emits paired
278        // Ed25519 + ML-DSA 65 signatures via `sign_hybrid`.
279        let (signature, signature_pqc) = match self.sig_class.sign_hybrid(&body_bytes) {
280            Some(hyb) => (Some(hyb.ed25519.to_vec()), Some(hyb.pqc)),
281            None => (self.sig_class.sign(&body_bytes).map(|s| s.to_vec()), None),
282        };
283
284        let record = WalRecord {
285            seq: self.next_seq,
286            at,
287            instance,
288            principal,
289            action_type_code,
290            action_bytes,
291            caps_bits,
292            stage,
293            auth_decision,
294            prev_chain_hash: self.prev_hash,
295            this_chain_hash: this_hash,
296            signature,
297            signature_pqc,
298        };
299        self.records.push(record);
300        self.prev_hash = this_hash;
301        Ok(self.records.last().expect("just pushed"))
302    }
303
304    /// Pinned WAL header.
305    pub fn header(&self) -> &WalHeader {
306        &self.header
307    }
308    /// All records appended so far, in append order.
309    pub fn records(&self) -> &[WalRecord] {
310        &self.records
311    }
312    /// Most recent record's `this_chain_hash`, or zero if empty.
313    pub fn chain_tip(&self) -> [u8; 32] {
314        self.prev_hash
315    }
316    /// Number of records currently buffered.
317    pub fn record_count(&self) -> usize {
318        self.records.len()
319    }
320}
321
322impl Wal {
323    /// Seal a [`WalWriter`] into a read-only [`Wal`].
324    pub fn from_writer(w: WalWriter) -> Self {
325        Self {
326            header: w.header,
327            records: w.records,
328        }
329    }
330
331    /// Encode the entire WAL (header + records) as canonical postcard
332    /// bytes.
333    pub fn serialize(&self) -> Result<Vec<u8>, WalError> {
334        postcard::to_allocvec(self).map_err(|e| WalError::SerializeFailed(format!("{}", e)))
335    }
336
337    /// Decode bytes produced by [`serialize`](Wal::serialize).
338    pub fn deserialize(bytes: &[u8]) -> Result<Self, WalError> {
339        postcard::from_bytes(bytes).map_err(|e| WalError::DeserializeFailed(format!("{}", e)))
340    }
341
342    /// Most recent record's `this_chain_hash`, or zero if empty.
343    pub fn chain_tip(&self) -> [u8; 32] {
344        self.records
345            .last()
346            .map(|r| r.this_chain_hash)
347            .unwrap_or([0u8; 32])
348    }
349
350    /// Verify every record's chain hash against the keyed BLAKE3 over
351    /// (prev_chain_hash || canonical body). When the header pins a
352    /// `verifying_key` (Tier 2 — Ed25519), each record's signature
353    /// is also checked against the same body bytes. Returns `Ok` if
354    /// every check passes.
355    pub fn verify_chain(&self, world_id: [u8; 32]) -> Result<(), WalError> {
356        let chain_key = build_chain_key(&world_id);
357        let verifier = VerifierClass::from_header_bytes(
358            self.header.verifying_key.as_ref(),
359            self.header.verifying_key_pqc.as_deref(),
360        )
361        .map_err(|e| match e {
362            crate::persist::signature::VerifierInitError::InvalidEd25519Key
363            | crate::persist::signature::VerifierInitError::InvalidPqcKey => {
364                WalError::InvalidVerifyingKey
365            }
366            crate::persist::signature::VerifierInitError::PqcWithoutEd25519 => {
367                WalError::PqcWithoutEd25519
368            }
369        })?;
370        let mut prev = [0u8; 32];
371        for (i, rec) in self.records.iter().enumerate() {
372            if blake3::Hash::from(rec.prev_chain_hash) != blake3::Hash::from(prev) {
373                return Err(WalError::ChainBroken { at_record: i });
374            }
375            let body = WalRecordBody::from_record(rec, prev);
376            let body_bytes = postcard::to_allocvec(&body)
377                .map_err(|e| WalError::SerializeFailed(format!("{}", e)))?;
378            let mut hasher = blake3::Hasher::new_keyed(&chain_key);
379            hasher.update(&prev);
380            hasher.update(&body_bytes);
381            let computed: [u8; 32] = *hasher.finalize().as_bytes();
382            if blake3::Hash::from(computed) != blake3::Hash::from(rec.this_chain_hash) {
383                return Err(WalError::HashMismatch { at_record: i });
384            }
385
386            match &verifier {
387                VerifierClass::None => {}
388                VerifierClass::Ed25519(_) => {
389                    let sig_vec = rec
390                        .signature
391                        .as_ref()
392                        .ok_or(WalError::MissingSignature { at_record: i })?;
393                    verifier
394                        .verify(&body_bytes, sig_vec)
395                        .map_err(|_| WalError::SignatureMismatch { at_record: i })?;
396                }
397                VerifierClass::Hybrid { .. } => {
398                    let sig_vec = rec
399                        .signature
400                        .as_ref()
401                        .ok_or(WalError::MissingSignature { at_record: i })?;
402                    let sig_pqc = rec
403                        .signature_pqc
404                        .as_ref()
405                        .ok_or(WalError::MissingPqcSignature { at_record: i })?;
406                    verifier
407                        .verify_hybrid(&body_bytes, sig_vec, sig_pqc)
408                        .map_err(|_| WalError::PqcSignatureMismatch { at_record: i })?;
409                }
410            }
411
412            prev = computed;
413        }
414        Ok(())
415    }
416}
417
418/// WAL operation failures. `#[non_exhaustive]` — adding variants is
419/// not a breaking change for external matchers.
420#[derive(Debug, Clone)]
421#[non_exhaustive]
422pub enum WalError {
423    /// Postcard refused to encode (carries the upstream message).
424    SerializeFailed(String),
425    /// Postcard refused to decode (carries the upstream message).
426    DeserializeFailed(String),
427    /// Record `at_record`'s `prev_chain_hash` doesn't match the running
428    /// expected hash from the previous record.
429    ChainBroken {
430        /// Index of the offending record.
431        at_record: usize,
432    },
433    /// Record `at_record`'s `this_chain_hash` doesn't match the
434    /// recomputed BLAKE3 keyed hash.
435    HashMismatch {
436        /// Index of the offending record.
437        at_record: usize,
438    },
439    /// Header pinning rejected (semver / abi / world / manifest mismatch).
440    HeaderIncompatible(String),
441    /// Header pins a `verifying_key` (Ed25519 or PQC) that fails to parse.
442    InvalidVerifyingKey,
443    /// Header pins a `verifying_key` but a record carries no signature.
444    MissingSignature {
445        /// Index of the offending record.
446        at_record: usize,
447    },
448    /// Signature does not validate against the header's verifying key.
449    SignatureMismatch {
450        /// Index of the offending record.
451        at_record: usize,
452    },
453    /// Header pins a Hybrid envelope (`verifying_key_pqc=Some`) but a
454    /// record carries no PQC signature (`signature_pqc=None`).
455    MissingPqcSignature {
456        /// Index of the offending record.
457        at_record: usize,
458    },
459    /// PQC signature does not validate against the header's PQC
460    /// verifying key (Hybrid AND-mode failure).
461    PqcSignatureMismatch {
462        /// Index of the offending record.
463        at_record: usize,
464    },
465    /// Invalid Hybrid envelope — `verifying_key_pqc=Some` without
466    /// `verifying_key=Some`. Ed25519 is the chain-anchor companion;
467    /// PQC-only envelope is rejected.
468    PqcWithoutEd25519,
469}
470
471impl core::fmt::Display for WalError {
472    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
473        match self {
474            Self::SerializeFailed(m) => write!(f, "wal serialize failed: {}", m),
475            Self::DeserializeFailed(m) => write!(f, "wal deserialize failed: {}", m),
476            Self::ChainBroken { at_record } => {
477                write!(f, "wal chain broken at record {}", at_record)
478            }
479            Self::HashMismatch { at_record } => {
480                write!(f, "wal hash mismatch at record {}", at_record)
481            }
482            Self::HeaderIncompatible(m) => write!(f, "wal header incompatible: {}", m),
483            Self::InvalidVerifyingKey => write!(
484                f,
485                "wal verifying_key invalid (not a valid Ed25519 public key)"
486            ),
487            Self::MissingSignature { at_record } => {
488                write!(f, "wal signature missing at record {}", at_record)
489            }
490            Self::SignatureMismatch { at_record } => {
491                write!(f, "wal signature mismatch at record {}", at_record)
492            }
493            Self::MissingPqcSignature { at_record } => {
494                write!(f, "wal PQC signature missing at record {}", at_record)
495            }
496            Self::PqcSignatureMismatch { at_record } => {
497                write!(f, "wal PQC signature mismatch at record {}", at_record)
498            }
499            Self::PqcWithoutEd25519 => write!(
500                f,
501                "wal envelope invalid (verifying_key_pqc set without verifying_key)"
502            ),
503        }
504    }
505}
506
507impl std::error::Error for WalError {}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use crate::abi::{EntityId, ExternalId, RouteId};
513    use crate::runtime::stage::{LedgerOp, StagedStateDelta};
514    use crate::state::EntityMeta;
515
516    fn world() -> [u8; 32] {
517        [7u8; 32]
518    }
519    fn manifest() -> [u8; 32] {
520        [3u8; 32]
521    }
522
523    fn sample_stage() -> StepStage {
524        let mut s = StepStage::default();
525        s.state_ops.push(StagedStateDelta::SpawnEntity {
526            id: EntityId::new(1).unwrap(),
527            meta: EntityMeta {
528                owner: Principal::System,
529                created: Tick(0),
530            },
531        });
532        s.ledger_delta
533            .ops
534            .push(LedgerOp::AddEntity(EntityId::new(1).unwrap()));
535        s.id_counters.next_entity_advance = 1;
536        s
537    }
538
539    #[test]
540    fn empty_writer_serializes_and_deserializes() {
541        let w = WalWriter::new(world(), manifest());
542        let wal = Wal::from_writer(w);
543        let bytes = wal.serialize().unwrap();
544        let back = Wal::deserialize(&bytes).unwrap();
545        assert_eq!(back.header, wal.header);
546        assert_eq!(back.records.len(), 0);
547        assert_eq!(back.chain_tip(), [0u8; 32]);
548    }
549
550    #[test]
551    fn single_append_produces_nonzero_chain_tip() {
552        let mut w = WalWriter::new(world(), manifest());
553        w.append(
554            Tick(5),
555            InstanceId::new(1).unwrap(),
556            Principal::System,
557            TypeCode(100),
558            vec![1, 2, 3],
559            0,
560            sample_stage(),
561            AuthDecisionAnnotation::AllAuthorized,
562        )
563        .unwrap();
564        let tip = w.chain_tip();
565        assert_ne!(tip, [0u8; 32]);
566        assert_eq!(w.record_count(), 1);
567    }
568
569    #[test]
570    fn multi_record_chain_links_each_record() {
571        let mut w = WalWriter::new(world(), manifest());
572        for i in 0..5 {
573            w.append(
574                Tick(i),
575                InstanceId::new(1).unwrap(),
576                Principal::System,
577                TypeCode(100),
578                vec![i as u8],
579                0,
580                StepStage::default(),
581                AuthDecisionAnnotation::AllAuthorized,
582            )
583            .unwrap();
584        }
585        let wal = Wal::from_writer(w);
586        assert_eq!(wal.records.len(), 5);
587        // Each record's prev_chain_hash equals previous record's this_chain_hash.
588        let mut prev = [0u8; 32];
589        for rec in &wal.records {
590            assert_eq!(rec.prev_chain_hash, prev);
591            prev = rec.this_chain_hash;
592        }
593        wal.verify_chain(world()).expect("clean chain");
594    }
595
596    #[test]
597    fn tampered_record_breaks_verify_chain() {
598        let mut w = WalWriter::new(world(), manifest());
599        for i in 0..3 {
600            w.append(
601                Tick(i),
602                InstanceId::new(1).unwrap(),
603                Principal::System,
604                TypeCode(100),
605                vec![i as u8],
606                0,
607                StepStage::default(),
608                AuthDecisionAnnotation::AllAuthorized,
609            )
610            .unwrap();
611        }
612        let mut wal = Wal::from_writer(w);
613        // Tamper: overwrite middle record's caps_bits.
614        wal.records[1].caps_bits = 0xDEAD_BEEF;
615        let result = wal.verify_chain(world());
616        assert!(matches!(result, Err(WalError::HashMismatch { .. })));
617    }
618
619    #[test]
620    fn verify_chain_detects_broken_prev_link() {
621        let mut w = WalWriter::new(world(), manifest());
622        for i in 0..3 {
623            w.append(
624                Tick(i),
625                InstanceId::new(1).unwrap(),
626                Principal::System,
627                TypeCode(100),
628                vec![i as u8],
629                0,
630                StepStage::default(),
631                AuthDecisionAnnotation::AllAuthorized,
632            )
633            .unwrap();
634        }
635        let mut wal = Wal::from_writer(w);
636        // Tamper: break the prev_chain_hash link of record 1 without
637        // touching its body. verify_chain must detect chain discontinuity
638        // (ChainBroken) before reaching the body-derived hash check
639        // (HashMismatch).
640        wal.records[1].prev_chain_hash[0] ^= 1;
641        let result = wal.verify_chain(world());
642        assert!(matches!(
643            result,
644            Err(WalError::ChainBroken { at_record: 1 })
645        ));
646    }
647
648    #[test]
649    fn different_world_id_produces_different_chain() {
650        let mut w1 = WalWriter::new([1u8; 32], manifest());
651        let mut w2 = WalWriter::new([2u8; 32], manifest());
652        for w in [&mut w1, &mut w2] {
653            w.append(
654                Tick(0),
655                InstanceId::new(1).unwrap(),
656                Principal::System,
657                TypeCode(100),
658                vec![],
659                0,
660                StepStage::default(),
661                AuthDecisionAnnotation::AllAuthorized,
662            )
663            .unwrap();
664        }
665        // Domain-separation: different world_id → different keyed-hash output.
666        assert_ne!(w1.chain_tip(), w2.chain_tip());
667    }
668
669    #[test]
670    fn verify_chain_against_wrong_world_id_fails() {
671        let mut w = WalWriter::new(world(), manifest());
672        w.append(
673            Tick(0),
674            InstanceId::new(1).unwrap(),
675            Principal::System,
676            TypeCode(100),
677            vec![],
678            0,
679            StepStage::default(),
680            AuthDecisionAnnotation::AllAuthorized,
681        )
682        .unwrap();
683        let wal = Wal::from_writer(w);
684        let result = wal.verify_chain([99u8; 32]);
685        assert!(matches!(result, Err(WalError::HashMismatch { .. })));
686    }
687
688    #[test]
689    fn auth_decision_annotation_round_trips() {
690        let mut w = WalWriter::new(world(), manifest());
691        w.append(
692            Tick(0),
693            InstanceId::new(1).unwrap(),
694            Principal::External(ExternalId(7)),
695            TypeCode(101),
696            vec![],
697            0,
698            StepStage::default(),
699            AuthDecisionAnnotation::SomeDenied,
700        )
701        .unwrap();
702        let wal = Wal::from_writer(w);
703        let bytes = wal.serialize().unwrap();
704        let back = Wal::deserialize(&bytes).unwrap();
705        assert_eq!(
706            back.records[0].auth_decision,
707            AuthDecisionAnnotation::SomeDenied
708        );
709    }
710
711    #[test]
712    fn header_carries_magic_and_versions() {
713        let h = WalWriter::new(world(), manifest()).header().clone();
714        assert_eq!(h.magic, *b"ARKHEWAL");
715        assert_eq!(h.kernel_semver, (0, 13, 0));
716        assert_eq!(h.world_id, world());
717        assert_eq!(h.manifest_digest, manifest());
718        assert!(h.type_registry_pins.is_empty());
719        assert!(h.verifying_key.is_none());
720        let _ = RouteId(1);
721    }
722
723    // ---- Ed25519 SignatureClass (Tier 2, A16) ----
724
725    fn append_one(w: &mut WalWriter) {
726        w.append(
727            Tick(0),
728            InstanceId::new(1).unwrap(),
729            Principal::System,
730            TypeCode(100),
731            vec![1, 2, 3],
732            0,
733            sample_stage(),
734            AuthDecisionAnnotation::AllAuthorized,
735        )
736        .unwrap();
737    }
738
739    #[test]
740    fn signature_class_none_produces_no_signature() {
741        let mut w = WalWriter::new(world(), manifest());
742        append_one(&mut w);
743        let wal = Wal::from_writer(w);
744        assert!(wal.header.verifying_key.is_none());
745        assert!(wal.records[0].signature.is_none());
746        wal.verify_chain(world())
747            .expect("Tier 1 chain still verifies");
748    }
749
750    #[test]
751    fn signature_class_ed25519_signs_each_record() {
752        let sig_class = SignatureClass::new_ed25519_from_secret([7u8; 32]);
753        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
754        for _ in 0..3 {
755            append_one(&mut w);
756        }
757        let wal = Wal::from_writer(w);
758        assert!(wal.header.verifying_key.is_some());
759        assert_eq!(wal.records.len(), 3);
760        for rec in &wal.records {
761            let sig = rec.signature.as_ref().expect("Ed25519 signs every record");
762            assert_eq!(sig.len(), 64);
763        }
764    }
765
766    #[test]
767    fn verify_chain_validates_signatures() {
768        let sig_class = SignatureClass::new_ed25519_from_secret([11u8; 32]);
769        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
770        for _ in 0..3 {
771            append_one(&mut w);
772        }
773        let wal = Wal::from_writer(w);
774        // Round-trip through serialize to confirm the on-disk shape verifies.
775        let bytes = wal.serialize().unwrap();
776        let back = Wal::deserialize(&bytes).unwrap();
777        back.verify_chain(world()).expect("signed chain verifies");
778    }
779
780    #[test]
781    fn tampered_signature_fails_verify() {
782        // Hash check would catch body tampering first; isolate the signature
783        // path by tampering ONLY the signature field.
784        let sig_class = SignatureClass::new_ed25519_from_secret([13u8; 32]);
785        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
786        append_one(&mut w);
787        append_one(&mut w);
788        let mut wal = Wal::from_writer(w);
789        // Flip a byte inside record[1]'s signature.
790        if let Some(sig) = wal.records[1].signature.as_mut() {
791            sig[0] ^= 0xFF;
792        }
793        let result = wal.verify_chain(world());
794        assert!(matches!(
795            result,
796            Err(WalError::SignatureMismatch { at_record: 1 })
797        ));
798    }
799
800    #[test]
801    fn missing_signature_fails_verify_when_header_has_key() {
802        let sig_class = SignatureClass::new_ed25519_from_secret([17u8; 32]);
803        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
804        append_one(&mut w);
805        let mut wal = Wal::from_writer(w);
806        // Header still pins a verifying_key, but the record claims no sig.
807        wal.records[0].signature = None;
808        let result = wal.verify_chain(world());
809        assert!(matches!(
810            result,
811            Err(WalError::MissingSignature { at_record: 0 })
812        ));
813    }
814
815    #[test]
816    fn wrong_key_fails_verify() {
817        let sig_class = SignatureClass::new_ed25519_from_secret([19u8; 32]);
818        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
819        append_one(&mut w);
820        let mut wal = Wal::from_writer(w);
821        // Replace the pinned verifying key with a different one — the records'
822        // signatures stop verifying.
823        let other = SignatureClass::new_ed25519_from_secret([23u8; 32])
824            .verifying_key_bytes()
825            .unwrap();
826        wal.header.verifying_key = Some(other);
827        let result = wal.verify_chain(world());
828        assert!(matches!(
829            result,
830            Err(WalError::SignatureMismatch { at_record: 0 })
831        ));
832    }
833
834    #[test]
835    fn signature_deterministic_across_runs() {
836        // RFC 8032 Ed25519 is deterministic — building two WALs with the
837        // same key and the same append sequence yields byte-identical
838        // record signatures.
839        let mk = |secret: [u8; 32]| -> Vec<Vec<u8>> {
840            let mut w = WalWriter::with_signature(
841                world(),
842                manifest(),
843                SignatureClass::new_ed25519_from_secret(secret),
844            );
845            append_one(&mut w);
846            append_one(&mut w);
847            let wal = Wal::from_writer(w);
848            wal.records
849                .iter()
850                .map(|r| r.signature.clone().unwrap())
851                .collect()
852        };
853        let sigs1 = mk([29u8; 32]);
854        let sigs2 = mk([29u8; 32]);
855        assert_eq!(sigs1, sigs2);
856        assert_eq!(sigs1[0].len(), 64);
857    }
858
859    #[test]
860    fn domain_ctx_byte_identity_blake3() {
861        // Layer A item 1 (DOMAIN_CTX literal) byte-level formal anchor.
862        // The literal must remain frozen across kernel semver bumps —
863        // every WAL chain ever produced is keyed via
864        // `blake3::derive_key(DOMAIN_CTX, world_id)`; one byte change
865        // rederives every chain key (A1/A14 byte-identity invariant).
866        //
867        // Two complementary witnesses pin the literal:
868        //   1. Byte-identity vs the canonical literal (rewrite catch).
869        //   2. BLAKE3 hash regression vs a frozen hex (silent edit
870        //      catch — the byte-level formal anchor of E14 / A14).
871        //
872        // Update procedure: if the literal must change (semver-bump
873        // escalation), regenerate the hex via
874        //   `printf '%s' "<new bytes>" | b3sum --no-names`
875        // and update both `EXPECTED` and `FROZEN_HEX` together.
876        // Layer A item 1 escalation review required.
877        const EXPECTED: &[u8] = b"arkhe-kernel v0.13 WAL chain domain separation context";
878        assert_eq!(WalHeader::DOMAIN_CTX, EXPECTED);
879        assert_eq!(WalHeader::DOMAIN_CTX.len(), 54);
880
881        // Frozen BLAKE3 hex of the canonical bytes (regression pin).
882        const FROZEN_HEX: &str = "a2537fb224ba77e9a3d9237ae7afac2db2d3cc1f45ddb1fd9d07548e6eee6ab8";
883        let actual_hex = blake3::hash(WalHeader::DOMAIN_CTX).to_hex();
884        assert_eq!(
885            actual_hex.as_str(),
886            FROZEN_HEX,
887            "DOMAIN_CTX BLAKE3 hash regression — byte-level edit detected",
888        );
889    }
890
891    // ---- PQC envelope wire format (Layer A item 8 post-extension) ----
892
893    #[test]
894    fn wal_record_postcard_layout_byte_identity() {
895        // Pin the postcard wire-format byte sequence for an Ed25519-signed
896        // WalRecord via BLAKE3 frozen-hash regression. Any silent reorder
897        // of WalRecord fields (or insertion of new fields without updating
898        // this pin) breaks this assertion.
899        let sig_class = SignatureClass::new_ed25519_from_secret([7u8; 32]);
900        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
901        append_one(&mut w);
902        let wal = Wal::from_writer(w);
903        let encoded = postcard::to_allocvec(&wal.records[0]).expect("postcard encode");
904        const FROZEN_HEX: &str = "63655e756cf063655522dff4b8cc053019ab44846b767f67310816dcdf04d167";
905        let actual = blake3::hash(&encoded);
906        assert_eq!(
907            actual.to_hex().as_str(),
908            FROZEN_HEX,
909            "WalRecord postcard byte sequence regression",
910        );
911    }
912
913    #[test]
914    fn wal_record_hybrid_layout_byte_identity() {
915        // Pin the postcard wire-format growth for a Hybrid record's PQC
916        // signature slot (envelope sized for ML-DSA 65 signature = 3309
917        // bytes). Verifies the wire format slot accommodates PQC signature
918        // sizes exceeding Ed25519's 64-byte fixed length.
919        let sig_class = SignatureClass::new_ed25519_from_secret([19u8; 32]);
920        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
921        append_one(&mut w);
922        let mut wal = Wal::from_writer(w);
923        // Baseline: signature_pqc=None.
924        let baseline_encoded = postcard::to_allocvec(&wal.records[0]).expect("baseline encode");
925        // Inject ML-DSA 65-sized placeholder signature (3309 bytes).
926        wal.records[0].signature_pqc = Some(vec![0xAB; 3309]);
927        let with_pqc_encoded = postcard::to_allocvec(&wal.records[0]).expect("with_pqc encode");
928        // Postcard Option<Vec<u8>>: None = 0x00 (1 byte).
929        // Some(vec[3309]) = 0x01 + varint(3309) + 3309 bytes.
930        // varint(3309) = 0xED 0x19 (2 bytes).
931        // Net growth: 1 + 2 + 3309 - 1 = 3311 bytes.
932        assert_eq!(
933            with_pqc_encoded.len() - baseline_encoded.len(),
934            3311,
935            "PQC signature envelope size mismatch — ML-DSA 65 must fit",
936        );
937    }
938
939    #[test]
940    fn wal_header_verifying_key_pqc_slot_pinned() {
941        // Pin the WalHeader PQC verifying-key envelope slot. None for
942        // non-Hybrid; Some(1952B) for Hybrid (ML-DSA 65 verifying key
943        // size). Verifies the wire format slot accommodates PQC public
944        // key sizes exceeding Ed25519's 32-byte fixed length.
945        let h = WalWriter::new(world(), manifest()).header().clone();
946        // Tier 1: both verifying keys absent.
947        assert!(h.verifying_key.is_none());
948        assert!(h.verifying_key_pqc.is_none());
949
950        let mut h_pqc = h.clone();
951        // Inject ML-DSA 65-sized placeholder verifying key (1952 bytes).
952        h_pqc.verifying_key_pqc = Some(vec![0xCD; 1952]);
953        let baseline = postcard::to_allocvec(&h).expect("encode baseline");
954        let with_pqc = postcard::to_allocvec(&h_pqc).expect("encode with pqc key");
955        // Postcard Option<Vec<u8>>: None = 0x00 (1 byte).
956        // Some(vec[1952]) = 0x01 + varint(1952) + 1952 bytes.
957        // varint(1952) = 0xA0 0x0F (2 bytes).
958        // Net growth: 1 + 2 + 1952 - 1 = 1954 bytes.
959        assert_eq!(
960            with_pqc.len() - baseline.len(),
961            1954,
962            "PQC verifying-key envelope size mismatch — ML-DSA 65 must fit",
963        );
964    }
965
966    #[test]
967    fn chain_hash_unchanged_for_ed25519_records() {
968        // Layer A item 1 (DOMAIN_CTX) byte-identity preservation under
969        // the WalRecord wire format extension. WalRecordBody (10-field
970        // chain hash input) UNCHANGED — chain hash for the same body
971        // input is identical pre/post extension. Pins the resulting
972        // this_chain_hash via BLAKE3 frozen-hex regression.
973        let mut w = WalWriter::new([7u8; 32], [3u8; 32]);
974        w.append(
975            Tick(0),
976            InstanceId::new(1).unwrap(),
977            Principal::System,
978            TypeCode(100),
979            vec![1, 2, 3],
980            0,
981            sample_stage(),
982            AuthDecisionAnnotation::AllAuthorized,
983        )
984        .unwrap();
985        let wal = Wal::from_writer(w);
986        const FROZEN_HEX: &str = "52c2764721d6ab8e709f13987c78c4482e05d41fe44f9aff5a538ac61af148d4";
987        let actual_hex = blake3::Hash::from(wal.records[0].this_chain_hash).to_hex();
988        assert_eq!(
989            actual_hex.as_str(),
990            FROZEN_HEX,
991            "chain hash regression — DOMAIN_CTX or WalRecordBody field order changed",
992        );
993    }
994
995    #[test]
996    fn wal_record_postcard_field_order_baseline() {
997        // Layer A item 8 post-extension baseline pin. Pins the WalRecord
998        // postcard byte sequence for a Tier 1 (no signature) record with
999        // distinctive inputs. Any silent reorder or addition of fields
1000        // breaks this BLAKE3 hash regression pin.
1001        let mut w = WalWriter::new([7u8; 32], [3u8; 32]);
1002        w.append(
1003            Tick(42),
1004            InstanceId::new(99).unwrap(),
1005            Principal::System,
1006            TypeCode(0xCAFE),
1007            vec![0xAA, 0xBB, 0xCC],
1008            0xFF,
1009            sample_stage(),
1010            AuthDecisionAnnotation::AllAuthorized,
1011        )
1012        .unwrap();
1013        let wal = Wal::from_writer(w);
1014        let encoded = postcard::to_allocvec(&wal.records[0]).expect("postcard encode");
1015        const FROZEN_HEX: &str = "d6ffb241f7f5a277ef2402fd25184620bac8f6539eb3f853f5d3562d2ce29ad8";
1016        let actual = blake3::hash(&encoded);
1017        assert_eq!(
1018            actual.to_hex().as_str(),
1019            FROZEN_HEX,
1020            "WalRecord postcard field order regression",
1021        );
1022    }
1023
1024    // ---- PQC Hybrid (Ed25519 + ML-DSA 65) wal-side wiring ----
1025
1026    #[test]
1027    fn hybrid_writer_emits_both_signatures() {
1028        // Hybrid sig_class populates both signature (Ed25519 64 bytes)
1029        // and signature_pqc (ML-DSA 65 3309 bytes) on every record.
1030        // Header pins both verifying_key (Ed25519 32 bytes) and
1031        // verifying_key_pqc (ML-DSA 65 1952 bytes).
1032        let sig_class = SignatureClass::new_hybrid_from_secrets([7u8; 32], [11u8; 32]);
1033        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1034        for _ in 0..3 {
1035            append_one(&mut w);
1036        }
1037        let wal = Wal::from_writer(w);
1038        assert_eq!(
1039            wal.header
1040                .verifying_key
1041                .expect("Hybrid pins Ed25519 vk")
1042                .len(),
1043            32
1044        );
1045        assert_eq!(
1046            wal.header
1047                .verifying_key_pqc
1048                .as_ref()
1049                .expect("Hybrid pins PQC vk")
1050                .len(),
1051            1952
1052        );
1053        assert_eq!(wal.records.len(), 3);
1054        for rec in &wal.records {
1055            assert_eq!(
1056                rec.signature
1057                    .as_ref()
1058                    .expect("Hybrid signs Ed25519 every record")
1059                    .len(),
1060                64
1061            );
1062            assert_eq!(
1063                rec.signature_pqc
1064                    .as_ref()
1065                    .expect("Hybrid signs PQC every record")
1066                    .len(),
1067                3309
1068            );
1069        }
1070    }
1071
1072    #[test]
1073    fn hybrid_verify_chain_and_mode_passes_with_both_valid() {
1074        // AND-mode positive: both Ed25519 and ML-DSA 65 signatures
1075        // valid → verify_chain succeeds. Round-trip through serialize
1076        // confirms the on-disk shape verifies.
1077        let sig_class = SignatureClass::new_hybrid_from_secrets([13u8; 32], [17u8; 32]);
1078        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1079        for _ in 0..3 {
1080            append_one(&mut w);
1081        }
1082        let wal = Wal::from_writer(w);
1083        let bytes = wal.serialize().unwrap();
1084        let back = Wal::deserialize(&bytes).unwrap();
1085        back.verify_chain(world())
1086            .expect("Hybrid signed chain verifies (AND-mode pass)");
1087    }
1088
1089    #[test]
1090    fn hybrid_verify_chain_rejects_missing_pqc() {
1091        // Strict (write-side): Hybrid envelope (header pins PQC vk) +
1092        // record without signature_pqc → MissingPqcSignature.
1093        let sig_class = SignatureClass::new_hybrid_from_secrets([19u8; 32], [23u8; 32]);
1094        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1095        append_one(&mut w);
1096        let mut wal = Wal::from_writer(w);
1097        // Header still pins PQC vk, but the record claims no PQC sig.
1098        wal.records[0].signature_pqc = None;
1099        let result = wal.verify_chain(world());
1100        assert!(matches!(
1101            result,
1102            Err(WalError::MissingPqcSignature { at_record: 0 })
1103        ));
1104    }
1105
1106    #[test]
1107    fn hybrid_verify_chain_rejects_corrupt_pqc_signature() {
1108        // Hybrid with valid Ed25519 + corrupt PQC signature →
1109        // verify_hybrid Ed25519 phase passes, PQC phase fails →
1110        // PqcSignatureMismatch.
1111        let sig_class = SignatureClass::new_hybrid_from_secrets([29u8; 32], [31u8; 32]);
1112        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1113        append_one(&mut w);
1114        append_one(&mut w);
1115        let mut wal = Wal::from_writer(w);
1116        // Flip a byte inside record[1]'s PQC signature.
1117        if let Some(sig_pqc) = wal.records[1].signature_pqc.as_mut() {
1118            sig_pqc[0] ^= 0xFF;
1119        }
1120        let result = wal.verify_chain(world());
1121        assert!(matches!(
1122            result,
1123            Err(WalError::PqcSignatureMismatch { at_record: 1 })
1124        ));
1125    }
1126
1127    #[test]
1128    fn hybrid_verify_chain_rejects_corrupt_ed25519_when_pqc_valid() {
1129        // AND-mode short-circuit: corrupt Ed25519 signature with valid
1130        // PQC signature → verify_hybrid Ed25519 phase fails first →
1131        // PqcSignatureMismatch (uniform AND-mode failure error — any
1132        // Hybrid signature failure maps to PqcSignatureMismatch at the
1133        // WalError surface).
1134        let sig_class = SignatureClass::new_hybrid_from_secrets([37u8; 32], [41u8; 32]);
1135        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1136        append_one(&mut w);
1137        append_one(&mut w);
1138        let mut wal = Wal::from_writer(w);
1139        // Flip a byte inside record[0]'s Ed25519 signature; PQC stays valid.
1140        if let Some(sig) = wal.records[0].signature.as_mut() {
1141            sig[0] ^= 0xFF;
1142        }
1143        let result = wal.verify_chain(world());
1144        assert!(matches!(
1145            result,
1146            Err(WalError::PqcSignatureMismatch { at_record: 0 })
1147        ));
1148    }
1149
1150    #[test]
1151    fn ed25519_only_wal_replays_under_hybrid_kernel() {
1152        // Backward-compat (read-side): Ed25519-only WAL bytes
1153        // (verifying_key_pqc=None, signature_pqc=None per record)
1154        // replay under a PQC-Hybrid-capable kernel via the
1155        // (Some, None) → VerifierClass::Ed25519 envelope-derived
1156        // dispatch arm. Strict mode applies write-side only.
1157        let sig_class = SignatureClass::new_ed25519_from_secret([43u8; 32]);
1158        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1159        for _ in 0..3 {
1160            append_one(&mut w);
1161        }
1162        let wal = Wal::from_writer(w);
1163        // Ed25519-only envelope: Ed25519 vk pinned, no PQC vk; records
1164        // have signature but no signature_pqc.
1165        assert!(wal.header.verifying_key.is_some());
1166        assert!(wal.header.verifying_key_pqc.is_none());
1167        for rec in &wal.records {
1168            assert!(rec.signature.is_some());
1169            assert!(rec.signature_pqc.is_none());
1170        }
1171        let bytes = wal.serialize().unwrap();
1172        let back = Wal::deserialize(&bytes).unwrap();
1173        back.verify_chain(world())
1174            .expect("Ed25519-only WAL replays under Hybrid-capable kernel");
1175    }
1176
1177    #[test]
1178    fn pqc_without_ed25519_envelope_rejected() {
1179        // Envelope-level invariant: verifying_key=None +
1180        // verifying_key_pqc=Some → invalid envelope. Ed25519 is the
1181        // chain-anchor companion; PQC-only envelope is rejected.
1182        // VerifierClass::from_header_bytes returns
1183        // VerifierInitError::PqcWithoutEd25519 → wal.rs caller maps to
1184        // WalError::PqcWithoutEd25519.
1185        let sig_class = SignatureClass::new_hybrid_from_secrets([47u8; 32], [53u8; 32]);
1186        let w = WalWriter::with_signature(world(), manifest(), sig_class);
1187        let mut wal = Wal::from_writer(w);
1188        // Strip the Ed25519 verifying-key while leaving PQC vk in place
1189        // → (None, Some) invalid envelope.
1190        wal.header.verifying_key = None;
1191        let result = wal.verify_chain(world());
1192        assert!(matches!(result, Err(WalError::PqcWithoutEd25519)));
1193    }
1194}