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}