Skip to main content

arkhe_kernel/persist/
replay.rs

1//! WAL → Kernel replay (A1 D1-Total bit-identical reconstruction).
2//!
3//! Replay is the from-fresh-state path: the caller re-creates the
4//! instances referenced by the WAL with matching configs before calling
5//! `replay_into`. The snapshot path (`KernelSnapshot` plus
6//! `Kernel::from_snapshot`) is the alternative — restore from a
7//! point-in-time blob without re-running history.
8
9use crate::abi::{CapabilityMask, InstanceId};
10use crate::runtime::Kernel;
11
12use super::wal::{
13    StepVerdict, TrustAnchor, Wal, WalError, WalHeader, WalRecordContent, WalWriter,
14};
15
16/// Aggregated outcome of [`replay_into`].
17#[derive(Debug, Default, Clone, PartialEq, Eq)]
18pub struct ReplayReport {
19    /// Number of `Submit` records re-admitted.
20    pub submits_replayed: u32,
21    /// Number of `Step` records re-executed.
22    pub steps_replayed: u32,
23    /// Sum of `effects_applied` across all replayed steps.
24    pub total_effects_applied: u32,
25    /// Sum of `effects_denied` across all replayed steps.
26    pub total_effects_denied: u32,
27    /// Chain tip MEASURED by re-sealing the replayed records through a
28    /// header-rebuilt writer (matches the sealed WAL's tip when the replay is
29    /// bit-identical — it is a recomputed witness, never a copy of the tip).
30    pub final_chain_tip: [u8; 32],
31}
32
33/// Failure modes for [`replay_into`].
34#[derive(Debug, Clone)]
35#[non_exhaustive]
36pub enum ReplayError {
37    /// WAL header magic doesn't match `WalHeader::MAGIC`.
38    HeaderIncompatible(String),
39    /// `kernel_semver` differs between WAL header and the running kernel.
40    KernelSemverMismatch {
41        /// Semver pinned in the WAL header.
42        expected: (u16, u16, u16),
43        /// Current running kernel semver.
44        got: (u16, u16, u16),
45    },
46    /// `abi_version` differs between WAL header and the running kernel.
47    AbiVersionMismatch {
48        /// ABI version pinned in the WAL header.
49        expected: (u16, u16),
50        /// Current running kernel ABI version.
51        got: (u16, u16),
52    },
53    /// `postcard_version` differs between WAL header and the running build.
54    PostcardVersionMismatch {
55        /// Postcard major pinned in the WAL header.
56        expected: u32,
57        /// Current running postcard major.
58        got: u32,
59    },
60    /// `blake3_version` differs between WAL header and the running build.
61    Blake3VersionMismatch {
62        /// BLAKE3 major pinned in the WAL header.
63        expected: u32,
64        /// Current running BLAKE3 major.
65        got: u32,
66    },
67    /// Underlying WAL chain/signature verification failure.
68    WalCorrupted(WalError),
69    /// `Kernel::submit_with_id` failed during replay (carries the formatted
70    /// upstream error).
71    SubmitFailed(String),
72    /// A `Step` record was reached but the instance had no due action to pop
73    /// — the re-derived scheduler diverged from the recorded history.
74    StepUnderflow {
75        /// Instance the absent step targeted.
76        instance: InstanceId,
77    },
78    /// A re-executed step popped a different `ScheduledActionId` than recorded
79    /// — the re-derived scheduler order diverged.
80    PoppedIdDivergence {
81        /// Sequence of the offending `Step` record.
82        seq: u64,
83    },
84    /// A re-executed step reached a different verdict than recorded — the
85    /// re-derived authorization/budget outcome diverged.
86    VerdictDivergence {
87        /// Sequence of the offending `Step` record.
88        seq: u64,
89        /// Verdict pinned in the WAL.
90        expected: StepVerdict,
91        /// Verdict re-derived on replay.
92        got: StepVerdict,
93    },
94    /// A re-executed step produced a different full-state digest than recorded
95    /// — the re-derived state diverged bit-for-bit (A1 failure).
96    StateDigestDivergence {
97        /// Sequence of the offending `Step` record.
98        seq: u64,
99    },
100    /// The chain tip re-measured from the replayed records does not equal the
101    /// sealed WAL's tip — the replay was not bit-identical end-to-end.
102    ChainTipDivergence {
103        /// Tip sealed in the source WAL.
104        expected: [u8; 32],
105        /// Tip re-measured from the replayed records.
106        measured: [u8; 32],
107    },
108    /// The WAL header's `manifest_digest` does not equal the replaying
109    /// kernel's declared manifest (A14) — the WAL was written under a
110    /// different `ModuleManifest`. Closes the otherwise-vacuous default-path
111    /// manifest gate without requiring a `TrustAnchor`.
112    ManifestDigestMismatch {
113        /// Digest pinned in the WAL header.
114        expected: [u8; 32],
115        /// Digest the replaying kernel declares.
116        got: [u8; 32],
117    },
118}
119
120impl From<WalError> for ReplayError {
121    fn from(e: WalError) -> Self {
122        Self::WalCorrupted(e)
123    }
124}
125
126impl core::fmt::Display for ReplayError {
127    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
128        match self {
129            Self::HeaderIncompatible(m) => write!(f, "wal header incompatible: {}", m),
130            Self::KernelSemverMismatch { expected, got } => {
131                write!(
132                    f,
133                    "kernel semver mismatch: expected {:?}, got {:?}",
134                    expected, got
135                )
136            }
137            Self::AbiVersionMismatch { expected, got } => {
138                write!(
139                    f,
140                    "abi version mismatch: expected {:?}, got {:?}",
141                    expected, got
142                )
143            }
144            Self::PostcardVersionMismatch { expected, got } => {
145                write!(
146                    f,
147                    "postcard version mismatch: expected {}, got {}",
148                    expected, got
149                )
150            }
151            Self::Blake3VersionMismatch { expected, got } => {
152                write!(f, "blake3 version mismatch: expected {}, got {}", expected, got)
153            }
154            Self::WalCorrupted(e) => write!(f, "wal corrupted: {}", e),
155            Self::SubmitFailed(m) => write!(f, "submit failed: {}", m),
156            Self::StepUnderflow { instance } => {
157                write!(f, "replay step underflow: no due action for {:?}", instance)
158            }
159            Self::PoppedIdDivergence { seq } => {
160                write!(f, "replay popped-id divergence at record {}", seq)
161            }
162            Self::VerdictDivergence { seq, expected, got } => write!(
163                f,
164                "replay verdict divergence at record {}: expected {:?}, got {:?}",
165                seq, expected, got
166            ),
167            Self::StateDigestDivergence { seq } => {
168                write!(f, "replay state-digest divergence at record {}", seq)
169            }
170            Self::ChainTipDivergence { expected, measured } => write!(
171                f,
172                "replay chain-tip divergence: expected {}, measured {}",
173                blake3::Hash::from(*expected).to_hex(),
174                blake3::Hash::from(*measured).to_hex()
175            ),
176            Self::ManifestDigestMismatch { expected, got } => write!(
177                f,
178                "replay manifest_digest mismatch: wal {}, kernel {}",
179                blake3::Hash::from(*expected).to_hex(),
180                blake3::Hash::from(*got).to_hex()
181            ),
182        }
183    }
184}
185
186impl std::error::Error for ReplayError {}
187
188/// Header-compatibility gates shared by [`replay_into`] and
189/// [`replay_into_verified`] (A14): magic, kernel semver major, ABI
190/// version, postcard / BLAKE3 major. A mismatch is a structural error.
191fn check_header_gates(wal: &Wal) -> Result<(), ReplayError> {
192    if wal.header.magic != WalHeader::MAGIC {
193        return Err(ReplayError::HeaderIncompatible(
194            "magic mismatch (expected ARKHEWAL)".to_string(),
195        ));
196    }
197    if wal.header.kernel_semver.0 != WalHeader::CURRENT_KERNEL_SEMVER.0 {
198        return Err(ReplayError::KernelSemverMismatch {
199            expected: WalHeader::CURRENT_KERNEL_SEMVER,
200            got: wal.header.kernel_semver,
201        });
202    }
203    if wal.header.abi_version != WalHeader::ABI_VERSION {
204        return Err(ReplayError::AbiVersionMismatch {
205            expected: WalHeader::ABI_VERSION,
206            got: wal.header.abi_version,
207        });
208    }
209    // postcard / BLAKE3 major versions are wire-format determinants (A14
210    // header pinning): a mismatch means the bytes were produced under a
211    // different codec/hash generation and must NOT be silently accepted.
212    if wal.header.postcard_version != WalHeader::POSTCARD_MAJOR {
213        return Err(ReplayError::PostcardVersionMismatch {
214            expected: WalHeader::POSTCARD_MAJOR,
215            got: wal.header.postcard_version,
216        });
217    }
218    if wal.header.blake3_version != WalHeader::BLAKE3_MAJOR {
219        return Err(ReplayError::Blake3VersionMismatch {
220            expected: WalHeader::BLAKE3_MAJOR,
221            got: wal.header.blake3_version,
222        });
223    }
224    Ok(())
225}
226
227/// Replay the (already chain-verified) Canonical Input Log into `kernel`.
228///
229/// Each `Submit` is re-admitted under its exact recorded id and ceiling; each
230/// `Step` is re-executed via the SAME per-instance driver the live kernel
231/// uses ([`Kernel::process_instance_record`]). Replay applies NO effect from
232/// the log and COPIES no hash: it re-derives every deterministic effect by
233/// re-execution, asserts each step's popped-id / verdict / full-state digest
234/// against the record (fail-fast tripwires), and re-seals the records through
235/// a header-rebuilt writer to MEASURE the chain tip — which must equal the
236/// sealed WAL's tip. This is the structural guarantee that internal
237/// scheduling, signal routing, and id allocation cannot drift on replay.
238fn replay_records(kernel: &mut Kernel, wal: &Wal) -> Result<ReplayReport, ReplayError> {
239    let mut report = ReplayReport::default();
240    // The replay-side measurement writer: same world_id-derived chain key,
241    // None signing (the chain hash is over the body, which excludes
242    // signatures, so an unsigned re-seal reproduces every `this_chain_hash`).
243    let mut writer = WalWriter::rebuild_from_header(&wal.header);
244
245    for rec in &wal.records {
246        match &rec.content {
247            WalRecordContent::Submit {
248                seq: _,
249                instance,
250                principal,
251                actor,
252                caps_at_submit,
253                at,
254                action_type_code,
255                action_bytes,
256                allocated_id,
257            } => {
258                let caps = CapabilityMask::from_bits_retain(*caps_at_submit);
259                kernel
260                    .submit_with_id(
261                        *instance,
262                        principal.clone(),
263                        *actor,
264                        caps,
265                        *allocated_id,
266                        *at,
267                        *action_type_code,
268                        action_bytes.clone(),
269                    )
270                    .map_err(|e| ReplayError::SubmitFailed(format!("{:?}", e)))?;
271                writer.append_submit(
272                    *instance,
273                    principal.clone(),
274                    *actor,
275                    *caps_at_submit,
276                    *at,
277                    *action_type_code,
278                    action_bytes.clone(),
279                    *allocated_id,
280                )?;
281                report.submits_replayed = report.submits_replayed.saturating_add(1);
282            }
283            WalRecordContent::Step {
284                seq,
285                instance,
286                popped_id,
287                now,
288                session_caps,
289                verdict,
290                post_state_digest,
291            } => {
292                let session = CapabilityMask::from_bits_retain(*session_caps);
293                let result = kernel
294                    .process_instance_record(*instance, *now, session, true)
295                    .ok_or(ReplayError::StepUnderflow {
296                        instance: *instance,
297                    })?;
298                // Tripwires — replay must re-reach the recorded facts exactly.
299                if result.popped_id != *popped_id {
300                    return Err(ReplayError::PoppedIdDivergence { seq: *seq });
301                }
302                if result.verdict != *verdict {
303                    return Err(ReplayError::VerdictDivergence {
304                        seq: *seq,
305                        expected: *verdict,
306                        got: result.verdict,
307                    });
308                }
309                if result.digest != *post_state_digest {
310                    return Err(ReplayError::StateDigestDivergence { seq: *seq });
311                }
312                writer.append_step(
313                    *instance,
314                    *popped_id,
315                    *now,
316                    *session_caps,
317                    *verdict,
318                    *post_state_digest,
319                )?;
320                report.steps_replayed = report.steps_replayed.saturating_add(1);
321                report.total_effects_applied = report
322                    .total_effects_applied
323                    .saturating_add(result.effects_applied);
324                report.total_effects_denied = report
325                    .total_effects_denied
326                    .saturating_add(result.effects_denied);
327            }
328        }
329    }
330
331    // The tip is MEASURED from the re-sealed records, then checked against the
332    // sealed WAL — never copied from it.
333    let measured = writer.chain_tip();
334    let sealed = wal.chain_tip();
335    if measured != sealed {
336        return Err(ReplayError::ChainTipDivergence {
337            expected: sealed,
338            measured,
339        });
340    }
341    report.final_chain_tip = measured;
342    Ok(report)
343}
344
345/// Replay every record into `kernel` (integrity-only). The caller must
346/// already have created the instances referenced by the WAL; for the
347/// integrated path (no manual pre-creation), use `Kernel::from_snapshot`
348/// against a `KernelSnapshot` instead.
349///
350/// This verifies the chain's internal self-consistency but TRUSTS the
351/// WAL's provenance — it derives the verification policy/keys and the
352/// chain `world_id` from the (potentially attacker-controlled) header. For
353/// an untrusted WAL (tampered log / peer snapshot), use
354/// [`replay_into_verified`] with a [`TrustAnchor`].
355pub fn replay_into(kernel: &mut Kernel, wal: &Wal) -> Result<ReplayReport, ReplayError> {
356    check_header_gates(wal)?;
357    // A14 default-path manifest gate: the WAL must have been written under the
358    // same `ModuleManifest` the replaying kernel declares. This is a
359    // self-consistency check (no `TrustAnchor` required) — for untrusted WAL
360    // bytes, `replay_into_verified` additionally pins keys/tier/tip.
361    if wal.header.manifest_digest != kernel.manifest_digest() {
362        return Err(ReplayError::ManifestDigestMismatch {
363            expected: wal.header.manifest_digest,
364            got: kernel.manifest_digest(),
365        });
366    }
367    wal.verify_chain(wal.header.world_id)?;
368    replay_records(kernel, wal)
369}
370
371/// Replay every record into `kernel`, authenticating the WAL against a
372/// caller-supplied [`TrustAnchor`] and a caller-supplied `world_id` (NOT
373/// read from the untrusted header). Rejects a tier downgrade, a
374/// verifying-key substitution, a manifest mismatch, and a tail truncation
375/// before any record is applied. Use this for WAL bytes from an untrusted
376/// source.
377pub fn replay_into_verified(
378    kernel: &mut Kernel,
379    wal: &Wal,
380    world_id: [u8; 32],
381    anchor: &TrustAnchor,
382) -> Result<ReplayReport, ReplayError> {
383    check_header_gates(wal)?;
384    wal.verify_chain_anchored(world_id, anchor)?;
385    replay_records(kernel, wal)
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use crate::abi::{EntityId, ExternalId, InstanceId, Principal, Tick, TypeCode};
392    use crate::persist::wal::{SkipReason, Wal, WalWriter};
393    use crate::runtime::Kernel;
394    use crate::state::traits::_sealed::Sealed;
395    use crate::state::{
396        Action, ActionCompute, ActionContext, ActionDeriv, InstanceConfig, Op,
397    };
398    use bytes::Bytes;
399    use serde::{Deserialize, Serialize};
400
401    fn world() -> [u8; 32] {
402        [11u8; 32]
403    }
404
405    /// Instance config that grants System full authority (SendSignal /
406    /// ScheduleAction need SYSTEM in the resolved effective caps — there is no
407    /// blanket System bypass).
408    fn priv_cfg() -> InstanceConfig {
409        InstanceConfig {
410            default_caps: CapabilityMask::SYSTEM,
411            max_entities: 1024,
412            max_scheduled: 1024,
413            max_inbox_per_route: 16,
414            memory_budget_bytes: 1 << 20,
415            ..Default::default()
416        }
417    }
418
419    // ---- Test actions ----
420
421    /// Spawns one entity with a fixed id. Basic op (no SYSTEM needed).
422    #[derive(Serialize, Deserialize)]
423    struct SpawnAction {
424        id: u64,
425    }
426    impl Sealed for SpawnAction {}
427    impl ActionDeriv for SpawnAction {
428        const TYPE_CODE: TypeCode = TypeCode(100);
429        const SCHEMA_VERSION: u32 = 1;
430    }
431    impl ActionCompute for SpawnAction {
432        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
433            vec![Op::SpawnEntity {
434                id: EntityId::new(self.id).unwrap(),
435                owner: Principal::System,
436            }]
437        }
438    }
439
440    /// Schedules a child `SpawnAction` for a future tick — an INTERNAL
441    /// `Op::ScheduleAction` that is re-derived on replay (never logged as a
442    /// Submit). The regression target for the double-execution bug.
443    #[derive(Serialize, Deserialize)]
444    struct ScheduleChildAction {
445        child_id: u64,
446        at: u64,
447    }
448    impl Sealed for ScheduleChildAction {}
449    impl ActionDeriv for ScheduleChildAction {
450        const TYPE_CODE: TypeCode = TypeCode(200);
451        const SCHEMA_VERSION: u32 = 1;
452    }
453    impl ActionCompute for ScheduleChildAction {
454        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
455            let child = SpawnAction { id: self.child_id };
456            vec![Op::ScheduleAction {
457                at: Tick(self.at),
458                actor: None,
459                action_type_code: SpawnAction::TYPE_CODE,
460                action_bytes: Bytes::from(Action::canonical_bytes(&child)),
461            }]
462        }
463    }
464
465    /// Sends a signal to `target` on a fixed route. Needs SYSTEM.
466    #[derive(Serialize, Deserialize)]
467    struct SignalAction {
468        target: u64,
469    }
470    impl Sealed for SignalAction {}
471    impl ActionDeriv for SignalAction {
472        const TYPE_CODE: TypeCode = TypeCode(300);
473        const SCHEMA_VERSION: u32 = 1;
474    }
475    impl ActionCompute for SignalAction {
476        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
477            vec![Op::SendSignal {
478                target: InstanceId::new(self.target).unwrap(),
479                route: crate::abi::RouteId(1),
480                payload: Bytes::from_static(b"ping"),
481            }]
482        }
483    }
484
485    /// Two SetComponents on one entity; the second can exceed a tight budget
486    /// (per-Op budget skip → `BudgetPartial`).
487    #[derive(Serialize, Deserialize)]
488    struct TwoSetAction {
489        a: u64,
490        b: u64,
491    }
492    impl Sealed for TwoSetAction {}
493    impl ActionDeriv for TwoSetAction {
494        const TYPE_CODE: TypeCode = TypeCode(400);
495        const SCHEMA_VERSION: u32 = 1;
496    }
497    impl ActionCompute for TwoSetAction {
498        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
499            let e = EntityId::new(1).unwrap();
500            vec![
501                Op::SpawnEntity {
502                    id: e,
503                    owner: Principal::System,
504                },
505                Op::SetComponent {
506                    entity: e,
507                    type_code: TypeCode(7),
508                    bytes: Bytes::from(vec![0u8; self.a as usize]),
509                    size: self.a,
510                },
511                Op::SetComponent {
512                    entity: e,
513                    type_code: TypeCode(8),
514                    bytes: Bytes::from(vec![0u8; self.b as usize]),
515                    size: self.b,
516                },
517            ]
518        }
519    }
520
521    /// Emits a single `Op::ScheduleAction` (needs SYSTEM) — used to force an
522    /// AuthDenied verdict under External caps.
523    #[derive(Serialize, Deserialize)]
524    struct ScheduleNeedsSystemAction;
525    impl Sealed for ScheduleNeedsSystemAction {}
526    impl ActionDeriv for ScheduleNeedsSystemAction {
527        const TYPE_CODE: TypeCode = TypeCode(500);
528        const SCHEMA_VERSION: u32 = 1;
529    }
530    impl ActionCompute for ScheduleNeedsSystemAction {
531        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
532            vec![Op::ScheduleAction {
533                at: Tick(9),
534                actor: None,
535                action_type_code: SpawnAction::TYPE_CODE,
536                action_bytes: Bytes::from_static(b""),
537            }]
538        }
539    }
540
541    // ---- header-gate tests (empty WAL) ----
542
543    #[test]
544    fn replay_empty_wal_succeeds() {
545        let w = WalWriter::new(world(), [0u8; 32]);
546        let wal = Wal::from_writer(w);
547        let mut kernel = Kernel::new();
548        let report = replay_into(&mut kernel, &wal).unwrap();
549        assert_eq!(report.submits_replayed, 0);
550        assert_eq!(report.steps_replayed, 0);
551    }
552
553    #[test]
554    fn replay_rejects_wrong_magic() {
555        let w = WalWriter::new(world(), [0u8; 32]);
556        let mut wal = Wal::from_writer(w);
557        wal.header.magic = *b"BADMAGIC";
558        let mut kernel = Kernel::new();
559        assert!(matches!(
560            replay_into(&mut kernel, &wal),
561            Err(ReplayError::HeaderIncompatible(_))
562        ));
563    }
564
565    #[test]
566    fn replay_rejects_kernel_semver_major_mismatch() {
567        let w = WalWriter::new(world(), [0u8; 32]);
568        let mut wal = Wal::from_writer(w);
569        wal.header.kernel_semver = (99, 0, 0);
570        let mut kernel = Kernel::new();
571        assert!(matches!(
572            replay_into(&mut kernel, &wal),
573            Err(ReplayError::KernelSemverMismatch { .. })
574        ));
575    }
576
577    #[test]
578    fn replay_rejects_postcard_version_mismatch() {
579        let w = WalWriter::new(world(), [0u8; 32]);
580        let mut wal = Wal::from_writer(w);
581        wal.header.postcard_version = 999;
582        let mut kernel = Kernel::new();
583        assert!(matches!(
584            replay_into(&mut kernel, &wal),
585            Err(ReplayError::PostcardVersionMismatch { .. })
586        ));
587    }
588
589    #[test]
590    fn replay_rejects_corrupted_chain() {
591        let mut w = WalWriter::new(world(), [0u8; 32]);
592        w.append_step(
593            InstanceId::new(1).unwrap(),
594            crate::state::ScheduledActionId::new(1).unwrap(),
595            Tick(0),
596            CapabilityMask::SYSTEM.bits(),
597            StepVerdict::Committed,
598            [9u8; 32],
599        )
600        .unwrap();
601        let mut wal = Wal::from_writer(w);
602        wal.records[0].this_chain_hash = [0xFFu8; 32];
603        let mut kernel = Kernel::new();
604        assert!(matches!(
605            replay_into(&mut kernel, &wal),
606            Err(ReplayError::WalCorrupted(_))
607        ));
608    }
609
610    // ---- CIL replay-determinism tests ----
611
612    /// THE critical-bug regression. The original schedules a child internally;
613    /// the WAL records only the parent Submit + two Step verdicts (parent and
614    /// child), NOT a Submit for the child. Replay re-derives the child
615    /// schedule by re-executing the parent — so the child runs exactly once,
616    /// under the same id, reaching a bit-identical chain tip. A double-execution
617    /// or id-drift would diverge a digest or the tip.
618    #[test]
619    fn replay_internal_scheduling_is_bit_identical() {
620        let build = || -> Kernel {
621            let mut k = Kernel::new_with_wal(world(), [0u8; 32]);
622            k.register_action::<ScheduleChildAction>();
623            k.register_action::<SpawnAction>();
624            let inst = k.create_instance(priv_cfg());
625            let parent = ScheduleChildAction {
626                child_id: 77,
627                at: 1,
628            };
629            k.submit(
630                inst,
631                Principal::System,
632                None,
633                CapabilityMask::SYSTEM,
634                Tick(0),
635                ScheduleChildAction::TYPE_CODE,
636                Action::canonical_bytes(&parent),
637            )
638            .unwrap();
639            k.step(Tick(0), CapabilityMask::SYSTEM); // runs parent → schedules child
640            k.step(Tick(1), CapabilityMask::SYSTEM); // runs child → spawns entity 77
641            k
642        };
643        let k1 = build();
644        let original_tip = k1.wal_chain_tip().unwrap();
645        let wal = k1.export_wal().unwrap();
646        // The child schedule is NOT a Submit — only the parent is.
647        let submit_count = wal
648            .records
649            .iter()
650            .filter(|r| matches!(r.kind(), crate::persist::WalRecordKind::Submit))
651            .count();
652        let step_count = wal
653            .records
654            .iter()
655            .filter(|r| matches!(r.kind(), crate::persist::WalRecordKind::Step))
656            .count();
657        assert_eq!(submit_count, 1, "only the parent is an exogenous submit");
658        assert_eq!(step_count, 2, "two pops: parent then re-derived child");
659
660        let mut k2 = Kernel::new_with_wal(world(), [0u8; 32]);
661        k2.register_action::<ScheduleChildAction>();
662        k2.register_action::<SpawnAction>();
663        let _ = k2.create_instance(priv_cfg());
664        let report = replay_into(&mut k2, &wal).expect("bit-identical replay");
665        assert_eq!(report.submits_replayed, 1);
666        assert_eq!(report.steps_replayed, 2);
667        assert_eq!(report.final_chain_tip, original_tip);
668    }
669
670    /// A step whose op authorize-denies (External lacking SYSTEM tries to
671    /// ScheduleAction → rollback → AuthDenied) replays to the same verdict and
672    /// the same (unchanged) post-state digest.
673    #[test]
674    fn replay_history_with_authorize_denial() {
675        let build = || -> Kernel {
676            let mut k = Kernel::new_with_wal(world(), [0u8; 32]);
677            k.register_action::<ScheduleNeedsSystemAction>();
678            k.register_action::<SpawnAction>();
679            // default_caps WITHOUT system: External submission can never reach
680            // SYSTEM, so ScheduleAction is denied.
681            let inst = k.create_instance(InstanceConfig {
682                max_scheduled: 1024,
683                ..Default::default()
684            });
685            k.submit(
686                inst,
687                Principal::External(ExternalId(7)),
688                None,
689                CapabilityMask::empty(),
690                Tick(0),
691                ScheduleNeedsSystemAction::TYPE_CODE,
692                Vec::new(),
693            )
694            .unwrap();
695            k.step(Tick(0), CapabilityMask::SYSTEM);
696            k
697        };
698        let k1 = build();
699        let original_tip = k1.wal_chain_tip().unwrap();
700        let wal = k1.export_wal().unwrap();
701        assert!(matches!(
702            wal.records[1].content,
703            WalRecordContent::Step {
704                verdict: StepVerdict::AuthDenied,
705                ..
706            }
707        ));
708
709        let mut k2 = Kernel::new_with_wal(world(), [0u8; 32]);
710        k2.register_action::<ScheduleNeedsSystemAction>();
711        k2.register_action::<SpawnAction>();
712        let _ = k2.create_instance(InstanceConfig {
713            max_scheduled: 1024,
714            ..Default::default()
715        });
716        let report = replay_into(&mut k2, &wal).expect("replay ok");
717        assert_eq!(report.final_chain_tip, original_tip);
718    }
719
720    /// A committed step that skips one Op on the per-Op budget gate replays to
721    /// the same `BudgetPartial { denied: 1 }` verdict and digest.
722    #[test]
723    fn replay_history_with_budget_partial() {
724        let build = || -> Kernel {
725            let mut k = Kernel::new_with_wal(world(), [0u8; 32]);
726            k.register_action::<TwoSetAction>();
727            // budget admits the spawn + the first 40-byte component, denies the
728            // second 80-byte one (40+80 > 100).
729            let inst = k.create_instance(InstanceConfig {
730                memory_budget_bytes: 100,
731                max_entities: 1024,
732                ..Default::default()
733            });
734            let a = TwoSetAction { a: 40, b: 80 };
735            k.submit(
736                inst,
737                Principal::System,
738                None,
739                CapabilityMask::SYSTEM,
740                Tick(0),
741                TwoSetAction::TYPE_CODE,
742                Action::canonical_bytes(&a),
743            )
744            .unwrap();
745            k.step(Tick(0), CapabilityMask::SYSTEM);
746            k
747        };
748        let k1 = build();
749        let original_tip = k1.wal_chain_tip().unwrap();
750        let wal = k1.export_wal().unwrap();
751        assert!(matches!(
752            wal.records[1].content,
753            WalRecordContent::Step {
754                verdict: StepVerdict::BudgetPartial { denied: 1 },
755                ..
756            }
757        ));
758
759        let mut k2 = Kernel::new_with_wal(world(), [0u8; 32]);
760        k2.register_action::<TwoSetAction>();
761        let _ = k2.create_instance(InstanceConfig {
762            memory_budget_bytes: 100,
763            max_entities: 1024,
764            ..Default::default()
765        });
766        let report = replay_into(&mut k2, &wal).expect("replay ok");
767        assert_eq!(report.final_chain_tip, original_tip);
768    }
769
770    /// Skipped verdicts (unregistered type, undeserializable bytes) are
771    /// recorded and replay re-reaches them (the kernels skip identically).
772    #[test]
773    fn replay_skipped_unregistered_and_deserfailed() {
774        let build = || -> Kernel {
775            let mut k = Kernel::new_with_wal(world(), [0u8; 32]);
776            // SpawnAction registered; ScheduleChildAction (200) intentionally NOT.
777            k.register_action::<SpawnAction>();
778            let inst = k.create_instance(priv_cfg());
779            // Unregistered type 200.
780            k.submit(
781                inst,
782                Principal::System,
783                None,
784                CapabilityMask::SYSTEM,
785                Tick(0),
786                TypeCode(200),
787                vec![1, 2, 3],
788            )
789            .unwrap();
790            // Registered type 100 but undeserializable bytes (SpawnAction needs a u64).
791            k.submit(
792                inst,
793                Principal::System,
794                None,
795                CapabilityMask::SYSTEM,
796                Tick(1),
797                SpawnAction::TYPE_CODE,
798                Vec::new(),
799            )
800            .unwrap();
801            k.step(Tick(0), CapabilityMask::SYSTEM);
802            k.step(Tick(1), CapabilityMask::SYSTEM);
803            k
804        };
805        let k1 = build();
806        let original_tip = k1.wal_chain_tip().unwrap();
807        let wal = k1.export_wal().unwrap();
808        let verdicts: Vec<StepVerdict> = wal
809            .records
810            .iter()
811            .filter_map(|r| match &r.content {
812                WalRecordContent::Step { verdict, .. } => Some(*verdict),
813                WalRecordContent::Submit { .. } => None,
814            })
815            .collect();
816        assert!(verdicts.contains(&StepVerdict::Skipped {
817            reason: SkipReason::Unregistered
818        }));
819        assert!(verdicts.contains(&StepVerdict::Skipped {
820            reason: SkipReason::DeserFailed
821        }));
822
823        let mut k2 = Kernel::new_with_wal(world(), [0u8; 32]);
824        k2.register_action::<SpawnAction>();
825        let _ = k2.create_instance(priv_cfg());
826        let report = replay_into(&mut k2, &wal).expect("replay ok");
827        assert_eq!(report.final_chain_tip, original_tip);
828    }
829
830    /// The scheduler's `next_seq` tiebreak counter is not a logged fact — it is
831    /// re-derived by re-execution. Two same-tick children scheduled in one step
832    /// keep their FIFO-by-seq order across replay (digest pins it).
833    #[test]
834    fn next_seq_tiebreak_survives_replay() {
835        #[derive(Serialize, Deserialize)]
836        struct ScheduleTwoSameTick;
837        impl Sealed for ScheduleTwoSameTick {}
838        impl ActionDeriv for ScheduleTwoSameTick {
839            const TYPE_CODE: TypeCode = TypeCode(600);
840            const SCHEMA_VERSION: u32 = 1;
841        }
842        impl ActionCompute for ScheduleTwoSameTick {
843            fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
844                let mk = |id: u64| {
845                    let child = SpawnAction { id };
846                    Op::ScheduleAction {
847                        at: Tick(5),
848                        actor: None,
849                        action_type_code: SpawnAction::TYPE_CODE,
850                        action_bytes: Bytes::from(Action::canonical_bytes(&child)),
851                    }
852                };
853                vec![mk(11), mk(22)]
854            }
855        }
856
857        let build = || -> Kernel {
858            let mut k = Kernel::new_with_wal(world(), [0u8; 32]);
859            k.register_action::<ScheduleTwoSameTick>();
860            k.register_action::<SpawnAction>();
861            let inst = k.create_instance(priv_cfg());
862            k.submit(
863                inst,
864                Principal::System,
865                None,
866                CapabilityMask::SYSTEM,
867                Tick(0),
868                TypeCode(600),
869                Vec::new(),
870            )
871            .unwrap();
872            k.step(Tick(0), CapabilityMask::SYSTEM);
873            k.step(Tick(5), CapabilityMask::SYSTEM); // first same-tick child
874            k.step(Tick(5), CapabilityMask::SYSTEM); // second same-tick child
875            k
876        };
877        let k1 = build();
878        let original_tip = k1.wal_chain_tip().unwrap();
879        let wal = k1.export_wal().unwrap();
880
881        let mut k2 = Kernel::new_with_wal(world(), [0u8; 32]);
882        k2.register_action::<ScheduleTwoSameTick>();
883        k2.register_action::<SpawnAction>();
884        let _ = k2.create_instance(priv_cfg());
885        let report = replay_into(&mut k2, &wal).expect("replay ok");
886        assert_eq!(report.final_chain_tip, original_tip);
887    }
888
889    /// Cross-instance signal routing (delivery into the target inbox + the
890    /// delivery-time refcount) is unlogged and re-derived on replay, reaching
891    /// a bit-identical tip.
892    #[test]
893    fn signal_routing_replays_bit_identical() {
894        let build = || -> Kernel {
895            let mut k = Kernel::new_with_wal(world(), [0u8; 32]);
896            k.register_action::<SignalAction>();
897            let a = k.create_instance(priv_cfg()); // inst 1
898            let _b = k.create_instance(priv_cfg()); // inst 2 (target)
899            let act = SignalAction { target: 2 };
900            k.submit(
901                a,
902                Principal::System,
903                None,
904                CapabilityMask::SYSTEM,
905                Tick(0),
906                SignalAction::TYPE_CODE,
907                Action::canonical_bytes(&act),
908            )
909            .unwrap();
910            k.step(Tick(0), CapabilityMask::SYSTEM);
911            k
912        };
913        let k1 = build();
914        let original_tip = k1.wal_chain_tip().unwrap();
915        let wal = k1.export_wal().unwrap();
916
917        let mut k2 = Kernel::new_with_wal(world(), [0u8; 32]);
918        k2.register_action::<SignalAction>();
919        let _ = k2.create_instance(priv_cfg());
920        let _ = k2.create_instance(priv_cfg());
921        let report = replay_into(&mut k2, &wal).expect("replay ok");
922        assert_eq!(report.final_chain_tip, original_tip);
923    }
924
925    /// The default `replay_into` path validates the header `manifest_digest`
926    /// against the replaying kernel's declared manifest (A14) — no `TrustAnchor`
927    /// needed. A mismatch is rejected before any record is applied.
928    #[test]
929    fn manifest_digest_validated_on_default_path() {
930        let mut k1 = Kernel::new_with_wal(world(), [0xAB; 32]);
931        k1.register_action::<SpawnAction>();
932        let inst = k1.create_instance(priv_cfg());
933        let a = SpawnAction { id: 1 };
934        k1.submit(
935            inst,
936            Principal::System,
937            None,
938            CapabilityMask::SYSTEM,
939            Tick(0),
940            SpawnAction::TYPE_CODE,
941            Action::canonical_bytes(&a),
942        )
943        .unwrap();
944        k1.step(Tick(0), CapabilityMask::SYSTEM);
945        let wal = k1.export_wal().unwrap();
946
947        // Replaying kernel declares a DIFFERENT manifest → rejected.
948        let mut k2 = Kernel::new_with_wal(world(), [0xCD; 32]);
949        k2.register_action::<SpawnAction>();
950        let _ = k2.create_instance(priv_cfg());
951        assert!(matches!(
952            replay_into(&mut k2, &wal),
953            Err(ReplayError::ManifestDigestMismatch { .. })
954        ));
955
956        // Matching manifest → accepted.
957        let mut k3 = Kernel::new_with_wal(world(), [0xAB; 32]);
958        k3.register_action::<SpawnAction>();
959        let _ = k3.create_instance(priv_cfg());
960        assert!(replay_into(&mut k3, &wal).is_ok());
961    }
962
963    /// Replay MEASURES the chain tip by re-sealing the re-derived records — it
964    /// does not copy the stored tip. A kernel that re-executes a step
965    /// differently (here: the action is unregistered on replay, so the step
966    /// Skips instead of Commits) is caught as a verdict divergence rather than
967    /// silently echoing the recorded tip.
968    #[test]
969    fn replay_measures_not_copies_tip() {
970        let mut k1 = Kernel::new_with_wal(world(), [0u8; 32]);
971        k1.register_action::<SpawnAction>();
972        let inst = k1.create_instance(priv_cfg());
973        let a = SpawnAction { id: 5 };
974        k1.submit(
975            inst,
976            Principal::System,
977            None,
978            CapabilityMask::SYSTEM,
979            Tick(0),
980            SpawnAction::TYPE_CODE,
981            Action::canonical_bytes(&a),
982        )
983        .unwrap();
984        k1.step(Tick(0), CapabilityMask::SYSTEM);
985        let wal = k1.export_wal().unwrap();
986
987        // Replaying kernel does NOT register SpawnAction → the step Skips
988        // (Unregistered) instead of Committing → VerdictDivergence, proving the
989        // tip is re-derived, not copied.
990        let mut k2 = Kernel::new_with_wal(world(), [0u8; 32]);
991        let _ = k2.create_instance(priv_cfg());
992        assert!(matches!(
993            replay_into(&mut k2, &wal),
994            Err(ReplayError::VerdictDivergence { .. })
995        ));
996    }
997
998    /// Replaying into a kernel that did not pre-create the referenced instance
999    /// returns a graceful error — never a panic (A12 panic-free discipline
1000    /// holds for untrusted / malformed WAL input).
1001    #[test]
1002    fn replay_into_kernel_missing_instance_is_graceful_error() {
1003        let mut k1 = Kernel::new_with_wal(world(), [0u8; 32]);
1004        k1.register_action::<SpawnAction>();
1005        let inst = k1.create_instance(priv_cfg());
1006        let a = SpawnAction { id: 1 };
1007        k1.submit(
1008            inst,
1009            Principal::System,
1010            None,
1011            CapabilityMask::SYSTEM,
1012            Tick(0),
1013            SpawnAction::TYPE_CODE,
1014            Action::canonical_bytes(&a),
1015        )
1016        .unwrap();
1017        k1.step(Tick(0), CapabilityMask::SYSTEM);
1018        let wal = k1.export_wal().unwrap();
1019
1020        // k2 registers the action but creates NO instance → the Submit cannot
1021        // be admitted; replay fails gracefully rather than panicking.
1022        let mut k2 = Kernel::new_with_wal(world(), [0u8; 32]);
1023        k2.register_action::<SpawnAction>();
1024        let result = replay_into(&mut k2, &wal);
1025        assert!(
1026            matches!(result, Err(ReplayError::SubmitFailed(_))),
1027            "missing instance must surface as a graceful ReplayError, got {result:?}"
1028        );
1029    }
1030}