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}