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 — authorization, dispatch, WAL append — internally.
9//!
10//! Forge actions are made kernel-compatible by the
11//! `arkhe-forge-macros::ArkheAction` derive: it emits both the
12//! forge-side sealed-trait stack **and** the kernel-side `Sealed +
13//! ActionDeriv + ActionCompute` stack, with the kernel-side
14//! `ActionCompute::compute` body delegating to
15//! `arkhe_forge_core::bridge::kernel_compute`. The bridge runs the
16//! forge `compute()` on a fresh forge `ActionContext` and returns the
17//! drained `Vec<Op>` to the kernel.
18//!
19//! ## WAL export
20//!
21//! After one or more `dispatch` calls, the caller may extract the
22//! kernel's internal WAL via [`RuntimeService::export_wal`] (consumes
23//! the service). Each [`arkhe_kernel::WalRecord`] in the returned
24//! [`arkhe_kernel::Wal`] can be streamed into a
25//! [`crate::wal_export::BufferedWalSink`] via [`wal_to_sink`] for
26//! durable backup; the sink frames each record
27//! with the standard magic + length-prefix shape per the firm
28//! requirements pinned in `wal_export`.
29//!
30//! ## Current scope
31//!
32//! Manifest-driven authz policy, the PG-UNIQUE-INDEX-backed
33//! idempotency dedup, and full
34//! [`ActorHandleIndex`](arkhe_forge_core::context::ActorHandleIndex)
35//! production paths are not yet wired through `RuntimeService` — a
36//! forge action's idempotency / actor-handle paths run with the L1
37//! defaults (no view, no index). Callers who need those layers attach
38//! them through the forge `ActionContext` builder directly while the
39//! L2 layer matures.
40
41use arkhe_forge_core::actor::ActorId;
42use arkhe_forge_core::context::{ActionContext, ActionError};
43use arkhe_forge_core::user::UserId;
44use arkhe_kernel::abi::{ArkheError, CapabilityMask, InstanceId, Principal, Tick};
45use arkhe_kernel::state::traits::Action;
46use arkhe_kernel::state::InstanceConfig;
47use arkhe_kernel::{Kernel, StepReport, Wal};
48
49use crate::wal_export::{BufferedWalSink, WalExportError, WalRecordSink};
50
51/// Error surface for [`RuntimeService::dispatch`].
52///
53/// `dispatch` is forge's own maturing L2 API, so it returns this richer
54/// enum rather than the kernel's [`ArkheError`] directly: the GDPR
55/// `ErasurePending` admission gate (C3) is an L2 concern with no kernel
56/// error variant, so it is surfaced as its own arm. Kernel-level errors
57/// pass through [`DispatchError::Kernel`] unchanged.
58#[derive(Debug, thiserror::Error)]
59#[non_exhaustive]
60pub enum DispatchError {
61    /// Kernel-side error from `submit` / `step` (e.g. `InstanceNotFound`).
62    #[error("kernel error: {0}")]
63    Kernel(#[from] ArkheError),
64
65    /// L2 admission gate rejected the action: the actor's backing user is
66    /// in `GdprStatus::ErasurePending`, so the action is refused before it
67    /// reaches the WAL (E-user-3 C3 — admission control at the L2 boundary).
68    #[error("user erasure pending: {user:?} scheduled at {tick:?}")]
69    ErasurePending {
70        /// Backing user whose erasure is in flight.
71        user: UserId,
72        /// Tick at which the action was attempted.
73        tick: Tick,
74    },
75
76    /// The admission-gate probe read corrupt view bytes while resolving the
77    /// actor's `UserBinding` / `UserGdprState`. Fail closed rather than admit.
78    #[error("GDPR admission probe failed: corrupt view state")]
79    ProbeViewCorrupt,
80}
81
82/// Errors surfaced by [`wal_to_sink`].
83#[derive(Debug, thiserror::Error)]
84#[non_exhaustive]
85pub enum WalSinkError {
86    /// `WalRecord` failed postcard encoding (should be unreachable —
87    /// `WalRecord` is `derive(Serialize)` on a stable wire shape).
88    #[error("WalRecord postcard encode failed: {0}")]
89    Encode(#[from] postcard::Error),
90    /// Sink rejected the framed record (length / append-only / overflow).
91    #[error("BufferedWalSink rejected record: {0}")]
92    Sink(#[from] WalExportError),
93}
94
95/// Service-layer wrapper around [`arkhe_kernel::Kernel`]. Builds a
96/// kernel with WAL configured and exposes a forge-shaped dispatch API.
97pub struct RuntimeService {
98    kernel: Kernel,
99}
100
101impl RuntimeService {
102    /// Construct a service backed by a chain-only WAL writer (L0
103    /// `SignatureClass::None`). `world_id` and `manifest_digest` are
104    /// pinned into the WAL header.
105    #[must_use]
106    pub fn new(world_id: [u8; 32], manifest_digest: [u8; 32]) -> Self {
107        Self {
108            kernel: Kernel::new_with_wal(world_id, manifest_digest),
109        }
110    }
111
112    /// Register a forge `ArkheAction` so the kernel will execute it
113    /// when scheduled. Any forge action whose type bears
114    /// `#[derive(ArkheAction)]` automatically satisfies the kernel
115    /// [`Action`] bound through the derive's emitted kernel-side
116    /// stack.
117    pub fn register_action<A: Action>(&mut self) {
118        self.kernel.register_action::<A>();
119    }
120
121    /// Create a fresh kernel instance and return its `InstanceId`.
122    pub fn create_instance(&mut self, config: InstanceConfig) -> InstanceId {
123        self.kernel.create_instance(config)
124    }
125
126    /// Dispatch a forge action — inject the authenticated actor through the
127    /// kernel actor channel, run the L2 GDPR admission gate on it,
128    /// postcard-encode the action's canonical bytes, submit at tick `at`,
129    /// then step the kernel once with `caps`. Returns the kernel's
130    /// `StepReport` so the caller can inspect `actions_executed` /
131    /// `effects_applied` / `effects_denied`.
132    ///
133    /// ## Single source of truth for the acting actor
134    ///
135    /// `authenticated_actor` is the caller identity the integrator's
136    /// auth / session layer (which sits ABOVE forge) has already verified —
137    /// e.g. resolved from a login session, bearer token, or passkey
138    /// assertion. `None` denotes a system / anonymous caller with no
139    /// authenticated actor.
140    ///
141    /// `dispatch` threads this actor into [`Kernel::submit`]'s actor channel
142    /// (as `Option<EntityId>` via [`ActorId::get`]). The kernel records it in
143    /// the WAL record and replays it into `KernelActionContext::actor`, which
144    /// the [`arkhe_forge_core::bridge`] injects as the forge
145    /// [`ActionContext::acting_actor`]. A user-scoped compute body reads its
146    /// acting identity from THAT channel and stamps it into the stored record
147    /// (`SpaceConfig.creator`, `ActivityRecord.actor`) — there is no
148    /// wire-controlled actor field to substitute, so the C3
149    /// actor-substitution attack is structurally impossible. A user-scoped
150    /// action submitted with `authenticated_actor == None` is rejected inside
151    /// compute (the bridge collapses the rejection to no Ops, so it never
152    /// reaches the WAL). A system action that does not read `acting_actor`
153    /// proceeds with `None`.
154    ///
155    /// ## GDPR `ErasurePending` admission gate (C3)
156    ///
157    /// The kernel `compute` path drives a forge action through a viewless
158    /// [`ActionContext`] (see [`arkhe_forge_core::bridge`]), so the
159    /// in-compute `ensure_actor_eligible` check soft-passes — it cannot
160    /// read the actor's `UserBinding` / `UserGdprState` without a bound
161    /// view. This method closes that gap at the L2 boundary: when
162    /// `authenticated_actor` is `Some`, the service binds a fresh
163    /// `InstanceView`, runs the existing `ensure_actor_eligible` logic on
164    /// that injected actor, and REJECTS the action before `submit` if the
165    /// backing user is `ErasurePending`. The gate is SOUND — the actor it
166    /// gates on is the authenticated caller, the same single source of truth
167    /// the compute records. It is also LIVE:
168    /// [`GdprEraseUser`](arkhe_forge_core::user::GdprEraseUser) transitions
169    /// the user's `UserGdprState` to `ErasurePending` with a blind write
170    /// (valid on the viewless compute path), so once erasure is requested
171    /// this gate rejects the user's subsequent actions before `submit` (never
172    /// reaches the WAL), as this method's tests demonstrate.
173    ///
174    /// # Errors
175    ///
176    /// * [`DispatchError::ErasurePending`] — the L2 gate rejected the
177    ///   action (backing user in `GdprStatus::ErasurePending`).
178    /// * [`DispatchError::Kernel`] — kernel-side error from `submit`
179    ///   (`InstanceNotFound` if `instance` is not live). Capability denial
180    ///   happens inside `step` and is reflected in the returned report's
181    ///   `effects_denied` count rather than as an `Err`.
182    pub fn dispatch<A>(
183        &mut self,
184        instance: InstanceId,
185        principal: Principal,
186        action: &A,
187        at: Tick,
188        caps: CapabilityMask,
189        authenticated_actor: Option<ActorId>,
190    ) -> Result<StepReport, DispatchError>
191    where
192        A: Action,
193    {
194        // L2 admission gate (C3) — runs on the injected authenticated actor,
195        // BEFORE submit, with the view dropped before the `&mut self.kernel`
196        // step call. Reuses the forge-core in-compute eligibility check; the
197        // probe context is read-only (zero world_seed, no Op emission). A
198        // system caller (`authenticated_actor == None`) has no user scope to
199        // gate, so the probe is skipped.
200        if let Some(actor) = authenticated_actor {
201            let view = self
202                .kernel
203                .instance_view(instance)
204                .ok_or(ArkheError::InstanceNotFound)?;
205            let probe = ActionContext::new([0u8; 32], instance, at, principal.clone(), caps)
206                .with_view(&view);
207            if let Err(err) = probe.ensure_actor_eligible(actor, at) {
208                return match err {
209                    ActionError::UserErasurePending { user, .. } => {
210                        Err(DispatchError::ErasurePending { user, tick: at })
211                    }
212                    // `ensure_actor_eligible` otherwise only fails with an
213                    // `InvalidInput` on corrupt view bytes; fail closed.
214                    _ => Err(DispatchError::ProbeViewCorrupt),
215                };
216            }
217        }
218
219        // Inject the authenticated actor through the kernel actor channel —
220        // the single source of truth. The kernel records it in the WAL and
221        // replays it into compute via the bridge.
222        let bytes = action.canonical_bytes();
223        self.kernel.submit(
224            instance,
225            principal,
226            authenticated_actor.map(ActorId::get),
227            at,
228            A::TYPE_CODE,
229            bytes,
230        )?;
231        Ok(self.kernel.step(at, caps))
232    }
233
234    /// Drain the kernel's internal WAL (consumes the service so the
235    /// kernel cannot continue stepping after export).
236    #[must_use]
237    pub fn export_wal(self) -> Option<Wal> {
238        self.kernel.export_wal()
239    }
240}
241
242/// Append every record of `wal` into the buffered sink, then flush.
243/// Each record is postcard-serialized via the kernel's stable
244/// [`arkhe_kernel::WalRecord`] wire shape (DO NOT TOUCH #7 —
245/// `seq: u64` first declared field) and the sink frames with the
246/// standard magic + length-prefix per `wal_export`'s firm
247/// requirements.
248///
249/// # Errors
250///
251/// Returns [`WalSinkError::Encode`] if a record fails postcard
252/// serialization (unreachable in practice — `WalRecord` derives
253/// `Serialize` over a stable shape) or [`WalSinkError::Sink`] if the
254/// sink rejects the framed record (length, append-only, overflow).
255pub fn wal_to_sink<W: std::io::Write>(
256    wal: &Wal,
257    sink: &mut BufferedWalSink<W>,
258) -> Result<(), WalSinkError> {
259    for record in &wal.records {
260        let bytes = postcard::to_allocvec(record)?;
261        sink.append_record(&bytes)?;
262    }
263    sink.flush()?;
264    Ok(())
265}
266
267#[cfg(test)]
268#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
269mod tests {
270    use super::*;
271    use arkhe_kernel::abi::{Principal, Tick};
272
273    /// Smoke — `RuntimeService::new` returns a service whose underlying
274    /// kernel reports zero records (the WAL header has been pinned but
275    /// no `step` has fired yet).
276    #[test]
277    fn fresh_service_has_zero_wal_records() {
278        let svc = RuntimeService::new([0x11u8; 32], [0x22u8; 32]);
279        assert_eq!(svc.kernel.wal_record_count(), Some(0));
280    }
281
282    /// `create_instance` increments the kernel's instance count.
283    #[test]
284    fn create_instance_grows_kernel() {
285        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
286        let _id = svc.create_instance(InstanceConfig::default());
287        assert_eq!(svc.kernel.instances_len(), 1);
288    }
289
290    /// `dispatch` returns `InstanceNotFound` for an unregistered
291    /// instance — verifies the `Result` plumbing without needing a
292    /// concrete forge action in the platform-crate test scope (forge
293    /// actions live in forge-core and downstream crates).
294    #[test]
295    fn dispatch_unknown_instance_returns_instance_not_found() {
296        // Use a dummy kernel-Action via the kernel's own derive —
297        // platform crate sees only kernel surface, no forge-core dep
298        // in test scope (avoids cross-crate test churn).
299        use arkhe_kernel::abi::EntityId;
300        use arkhe_kernel::state::{ActionCompute, ActionContext, Op};
301        use arkhe_kernel::ArkheAction;
302        use serde::{Deserialize, Serialize};
303
304        #[derive(Serialize, Deserialize, ArkheAction)]
305        #[arkhe(type_code = 0x0001_5101, schema_version = 1)]
306        struct NoopAction;
307
308        impl ActionCompute for NoopAction {
309            fn compute(&self, _ctx: &ActionContext<'_>) -> Vec<Op> {
310                vec![Op::SpawnEntity {
311                    id: EntityId::new(1).unwrap(),
312                    owner: Principal::System,
313                }]
314            }
315        }
316
317        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
318        svc.register_action::<NoopAction>();
319        // No `create_instance` call — InstanceId(99) is not live.
320        let bogus = InstanceId::new(99).unwrap();
321        let result = svc.dispatch(
322            bogus,
323            Principal::System,
324            &NoopAction,
325            Tick(1),
326            CapabilityMask::SYSTEM,
327            None,
328        );
329        assert!(matches!(
330            result,
331            Err(DispatchError::Kernel(ArkheError::InstanceNotFound))
332        ));
333    }
334
335    /// Happy-path dispatch — register → create_instance → dispatch
336    /// returns `Ok(StepReport)` with `actions_executed = 1`.
337    #[test]
338    fn dispatch_happy_path_executes_one_action() {
339        use arkhe_kernel::abi::EntityId;
340        use arkhe_kernel::state::{ActionCompute, ActionContext, Op};
341        use arkhe_kernel::ArkheAction;
342        use serde::{Deserialize, Serialize};
343
344        #[derive(Serialize, Deserialize, ArkheAction)]
345        #[arkhe(type_code = 0x0001_5102, schema_version = 1)]
346        struct SpawnOne;
347
348        impl ActionCompute for SpawnOne {
349            fn compute(&self, _ctx: &ActionContext<'_>) -> Vec<Op> {
350                vec![Op::SpawnEntity {
351                    id: EntityId::new(1).unwrap(),
352                    owner: Principal::System,
353                }]
354            }
355        }
356
357        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
358        svc.register_action::<SpawnOne>();
359        let inst = svc.create_instance(InstanceConfig::default());
360        let report = svc
361            .dispatch(
362                inst,
363                Principal::System,
364                &SpawnOne,
365                Tick(0),
366                CapabilityMask::SYSTEM,
367                None,
368            )
369            .expect("dispatch must succeed for live instance");
370        assert_eq!(report.actions_executed, 1);
371        assert_eq!(report.effects_applied, 1);
372        assert_eq!(report.effects_denied, 0);
373    }
374
375    /// `wal_to_sink` round-trips: dispatch one action, export WAL,
376    /// stream into `BufferedWalSink<Vec<u8>>` — sink buffer ends up
377    /// non-empty + starts with the stream-header magic.
378    #[test]
379    fn wal_to_sink_round_trips_single_record() {
380        use arkhe_kernel::abi::EntityId;
381        use arkhe_kernel::state::{ActionCompute, ActionContext, Op};
382        use arkhe_kernel::ArkheAction;
383        use serde::{Deserialize, Serialize};
384
385        #[derive(Serialize, Deserialize, ArkheAction)]
386        #[arkhe(type_code = 0x0001_5103, schema_version = 1)]
387        struct SpawnOne;
388
389        impl ActionCompute for SpawnOne {
390            fn compute(&self, _ctx: &ActionContext<'_>) -> Vec<Op> {
391                vec![Op::SpawnEntity {
392                    id: EntityId::new(1).unwrap(),
393                    owner: Principal::System,
394                }]
395            }
396        }
397
398        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
399        svc.register_action::<SpawnOne>();
400        let inst = svc.create_instance(InstanceConfig::default());
401        let _ = svc
402            .dispatch(
403                inst,
404                Principal::System,
405                &SpawnOne,
406                Tick(0),
407                CapabilityMask::SYSTEM,
408                None,
409            )
410            .unwrap();
411
412        let wal = svc.export_wal().expect("WAL is configured");
413        assert_eq!(wal.records.len(), 1);
414
415        let mut buffer: Vec<u8> = Vec::new();
416        let mut sink = BufferedWalSink::new(&mut buffer);
417        wal_to_sink(&wal, &mut sink).expect("wal_to_sink must succeed");
418        // After flush the sink's internal buffer is empty; the writer
419        // (our `&mut buffer`) carries the bytes.
420        assert!(!buffer.is_empty(), "sink writer must hold framed bytes");
421        assert!(
422            buffer.starts_with(&crate::wal_export::STREAM_HEADER_MAGIC),
423            "sink stream must begin with ARKHEXP1 magic",
424        );
425    }
426
427    /// Multi-record dispatch + export: 3 ticks × 1 action each → 3
428    /// WAL records; `wal_to_sink` frames all three.
429    #[test]
430    fn wal_to_sink_handles_multi_record_stream() {
431        use arkhe_kernel::abi::EntityId;
432        use arkhe_kernel::state::{ActionCompute, ActionContext, Op};
433        use arkhe_kernel::ArkheAction;
434        use serde::{Deserialize, Serialize};
435
436        #[derive(Serialize, Deserialize, ArkheAction)]
437        #[arkhe(type_code = 0x0001_5104, schema_version = 1)]
438        struct SpawnAt(u64);
439
440        impl ActionCompute for SpawnAt {
441            fn compute(&self, _ctx: &ActionContext<'_>) -> Vec<Op> {
442                vec![Op::SpawnEntity {
443                    id: EntityId::new(self.0.max(1)).unwrap(),
444                    owner: Principal::System,
445                }]
446            }
447        }
448
449        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
450        svc.register_action::<SpawnAt>();
451        let inst = svc.create_instance(InstanceConfig::default());
452        for i in 1..=3 {
453            svc.dispatch(
454                inst,
455                Principal::System,
456                &SpawnAt(i),
457                Tick(i),
458                CapabilityMask::SYSTEM,
459                None,
460            )
461            .unwrap();
462        }
463        let wal = svc.export_wal().expect("WAL configured");
464        assert_eq!(wal.records.len(), 3);
465
466        let mut buffer: Vec<u8> = Vec::new();
467        let mut sink = BufferedWalSink::new(&mut buffer);
468        wal_to_sink(&wal, &mut sink).unwrap();
469        assert!(!buffer.is_empty());
470        assert!(buffer.starts_with(&crate::wal_export::STREAM_HEADER_MAGIC));
471    }
472
473    /// End-to-end proof of the #2 L2 GDPR admission gate.
474    ///
475    /// A test-only seeding action writes a `UserBinding` (actor → user) plus a
476    /// `UserGdprState { status }` into a live kernel instance so the
477    /// `InstanceView` the gate reads is populated. A real forge `CreateSpace`
478    /// for an `ErasurePending` user is then REJECTED at dispatch (no WAL
479    /// record), while the same action for an `Active` user proceeds and
480    /// appends a record. This exercises the full
481    /// `RuntimeService::dispatch -> injected actor -> instance_view ->
482    /// ensure_actor_eligible` path that the viewless bridge cannot cover.
483    #[test]
484    fn dispatch_rejects_erasure_pending_actor_before_wal() {
485        use arkhe_forge_core::actor::{ActorId, UserBinding};
486        use arkhe_forge_core::brand::ShellId;
487        use arkhe_forge_core::component::{ArkheComponent, BoundedString};
488        use arkhe_forge_core::space::{CreateSpace, SpaceConfigDraft, SpaceKind, Visibility};
489        use arkhe_forge_core::user::{GdprStatus, UserGdprState, UserId};
490        use arkhe_kernel::abi::{EntityId, TypeCode};
491        use arkhe_kernel::state::{ActionCompute, ActionContext, Op};
492        use arkhe_kernel::ArkheAction;
493        use serde::{Deserialize, Serialize};
494
495        // Test-only kernel action: stage `UserBinding` on the actor entity and
496        // `UserGdprState` on the user entity so the gate's `InstanceView` reads
497        // are populated. Carries the desired GDPR status as a wire byte.
498        #[derive(Serialize, Deserialize, ArkheAction)]
499        #[arkhe(type_code = 0x0001_5105, schema_version = 1)]
500        struct SeedBinding {
501            actor: u64,
502            user: u64,
503            erasing: bool,
504        }
505
506        impl ActionCompute for SeedBinding {
507            fn compute(&self, _ctx: &ActionContext<'_>) -> Vec<Op> {
508                let binding = UserBinding {
509                    schema_version: 1,
510                    user_id: UserId::new(EntityId::new(self.user).unwrap()),
511                };
512                let state = UserGdprState {
513                    schema_version: 1,
514                    status: if self.erasing {
515                        GdprStatus::ErasurePending
516                    } else {
517                        GdprStatus::Active
518                    },
519                };
520                let bb = postcard::to_allocvec(&binding).unwrap();
521                let sb = postcard::to_allocvec(&state).unwrap();
522                vec![
523                    Op::SetComponent {
524                        entity: EntityId::new(self.actor).unwrap(),
525                        type_code: TypeCode(UserBinding::TYPE_CODE),
526                        size: bb.len() as u64,
527                        bytes: bytes::Bytes::from(bb),
528                    },
529                    Op::SetComponent {
530                        entity: EntityId::new(self.user).unwrap(),
531                        type_code: TypeCode(UserGdprState::TYPE_CODE),
532                        size: sb.len() as u64,
533                        bytes: bytes::Bytes::from(sb),
534                    },
535                ]
536            }
537        }
538
539        fn create_space() -> CreateSpace {
540            CreateSpace {
541                schema_version: 1,
542                config: SpaceConfigDraft {
543                    schema_version: 1,
544                    shell_id: ShellId([0xC3; 16]),
545                    slug: BoundedString::<32>::new("forbidden").unwrap(),
546                    kind: SpaceKind::Flat,
547                    visibility: Visibility::Public,
548                    parent_space: None,
549                    created_tick: Tick(100),
550                },
551            }
552        }
553
554        // --- ErasurePending user is rejected, no WAL record ---
555        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
556        svc.register_action::<SeedBinding>();
557        svc.register_action::<CreateSpace>();
558        let inst = svc.create_instance(InstanceConfig::default());
559
560        svc.dispatch(
561            inst,
562            Principal::System,
563            &SeedBinding {
564                actor: 8,
565                user: 7,
566                erasing: true,
567            },
568            Tick(1),
569            CapabilityMask::SYSTEM,
570            None,
571        )
572        .expect("seed must succeed");
573        let after_seed = svc.kernel.wal_record_count();
574        assert_eq!(after_seed, Some(1), "seed action appends one record");
575
576        // Authenticated as actor 8 — passes the auth gate, then the
577        // erasure gate rejects because actor 8's user is ErasurePending.
578        let rejected = svc.dispatch(
579            inst,
580            Principal::System,
581            &create_space(),
582            Tick(2),
583            CapabilityMask::SYSTEM,
584            Some(ActorId::new(EntityId::new(8).unwrap())),
585        );
586        match rejected {
587            Err(DispatchError::ErasurePending { user, tick }) => {
588                assert_eq!(user, UserId::new(EntityId::new(7).unwrap()));
589                assert_eq!(tick, Tick(2));
590            }
591            other => panic!("expected ErasurePending rejection, got {:?}", other),
592        }
593        assert_eq!(
594            svc.kernel.wal_record_count(),
595            Some(1),
596            "rejected action must NOT append a WAL record",
597        );
598
599        // --- Active user proceeds and appends a record ---
600        let mut svc2 = RuntimeService::new([0u8; 32], [0u8; 32]);
601        svc2.register_action::<SeedBinding>();
602        svc2.register_action::<CreateSpace>();
603        let inst2 = svc2.create_instance(InstanceConfig::default());
604        svc2.dispatch(
605            inst2,
606            Principal::System,
607            &SeedBinding {
608                actor: 8,
609                user: 7,
610                erasing: false,
611            },
612            Tick(1),
613            CapabilityMask::SYSTEM,
614            None,
615        )
616        .expect("seed must succeed");
617        let report = svc2
618            .dispatch(
619                inst2,
620                Principal::System,
621                &create_space(),
622                Tick(2),
623                CapabilityMask::SYSTEM,
624                Some(ActorId::new(EntityId::new(8).unwrap())),
625            )
626            .expect("Active user must proceed");
627        assert_eq!(report.actions_executed, 1);
628        assert_eq!(
629            svc2.kernel.wal_record_count(),
630            Some(2),
631            "Active-user action appends a second WAL record",
632        );
633    }
634
635    // ---------- #1/#2 single-source-of-truth acting actor (A+) ----------
636
637    use arkhe_forge_core::actor::ActorId;
638    use arkhe_forge_core::brand::ShellId;
639    use arkhe_forge_core::component::{ArkheComponent as _, BoundedString};
640    use arkhe_forge_core::space::{
641        CreateSpace, SpaceConfig, SpaceConfigDraft, SpaceKind, Visibility,
642    };
643    use arkhe_kernel::abi::{EntityId, TypeCode};
644
645    /// Build a user-scoped `CreateSpace`. The payload has NO creator field —
646    /// the creating actor is injected by the runtime, not carried on the wire.
647    fn user_create_space() -> CreateSpace {
648        CreateSpace {
649            schema_version: 1,
650            config: SpaceConfigDraft {
651                schema_version: 1,
652                shell_id: ShellId([0xC3; 16]),
653                slug: BoundedString::<32>::new("space").unwrap(),
654                kind: SpaceKind::Flat,
655                visibility: Visibility::Public,
656                parent_space: None,
657                created_tick: Tick(100),
658            },
659        }
660    }
661
662    fn actor(id: u64) -> ActorId {
663        ActorId::new(EntityId::new(id).unwrap())
664    }
665
666    /// Read the creator of the single stored `SpaceConfig` in a live instance.
667    /// Walks the view's `SpaceConfig` components (the dispatch produced exactly
668    /// one) without predicting the derived entity id.
669    fn stored_space_creator(svc: &RuntimeService, inst: InstanceId) -> Option<ActorId> {
670        let view = svc.kernel.instance_view(inst)?;
671        view.components_by_type(TypeCode(SpaceConfig::TYPE_CODE))
672            .find_map(|(_eid, bytes)| postcard::from_bytes::<SpaceConfig>(bytes).ok())
673            .map(|cfg| cfg.creator)
674    }
675
676    /// A+ core: a created space records the INJECTED authenticated actor as its
677    /// creator. There is no client-supplied creator field, so the recorded
678    /// identity is exactly the actor the runtime injected — actor-substitution
679    /// is structurally impossible.
680    #[test]
681    fn dispatch_records_injected_actor_as_creator() {
682        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
683        svc.register_action::<CreateSpace>();
684        let inst = svc.create_instance(InstanceConfig::default());
685
686        // No `UserBinding` seeded → the erasure gate soft-passes (Ok(None)),
687        // so an authenticated actor proceeds.
688        let report = svc
689            .dispatch(
690                inst,
691                Principal::System,
692                &user_create_space(),
693                Tick(1),
694                CapabilityMask::SYSTEM,
695                Some(actor(7)),
696            )
697            .expect("authenticated actor must proceed");
698        assert_eq!(report.actions_executed, 1);
699        assert_eq!(
700            svc.kernel.wal_record_count(),
701            Some(1),
702            "authenticated user-scoped action appends a WAL record",
703        );
704        assert_eq!(
705            stored_space_creator(&svc, inst),
706            Some(actor(7)),
707            "stored creator must equal the injected authenticated actor",
708        );
709    }
710
711    /// A+ core: the recorded creator tracks the INJECTED identity, not any
712    /// client value. Dispatching the same payload under a different injected
713    /// actor records that different actor — the acting identity is whatever
714    /// the runtime injected, full stop.
715    #[test]
716    fn dispatch_creator_follows_injected_identity() {
717        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
718        svc.register_action::<CreateSpace>();
719        let inst = svc.create_instance(InstanceConfig::default());
720        svc.dispatch(
721            inst,
722            Principal::System,
723            &user_create_space(),
724            Tick(1),
725            CapabilityMask::SYSTEM,
726            Some(actor(42)),
727        )
728        .expect("authenticated actor must proceed");
729        assert_eq!(
730            stored_space_creator(&svc, inst),
731            Some(actor(42)),
732            "stored creator equals the injected actor, whatever it is",
733        );
734    }
735
736    /// A+ core: a user-scoped action dispatched with no authenticated actor
737    /// (`authenticated_actor = None`) is rejected inside compute and never
738    /// reaches the WAL — a user-scoped action cannot proceed without an
739    /// injected identity. The kernel records the action submission envelope
740    /// but compute produces no Ops (no SpawnEntity / SetComponent), so no
741    /// Space is created.
742    #[test]
743    fn dispatch_unauthenticated_user_action_creates_no_space() {
744        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
745        svc.register_action::<CreateSpace>();
746        let inst = svc.create_instance(InstanceConfig::default());
747
748        let report = svc
749            .dispatch(
750                inst,
751                Principal::System,
752                &user_create_space(),
753                Tick(1),
754                CapabilityMask::SYSTEM,
755                None,
756            )
757            .expect("dispatch returns Ok — compute self-rejects, no error surface");
758        // Compute rejected → empty Op vec → no effects applied, no Space.
759        assert_eq!(report.effects_applied, 0);
760        assert_eq!(
761            stored_space_creator(&svc, inst),
762            None,
763            "no Space may be created without an injected actor",
764        );
765    }
766
767    /// Round-trip / replay: the WAL records the authenticated acting actor
768    /// (the value injected into `Kernel::submit`), and a fresh replay
769    /// reproduces the same stored creator — the recorded identity is canonical
770    /// input, not a re-derived guess.
771    #[test]
772    fn wal_replay_reproduces_injected_creator() {
773        let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
774        svc.register_action::<CreateSpace>();
775        let inst = svc.create_instance(InstanceConfig::default());
776        svc.dispatch(
777            inst,
778            Principal::System,
779            &user_create_space(),
780            Tick(1),
781            CapabilityMask::SYSTEM,
782            Some(actor(7)),
783        )
784        .expect("authenticated actor proceeds");
785
786        let wal = svc.export_wal().expect("WAL configured");
787        assert_eq!(wal.records.len(), 1);
788        // The WAL record's actor IS the injected authenticated actor.
789        assert_eq!(
790            wal.records[0].actor,
791            Some(EntityId::new(7).unwrap()),
792            "WAL must record the injected acting actor as canonical input",
793        );
794
795        // Replay the recorded action into a fresh service through the same
796        // submit/step path — the replayed actor comes from the WAL record, so
797        // the reconstructed Space records the same creator.
798        let mut replay = RuntimeService::new([0u8; 32], [0u8; 32]);
799        replay.register_action::<CreateSpace>();
800        let rinst = replay.create_instance(InstanceConfig::default());
801        replay
802            .dispatch(
803                rinst,
804                Principal::System,
805                &user_create_space(),
806                Tick(1),
807                CapabilityMask::SYSTEM,
808                wal.records[0].actor.map(ActorId::new),
809            )
810            .expect("replay proceeds");
811        assert_eq!(
812            stored_space_creator(&replay, rinst),
813            Some(actor(7)),
814            "replay reproduces the WAL-recorded acting actor as creator",
815        );
816    }
817}