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::state::ScheduledActionId;
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, 15, 0);
72    /// ABI semver pinned by [`WalWriter::new`].
73    pub const ABI_VERSION: (u16, u16) = (0, 15);
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.15" anchor pins the chain
80    /// epoch; it advances with a release whose persisted wire format
81    /// changes. The v0.15 epoch restructures `WalRecordBody` into the
82    /// Canonical Input Log (Submit/Step records, effects re-derived on
83    /// replay), so the chain key rederives and all prior-epoch (v0.14)
84    /// WAL chains are not replayable under v0.15 (Layer A item 1
85    /// byte-identity invariant — A1/A14; forward-only, pre-public).
86    pub const DOMAIN_CTX: &'static [u8] = b"arkhe-kernel v0.15 WAL chain domain separation context";
87}
88
89/// Which fact a [`WalRecord`] captures under the Canonical Input Log
90/// model: an exogenous external submission, or a per-pop step verdict.
91/// The kind is the serde variant tag — the first byte of the hashed body.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum WalRecordKind {
94    /// An external action was admitted into an instance's scheduler.
95    Submit,
96    /// One scheduled action was popped and executed (or denied/skipped).
97    Step,
98}
99
100/// Outcome of a single `step_one` pop, recorded on a `Step` record. Replay
101/// re-reaches the same verdict by re-execution; a mismatch fails fast.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103pub enum StepVerdict {
104    /// Every Op authorized and the stage committed.
105    Committed,
106    /// An Op failed authorization; the whole stage rolled back.
107    AuthDenied,
108    /// Authorized, but per-Op budget/quota gates skipped `denied` Ops
109    /// (partial commit — the committed siblings still applied).
110    BudgetPartial {
111        /// Number of Ops the per-Op gates skipped this step.
112        denied: u32,
113    },
114    /// The popped action did not run (unregistered type or undeserializable
115    /// bytes). No state changed.
116    Skipped {
117        /// Why the action was skipped.
118        reason: SkipReason,
119    },
120}
121
122/// Why a popped action produced no execution (a `Skipped` verdict).
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124pub enum SkipReason {
125    /// No `ActionRegistry` entry for the action's `TypeCode`.
126    Unregistered,
127    /// The action bytes failed to deserialize under the registered schema.
128    DeserFailed,
129}
130
131/// Kind-discriminated canonical content of a WAL record. The serde variant
132/// tag is the record [`WalRecordKind`] and the first hashed body field. The
133/// CIL records only non-reproducible facts: exogenous submissions and
134/// per-step verdicts + post-state digest — every deterministic effect
135/// (child schedules, signal routing, internal ids) is re-derived on replay.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub enum WalRecordContent {
138    /// Exogenous admission of an external action — the only non-reproducible
139    /// scheduling input (internal `Op::ScheduleAction` schedules are
140    /// re-derived by re-executing the parent, never logged).
141    Submit {
142        /// Monotonic record sequence within this WAL.
143        seq: u64,
144        /// Instance the action was submitted to.
145        instance: InstanceId,
146        /// Principal the external caller submitted under.
147        principal: Principal,
148        /// Submitting entity, if any (feeds `ActionContext::actor`, so it is
149        /// canonical input and chain-hashed).
150        actor: Option<EntityId>,
151        /// Capability ceiling granted to this submission — bounds the
152        /// action's effective caps at execution (replay reconstructs it).
153        caps_at_submit: u64,
154        /// Tick the action is scheduled for.
155        at: Tick,
156        /// Type code of the submitted action.
157        action_type_code: TypeCode,
158        /// Canonical action bytes (replay deserializes from these).
159        action_bytes: Vec<u8>,
160        /// ScheduledActionId the kernel minted — replay re-injects with this
161        /// exact id so the id sequence is reproduced verbatim.
162        allocated_id: ScheduledActionId,
163    },
164    /// One `step_one` pop: which entry ran, when, under what operator session
165    /// ceiling, with what verdict, and the full-state digest afterward (the
166    /// bit-identity witness).
167    Step {
168        /// Monotonic record sequence within this WAL.
169        seq: u64,
170        /// Instance the step ran against.
171        instance: InstanceId,
172        /// ScheduledActionId popped this step (scheduler-order witness).
173        popped_id: ScheduledActionId,
174        /// Tick the step ran at.
175        now: Tick,
176        /// Operator session capability ceiling in force at step time — the
177        /// final intersection applied over the action's resolved caps. It is
178        /// a non-reproducible per-step operator input (like `caps_at_submit`
179        /// is per submission), so it is recorded for the verdict to be
180        /// re-derivable on replay.
181        session_caps: u64,
182        /// Step outcome — replay must re-reach this exact verdict.
183        verdict: StepVerdict,
184        /// BLAKE3 digest of the instance's full post-step state; replay
185        /// measures the replayed instance and asserts equality (A1).
186        post_state_digest: [u8; 32],
187    },
188}
189
190/// Single record in the WAL chain (Canonical Input Log). Carries the
191/// kind-discriminated [`WalRecordContent`] plus the chain/signature fields
192/// common to both kinds.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct WalRecord {
195    /// Kind-discriminated canonical content (its variant tag is the kind).
196    pub content: WalRecordContent,
197    /// Previous record's `this_chain_hash` (or zero for record 0).
198    pub prev_chain_hash: [u8; 32],
199    /// `blake3::keyed(chain_key, prev_chain_hash || canonical(body))`.
200    pub this_chain_hash: [u8; 32],
201    /// Ed25519 signature over the canonical body bytes (same bytes hashed
202    /// into `this_chain_hash`). `None` for Tier 1. Stored as `Vec<u8>`
203    /// (64 bytes when present) per the serde 32-byte array-deserializer cap.
204    pub signature: Option<Vec<u8>>,
205    /// PQC signature bytes for Hybrid mode (3309 bytes for ML-DSA 65 when
206    /// present); `None` otherwise.
207    pub signature_pqc: Option<Vec<u8>>,
208}
209
210impl WalRecord {
211    /// This record's monotonic sequence (kind-agnostic).
212    pub fn seq(&self) -> u64 {
213        match &self.content {
214            WalRecordContent::Submit { seq, .. } | WalRecordContent::Step { seq, .. } => *seq,
215        }
216    }
217
218    /// This record's [`WalRecordKind`].
219    pub fn kind(&self) -> WalRecordKind {
220        match &self.content {
221            WalRecordContent::Submit { .. } => WalRecordKind::Submit,
222            WalRecordContent::Step { .. } => WalRecordKind::Step,
223        }
224    }
225}
226
227/// Borrowed canonical body — the bytes hashed into `this_chain_hash` and
228/// signed. The serde variant tag of `content` is the record kind (first
229/// hashed field); `prev_chain_hash` is folded in so reordering records
230/// breaks the chain.
231#[derive(Serialize)]
232struct WalRecordBody<'a> {
233    content: WalRecordBodyContent<'a>,
234    prev_chain_hash: [u8; 32],
235}
236
237#[derive(Serialize)]
238enum WalRecordBodyContent<'a> {
239    Submit {
240        seq: u64,
241        instance: InstanceId,
242        principal: &'a Principal,
243        actor: Option<EntityId>,
244        caps_at_submit: u64,
245        at: Tick,
246        action_type_code: TypeCode,
247        action_bytes: &'a [u8],
248        allocated_id: ScheduledActionId,
249    },
250    Step {
251        seq: u64,
252        instance: InstanceId,
253        popped_id: ScheduledActionId,
254        now: Tick,
255        session_caps: u64,
256        verdict: StepVerdict,
257        post_state_digest: [u8; 32],
258    },
259}
260
261impl<'a> WalRecordBody<'a> {
262    /// Reconstruct the canonical body view from a stored `WalRecord` plus
263    /// the running `prev_chain_hash` (used by `verify_chain`).
264    fn from_record(rec: &'a WalRecord, prev: [u8; 32]) -> Self {
265        Self::from_content(&rec.content, prev)
266    }
267
268    /// Borrow a [`WalRecordContent`] as the canonical body view (the bytes
269    /// hashed + signed), folding in `prev` so reordering breaks the chain.
270    /// Used both on append (seal the new record) and on verify.
271    fn from_content(content_ref: &'a WalRecordContent, prev: [u8; 32]) -> Self {
272        let content = match content_ref {
273            WalRecordContent::Submit {
274                seq,
275                instance,
276                principal,
277                actor,
278                caps_at_submit,
279                at,
280                action_type_code,
281                action_bytes,
282                allocated_id,
283            } => WalRecordBodyContent::Submit {
284                seq: *seq,
285                instance: *instance,
286                principal,
287                actor: *actor,
288                caps_at_submit: *caps_at_submit,
289                at: *at,
290                action_type_code: *action_type_code,
291                action_bytes,
292                allocated_id: *allocated_id,
293            },
294            WalRecordContent::Step {
295                seq,
296                instance,
297                popped_id,
298                now,
299                session_caps,
300                verdict,
301                post_state_digest,
302            } => WalRecordBodyContent::Step {
303                seq: *seq,
304                instance: *instance,
305                popped_id: *popped_id,
306                now: *now,
307                session_caps: *session_caps,
308                verdict: *verdict,
309                post_state_digest: *post_state_digest,
310            },
311        };
312        Self {
313            content,
314            prev_chain_hash: prev,
315        }
316    }
317}
318
319/// Sealed WAL — the durable read-side counterpart to [`WalWriter`].
320/// Produced by [`Wal::from_writer`] or [`Wal::deserialize`]; consumed
321/// by [`Wal::verify_chain`] / [`replay_into`](super::replay::replay_into).
322#[derive(Debug, Serialize, Deserialize)]
323pub struct Wal {
324    /// Header pinned at writer construction.
325    pub header: WalHeader,
326    /// Records in append order.
327    pub records: Vec<WalRecord>,
328}
329
330/// Signature strength tier advertised by a WAL header, ordered
331/// `None < Ed25519 < Hybrid`. Compared against [`TrustAnchor::min_tier`]
332/// to reject a downgrade.
333#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
334pub enum SignatureTier {
335    /// No signatures (chain integrity only).
336    None,
337    /// RFC 8032 Ed25519.
338    Ed25519,
339    /// Hybrid Ed25519 + ML-DSA 65.
340    Hybrid,
341}
342
343/// Operator-supplied trust anchor for authenticated WAL verification
344/// ([`Wal::verify_chain_anchored`]).
345///
346/// [`Wal::verify_chain`] derives its verification policy and keys from the
347/// WAL header. Under the threat model where WAL bytes are attacker-
348/// controlled (a tampered on-disk log or a malicious peer's snapshot),
349/// that lets an attacker downgrade the tier to `None` (skipping all
350/// signature checks) or substitute their own keys and re-sign the whole
351/// chain. A `TrustAnchor` closes that gap: the caller pins — out-of-band —
352/// the minimum tier and the exact verifying key(s) it trusts. The kernel
353/// provides the MECHANISM (compare the header against the anchor; reject
354/// downgrade / substitution / truncation); the caller owns the POLICY
355/// (which key and tier to require). Unset (`None`) fields impose no check.
356#[derive(Debug, Clone, Default)]
357pub struct TrustAnchor {
358    /// Minimum acceptable signature tier. A header weaker than this is
359    /// rejected with [`WalError::TierDowngrade`].
360    pub min_tier: Option<SignatureTier>,
361    /// Expected Ed25519 verifying-key bytes; the header's pinned key must
362    /// equal this (else [`WalError::VerifyingKeyMismatch`]).
363    pub ed25519_verifying_key: Option<[u8; 32]>,
364    /// Expected ML-DSA 65 verifying-key bytes (Hybrid); the header's
365    /// pinned PQC key must equal this.
366    pub mldsa_verifying_key: Option<Vec<u8>>,
367    /// Expected `manifest_digest` (A14); rejects a WAL written under a
368    /// different `ModuleManifest`.
369    pub expected_manifest_digest: Option<[u8; 32]>,
370    /// Expected final chain tip; pins the record count so a tail
371    /// truncation (a chain-consistent prefix) is detected.
372    pub expected_chain_tip: Option<[u8; 32]>,
373}
374
375/// Append-only WAL writer. Each successful `Kernel::step` writes one
376/// [`WalRecord`] via the kernel's internal append path.
377pub struct WalWriter {
378    header: WalHeader,
379    records: Vec<WalRecord>,
380    next_seq: u64,
381    prev_hash: [u8; 32],
382    chain_key: [u8; 32],
383    sig_class: SignatureClass,
384}
385
386/// `seal` output: `(this_chain_hash, ed25519_signature, pqc_signature)`. The
387/// two signature slots are `None` for Tier 1 (chain-only).
388type SealedRecordParts = ([u8; 32], Option<Vec<u8>>, Option<Vec<u8>>);
389
390fn build_chain_key(world_id: &[u8; 32]) -> [u8; 32] {
391    let ctx = core::str::from_utf8(WalHeader::DOMAIN_CTX).expect("DOMAIN_CTX is valid UTF-8 ASCII");
392    blake3::derive_key(ctx, world_id)
393}
394
395fn build_dsc() -> Vec<u8> {
396    WalHeader::DOMAIN_CTX.to_vec()
397}
398
399impl WalWriter {
400    /// Construct a chain-only writer (Tier 1 — no signature).
401    pub fn new(world_id: [u8; 32], manifest_digest: [u8; 32]) -> Self {
402        Self::with_signature(world_id, manifest_digest, SignatureClass::None)
403    }
404
405    /// Construct a writer that signs each record under `sig_class`. The
406    /// verifying key is pinned in the header so post-hoc verification
407    /// works against the WAL bytes alone.
408    pub fn with_signature(
409        world_id: [u8; 32],
410        manifest_digest: [u8; 32],
411        sig_class: SignatureClass,
412    ) -> Self {
413        let chain_key = build_chain_key(&world_id);
414        let header = WalHeader {
415            magic: WalHeader::MAGIC,
416            kernel_semver: WalHeader::CURRENT_KERNEL_SEMVER,
417            postcard_version: WalHeader::POSTCARD_MAJOR,
418            blake3_version: WalHeader::BLAKE3_MAJOR,
419            domain_separation_context: build_dsc(),
420            world_id,
421            abi_version: WalHeader::ABI_VERSION,
422            manifest_digest,
423            type_registry_pins: Vec::new(),
424            verifying_key: sig_class.verifying_key_bytes(),
425            verifying_key_pqc: sig_class.verifying_key_pqc_bytes(),
426        };
427        Self {
428            header,
429            records: Vec::new(),
430            next_seq: 0,
431            prev_hash: [0u8; 32],
432            chain_key,
433            sig_class,
434        }
435    }
436
437    /// Reconstruct a measurement-only writer from a sealed header: same
438    /// `world_id`-derived chain key, fresh `prev_hash`, no signing. Replay
439    /// uses this to RE-MEASURE the chain tip from the same inputs/verdicts
440    /// (the chain hash is over the body bytes, which exclude the signature,
441    /// so a None-signing rebuild reproduces every `this_chain_hash`).
442    pub(crate) fn rebuild_from_header(header: &WalHeader) -> Self {
443        let chain_key = build_chain_key(&header.world_id);
444        Self {
445            header: header.clone(),
446            records: Vec::new(),
447            next_seq: 0,
448            prev_hash: [0u8; 32],
449            chain_key,
450            sig_class: SignatureClass::None,
451        }
452    }
453
454    /// Hash + sign a borrowed body, returning `(this_chain_hash, sig, pqc)`.
455    /// The hash is `blake3::keyed(chain_key, prev_hash || canonical(body))`;
456    /// signatures (if any) cover the same body bytes.
457    fn seal(&self, body: &WalRecordBody) -> Result<SealedRecordParts, WalError> {
458        let body_bytes =
459            postcard::to_allocvec(body).map_err(|e| WalError::SerializeFailed(format!("{}", e)))?;
460        let mut hasher = blake3::Hasher::new_keyed(&self.chain_key);
461        hasher.update(&self.prev_hash);
462        hasher.update(&body_bytes);
463        let this_hash: [u8; 32] = *hasher.finalize().as_bytes();
464        // Tier 1 (None) leaves both `None`. Hybrid emits paired
465        // Ed25519 + ML-DSA 65 signatures via `sign_hybrid`.
466        let (signature, signature_pqc) = match self.sig_class.sign_hybrid(&body_bytes) {
467            Some(hyb) => (Some(hyb.ed25519.to_vec()), Some(hyb.pqc)),
468            None => (self.sig_class.sign(&body_bytes).map(|s| s.to_vec()), None),
469        };
470        Ok((this_hash, signature, signature_pqc))
471    }
472
473    fn push_sealed(&mut self, content: WalRecordContent) -> Result<&WalRecord, WalError> {
474        let prev = self.prev_hash;
475        // Seal a borrowed body view of `content`; the borrow ends before
476        // `content` is moved into the record below (NLL).
477        let (this_hash, signature, signature_pqc) = {
478            let body = WalRecordBody::from_content(&content, prev);
479            self.seal(&body)?
480        };
481        let record = WalRecord {
482            content,
483            prev_chain_hash: prev,
484            this_chain_hash: this_hash,
485            signature,
486            signature_pqc,
487        };
488        self.records.push(record);
489        self.prev_hash = this_hash;
490        Ok(self.records.last().expect("just pushed"))
491    }
492
493    /// Append a `Submit` record — the exogenous admission of an external
494    /// action (with the ScheduledActionId the kernel minted for it).
495    #[allow(clippy::too_many_arguments)]
496    pub(crate) fn append_submit(
497        &mut self,
498        instance: InstanceId,
499        principal: Principal,
500        actor: Option<EntityId>,
501        caps_at_submit: u64,
502        at: Tick,
503        action_type_code: TypeCode,
504        action_bytes: Vec<u8>,
505        allocated_id: ScheduledActionId,
506    ) -> Result<&WalRecord, WalError> {
507        self.next_seq = self.next_seq.saturating_add(1);
508        let seq = self.next_seq;
509        let content = WalRecordContent::Submit {
510            seq,
511            instance,
512            principal,
513            actor,
514            caps_at_submit,
515            at,
516            action_type_code,
517            action_bytes,
518            allocated_id,
519        };
520        self.push_sealed(content)
521    }
522
523    /// Append a `Step` record — one `step_one` pop's verdict + the full
524    /// post-step state digest, under the operator session ceiling in force.
525    pub(crate) fn append_step(
526        &mut self,
527        instance: InstanceId,
528        popped_id: ScheduledActionId,
529        now: Tick,
530        session_caps: u64,
531        verdict: StepVerdict,
532        post_state_digest: [u8; 32],
533    ) -> Result<&WalRecord, WalError> {
534        self.next_seq = self.next_seq.saturating_add(1);
535        let seq = self.next_seq;
536        let content = WalRecordContent::Step {
537            seq,
538            instance,
539            popped_id,
540            now,
541            session_caps,
542            verdict,
543            post_state_digest,
544        };
545        self.push_sealed(content)
546    }
547
548    /// Pinned WAL header.
549    pub fn header(&self) -> &WalHeader {
550        &self.header
551    }
552    /// All records appended so far, in append order.
553    pub fn records(&self) -> &[WalRecord] {
554        &self.records
555    }
556    /// Most recent record's `this_chain_hash`, or zero if empty.
557    pub fn chain_tip(&self) -> [u8; 32] {
558        self.prev_hash
559    }
560    /// Number of records currently buffered.
561    pub fn record_count(&self) -> usize {
562        self.records.len()
563    }
564}
565
566impl Wal {
567    /// Seal a [`WalWriter`] into a read-only [`Wal`].
568    pub fn from_writer(w: WalWriter) -> Self {
569        Self {
570            header: w.header,
571            records: w.records,
572        }
573    }
574
575    /// Encode the entire WAL (header + records) as canonical postcard
576    /// bytes.
577    pub fn serialize(&self) -> Result<Vec<u8>, WalError> {
578        postcard::to_allocvec(self).map_err(|e| WalError::SerializeFailed(format!("{}", e)))
579    }
580
581    /// Decode bytes produced by [`serialize`](Wal::serialize).
582    pub fn deserialize(bytes: &[u8]) -> Result<Self, WalError> {
583        postcard::from_bytes(bytes).map_err(|e| WalError::DeserializeFailed(format!("{}", e)))
584    }
585
586    /// Most recent record's `this_chain_hash`, or zero if empty.
587    pub fn chain_tip(&self) -> [u8; 32] {
588        self.records
589            .last()
590            .map(|r| r.this_chain_hash)
591            .unwrap_or([0u8; 32])
592    }
593
594    /// Verify every record's chain hash against the keyed BLAKE3 over
595    /// (prev_chain_hash || canonical body). When the header pins a
596    /// `verifying_key` (Tier 2 — Ed25519), each record's signature
597    /// is also checked against the same body bytes. Returns `Ok` if
598    /// every check passes.
599    pub fn verify_chain(&self, world_id: [u8; 32]) -> Result<(), WalError> {
600        let chain_key = build_chain_key(&world_id);
601        let verifier = VerifierClass::from_header_bytes(
602            self.header.verifying_key.as_ref(),
603            self.header.verifying_key_pqc.as_deref(),
604        )
605        .map_err(|e| match e {
606            crate::persist::signature::VerifierInitError::InvalidEd25519Key
607            | crate::persist::signature::VerifierInitError::InvalidPqcKey => {
608                WalError::InvalidVerifyingKey
609            }
610            crate::persist::signature::VerifierInitError::PqcWithoutEd25519 => {
611                WalError::PqcWithoutEd25519
612            }
613        })?;
614        let mut prev = [0u8; 32];
615        for (i, rec) in self.records.iter().enumerate() {
616            if blake3::Hash::from(rec.prev_chain_hash) != blake3::Hash::from(prev) {
617                return Err(WalError::ChainBroken { at_record: i });
618            }
619            let body = WalRecordBody::from_record(rec, prev);
620            let body_bytes = postcard::to_allocvec(&body)
621                .map_err(|e| WalError::SerializeFailed(format!("{}", e)))?;
622            let mut hasher = blake3::Hasher::new_keyed(&chain_key);
623            hasher.update(&prev);
624            hasher.update(&body_bytes);
625            let computed: [u8; 32] = *hasher.finalize().as_bytes();
626            if blake3::Hash::from(computed) != blake3::Hash::from(rec.this_chain_hash) {
627                return Err(WalError::HashMismatch { at_record: i });
628            }
629
630            match &verifier {
631                VerifierClass::None => {}
632                VerifierClass::Ed25519(_) => {
633                    let sig_vec = rec
634                        .signature
635                        .as_ref()
636                        .ok_or(WalError::MissingSignature { at_record: i })?;
637                    verifier
638                        .verify(&body_bytes, sig_vec)
639                        .map_err(|_| WalError::SignatureMismatch { at_record: i })?;
640                }
641                VerifierClass::Hybrid { .. } => {
642                    let sig_vec = rec
643                        .signature
644                        .as_ref()
645                        .ok_or(WalError::MissingSignature { at_record: i })?;
646                    let sig_pqc = rec
647                        .signature_pqc
648                        .as_ref()
649                        .ok_or(WalError::MissingPqcSignature { at_record: i })?;
650                    verifier
651                        .verify_hybrid(&body_bytes, sig_vec, sig_pqc)
652                        .map_err(|_| WalError::PqcSignatureMismatch { at_record: i })?;
653                }
654            }
655
656            prev = computed;
657        }
658        Ok(())
659    }
660
661    /// The signature tier the header advertises, derived from which
662    /// verifying-key slots are populated. `(None, Some)` is the invalid
663    /// PQC-without-Ed25519 envelope.
664    fn header_tier(&self) -> Result<SignatureTier, WalError> {
665        match (
666            self.header.verifying_key.is_some(),
667            self.header.verifying_key_pqc.is_some(),
668        ) {
669            (false, false) => Ok(SignatureTier::None),
670            (true, false) => Ok(SignatureTier::Ed25519),
671            (true, true) => Ok(SignatureTier::Hybrid),
672            (false, true) => Err(WalError::PqcWithoutEd25519),
673        }
674    }
675
676    /// Verify the chain AND authenticate it against a caller-supplied
677    /// [`TrustAnchor`]. Use this when the WAL bytes are untrusted (a
678    /// tampered log or a peer's snapshot): beyond [`Self::verify_chain`]'s
679    /// integrity checks it rejects a tier downgrade below `anchor
680    /// .min_tier`, a verifying-key substitution (header key != the
681    /// anchored key), a `manifest_digest` mismatch, and a tail truncation
682    /// (tip != the anchored `expected_chain_tip`). `world_id` is supplied
683    /// by the caller out-of-band — NOT read from the (untrusted) header.
684    pub fn verify_chain_anchored(
685        &self,
686        world_id: [u8; 32],
687        anchor: &TrustAnchor,
688    ) -> Result<(), WalError> {
689        // (1) Tier floor — reject a downgrade (e.g. header stripped to None).
690        let tier = self.header_tier()?;
691        if let Some(floor) = anchor.min_tier {
692            if tier < floor {
693                return Err(WalError::TierDowngrade);
694            }
695        }
696        // (2) Key pinning — the header's keys must equal the anchored keys,
697        // so an attacker cannot substitute their own keypair and re-sign.
698        if let Some(expected) = anchor.ed25519_verifying_key {
699            match self.header.verifying_key {
700                Some(k) if k == expected => {}
701                _ => return Err(WalError::VerifyingKeyMismatch),
702            }
703        }
704        if let Some(expected) = anchor.mldsa_verifying_key.as_deref() {
705            match self.header.verifying_key_pqc.as_deref() {
706                Some(k) if k == expected => {}
707                _ => return Err(WalError::VerifyingKeyMismatch),
708            }
709        }
710        // (3) Manifest pinning (A14).
711        if let Some(expected) = anchor.expected_manifest_digest {
712            if self.header.manifest_digest != expected {
713                return Err(WalError::ManifestDigestMismatch);
714            }
715        }
716        // (4) Integrity + signature verification over the caller's world_id.
717        self.verify_chain(world_id)?;
718        // (5) Tail-truncation — the verified tip must equal the anchored tip.
719        if let Some(expected_tip) = anchor.expected_chain_tip {
720            if self.chain_tip() != expected_tip {
721                return Err(WalError::ChainTipMismatch);
722            }
723        }
724        Ok(())
725    }
726}
727
728/// WAL operation failures. `#[non_exhaustive]` — adding variants is
729/// not a breaking change for external matchers.
730#[derive(Debug, Clone)]
731#[non_exhaustive]
732pub enum WalError {
733    /// Postcard refused to encode (carries the upstream message).
734    SerializeFailed(String),
735    /// Postcard refused to decode (carries the upstream message).
736    DeserializeFailed(String),
737    /// Record `at_record`'s `prev_chain_hash` doesn't match the running
738    /// expected hash from the previous record.
739    ChainBroken {
740        /// Index of the offending record.
741        at_record: usize,
742    },
743    /// Record `at_record`'s `this_chain_hash` doesn't match the
744    /// recomputed BLAKE3 keyed hash.
745    HashMismatch {
746        /// Index of the offending record.
747        at_record: usize,
748    },
749    /// Header pinning rejected (semver / abi / world / manifest mismatch).
750    HeaderIncompatible(String),
751    /// Header pins a `verifying_key` (Ed25519 or PQC) that fails to parse.
752    InvalidVerifyingKey,
753    /// Header pins a `verifying_key` but a record carries no signature.
754    MissingSignature {
755        /// Index of the offending record.
756        at_record: usize,
757    },
758    /// Signature does not validate against the header's verifying key.
759    SignatureMismatch {
760        /// Index of the offending record.
761        at_record: usize,
762    },
763    /// Header pins a Hybrid envelope (`verifying_key_pqc=Some`) but a
764    /// record carries no PQC signature (`signature_pqc=None`).
765    MissingPqcSignature {
766        /// Index of the offending record.
767        at_record: usize,
768    },
769    /// PQC signature does not validate against the header's PQC
770    /// verifying key (Hybrid AND-mode failure).
771    PqcSignatureMismatch {
772        /// Index of the offending record.
773        at_record: usize,
774    },
775    /// Invalid Hybrid envelope — `verifying_key_pqc=Some` without
776    /// `verifying_key=Some`. Ed25519 is the chain-anchor companion;
777    /// PQC-only envelope is rejected.
778    PqcWithoutEd25519,
779    /// The header advertises a signature tier weaker than the caller's
780    /// [`TrustAnchor::min_tier`] — a downgrade attempt (e.g. a header
781    /// stripped to `None` to skip all signature verification).
782    TierDowngrade,
783    /// The header's pinned verifying key does not equal the key the
784    /// caller's [`TrustAnchor`] expects — a key-substitution attempt.
785    VerifyingKeyMismatch,
786    /// The header's `manifest_digest` does not equal the caller's expected
787    /// digest (A14 manifest pinning under a [`TrustAnchor`]).
788    ManifestDigestMismatch,
789    /// The verified chain tip does not equal the caller's expected tip —
790    /// a tail truncation (a chain-consistent prefix) was detected.
791    ChainTipMismatch,
792}
793
794impl core::fmt::Display for WalError {
795    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
796        match self {
797            Self::SerializeFailed(m) => write!(f, "wal serialize failed: {}", m),
798            Self::DeserializeFailed(m) => write!(f, "wal deserialize failed: {}", m),
799            Self::ChainBroken { at_record } => {
800                write!(f, "wal chain broken at record {}", at_record)
801            }
802            Self::HashMismatch { at_record } => {
803                write!(f, "wal hash mismatch at record {}", at_record)
804            }
805            Self::HeaderIncompatible(m) => write!(f, "wal header incompatible: {}", m),
806            Self::InvalidVerifyingKey => write!(
807                f,
808                "wal verifying_key invalid (not a valid Ed25519 public key)"
809            ),
810            Self::MissingSignature { at_record } => {
811                write!(f, "wal signature missing at record {}", at_record)
812            }
813            Self::SignatureMismatch { at_record } => {
814                write!(f, "wal signature mismatch at record {}", at_record)
815            }
816            Self::MissingPqcSignature { at_record } => {
817                write!(f, "wal PQC signature missing at record {}", at_record)
818            }
819            Self::PqcSignatureMismatch { at_record } => {
820                write!(f, "wal PQC signature mismatch at record {}", at_record)
821            }
822            Self::PqcWithoutEd25519 => write!(
823                f,
824                "wal envelope invalid (verifying_key_pqc set without verifying_key)"
825            ),
826            Self::TierDowngrade => write!(
827                f,
828                "wal signature tier downgraded below the trust anchor's minimum"
829            ),
830            Self::VerifyingKeyMismatch => write!(
831                f,
832                "wal verifying key does not match the trust anchor's expected key"
833            ),
834            Self::ManifestDigestMismatch => {
835                write!(f, "wal manifest_digest does not match the expected digest")
836            }
837            Self::ChainTipMismatch => {
838                write!(f, "wal chain tip does not match the expected tip (tail truncation?)")
839            }
840        }
841    }
842}
843
844impl std::error::Error for WalError {}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849    use crate::abi::{EntityId, ExternalId, InstanceId, Principal, RouteId, Tick, TypeCode};
850    use crate::state::ScheduledActionId;
851
852    fn world() -> [u8; 32] {
853        [7u8; 32]
854    }
855    fn manifest() -> [u8; 32] {
856        [3u8; 32]
857    }
858    fn sid(n: u64) -> ScheduledActionId {
859        ScheduledActionId::new(n).unwrap()
860    }
861
862    /// Append one canonical `Submit` record with fixed inputs.
863    fn submit_one(w: &mut WalWriter) {
864        w.append_submit(
865            InstanceId::new(1).unwrap(),
866            Principal::System,
867            None,
868            0xFF,
869            Tick(0),
870            TypeCode(100),
871            vec![1, 2, 3],
872            sid(1),
873        )
874        .unwrap();
875    }
876
877    /// Append one canonical `Step` record; `n` varies the body so a multi-record
878    /// chain has distinct record bodies.
879    fn step_n(w: &mut WalWriter, n: u8) {
880        w.append_step(
881            InstanceId::new(1).unwrap(),
882            sid(1),
883            Tick(n as u64),
884            0xFF,
885            StepVerdict::Committed,
886            [n; 32],
887        )
888        .unwrap();
889    }
890
891    /// One record used by the signature tests (a `Step`).
892    fn append_one(w: &mut WalWriter) {
893        step_n(w, 1);
894    }
895
896    #[test]
897    fn empty_writer_serializes_and_deserializes() {
898        let w = WalWriter::new(world(), manifest());
899        let wal = Wal::from_writer(w);
900        let bytes = wal.serialize().unwrap();
901        let back = Wal::deserialize(&bytes).unwrap();
902        assert_eq!(back.header, wal.header);
903        assert_eq!(back.records.len(), 0);
904        assert_eq!(back.chain_tip(), [0u8; 32]);
905    }
906
907    #[test]
908    fn single_step_append_produces_nonzero_chain_tip() {
909        let mut w = WalWriter::new(world(), manifest());
910        append_one(&mut w);
911        let tip = w.chain_tip();
912        assert_ne!(tip, [0u8; 32]);
913        assert_eq!(w.record_count(), 1);
914    }
915
916    #[test]
917    fn multi_record_chain_links_each_record() {
918        let mut w = WalWriter::new(world(), manifest());
919        for i in 0..5 {
920            step_n(&mut w, i);
921        }
922        let wal = Wal::from_writer(w);
923        assert_eq!(wal.records.len(), 5);
924        let mut prev = [0u8; 32];
925        for rec in &wal.records {
926            assert_eq!(rec.prev_chain_hash, prev);
927            prev = rec.this_chain_hash;
928        }
929        wal.verify_chain(world()).expect("clean chain");
930    }
931
932    #[test]
933    fn submit_and_step_chain_verifies() {
934        // A Submit followed by a Step (the canonical admission → execution
935        // sequence) links and verifies.
936        let mut w = WalWriter::new(world(), manifest());
937        submit_one(&mut w);
938        step_n(&mut w, 2);
939        let wal = Wal::from_writer(w);
940        assert_eq!(wal.records.len(), 2);
941        assert!(matches!(wal.records[0].kind(), WalRecordKind::Submit));
942        assert!(matches!(wal.records[1].kind(), WalRecordKind::Step));
943        assert_eq!(wal.records[0].seq(), 1);
944        assert_eq!(wal.records[1].seq(), 2);
945        wal.verify_chain(world()).expect("clean chain");
946    }
947
948    #[test]
949    fn tampered_step_body_breaks_verify_chain() {
950        let mut w = WalWriter::new(world(), manifest());
951        for i in 0..3 {
952            step_n(&mut w, i);
953        }
954        let mut wal = Wal::from_writer(w);
955        // Tamper the middle record's post_state_digest (a hashed body field).
956        if let WalRecordContent::Step {
957            post_state_digest, ..
958        } = &mut wal.records[1].content
959        {
960            post_state_digest[0] ^= 0xFF;
961        }
962        assert!(matches!(
963            wal.verify_chain(world()),
964            Err(WalError::HashMismatch { .. })
965        ));
966    }
967
968    #[test]
969    fn verify_chain_detects_broken_prev_link() {
970        let mut w = WalWriter::new(world(), manifest());
971        for i in 0..3 {
972            step_n(&mut w, i);
973        }
974        let mut wal = Wal::from_writer(w);
975        wal.records[1].prev_chain_hash[0] ^= 1;
976        assert!(matches!(
977            wal.verify_chain(world()),
978            Err(WalError::ChainBroken { at_record: 1 })
979        ));
980    }
981
982    #[test]
983    fn different_world_id_produces_different_chain() {
984        let mut w1 = WalWriter::new([1u8; 32], manifest());
985        let mut w2 = WalWriter::new([2u8; 32], manifest());
986        append_one(&mut w1);
987        append_one(&mut w2);
988        assert_ne!(w1.chain_tip(), w2.chain_tip());
989    }
990
991    #[test]
992    fn verify_chain_against_wrong_world_id_fails() {
993        let mut w = WalWriter::new(world(), manifest());
994        append_one(&mut w);
995        let wal = Wal::from_writer(w);
996        assert!(matches!(
997            wal.verify_chain([99u8; 32]),
998            Err(WalError::HashMismatch { .. })
999        ));
1000    }
1001
1002    #[test]
1003    fn step_verdict_round_trips() {
1004        // Replaces the old AuthDecisionAnnotation round-trip: a Step verdict
1005        // (here BudgetPartial) survives serialize → deserialize verbatim.
1006        let mut w = WalWriter::new(world(), manifest());
1007        w.append_step(
1008            InstanceId::new(1).unwrap(),
1009            sid(1),
1010            Tick(0),
1011            0xFF,
1012            StepVerdict::BudgetPartial { denied: 3 },
1013            [4u8; 32],
1014        )
1015        .unwrap();
1016        let wal = Wal::from_writer(w);
1017        let bytes = wal.serialize().unwrap();
1018        let back = Wal::deserialize(&bytes).unwrap();
1019        match &back.records[0].content {
1020            WalRecordContent::Step { verdict, .. } => {
1021                assert_eq!(*verdict, StepVerdict::BudgetPartial { denied: 3 });
1022            }
1023            WalRecordContent::Submit { .. } => panic!("expected Step"),
1024        }
1025    }
1026
1027    #[test]
1028    fn submit_record_round_trips_inputs() {
1029        let actor = Some(EntityId::new(42).unwrap());
1030        let mut w = WalWriter::new(world(), manifest());
1031        w.append_submit(
1032            InstanceId::new(9).unwrap(),
1033            Principal::External(ExternalId(7)),
1034            actor,
1035            0xDEAD_BEEF,
1036            Tick(5),
1037            TypeCode(101),
1038            vec![9, 8, 7],
1039            sid(3),
1040        )
1041        .unwrap();
1042        let wal = Wal::from_writer(w);
1043        let bytes = wal.serialize().unwrap();
1044        let back = Wal::deserialize(&bytes).unwrap();
1045        match &back.records[0].content {
1046            WalRecordContent::Submit {
1047                instance,
1048                actor: a,
1049                caps_at_submit,
1050                allocated_id,
1051                action_bytes,
1052                ..
1053            } => {
1054                assert_eq!(*instance, InstanceId::new(9).unwrap());
1055                assert_eq!(*a, actor);
1056                assert_eq!(*caps_at_submit, 0xDEAD_BEEF);
1057                assert_eq!(*allocated_id, sid(3));
1058                assert_eq!(action_bytes, &vec![9, 8, 7]);
1059            }
1060            WalRecordContent::Step { .. } => panic!("expected Submit"),
1061        }
1062        assert!(back.verify_chain(world()).is_ok());
1063    }
1064
1065    #[test]
1066    fn header_carries_magic_and_versions() {
1067        let h = WalWriter::new(world(), manifest()).header().clone();
1068        assert_eq!(h.magic, *b"ARKHEWAL");
1069        assert_eq!(h.kernel_semver, (0, 15, 0));
1070        assert_eq!(h.abi_version, (0, 15));
1071        assert_eq!(h.world_id, world());
1072        assert_eq!(h.manifest_digest, manifest());
1073        assert!(h.type_registry_pins.is_empty());
1074        assert!(h.verifying_key.is_none());
1075        let _ = RouteId(1);
1076    }
1077
1078    // ---- Ed25519 SignatureClass (Tier 2, A16) ----
1079
1080    #[test]
1081    fn signature_class_none_produces_no_signature() {
1082        let mut w = WalWriter::new(world(), manifest());
1083        append_one(&mut w);
1084        let wal = Wal::from_writer(w);
1085        assert!(wal.header.verifying_key.is_none());
1086        assert!(wal.records[0].signature.is_none());
1087        wal.verify_chain(world())
1088            .expect("Tier 1 chain still verifies");
1089    }
1090
1091    #[test]
1092    fn signature_class_ed25519_signs_each_record() {
1093        let sig_class = SignatureClass::new_ed25519_from_secret([7u8; 32]);
1094        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1095        for _ in 0..3 {
1096            append_one(&mut w);
1097        }
1098        let wal = Wal::from_writer(w);
1099        assert!(wal.header.verifying_key.is_some());
1100        assert_eq!(wal.records.len(), 3);
1101        for rec in &wal.records {
1102            let sig = rec.signature.as_ref().expect("Ed25519 signs every record");
1103            assert_eq!(sig.len(), 64);
1104        }
1105    }
1106
1107    #[test]
1108    fn verify_chain_validates_signatures() {
1109        let sig_class = SignatureClass::new_ed25519_from_secret([11u8; 32]);
1110        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1111        for _ in 0..3 {
1112            append_one(&mut w);
1113        }
1114        let wal = Wal::from_writer(w);
1115        let bytes = wal.serialize().unwrap();
1116        let back = Wal::deserialize(&bytes).unwrap();
1117        back.verify_chain(world()).expect("signed chain verifies");
1118    }
1119
1120    #[test]
1121    fn tampered_signature_fails_verify() {
1122        let sig_class = SignatureClass::new_ed25519_from_secret([13u8; 32]);
1123        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1124        append_one(&mut w);
1125        append_one(&mut w);
1126        let mut wal = Wal::from_writer(w);
1127        if let Some(sig) = wal.records[1].signature.as_mut() {
1128            sig[0] ^= 0xFF;
1129        }
1130        assert!(matches!(
1131            wal.verify_chain(world()),
1132            Err(WalError::SignatureMismatch { at_record: 1 })
1133        ));
1134    }
1135
1136    #[test]
1137    fn missing_signature_fails_verify_when_header_has_key() {
1138        let sig_class = SignatureClass::new_ed25519_from_secret([17u8; 32]);
1139        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1140        append_one(&mut w);
1141        let mut wal = Wal::from_writer(w);
1142        wal.records[0].signature = None;
1143        assert!(matches!(
1144            wal.verify_chain(world()),
1145            Err(WalError::MissingSignature { at_record: 0 })
1146        ));
1147    }
1148
1149    #[test]
1150    fn wrong_key_fails_verify() {
1151        let sig_class = SignatureClass::new_ed25519_from_secret([19u8; 32]);
1152        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1153        append_one(&mut w);
1154        let mut wal = Wal::from_writer(w);
1155        let other = SignatureClass::new_ed25519_from_secret([23u8; 32])
1156            .verifying_key_bytes()
1157            .unwrap();
1158        wal.header.verifying_key = Some(other);
1159        assert!(matches!(
1160            wal.verify_chain(world()),
1161            Err(WalError::SignatureMismatch { at_record: 0 })
1162        ));
1163    }
1164
1165    #[test]
1166    fn signature_deterministic_across_runs() {
1167        let mk = |secret: [u8; 32]| -> Vec<Vec<u8>> {
1168            let mut w = WalWriter::with_signature(
1169                world(),
1170                manifest(),
1171                SignatureClass::new_ed25519_from_secret(secret),
1172            );
1173            append_one(&mut w);
1174            append_one(&mut w);
1175            let wal = Wal::from_writer(w);
1176            wal.records
1177                .iter()
1178                .map(|r| r.signature.clone().unwrap())
1179                .collect()
1180        };
1181        let sigs1 = mk([29u8; 32]);
1182        let sigs2 = mk([29u8; 32]);
1183        assert_eq!(sigs1, sigs2);
1184        assert_eq!(sigs1[0].len(), 64);
1185    }
1186
1187    #[test]
1188    fn domain_ctx_byte_identity_blake3() {
1189        // Layer A item 1 (DOMAIN_CTX literal) byte-level formal anchor. The
1190        // literal must remain frozen for the v0.15 epoch — every WAL chain is
1191        // keyed via `blake3::derive_key(DOMAIN_CTX, world_id)`; one byte change
1192        // rederives every chain key (A1/A14 byte-identity invariant).
1193        //
1194        // Update procedure (semver-bump escalation only): regenerate the hex via
1195        //   `printf '%s' "<new bytes>" | b3sum --no-names`
1196        // and update EXPECTED and FROZEN_HEX together. Layer A item 1
1197        // escalation review required.
1198        const EXPECTED: &[u8] = b"arkhe-kernel v0.15 WAL chain domain separation context";
1199        assert_eq!(WalHeader::DOMAIN_CTX, EXPECTED);
1200        assert_eq!(WalHeader::DOMAIN_CTX.len(), 54);
1201
1202        const FROZEN_HEX: &str = "3e1aa9478e76820fbdb31ca8fdf136df81d83635e2925584934cfa110f682129";
1203        let actual_hex = blake3::hash(WalHeader::DOMAIN_CTX).to_hex();
1204        assert_eq!(
1205            actual_hex.as_str(),
1206            FROZEN_HEX,
1207            "DOMAIN_CTX BLAKE3 hash regression — byte-level edit detected",
1208        );
1209    }
1210
1211    #[test]
1212    fn wal_record_persists_actor_for_replay_determinism() {
1213        // Regression (#1): the submit-time actor is canonical input (it feeds
1214        // `ctx.actor`) and must survive into the record AND the chain hash.
1215        let actor = Some(EntityId::new(42).unwrap());
1216        let mut w = WalWriter::new(world(), manifest());
1217        w.append_submit(
1218            InstanceId::new(1).unwrap(),
1219            Principal::System,
1220            actor,
1221            0,
1222            Tick(0),
1223            TypeCode(100),
1224            vec![1, 2, 3],
1225            sid(1),
1226        )
1227        .unwrap();
1228        let wal = Wal::from_writer(w);
1229        match &wal.records[0].content {
1230            WalRecordContent::Submit { actor: a, .. } => assert_eq!(*a, actor),
1231            WalRecordContent::Step { .. } => panic!("expected Submit"),
1232        }
1233        assert!(wal.verify_chain(world()).is_ok());
1234    }
1235
1236    #[test]
1237    fn verify_chain_anchored_rejects_downgrade_and_substitution() {
1238        let sig = SignatureClass::new_ed25519_from_secret([7u8; 32]);
1239        let vk = sig.verifying_key_bytes().unwrap();
1240        let mut w = WalWriter::with_signature(world(), manifest(), sig);
1241        append_one(&mut w);
1242        let wal = Wal::from_writer(w);
1243
1244        let good = TrustAnchor {
1245            min_tier: Some(SignatureTier::Ed25519),
1246            ed25519_verifying_key: Some(vk),
1247            ..Default::default()
1248        };
1249        assert!(wal.verify_chain_anchored(world(), &good).is_ok());
1250
1251        let downgrade = TrustAnchor {
1252            min_tier: Some(SignatureTier::Hybrid),
1253            ..Default::default()
1254        };
1255        assert!(matches!(
1256            wal.verify_chain_anchored(world(), &downgrade),
1257            Err(WalError::TierDowngrade)
1258        ));
1259
1260        let wrong_key = TrustAnchor {
1261            min_tier: Some(SignatureTier::Ed25519),
1262            ed25519_verifying_key: Some([0xAB; 32]),
1263            ..Default::default()
1264        };
1265        assert!(matches!(
1266            wal.verify_chain_anchored(world(), &wrong_key),
1267            Err(WalError::VerifyingKeyMismatch)
1268        ));
1269    }
1270
1271    // ---- frozen wire-format anchors (Layer A item 7 — CIL record shape) ----
1272
1273    #[test]
1274    fn submit_record_postcard_layout_byte_identity() {
1275        // Pin the postcard byte sequence of a Tier-1 Submit record. Any silent
1276        // reorder / field add breaks this BLAKE3 regression.
1277        let mut w = WalWriter::new([7u8; 32], [3u8; 32]);
1278        w.append_submit(
1279            InstanceId::new(99).unwrap(),
1280            Principal::System,
1281            Some(EntityId::new(5).unwrap()),
1282            0xCAFE,
1283            Tick(42),
1284            TypeCode(0xBEEF),
1285            vec![0xAA, 0xBB, 0xCC],
1286            sid(7),
1287        )
1288        .unwrap();
1289        let wal = Wal::from_writer(w);
1290        let encoded = postcard::to_allocvec(&wal.records[0]).expect("postcard encode");
1291        const FROZEN_HEX: &str = "43e10a825aa2b78cbbe59114fc546c31f64c8ffdf00dada54127bdc598967562";
1292        assert_eq!(
1293            blake3::hash(&encoded).to_hex().as_str(),
1294            FROZEN_HEX,
1295            "Submit record postcard byte sequence regression",
1296        );
1297    }
1298
1299    #[test]
1300    fn step_record_postcard_layout_byte_identity() {
1301        let mut w = WalWriter::new([7u8; 32], [3u8; 32]);
1302        w.append_step(
1303            InstanceId::new(99).unwrap(),
1304            sid(13),
1305            Tick(42),
1306            0xC0FFEE,
1307            StepVerdict::BudgetPartial { denied: 2 },
1308            [0x5A; 32],
1309        )
1310        .unwrap();
1311        let wal = Wal::from_writer(w);
1312        let encoded = postcard::to_allocvec(&wal.records[0]).expect("postcard encode");
1313        const FROZEN_HEX: &str = "44a440370648b160fa342075d77a1fd72629c1fde3467c0458c34c36d2ae4aa9";
1314        assert_eq!(
1315            blake3::hash(&encoded).to_hex().as_str(),
1316            FROZEN_HEX,
1317            "Step record postcard byte sequence regression",
1318        );
1319    }
1320
1321    #[test]
1322    fn chain_hash_frozen_for_step_record() {
1323        // Pins the resulting this_chain_hash for a fixed Step body under the
1324        // v0.15 DOMAIN_CTX-derived chain key (DOMAIN_CTX + body field order).
1325        let mut w = WalWriter::new([7u8; 32], [3u8; 32]);
1326        w.append_step(
1327            InstanceId::new(1).unwrap(),
1328            sid(1),
1329            Tick(0),
1330            0xFF,
1331            StepVerdict::Committed,
1332            [9u8; 32],
1333        )
1334        .unwrap();
1335        let wal = Wal::from_writer(w);
1336        const FROZEN_HEX: &str = "0627f6472e44e5af82b9019698b80adc08fe02a5ffcf081b79a3ea824556df14";
1337        assert_eq!(
1338            blake3::Hash::from(wal.records[0].this_chain_hash)
1339                .to_hex()
1340                .as_str(),
1341            FROZEN_HEX,
1342            "chain hash regression — DOMAIN_CTX or Step body field order changed",
1343        );
1344    }
1345
1346    #[test]
1347    fn wal_record_hybrid_layout_byte_identity() {
1348        // Pin the postcard wire-format growth for a record's PQC signature slot
1349        // (ML-DSA 65 signature = 3309 bytes).
1350        let sig_class = SignatureClass::new_ed25519_from_secret([19u8; 32]);
1351        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1352        append_one(&mut w);
1353        let mut wal = Wal::from_writer(w);
1354        let baseline_encoded = postcard::to_allocvec(&wal.records[0]).expect("baseline encode");
1355        wal.records[0].signature_pqc = Some(vec![0xAB; 3309]);
1356        let with_pqc_encoded = postcard::to_allocvec(&wal.records[0]).expect("with_pqc encode");
1357        // None = 0x00 (1 byte); Some(vec[3309]) = 0x01 + varint(3309)=2 + 3309.
1358        assert_eq!(
1359            with_pqc_encoded.len() - baseline_encoded.len(),
1360            3311,
1361            "PQC signature envelope size mismatch — ML-DSA 65 must fit",
1362        );
1363    }
1364
1365    #[test]
1366    fn wal_header_verifying_key_pqc_slot_pinned() {
1367        let h = WalWriter::new(world(), manifest()).header().clone();
1368        assert!(h.verifying_key.is_none());
1369        assert!(h.verifying_key_pqc.is_none());
1370
1371        let mut h_pqc = h.clone();
1372        h_pqc.verifying_key_pqc = Some(vec![0xCD; 1952]);
1373        let baseline = postcard::to_allocvec(&h).expect("encode baseline");
1374        let with_pqc = postcard::to_allocvec(&h_pqc).expect("encode with pqc key");
1375        // None = 1 byte; Some(vec[1952]) = 0x01 + varint(1952)=2 + 1952.
1376        assert_eq!(
1377            with_pqc.len() - baseline.len(),
1378            1954,
1379            "PQC verifying-key envelope size mismatch — ML-DSA 65 must fit",
1380        );
1381    }
1382
1383    // ---- PQC Hybrid (Ed25519 + ML-DSA 65) wal-side wiring ----
1384
1385    #[test]
1386    fn hybrid_writer_emits_both_signatures() {
1387        let sig_class = SignatureClass::new_hybrid_from_secrets([7u8; 32], [11u8; 32]);
1388        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1389        for _ in 0..3 {
1390            append_one(&mut w);
1391        }
1392        let wal = Wal::from_writer(w);
1393        assert_eq!(wal.header.verifying_key.expect("Hybrid pins Ed25519 vk").len(), 32);
1394        assert_eq!(
1395            wal.header
1396                .verifying_key_pqc
1397                .as_ref()
1398                .expect("Hybrid pins PQC vk")
1399                .len(),
1400            1952
1401        );
1402        assert_eq!(wal.records.len(), 3);
1403        for rec in &wal.records {
1404            assert_eq!(
1405                rec.signature
1406                    .as_ref()
1407                    .expect("Hybrid signs Ed25519 every record")
1408                    .len(),
1409                64
1410            );
1411            assert_eq!(
1412                rec.signature_pqc
1413                    .as_ref()
1414                    .expect("Hybrid signs PQC every record")
1415                    .len(),
1416                3309
1417            );
1418        }
1419    }
1420
1421    #[test]
1422    fn hybrid_verify_chain_and_mode_passes_with_both_valid() {
1423        let sig_class = SignatureClass::new_hybrid_from_secrets([13u8; 32], [17u8; 32]);
1424        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1425        for _ in 0..3 {
1426            append_one(&mut w);
1427        }
1428        let wal = Wal::from_writer(w);
1429        let bytes = wal.serialize().unwrap();
1430        let back = Wal::deserialize(&bytes).unwrap();
1431        back.verify_chain(world())
1432            .expect("Hybrid signed chain verifies (AND-mode pass)");
1433    }
1434
1435    #[test]
1436    fn hybrid_verify_chain_rejects_missing_pqc() {
1437        let sig_class = SignatureClass::new_hybrid_from_secrets([19u8; 32], [23u8; 32]);
1438        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1439        append_one(&mut w);
1440        let mut wal = Wal::from_writer(w);
1441        wal.records[0].signature_pqc = None;
1442        assert!(matches!(
1443            wal.verify_chain(world()),
1444            Err(WalError::MissingPqcSignature { at_record: 0 })
1445        ));
1446    }
1447
1448    #[test]
1449    fn hybrid_verify_chain_rejects_corrupt_pqc_signature() {
1450        let sig_class = SignatureClass::new_hybrid_from_secrets([29u8; 32], [31u8; 32]);
1451        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1452        append_one(&mut w);
1453        append_one(&mut w);
1454        let mut wal = Wal::from_writer(w);
1455        if let Some(sig_pqc) = wal.records[1].signature_pqc.as_mut() {
1456            sig_pqc[0] ^= 0xFF;
1457        }
1458        assert!(matches!(
1459            wal.verify_chain(world()),
1460            Err(WalError::PqcSignatureMismatch { at_record: 1 })
1461        ));
1462    }
1463
1464    #[test]
1465    fn hybrid_verify_chain_rejects_corrupt_ed25519_when_pqc_valid() {
1466        let sig_class = SignatureClass::new_hybrid_from_secrets([37u8; 32], [41u8; 32]);
1467        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1468        append_one(&mut w);
1469        append_one(&mut w);
1470        let mut wal = Wal::from_writer(w);
1471        if let Some(sig) = wal.records[0].signature.as_mut() {
1472            sig[0] ^= 0xFF;
1473        }
1474        assert!(matches!(
1475            wal.verify_chain(world()),
1476            Err(WalError::PqcSignatureMismatch { at_record: 0 })
1477        ));
1478    }
1479
1480    #[test]
1481    fn ed25519_only_wal_replays_under_hybrid_kernel() {
1482        let sig_class = SignatureClass::new_ed25519_from_secret([43u8; 32]);
1483        let mut w = WalWriter::with_signature(world(), manifest(), sig_class);
1484        for _ in 0..3 {
1485            append_one(&mut w);
1486        }
1487        let wal = Wal::from_writer(w);
1488        assert!(wal.header.verifying_key.is_some());
1489        assert!(wal.header.verifying_key_pqc.is_none());
1490        for rec in &wal.records {
1491            assert!(rec.signature.is_some());
1492            assert!(rec.signature_pqc.is_none());
1493        }
1494        let bytes = wal.serialize().unwrap();
1495        let back = Wal::deserialize(&bytes).unwrap();
1496        back.verify_chain(world())
1497            .expect("Ed25519-only WAL replays under Hybrid-capable kernel");
1498    }
1499
1500    #[test]
1501    fn pqc_without_ed25519_envelope_rejected() {
1502        let sig_class = SignatureClass::new_hybrid_from_secrets([47u8; 32], [53u8; 32]);
1503        let w = WalWriter::with_signature(world(), manifest(), sig_class);
1504        let mut wal = Wal::from_writer(w);
1505        wal.header.verifying_key = None;
1506        assert!(matches!(
1507            wal.verify_chain(world()),
1508            Err(WalError::PqcWithoutEd25519)
1509        ));
1510    }
1511}