Skip to main content

arkhe_forge_platform/
dispatcher.rs

1//! L2 service layer — drives forge actions through the kernel's
2//! authorize → dispatch → WAL append loop.
3//!
4//! `RuntimeService` wraps a [`Kernel`] (with WAL) and exposes a single
5//! `dispatch` method that takes a forge `ArkheAction`, postcard-encodes
6//! its canonical bytes, calls [`Kernel::submit`] + [`Kernel::step`] in
7//! one shot, and returns the kernel's `StepReport`. The kernel handles
8//! the L0 work internally: `submit` admits the action and appends a WAL
9//! `Submit` record (the Canonical Input Log's exogenous-input fact);
10//! `step` pops it, authorizes against
11//! `effective_caps(default_caps, principal, ceiling)` ∩ session ceiling,
12//! and appends a `Step` record carrying the verdict + post-state digest.
13//! One successful dispatch therefore appends a Submit + Step record pair.
14//!
15//! Forge actions are made kernel-compatible by the
16//! `arkhe-forge-macros::ArkheAction` derive: it emits both the
17//! forge-side sealed-trait stack **and** the kernel-side `Sealed +
18//! ActionDeriv + ActionCompute` stack, with the kernel-side
19//! `ActionCompute::compute` body delegating to
20//! `arkhe_forge_core::bridge::kernel_compute`. The bridge runs the
21//! forge `compute()` on a fresh forge `ActionContext` and returns the
22//! drained `Vec<Op>` to the kernel.
23//!
24//! ## WAL export
25//!
26//! After one or more `dispatch` calls, the caller may extract the
27//! kernel's internal WAL via [`RuntimeService::export_wal`] (consumes
28//! the service). Each [`arkhe_kernel::WalRecord`] in the returned
29//! [`arkhe_kernel::Wal`] can be streamed into a
30//! [`crate::wal_export::BufferedWalSink`] via [`wal_to_sink`] for
31//! durable backup; the sink frames each record
32//! with the standard magic + length-prefix shape per the firm
33//! requirements pinned in `wal_export`.
34//!
35//! ## Current scope
36//!
37//! Manifest-driven authz policy, the PG-UNIQUE-INDEX-backed
38//! idempotency dedup, and full
39//! [`ActorHandleIndex`](arkhe_forge_core::context::ActorHandleIndex)
40//! production paths are not yet wired through `RuntimeService` — a
41//! forge action's idempotency / actor-handle paths run with the L1
42//! defaults (no view, no index). Callers who need those layers attach
43//! them through the forge `ActionContext` builder directly while the
44//! L2 layer matures.
45
46use arkhe_forge_core::actor::ActorId;
47use arkhe_forge_core::context::{ActionContext, ActionError};
48use arkhe_forge_core::user::UserId;
49use arkhe_kernel::abi::{ArkheError, CapabilityMask, InstanceId, Principal, Tick};
50use arkhe_kernel::state::traits::Action;
51use arkhe_kernel::state::InstanceConfig;
52use arkhe_kernel::{Kernel, StepReport, Wal};
53
54use crate::wal_export::{BufferedWalSink, WalExportError, WalRecordSink};
55
56/// Error surface for [`RuntimeService::dispatch`].
57///
58/// `dispatch` is forge's own maturing L2 API, so it returns this richer
59/// enum rather than the kernel's [`ArkheError`] directly: the GDPR
60/// `ErasurePending` admission gate (C3) is an L2 concern with no kernel
61/// error variant, so it is surfaced as its own arm. Kernel-level errors
62/// pass through [`DispatchError::Kernel`] unchanged.
63#[derive(Debug, thiserror::Error)]
64#[non_exhaustive]
65pub enum DispatchError {
66    /// Kernel-side error from `submit` / `step` (e.g. `InstanceNotFound`).
67    #[error("kernel error: {0}")]
68    Kernel(#[from] ArkheError),
69
70    /// L2 admission gate rejected the action: the actor's backing user is
71    /// in `GdprStatus::ErasurePending`, so the action is refused before it
72    /// reaches the WAL (E-user-3 C3 — admission control at the L2 boundary).
73    #[error("user erasure pending: {user:?} scheduled at {tick:?}")]
74    ErasurePending {
75        /// Backing user whose erasure is in flight.
76        user: UserId,
77        /// Tick at which the action was attempted.
78        tick: Tick,
79    },
80
81    /// The actor's `UserBinding` resolves to a user with no
82    /// `UserGdprState` lifecycle pointer — an unregistered or incompletely
83    /// registered binding target. The gate fails closed (E-user-3 C3):
84    /// admitting it would create an actor whose erasure request could
85    /// never arm the gate.
86    #[error("actor bound to user without GDPR lifecycle state: {user:?}")]
87    UnboundUserLifecycle {
88        /// Bound user with no reachable lifecycle state.
89        user: UserId,
90    },
91
92    /// The admission-gate probe read corrupt view bytes while resolving the
93    /// actor's `UserBinding` / `UserGdprState`. Fail closed rather than admit.
94    #[error("GDPR admission probe failed: corrupt view state")]
95    ProbeViewCorrupt,
96}
97
98/// Errors surfaced by [`wal_to_sink`].
99#[derive(Debug, thiserror::Error)]
100#[non_exhaustive]
101pub enum WalSinkError {
102    /// `WalRecord` failed postcard encoding (should be unreachable —
103    /// `WalRecord` is `derive(Serialize)` on a stable wire shape).
104    #[error("WalRecord postcard encode failed: {0}")]
105    Encode(#[from] postcard::Error),
106    /// Sink rejected the framed record (length / append-only / overflow).
107    #[error("BufferedWalSink rejected record: {0}")]
108    Sink(#[from] WalExportError),
109}
110
111/// Service-layer wrapper around [`arkhe_kernel::Kernel`]. Builds a
112/// kernel with WAL configured and exposes a forge-shaped dispatch API.
113pub struct RuntimeService {
114    kernel: Kernel,
115}
116
117impl RuntimeService {
118    /// Construct a service backed by a chain-only WAL writer (L0
119    /// `SignatureClass::None`). `world_id` and `manifest_digest` are
120    /// pinned into the WAL header.
121    #[must_use]
122    pub fn new(world_id: [u8; 32], manifest_digest: [u8; 32]) -> Self {
123        Self {
124            kernel: Kernel::new_with_wal(world_id, manifest_digest),
125        }
126    }
127
128    /// Register a forge `ArkheAction` so the kernel will execute it
129    /// when scheduled. Any forge action whose type bears
130    /// `#[derive(ArkheAction)]` automatically satisfies the kernel
131    /// [`Action`] bound through the derive's emitted kernel-side
132    /// stack.
133    pub fn register_action<A: Action>(&mut self) {
134        self.kernel.register_action::<A>();
135    }
136
137    /// Create a fresh kernel instance and return its `InstanceId`.
138    pub fn create_instance(&mut self, config: InstanceConfig) -> InstanceId {
139        self.kernel.create_instance(config)
140    }
141
142    /// Dispatch a forge action — inject the authenticated actor through the
143    /// kernel actor channel, run the L2 GDPR admission gate on it,
144    /// postcard-encode the action's canonical bytes, submit at tick `at`
145    /// under the `caps` ceiling, then step the kernel once with `caps` as
146    /// the operator session ceiling. Returns the kernel's `StepReport` so
147    /// the caller can inspect `actions_executed` / `effects_applied` /
148    /// `effects_denied`.
149    ///
150    /// ## Capability ceiling
151    ///
152    /// `caps` plays both kernel roles of this single-shot dispatch: it is
153    /// recorded on the `Submit` record as the submission ceiling (bounding
154    /// the action and any children it schedules) and passed to `step` as
155    /// the operator session ceiling. The kernel resolves the executing
156    /// action's authority as `effective_caps(default_caps, principal,
157    /// caps) ∩ caps` — `Principal::System` holds no blanket bypass, so an
158    /// instance whose `default_caps` lacks a SYSTEM-gated capability
159    /// (`ScheduleAction` / `SendSignal`) denies it for every principal.
160    ///
161    /// ## Single source of truth for the acting actor
162    ///
163    /// `authenticated_actor` is the caller identity the integrator's
164    /// auth / session layer (which sits ABOVE forge) has already verified —
165    /// e.g. resolved from a login session, bearer token, or passkey
166    /// assertion. `None` denotes a system / anonymous caller with no
167    /// authenticated actor.
168    ///
169    /// `dispatch` threads this actor into [`Kernel::submit`]'s actor channel
170    /// (as `Option<EntityId>` via [`ActorId::get`]). The kernel records it in
171    /// the WAL `Submit` record and replays it into `KernelActionContext::actor`, which
172    /// the [`arkhe_forge_core::bridge`] injects as the forge
173    /// [`ActionContext::acting_actor`]. A user-scoped compute body reads its
174    /// acting identity from THAT channel and stamps it into the stored record
175    /// (`SpaceConfig.creator`, `ActivityRecord.actor`) — there is no
176    /// wire-controlled actor field to substitute, so the C3
177    /// actor-substitution attack is structurally impossible. A user-scoped
178    /// action submitted with `authenticated_actor == None` is rejected inside
179    /// compute (the bridge collapses the rejection to no Ops — the WAL holds
180    /// the Submit/Step envelope but no effect materializes). A system action
181    /// that does not read `acting_actor` proceeds with `None`.
182    ///
183    /// ## GDPR `ErasurePending` admission gate (C3)
184    ///
185    /// The kernel `compute` path drives a forge action through a viewless
186    /// [`ActionContext`] (see [`arkhe_forge_core::bridge`]), so the
187    /// in-compute `ensure_actor_eligible` check soft-passes — it cannot
188    /// read the actor's `UserBinding` / `UserGdprState` without a bound
189    /// view. This method closes that gap at the L2 boundary: when
190    /// `authenticated_actor` is `Some`, the service binds a fresh
191    /// `InstanceView`, runs the existing `ensure_actor_eligible` logic on
192    /// that injected actor, and REJECTS the action before `submit` if the
193    /// backing user is `ErasurePending`. The gate is SOUND — the actor it
194    /// gates on is the authenticated caller, the same single source of truth
195    /// the compute records. Its liveness has a production precondition: the
196    /// gate resolves actor → user through the `UserBinding` that
197    /// [`RegisterActor`](arkhe_forge_core::actor::RegisterActor) writes onto
198    /// the actor entity at registration time — an actor with no binding has
199    /// no resolvable user scope and soft-passes (system / anonymous actors).
200    /// For an actor registered through `RegisterActor`,
201    /// [`GdprEraseUser`](arkhe_forge_core::user::GdprEraseUser) transitions
202    /// the user's `UserGdprState` to `ErasurePending` with a blind write
203    /// (valid on the viewless compute path), so once erasure is requested
204    /// this gate rejects the user's subsequent actions before `submit` (never
205    /// reaches the WAL), as this method's production-path test demonstrates
206    /// end-to-end.
207    ///
208    /// # Errors
209    ///
210    /// * [`DispatchError::ErasurePending`] — the L2 gate rejected the
211    ///   action (backing user in `GdprStatus::ErasurePending`).
212    /// * [`DispatchError::UnboundUserLifecycle`] — the actor's
213    ///   `UserBinding` resolves to a user with no `UserGdprState`
214    ///   (fail-closed admission for an unregistered binding target).
215    /// * [`DispatchError::ProbeViewCorrupt`] — the gate probe read
216    ///   corrupt view bytes; fail closed.
217    /// * [`DispatchError::Kernel`] — kernel-side error from `submit`
218    ///   (`InstanceNotFound` if `instance` is not live). Capability denial
219    ///   happens inside `step` and is reflected in the returned report's
220    ///   `effects_denied` count rather than as an `Err`.
221    pub fn dispatch<A>(
222        &mut self,
223        instance: InstanceId,
224        principal: Principal,
225        action: &A,
226        at: Tick,
227        caps: CapabilityMask,
228        authenticated_actor: Option<ActorId>,
229    ) -> Result<StepReport, DispatchError>
230    where
231        A: Action,
232    {
233        // L2 admission gate (C3) — runs on the injected authenticated actor,
234        // BEFORE submit, with the view dropped before the `&mut self.kernel`
235        // step call. Reuses the forge-core in-compute eligibility check; the
236        // probe context is read-only (zero world_seed, no Op emission). A
237        // system caller (`authenticated_actor == None`) has no user scope to
238        // gate, so the probe is skipped.
239        if let Some(actor) = authenticated_actor {
240            let view = self
241                .kernel
242                .instance_view(instance)
243                .ok_or(ArkheError::InstanceNotFound)?;
244            let probe = ActionContext::new([0u8; 32], instance, at, principal.clone(), caps)
245                .with_view(&view);
246            if let Err(err) = probe.ensure_actor_eligible(actor, at) {
247                return match err {
248                    ActionError::UserErasurePending { user, .. } => {
249                        Err(DispatchError::ErasurePending { user, tick: at })
250                    }
251                    ActionError::UserLifecycleUnresolved { user } => {
252                        Err(DispatchError::UnboundUserLifecycle { user })
253                    }
254                    // `ensure_actor_eligible` otherwise only fails with an
255                    // `InvalidInput` on corrupt view bytes; fail closed.
256                    _ => Err(DispatchError::ProbeViewCorrupt),
257                };
258            }
259        }
260
261        // Inject the authenticated actor through the kernel actor channel —
262        // the single source of truth. The kernel records it in the WAL
263        // Submit record and replays it into compute via the bridge.
264        let bytes = action.canonical_bytes();
265        self.kernel.submit(
266            instance,
267            principal,
268            authenticated_actor.map(ActorId::get),
269            caps,
270            at,
271            A::TYPE_CODE,
272            bytes,
273        )?;
274        Ok(self.kernel.step(at, caps))
275    }
276
277    /// Drain the kernel's internal WAL (consumes the service so the
278    /// kernel cannot continue stepping after export).
279    #[must_use]
280    pub fn export_wal(self) -> Option<Wal> {
281        self.kernel.export_wal()
282    }
283}
284
285/// Append every record of `wal` into the buffered sink, then flush.
286/// Each record is postcard-serialized via the kernel's stable
287/// [`arkhe_kernel::WalRecord`] wire shape (DO NOT TOUCH #7 — frozen
288/// per-variant field order of the kind-discriminated `Submit`/`Step`
289/// content) and the sink frames with the standard magic +
290/// length-prefix per `wal_export`'s firm requirements. The record's
291/// kind-agnostic monotonic sequence is read through the typed
292/// [`WalRecord::seq`](arkhe_kernel::WalRecord) accessor and handed to
293/// the sink, which enforces the append-only succession — the sink
294/// never parses kernel record bytes itself.
295///
296/// The export streams within the sink's capacity: the encode scratch is
297/// reused across records (no per-record allocation), and when the next
298/// frame would overflow the sink's buffer the sink is flushed mid-stream
299/// and the append retried — `flush` drains the buffer while preserving
300/// the stream header and seq succession, so a WAL of any size exports
301/// through a bounded-memory sink.
302///
303/// # Errors
304///
305/// Returns [`WalSinkError::Encode`] if a record fails postcard
306/// serialization (unreachable in practice — `WalRecord` derives
307/// `Serialize` over a stable shape) or [`WalSinkError::Sink`] if the
308/// sink rejects the framed record (length, append-only, or a frame
309/// larger than the sink's whole capacity).
310pub fn wal_to_sink<W: std::io::Write>(
311    wal: &Wal,
312    sink: &mut BufferedWalSink<W>,
313) -> Result<(), WalSinkError> {
314    let mut scratch: Vec<u8> = Vec::with_capacity(256);
315    for record in &wal.records {
316        scratch.clear();
317        scratch = postcard::to_extend(record, scratch)?;
318        match sink.append_record(record.seq(), &scratch) {
319            Ok(()) => {}
320            Err(WalExportError::BufferOverflow { .. }) => {
321                // Drain and retry once — flush clears the buffer without
322                // touching header/seq state, so any frame that fits an
323                // empty sink fits now. A second overflow means the frame
324                // exceeds the sink capacity outright; surface it.
325                sink.flush()?;
326                sink.append_record(record.seq(), &scratch)?;
327            }
328            Err(e) => return Err(e.into()),
329        }
330    }
331    sink.flush()?;
332    Ok(())
333}
334
335#[cfg(test)]
336#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
337mod tests {
338    use super::*;
339    use arkhe_kernel::abi::{Principal, Tick};
340
341    /// Smoke — `RuntimeService::new` returns a service whose underlying
342    /// kernel reports zero records (the WAL header has been pinned but
343    /// no `step` has fired yet).
344    #[test]
345    fn fresh_service_has_zero_wal_records() {
346        let svc = RuntimeService::new([0x11u8; 32], [0x22u8; 32]);
347        assert_eq!(svc.kernel.wal_record_count(), Some(0));
348    }
349
350    /// `create_instance` increments the kernel's instance count.
351    #[test]
352    fn create_instance_grows_kernel() {
353        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
354        let _id = svc.create_instance(InstanceConfig::default());
355        assert_eq!(svc.kernel.instances_len(), 1);
356    }
357
358    /// `dispatch` returns `InstanceNotFound` for an unregistered
359    /// instance — verifies the `Result` plumbing without needing a
360    /// concrete forge action in the platform-crate test scope (forge
361    /// actions live in forge-core and downstream crates).
362    #[test]
363    fn dispatch_unknown_instance_returns_instance_not_found() {
364        // Use a dummy kernel-Action via the kernel's own derive —
365        // platform crate sees only kernel surface, no forge-core dep
366        // in test scope (avoids cross-crate test churn).
367        use arkhe_kernel::abi::EntityId;
368        use arkhe_kernel::state::{ActionCompute, ActionContext, Op};
369        use arkhe_kernel::ArkheAction;
370        use serde::{Deserialize, Serialize};
371
372        #[derive(Serialize, Deserialize, ArkheAction)]
373        #[arkhe(type_code = 0x0001_5101, schema_version = 1)]
374        struct NoopAction;
375
376        impl ActionCompute for NoopAction {
377            fn compute(&self, _ctx: &ActionContext<'_>) -> Vec<Op> {
378                vec![Op::SpawnEntity {
379                    id: EntityId::new(1).unwrap(),
380                    owner: Principal::System,
381                }]
382            }
383        }
384
385        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
386        svc.register_action::<NoopAction>();
387        // No `create_instance` call — InstanceId(99) is not live.
388        let bogus = InstanceId::new(99).unwrap();
389        let result = svc.dispatch(
390            bogus,
391            Principal::System,
392            &NoopAction,
393            Tick(1),
394            CapabilityMask::SYSTEM,
395            None,
396        );
397        assert!(matches!(
398            result,
399            Err(DispatchError::Kernel(ArkheError::InstanceNotFound))
400        ));
401    }
402
403    /// Happy-path dispatch — register → create_instance → dispatch
404    /// returns `Ok(StepReport)` with `actions_executed = 1`.
405    #[test]
406    fn dispatch_happy_path_executes_one_action() {
407        use arkhe_kernel::abi::EntityId;
408        use arkhe_kernel::state::{ActionCompute, ActionContext, Op};
409        use arkhe_kernel::ArkheAction;
410        use serde::{Deserialize, Serialize};
411
412        #[derive(Serialize, Deserialize, ArkheAction)]
413        #[arkhe(type_code = 0x0001_5102, schema_version = 1)]
414        struct SpawnOne;
415
416        impl ActionCompute for SpawnOne {
417            fn compute(&self, _ctx: &ActionContext<'_>) -> Vec<Op> {
418                vec![Op::SpawnEntity {
419                    id: EntityId::new(1).unwrap(),
420                    owner: Principal::System,
421                }]
422            }
423        }
424
425        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
426        svc.register_action::<SpawnOne>();
427        let inst = svc.create_instance(InstanceConfig::default());
428        let report = svc
429            .dispatch(
430                inst,
431                Principal::System,
432                &SpawnOne,
433                Tick(0),
434                CapabilityMask::SYSTEM,
435                None,
436            )
437            .expect("dispatch must succeed for live instance");
438        assert_eq!(report.actions_executed, 1);
439        assert_eq!(report.effects_applied, 1);
440        assert_eq!(report.effects_denied, 0);
441    }
442
443    /// `wal_to_sink` round-trips: dispatch one action, export WAL,
444    /// stream into `BufferedWalSink<Vec<u8>>` — sink buffer ends up
445    /// non-empty + starts with the stream-header magic. One dispatch
446    /// appends a Submit + Step record pair.
447    #[test]
448    fn wal_to_sink_round_trips_single_dispatch() {
449        use arkhe_kernel::abi::EntityId;
450        use arkhe_kernel::state::{ActionCompute, ActionContext, Op};
451        use arkhe_kernel::ArkheAction;
452        use serde::{Deserialize, Serialize};
453
454        #[derive(Serialize, Deserialize, ArkheAction)]
455        #[arkhe(type_code = 0x0001_5103, schema_version = 1)]
456        struct SpawnOne;
457
458        impl ActionCompute for SpawnOne {
459            fn compute(&self, _ctx: &ActionContext<'_>) -> Vec<Op> {
460                vec![Op::SpawnEntity {
461                    id: EntityId::new(1).unwrap(),
462                    owner: Principal::System,
463                }]
464            }
465        }
466
467        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
468        svc.register_action::<SpawnOne>();
469        let inst = svc.create_instance(InstanceConfig::default());
470        let _ = svc
471            .dispatch(
472                inst,
473                Principal::System,
474                &SpawnOne,
475                Tick(0),
476                CapabilityMask::SYSTEM,
477                None,
478            )
479            .unwrap();
480
481        let wal = svc.export_wal().expect("WAL is configured");
482        assert_eq!(wal.records.len(), 2, "one dispatch = Submit + Step pair");
483
484        let mut buffer: Vec<u8> = Vec::new();
485        let mut sink = BufferedWalSink::new(&mut buffer);
486        wal_to_sink(&wal, &mut sink).expect("wal_to_sink must succeed");
487        // After flush the sink's internal buffer is empty; the writer
488        // (our `&mut buffer`) carries the bytes.
489        assert!(!buffer.is_empty(), "sink writer must hold framed bytes");
490        assert!(
491            buffer.starts_with(&crate::wal_export::STREAM_HEADER_MAGIC),
492            "sink stream must begin with ARKHEXP1 magic",
493        );
494    }
495
496    /// A WAL larger than the sink capacity exports through mid-stream
497    /// drain-and-retry: the framed output is byte-identical to an
498    /// export through a sink large enough to hold the whole WAL.
499    #[test]
500    fn wal_to_sink_drains_mid_stream_when_capacity_is_tight() {
501        use arkhe_kernel::abi::EntityId;
502        use arkhe_kernel::state::{ActionCompute, ActionContext, Op};
503        use arkhe_kernel::ArkheAction;
504        use serde::{Deserialize, Serialize};
505
506        #[derive(Serialize, Deserialize, ArkheAction)]
507        #[arkhe(type_code = 0x0001_5106, schema_version = 1)]
508        struct SpawnAt(u64);
509
510        impl ActionCompute for SpawnAt {
511            fn compute(&self, _ctx: &ActionContext<'_>) -> Vec<Op> {
512                vec![Op::SpawnEntity {
513                    id: EntityId::new(self.0.max(1)).unwrap(),
514                    owner: Principal::System,
515                }]
516            }
517        }
518
519        fn export(records_capacity: Option<usize>, wal: &Wal) -> Vec<u8> {
520            let mut buffer: Vec<u8> = Vec::new();
521            {
522                let mut sink = match records_capacity {
523                    Some(cap) => BufferedWalSink::with_capacity(&mut buffer, cap),
524                    None => BufferedWalSink::new(&mut buffer),
525                };
526                wal_to_sink(wal, &mut sink).expect("wal_to_sink succeeds");
527            }
528            buffer
529        }
530
531        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
532        svc.register_action::<SpawnAt>();
533        let inst = svc.create_instance(InstanceConfig::default());
534        for i in 1..=4 {
535            svc.dispatch(
536                inst,
537                Principal::System,
538                &SpawnAt(i),
539                Tick(i),
540                CapabilityMask::SYSTEM,
541                None,
542            )
543            .unwrap();
544        }
545        let wal = svc.export_wal().expect("WAL configured");
546        assert_eq!(wal.records.len(), 8);
547
548        let roomy = export(None, &wal);
549        // Capacity that holds the header + roughly one frame — every
550        // subsequent append overflows and must drain mid-stream.
551        let largest_frame = wal
552            .records
553            .iter()
554            .map(|r| 8 + postcard::to_allocvec(r).unwrap().len())
555            .max()
556            .unwrap();
557        let tight = export(Some(8 + largest_frame), &wal);
558        assert_eq!(
559            tight, roomy,
560            "drain-and-retry export must be byte-identical to a roomy export"
561        );
562    }
563
564    /// Multi-record dispatch + export: 3 ticks × 1 action each → 3
565    /// Submit + Step pairs (6 WAL records); `wal_to_sink` frames all six.
566    #[test]
567    fn wal_to_sink_handles_multi_record_stream() {
568        use arkhe_kernel::abi::EntityId;
569        use arkhe_kernel::state::{ActionCompute, ActionContext, Op};
570        use arkhe_kernel::ArkheAction;
571        use serde::{Deserialize, Serialize};
572
573        #[derive(Serialize, Deserialize, ArkheAction)]
574        #[arkhe(type_code = 0x0001_5104, schema_version = 1)]
575        struct SpawnAt(u64);
576
577        impl ActionCompute for SpawnAt {
578            fn compute(&self, _ctx: &ActionContext<'_>) -> Vec<Op> {
579                vec![Op::SpawnEntity {
580                    id: EntityId::new(self.0.max(1)).unwrap(),
581                    owner: Principal::System,
582                }]
583            }
584        }
585
586        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
587        svc.register_action::<SpawnAt>();
588        let inst = svc.create_instance(InstanceConfig::default());
589        for i in 1..=3 {
590            svc.dispatch(
591                inst,
592                Principal::System,
593                &SpawnAt(i),
594                Tick(i),
595                CapabilityMask::SYSTEM,
596                None,
597            )
598            .unwrap();
599        }
600        let wal = svc.export_wal().expect("WAL configured");
601        assert_eq!(wal.records.len(), 6, "3 dispatches = 3 Submit + Step pairs");
602
603        let mut buffer: Vec<u8> = Vec::new();
604        let mut sink = BufferedWalSink::new(&mut buffer);
605        wal_to_sink(&wal, &mut sink).unwrap();
606        assert!(!buffer.is_empty());
607        assert!(buffer.starts_with(&crate::wal_export::STREAM_HEADER_MAGIC));
608    }
609
610    /// End-to-end liveness proof of the L2 GDPR admission gate through
611    /// PRODUCTION actions only — no test-only seeding.
612    ///
613    /// `RegisterUser` spawns the user entity (with `UserGdprState::Active`),
614    /// `RegisterActor` spawns the actor entity and writes the actor → user
615    /// `UserBinding` the gate resolves through, and the actor's user-scoped
616    /// `CreateSpace` proceeds while the user is `Active`. After
617    /// `GdprEraseUser` flips the user to `ErasurePending`, the same actor's
618    /// next user-scoped action is REJECTED at dispatch with
619    /// `DispatchError::ErasurePending` BEFORE `submit` (no WAL record). This
620    /// exercises the full `RuntimeService::dispatch -> injected actor ->
621    /// instance_view -> ensure_actor_eligible` path that the viewless bridge
622    /// cannot cover, with every state write performed by a production action.
623    #[test]
624    fn dispatch_gdpr_gate_is_live_through_production_binding_path() {
625        use arkhe_forge_core::actor::{ActorKind, ActorProfile, RegisterActor, UserBinding};
626        use arkhe_forge_core::user::{
627            AuthCredential, AuthKind, GdprEraseUser, KdfKind, KdfParams, RegisterUser, UserId,
628            UserProfile,
629        };
630
631        fn create_space(slug: &str) -> CreateSpace {
632            CreateSpace {
633                schema_version: 1,
634                config: SpaceConfigDraft {
635                    schema_version: 1,
636                    shell_id: ShellId([0xC3; 16]),
637                    slug: BoundedString::<32>::new(slug).unwrap(),
638                    kind: SpaceKind::Flat,
639                    visibility: Visibility::Public,
640                    parent_space: None,
641                    created_tick: Tick(100),
642                },
643            }
644        }
645
646        /// Read the single component of type `C` in the live instance,
647        /// returning its entity id — recovers the runtime-derived user /
648        /// actor ids without predicting the id derivation.
649        fn single_entity_with<C: arkhe_forge_core::component::ArkheComponent>(
650            svc: &RuntimeService,
651            inst: InstanceId,
652        ) -> Option<EntityId> {
653            let view = svc.kernel.instance_view(inst)?;
654            let mut found = view
655                .components_by_type(TypeCode(C::TYPE_CODE))
656                .map(|(eid, _)| eid);
657            let first = found.next();
658            assert!(found.next().is_none(), "expected exactly one component");
659            first
660        }
661
662        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
663        svc.register_action::<RegisterUser>();
664        svc.register_action::<RegisterActor>();
665        svc.register_action::<GdprEraseUser>();
666        svc.register_action::<CreateSpace>();
667        let inst = svc.create_instance(InstanceConfig::default());
668
669        // 1 — register the user (system-scoped; spawns the user entity and
670        // seeds `UserGdprState::Active`).
671        svc.dispatch(
672            inst,
673            Principal::System,
674            &RegisterUser {
675                schema_version: 1,
676                profile: UserProfile {
677                    schema_version: 1,
678                    created_tick: Tick(1),
679                    primary_auth_kind: AuthKind::Passkey,
680                },
681                credential: AuthCredential {
682                    schema_version: 1,
683                    kind: AuthKind::Passkey,
684                    kdf: KdfKind::Argon2id,
685                    salt: [0u8; 16],
686                    credential_hash: [0u8; 32],
687                    kdf_params: KdfParams {
688                        m_cost: AuthCredential::MIN_ARGON2ID_M_COST,
689                        t_cost: AuthCredential::MIN_ARGON2ID_T_COST,
690                        p_cost: AuthCredential::MIN_ARGON2ID_P_COST,
691                    },
692                    expires_tick: None,
693                    bound_tick: Tick(1),
694                },
695            },
696            Tick(1),
697            CapabilityMask::SYSTEM,
698            None,
699        )
700        .expect("RegisterUser must succeed");
701        let user = UserId::new(
702            single_entity_with::<UserProfile>(&svc, inst).expect("user entity spawned"),
703        );
704
705        // 2 — register the actor bound to that user (system-scoped
706        // registration flow; writes the `UserBinding` the gate reads).
707        svc.dispatch(
708            inst,
709            Principal::System,
710            &RegisterActor {
711                schema_version: 1,
712                profile: ActorProfile {
713                    schema_version: 1,
714                    shell_id: ShellId([0xC3; 16]),
715                    handle: BoundedString::<32>::new("alice").unwrap(),
716                    kind: ActorKind::Human,
717                    created_tick: Tick(2),
718                },
719                user,
720            },
721            Tick(2),
722            CapabilityMask::SYSTEM,
723            None,
724        )
725        .expect("RegisterActor must succeed");
726        let actor = ActorId::new(
727            single_entity_with::<UserBinding>(&svc, inst).expect("actor entity spawned"),
728        );
729
730        // 3 — while the user is Active, the actor's user-scoped action
731        // passes the gate and lands in the WAL.
732        let report = svc
733            .dispatch(
734                inst,
735                Principal::System,
736                &create_space("welcome"),
737                Tick(3),
738                CapabilityMask::SYSTEM,
739                Some(actor),
740            )
741            .expect("Active user's actor must proceed");
742        assert_eq!(report.actions_executed, 1);
743
744        // 4 — request erasure (production blind write of `ErasurePending`).
745        svc.dispatch(
746            inst,
747            Principal::System,
748            &GdprEraseUser {
749                schema_version: 1,
750                user,
751            },
752            Tick(4),
753            CapabilityMask::SYSTEM,
754            None,
755        )
756        .expect("GdprEraseUser must succeed");
757
758        // 5 — the same actor is now rejected BEFORE submit: the error names
759        // the backing user and no WAL record is appended.
760        let wal_before = svc.kernel.wal_record_count();
761        let rejected = svc.dispatch(
762            inst,
763            Principal::System,
764            &create_space("forbidden"),
765            Tick(5),
766            CapabilityMask::SYSTEM,
767            Some(actor),
768        );
769        match rejected {
770            Err(DispatchError::ErasurePending { user: u, tick }) => {
771                assert_eq!(u, user, "rejection must name the backing user");
772                assert_eq!(tick, Tick(5));
773            }
774            other => panic!("expected ErasurePending rejection, got {:?}", other),
775        }
776        assert_eq!(
777            svc.kernel.wal_record_count(),
778            wal_before,
779            "rejected action must NOT append a WAL record",
780        );
781    }
782
783    /// Fail-closed companion to the production-binding liveness test: an
784    /// actor whose `UserBinding` names a NEVER-registered user (no
785    /// `UserGdprState` reachable) is rejected at admission with
786    /// `UnboundUserLifecycle` — admitting it would create a permanently
787    /// ungateable actor (erasing the unregistered user no-ops).
788    #[test]
789    fn dispatch_rejects_actor_bound_to_unregistered_user() {
790        use arkhe_forge_core::actor::{ActorKind, ActorProfile, RegisterActor, UserBinding};
791        use arkhe_forge_core::user::UserId;
792
793        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
794        svc.register_action::<RegisterActor>();
795        svc.register_action::<CreateSpace>();
796        let inst = svc.create_instance(InstanceConfig::default());
797
798        // Bind an actor to a user id that was never registered.
799        let phantom_user = UserId::new(EntityId::new(999).unwrap());
800        svc.dispatch(
801            inst,
802            Principal::System,
803            &RegisterActor {
804                schema_version: 1,
805                profile: ActorProfile {
806                    schema_version: 1,
807                    shell_id: ShellId([0xC3; 16]),
808                    handle: BoundedString::<32>::new("ghost").unwrap(),
809                    kind: ActorKind::Human,
810                    created_tick: Tick(1),
811                },
812                user: phantom_user,
813            },
814            Tick(1),
815            CapabilityMask::SYSTEM,
816            None,
817        )
818        .expect("RegisterActor itself is system-scoped and succeeds");
819        let actor_entity = svc
820            .kernel
821            .instance_view(inst)
822            .expect("instance live")
823            .components_by_type(TypeCode(UserBinding::TYPE_CODE))
824            .map(|(eid, _)| eid)
825            .next()
826            .expect("actor entity spawned with binding");
827
828        let wal_before = svc.kernel.wal_record_count();
829        let rejected = svc.dispatch(
830            inst,
831            Principal::System,
832            &user_create_space(),
833            Tick(2),
834            CapabilityMask::SYSTEM,
835            Some(ActorId::new(actor_entity)),
836        );
837        match rejected {
838            Err(DispatchError::UnboundUserLifecycle { user }) => {
839                assert_eq!(user, phantom_user, "rejection names the phantom user");
840            }
841            other => panic!("expected UnboundUserLifecycle, got {:?}", other),
842        }
843        assert_eq!(
844            svc.kernel.wal_record_count(),
845            wal_before,
846            "rejected action must NOT append a WAL record",
847        );
848    }
849
850    // ---------- #1/#2 single-source-of-truth acting actor (A+) ----------
851
852    use arkhe_forge_core::actor::ActorId;
853    use arkhe_forge_core::brand::ShellId;
854    use arkhe_forge_core::component::{ArkheComponent as _, BoundedString};
855    use arkhe_forge_core::space::{
856        CreateSpace, SpaceConfig, SpaceConfigDraft, SpaceKind, Visibility,
857    };
858    use arkhe_kernel::abi::{EntityId, TypeCode};
859
860    /// Build a user-scoped `CreateSpace`. The payload has NO creator field —
861    /// the creating actor is injected by the runtime, not carried on the wire.
862    fn user_create_space() -> CreateSpace {
863        CreateSpace {
864            schema_version: 1,
865            config: SpaceConfigDraft {
866                schema_version: 1,
867                shell_id: ShellId([0xC3; 16]),
868                slug: BoundedString::<32>::new("space").unwrap(),
869                kind: SpaceKind::Flat,
870                visibility: Visibility::Public,
871                parent_space: None,
872                created_tick: Tick(100),
873            },
874        }
875    }
876
877    fn actor(id: u64) -> ActorId {
878        ActorId::new(EntityId::new(id).unwrap())
879    }
880
881    /// Read the creator of the single stored `SpaceConfig` in a live instance.
882    /// Walks the view's `SpaceConfig` components (the dispatch produced exactly
883    /// one) without predicting the derived entity id.
884    fn stored_space_creator(svc: &RuntimeService, inst: InstanceId) -> Option<ActorId> {
885        let view = svc.kernel.instance_view(inst)?;
886        view.components_by_type(TypeCode(SpaceConfig::TYPE_CODE))
887            .find_map(|(_eid, bytes)| postcard::from_bytes::<SpaceConfig>(bytes).ok())
888            .map(|cfg| cfg.creator)
889    }
890
891    /// A+ core: a created space records the INJECTED authenticated actor as its
892    /// creator. There is no client-supplied creator field, so the recorded
893    /// identity is exactly the actor the runtime injected — actor-substitution
894    /// is structurally impossible.
895    #[test]
896    fn dispatch_records_injected_actor_as_creator() {
897        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
898        svc.register_action::<CreateSpace>();
899        let inst = svc.create_instance(InstanceConfig::default());
900
901        // No `UserBinding` seeded → the erasure gate soft-passes (Ok(None)),
902        // so an authenticated actor proceeds.
903        let report = svc
904            .dispatch(
905                inst,
906                Principal::System,
907                &user_create_space(),
908                Tick(1),
909                CapabilityMask::SYSTEM,
910                Some(actor(7)),
911            )
912            .expect("authenticated actor must proceed");
913        assert_eq!(report.actions_executed, 1);
914        assert_eq!(
915            svc.kernel.wal_record_count(),
916            Some(2),
917            "authenticated user-scoped action appends a Submit + Step pair",
918        );
919        assert_eq!(
920            stored_space_creator(&svc, inst),
921            Some(actor(7)),
922            "stored creator must equal the injected authenticated actor",
923        );
924    }
925
926    /// A+ core: the recorded creator tracks the INJECTED identity, not any
927    /// client value. Dispatching the same payload under a different injected
928    /// actor records that different actor — the acting identity is whatever
929    /// the runtime injected, full stop.
930    #[test]
931    fn dispatch_creator_follows_injected_identity() {
932        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
933        svc.register_action::<CreateSpace>();
934        let inst = svc.create_instance(InstanceConfig::default());
935        svc.dispatch(
936            inst,
937            Principal::System,
938            &user_create_space(),
939            Tick(1),
940            CapabilityMask::SYSTEM,
941            Some(actor(42)),
942        )
943        .expect("authenticated actor must proceed");
944        assert_eq!(
945            stored_space_creator(&svc, inst),
946            Some(actor(42)),
947            "stored creator equals the injected actor, whatever it is",
948        );
949    }
950
951    /// A+ core: a user-scoped action dispatched with no authenticated actor
952    /// (`authenticated_actor = None`) is rejected inside compute and never
953    /// reaches the WAL — a user-scoped action cannot proceed without an
954    /// injected identity. The kernel records the action submission envelope
955    /// but compute produces no Ops (no SpawnEntity / SetComponent), so no
956    /// Space is created.
957    #[test]
958    fn dispatch_unauthenticated_user_action_creates_no_space() {
959        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
960        svc.register_action::<CreateSpace>();
961        let inst = svc.create_instance(InstanceConfig::default());
962
963        let report = svc
964            .dispatch(
965                inst,
966                Principal::System,
967                &user_create_space(),
968                Tick(1),
969                CapabilityMask::SYSTEM,
970                None,
971            )
972            .expect("dispatch returns Ok — compute self-rejects, no error surface");
973        // Compute rejected → empty Op vec → no effects applied, no Space.
974        assert_eq!(report.effects_applied, 0);
975        assert_eq!(
976            stored_space_creator(&svc, inst),
977            None,
978            "no Space may be created without an injected actor",
979        );
980    }
981
982    /// Round-trip / replay: the WAL records the authenticated acting actor
983    /// (the value injected into `Kernel::submit`), and a fresh replay
984    /// reproduces the same stored creator — the recorded identity is canonical
985    /// input, not a re-derived guess.
986    #[test]
987    fn wal_replay_reproduces_injected_creator() {
988        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
989        svc.register_action::<CreateSpace>();
990        let inst = svc.create_instance(InstanceConfig::default());
991        svc.dispatch(
992            inst,
993            Principal::System,
994            &user_create_space(),
995            Tick(1),
996            CapabilityMask::SYSTEM,
997            Some(actor(7)),
998        )
999        .expect("authenticated actor proceeds");
1000
1001        let wal = svc.export_wal().expect("WAL configured");
1002        assert_eq!(wal.records.len(), 2, "one dispatch = Submit + Step pair");
1003        // The Submit record's actor IS the injected authenticated actor.
1004        let arkhe_kernel::persist::WalRecordContent::Submit {
1005            actor: recorded_actor,
1006            ..
1007        } = wal.records[0].content
1008        else {
1009            panic!("record 0 of a dispatch must be the Submit record");
1010        };
1011        assert_eq!(
1012            recorded_actor,
1013            Some(EntityId::new(7).unwrap()),
1014            "WAL Submit record must carry the injected acting actor as canonical input",
1015        );
1016
1017        // Replay the recorded action into a fresh service through the same
1018        // submit/step path — the replayed actor comes from the WAL Submit
1019        // record, so the reconstructed Space records the same creator.
1020        let mut replay = RuntimeService::new([0u8; 32], [0u8; 32]);
1021        replay.register_action::<CreateSpace>();
1022        let rinst = replay.create_instance(InstanceConfig::default());
1023        replay
1024            .dispatch(
1025                rinst,
1026                Principal::System,
1027                &user_create_space(),
1028                Tick(1),
1029                CapabilityMask::SYSTEM,
1030                recorded_actor.map(ActorId::new),
1031            )
1032            .expect("replay proceeds");
1033        assert_eq!(
1034            stored_space_creator(&replay, rinst),
1035            Some(actor(7)),
1036            "replay reproduces the WAL-recorded acting actor as creator",
1037        );
1038    }
1039}