arkhe_forge_core/context.rs
1//! L1 compute context — primitive-facing interface for Action `compute()`.
2//!
3//! `ActionContext<'i>` carries the per-tick state an Action needs to produce
4//! effects: derived entity id generator, `EventRecord` buffer, `Op`
5//! accumulator (bridges into the L0 dispatch path), principal / caps,
6//! idempotency-key lookup.
7//!
8//! ## L0 bridge model
9//!
10//! Each `compute()` invocation accumulates L0 `Op`s (`SpawnEntity` /
11//! `SetComponent` / `RemoveComponent` / `EmitEvent` / …) into an internal
12//! `Vec<Op>`. The L2 service layer (`RuntimeService` in
13//! `arkhe-forge-platform`; reference by name only — forge-core does not
14//! import forge-platform, layer-independence directive) drives the
15//! [`Kernel::submit`](arkhe_kernel::Kernel::submit) +
16//! [`Kernel::step`](arkhe_kernel::Kernel::step) loop; the kernel-side
17//! [`ActionCompute`](arkhe_kernel::state::ActionCompute) impl emitted
18//! by `#[derive(ArkheAction)]` invokes
19//! [`crate::bridge::kernel_compute`], which reconstructs a fresh
20//! `ActionContext` from the kernel's read-only context view, runs the
21//! forge `compute()` body, and returns the drained `Vec<Op>` to the
22//! kernel. The kernel performs authorize → dispatch on its own
23//! internal `Effect<'i, _>` lifecycle and appends a WAL `Step` record
24//! (verdict + post-state digest) per pop — the `Submit` record was
25//! already appended at admission (the `Effect` constructor and
26//! `authorize` function are kernel-private, by design).
27//!
28//! ## `'i` brand
29//!
30//! The `'i` lifetime parameter is currently a phantom on the public
31//! API surface, reserved for a future L0 expansion that exposes the
32//! `Effect<'i, _>` brand to the L2 service layer. The kernel keeps
33//! the `Effect` brand internal, so the forge-side `'i` cannot be
34//! wired to a kernel-side `Effect<'i, _>` cross-call. The phantom
35//! position is preserved on the public API so a later release can
36//! switch from phantom to real-brand without breaking signatures.
37
38use core::marker::PhantomData;
39
40use arkhe_kernel::abi::{CapabilityMask, EntityId, InstanceId, Principal, Tick, TypeCode};
41use arkhe_kernel::state::Op;
42use arkhe_kernel::InstanceView;
43use bytes::Bytes;
44use serde::Serialize;
45
46use crate::actor::{ActorId, UserBinding};
47use crate::brand::ShellId;
48use crate::component::{ArkheComponent, BoundedString};
49use crate::derive_entity_id;
50use crate::user::{GdprStatus, UserGdprState, UserId};
51
52/// L2-provided dedup backend — resolves idempotency keys to prior
53/// `(EntityId, Tick)` assignments.
54///
55/// `arkhe-forge-platform::dedup` ships an in-memory implementation; the
56/// production path swaps in a PG-UNIQUE-INDEX-backed impl.
57/// The L0 kernel may layer a WAL-scan fallback underneath the same trait.
58pub trait IdempotencyIndex: Send + Sync {
59 /// Look up a prior assignment of `key`. `None` indicates the key is
60 /// unused (callers may then insert).
61 fn lookup(&self, key: &[u8; 16]) -> Option<(EntityId, Tick)>;
62}
63
64/// L2-provided `(shell_id, handle) → ActorId` index, backing E-actor-3
65/// uniqueness enforcement.
66///
67/// `InstanceView::component(...)` is keyed by `EntityId`, so the runtime
68/// cannot scan for an Actor by `(shell, handle)` directly. The L2 layer
69/// maintains a BTreeMap projection (deterministic, L0 A5 succession) and
70/// hands a `&dyn ActorHandleIndex` to the [`ActionContext`] before
71/// dispatching `compute`.
72pub trait ActorHandleIndex: Send + Sync {
73 /// Return the `ActorId` already holding `(shell, handle)`, if any.
74 /// `None` means the handle is free for the caller's spawn / rename.
75 fn lookup(&self, shell: ShellId, handle: &BoundedString<32>) -> Option<ActorId>;
76}
77
78/// Taxonomy of compute-time rejections. `ActionCompute::compute` returns
79/// `Result<(), ActionError>`; the pipeline converts these to rejection
80/// records while preserving deterministic bytes (no panicking paths).
81#[non_exhaustive]
82#[derive(Debug, thiserror::Error)]
83pub enum ActionError {
84 /// Authorization gate rejected the caller (L2 capability missing or
85 /// principal unsuitable for the Action).
86 #[error("authorization failed: {0}")]
87 AuthorizationFailed(&'static str),
88
89 /// Idempotency-key collision — the key hit a prior record.
90 #[error("idempotency conflict")]
91 IdempotencyConflict([u8; 16]),
92
93 /// A capability bit was absent from the caller's mask.
94 #[error("capability denied: {0}")]
95 CapabilityDenied(&'static str),
96
97 /// Schema-version mismatch (wire version does not match a runtime
98 /// decoder).
99 #[error("schema version mismatch: expected {expected}, got {got}")]
100 SchemaMismatch {
101 /// Expected schema version.
102 expected: u16,
103 /// Observed schema version.
104 got: u16,
105 },
106
107 /// Replay / admin path detected a cross-shell activity (E-act-2 RA).
108 #[error("cross-shell activity")]
109 CrossShellActivity,
110
111 /// User has `ErasurePending` GDPR status; actor-originated Actions are
112 /// blocked until the cascade completes (C3 contract).
113 #[error("GDPR policy violation")]
114 GdprPolicyViolation,
115
116 /// `derive_entity_id` exhausted its retry bound.
117 #[error("id exhaustion")]
118 IdExhaustion,
119
120 /// Action input failed a compute-path validator (KDF params, depth cap,
121 /// etc.).
122 #[error("invalid input: {0}")]
123 InvalidInput(&'static str),
124
125 /// E-user-3 C3 — actor's backing user is in an erasure-lifecycle state
126 /// (`GdprStatus::ErasurePending` or the terminal `Erased`), so any
127 /// actor-originated Action is blocked.
128 #[error("user erasure pending: {user:?} scheduled at {scheduled_tick:?}")]
129 UserErasurePending {
130 /// Backing user whose erasure is in flight.
131 user: UserId,
132 /// Tick at which the cascade was scheduled.
133 scheduled_tick: Tick,
134 },
135
136 /// E-user-3 C3 — the actor's `UserBinding` resolves to a user with no
137 /// `UserGdprState` lifecycle pointer (an unregistered or incompletely
138 /// registered target). Fail closed: every `RegisterUser`-registered
139 /// user carries the pointer, so a resolved binding that cannot reach
140 /// one must not act — otherwise an actor bound to a never-registered
141 /// user id would be permanently ungateable (its erasure request would
142 /// no-op against the never-spawned user entity).
143 #[error("user bound without GDPR lifecycle state: {user:?}")]
144 UserLifecycleUnresolved {
145 /// Bound user that has no reachable `UserGdprState`.
146 user: UserId,
147 },
148
149 /// E-act-7 — an Action attempted to overwrite an entity's existing
150 /// `EntityShellId` with a different shell. The runtime refuses the
151 /// reassignment to defend against type-erased-id brand bypass (spec
152 /// E-act-2 Extension MC).
153 #[error("EntityShellId reassign rejected for {entity:?}: {old_shell:?} → {new_shell:?}")]
154 EntityShellIdReassign {
155 /// Target entity.
156 entity: EntityId,
157 /// Currently bound shell.
158 old_shell: ShellId,
159 /// Attempted new shell.
160 new_shell: ShellId,
161 },
162
163 /// E-actor-3 — `(shell_id, handle)` is already held by another Actor.
164 /// Spawning or renaming with a colliding handle is rejected (spec
165 /// E-actor-3 invariant).
166 #[error("actor handle collision in shell {shell_id:?}: {handle:?}")]
167 ActorHandleCollision {
168 /// Shell scope of the collision.
169 shell_id: ShellId,
170 /// Offending handle.
171 handle: BoundedString<32>,
172 },
173}
174
175/// Validate a wire-supplied `schema_version` field against the type's
176/// canonical schema-version constant.
177///
178/// Every production `compute()` body runs this as its first check, once for
179/// the action's own `schema_version` and once per nested draft / payload
180/// `schema_version` — validate-then-copy, so a stale or forged wire version
181/// is rejected loudly instead of being persisted verbatim into stored
182/// components.
183///
184/// # Errors
185///
186/// Returns [`ActionError::SchemaMismatch`] when `got != expected`.
187pub fn ensure_schema_version(expected: u16, got: u16) -> Result<(), ActionError> {
188 if got == expected {
189 Ok(())
190 } else {
191 Err(ActionError::SchemaMismatch { expected, got })
192 }
193}
194
195/// Deterministic event record — what `emit_event` accumulates per tick
196/// before the pipeline drains them into the L0 `Op::EmitEvent` stream
197/// (events are re-derived on replay, not WAL-persisted; chain coverage
198/// is indirect via the `Submit` record's action bytes + deterministic
199/// re-execution).
200#[derive(Debug, Clone, Eq, PartialEq)]
201pub struct EventRecord {
202 /// Event TypeCode (runtime registry pin).
203 pub type_code: u32,
204 /// Per-context monotone sequence — establishes emission order within
205 /// the same tick.
206 pub sequence: u64,
207 /// Tick at which the event was emitted.
208 pub tick: Tick,
209 /// Postcard-serialized event payload.
210 pub payload: Bytes,
211}
212
213/// Per-Action compute context.
214///
215/// The `'i` lifetime is currently a phantom on the public API surface.
216/// The kernel keeps `Effect<'i, _>` private, so the forge-side `'i`
217/// brand cannot be wired to a kernel-side brand cross-call; the
218/// position is preserved so a future L0 expansion can switch to a
219/// real-brand binding without breaking the forge signature.
220pub struct ActionContext<'i> {
221 world_seed: [u8; 32],
222 instance_id: InstanceId,
223 tick: Tick,
224 principal: Principal,
225 caps: CapabilityMask,
226 acting_actor: Option<ActorId>,
227 id_seq: u32,
228 event_seq: u64,
229 events: Vec<EventRecord>,
230 ops: Vec<Op>,
231 view: Option<&'i InstanceView<'i>>,
232 idempotency_index: Option<&'i dyn IdempotencyIndex>,
233 actor_handle_index: Option<&'i dyn ActorHandleIndex>,
234 _phantom: PhantomData<&'i ()>,
235}
236
237impl<'i> ActionContext<'i> {
238 /// Construct a fresh context. The `world_seed` is a non-exportable
239 /// L0 configuration secret; tests may pass any fixed
240 /// 32-byte value.
241 #[must_use]
242 pub fn new(
243 world_seed: [u8; 32],
244 instance_id: InstanceId,
245 tick: Tick,
246 principal: Principal,
247 caps: CapabilityMask,
248 ) -> Self {
249 Self {
250 world_seed,
251 instance_id,
252 tick,
253 principal,
254 caps,
255 acting_actor: None,
256 id_seq: 0,
257 event_seq: 0,
258 events: Vec::new(),
259 ops: Vec::new(),
260 view: None,
261 idempotency_index: None,
262 actor_handle_index: None,
263 _phantom: PhantomData,
264 }
265 }
266
267 /// Inject the authenticated acting actor — the single source of truth for
268 /// the identity on whose behalf this Action runs. The runtime threads the
269 /// caller's authenticated `ActorId` here at the dispatch boundary (via the
270 /// kernel `submit` actor channel + the [`crate::bridge`]); user-scoped
271 /// compute bodies read it through [`ActionContext::acting_actor`] instead
272 /// of trusting a wire-supplied field. `None` = system / unauthenticated.
273 #[inline]
274 #[must_use]
275 pub fn with_actor(mut self, actor: Option<ActorId>) -> Self {
276 self.acting_actor = actor;
277 self
278 }
279
280 /// The authenticated acting actor injected at the dispatch boundary.
281 ///
282 /// This is the identity the caller's auth layer verified, threaded through
283 /// the kernel `submit` actor channel — NOT a wire-controlled payload
284 /// field. A user-scoped compute body uses this as both the eligibility
285 /// subject ([`ActionContext::ensure_actor_eligible`]) and the recorded
286 /// author/creator, so actor-substitution is structurally impossible.
287 /// `None` = no authenticated identity (system / unauthenticated caller);
288 /// a user-scoped action must reject in that case.
289 #[inline]
290 #[must_use]
291 pub fn acting_actor(&self) -> Option<ActorId> {
292 self.acting_actor
293 }
294
295 /// Attach an L0 `InstanceView` snapshot — enables
296 /// [`ActionContext::read`] lookups. Method-chain builder style so
297 /// callers can write `ActionContext::new(...).with_view(&view)`.
298 #[inline]
299 #[must_use]
300 pub fn with_view(mut self, view: &'i InstanceView<'i>) -> Self {
301 self.view = Some(view);
302 self
303 }
304
305 /// Attach an `IdempotencyIndex` backend — enables
306 /// [`ActionContext::idempotency_lookup`] to consult the L2 PG UNIQUE
307 /// INDEX (or equivalent). Without this call the lookup always returns
308 /// `None` (forward-compat stub).
309 #[inline]
310 #[must_use]
311 pub fn with_idempotency_index(mut self, index: &'i dyn IdempotencyIndex) -> Self {
312 self.idempotency_index = Some(index);
313 self
314 }
315
316 /// Attach an [`ActorHandleIndex`] backend — enables
317 /// [`ActionContext::actor_by_handle`] to enforce E-actor-3 uniqueness
318 /// Without this call `actor_by_handle` always returns
319 /// `None`, so handle-collision rejection only fires when an L2
320 /// implementation is bound.
321 #[inline]
322 #[must_use]
323 pub fn with_actor_handle_index(mut self, index: &'i dyn ActorHandleIndex) -> Self {
324 self.actor_handle_index = Some(index);
325 self
326 }
327
328 /// Current tick.
329 #[inline]
330 #[must_use]
331 pub fn tick(&self) -> Tick {
332 self.tick
333 }
334
335 /// Caller principal (authorization subject).
336 #[inline]
337 #[must_use]
338 pub fn principal(&self) -> &Principal {
339 &self.principal
340 }
341
342 /// Caller capability mask (L2-granted authority set).
343 #[inline]
344 #[must_use]
345 pub fn caps(&self) -> CapabilityMask {
346 self.caps
347 }
348
349 /// Instance identifier.
350 #[inline]
351 #[must_use]
352 pub fn instance_id(&self) -> InstanceId {
353 self.instance_id
354 }
355
356 /// Derive the next `EntityId` for a given `type_code`. Increments the
357 /// internal per-context sequence so repeated calls within the same
358 /// tick produce distinct entities.
359 pub fn next_id(&mut self, type_code: u32) -> Result<EntityId, ActionError> {
360 let seq = self.id_seq;
361 // Checked, not wrapping: a 2^32 overflow would silently restart the
362 // sequence at 0 and re-derive an `EntityId` already assigned earlier
363 // in this compute. Advance first so a wrap is caught before this
364 // call hands out an id whose successor cannot be allocated.
365 self.id_seq = seq.checked_add(1).ok_or(ActionError::IdExhaustion)?;
366 derive_entity_id(
367 &self.world_seed,
368 self.instance_id,
369 TypeCode(type_code),
370 self.tick,
371 seq,
372 )
373 .ok_or(ActionError::IdExhaustion)
374 }
375
376 /// Emit an event. Dual-path — the payload is appended to the
377 /// [`EventRecord`] buffer (drained by [`drain_events`](Self::drain_events),
378 /// the L1 [`pipeline::process_action`](crate::pipeline::process_action)
379 /// surface) **and** pushed onto the L0 `Op::EmitEvent` accumulator
380 /// (drained via [`ActionContext::drain_ops`] by the L2 service
381 /// layer's bridge into the kernel).
382 pub fn emit_event<E>(&mut self, event: &E) -> Result<(), ActionError>
383 where
384 E: Serialize + crate::event::ArkheEvent,
385 {
386 let payload = postcard::to_stdvec(event)
387 .map_err(|_| ActionError::InvalidInput("event serialization failed"))?;
388 let payload_bytes: Bytes = Bytes::from(payload);
389
390 let record = EventRecord {
391 type_code: E::TYPE_CODE,
392 sequence: self.event_seq,
393 tick: self.tick,
394 payload: payload_bytes.clone(),
395 };
396 self.event_seq = self.event_seq.saturating_add(1);
397 self.events.push(record);
398
399 self.ops.push(Op::EmitEvent {
400 actor: None,
401 event_type_code: TypeCode(E::TYPE_CODE),
402 event_bytes: payload_bytes,
403 });
404 Ok(())
405 }
406
407 /// Spawn a fresh entity in the namespace of `C`. Allocates an
408 /// `EntityId` via [`ActionContext::next_id`] using `C::TYPE_CODE` as
409 /// the id-derivation namespace, and pushes a matching
410 /// `Op::SpawnEntity` into the accumulator.
411 ///
412 /// The component itself is **not** attached here — follow with
413 /// [`ActionContext::set_component`] to attach the payload.
414 pub fn spawn_entity_for<C: ArkheComponent>(&mut self) -> Result<EntityId, ActionError> {
415 let id = self.next_id(C::TYPE_CODE)?;
416 let owner = self.principal.clone();
417 self.ops.push(Op::SpawnEntity { id, owner });
418 Ok(id)
419 }
420
421 /// Attach (or replace) component `component` on `entity`. Serializes
422 /// the payload via postcard and pushes a `Op::SetComponent`. The
423 /// runtime ledger uses the encoded size for quota accounting (L0
424 /// memory budget enforcement).
425 pub fn set_component<C: ArkheComponent>(
426 &mut self,
427 entity: EntityId,
428 component: &C,
429 ) -> Result<(), ActionError> {
430 let bytes = postcard::to_stdvec(component)
431 .map_err(|_| ActionError::InvalidInput("component serialization failed"))?;
432 let size = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
433 self.ops.push(Op::SetComponent {
434 entity,
435 type_code: TypeCode(C::TYPE_CODE),
436 bytes: Bytes::from(bytes),
437 size,
438 });
439 Ok(())
440 }
441
442 /// Detach component `C` from `entity`. `prior_size` must match the
443 /// size the matching `SetComponent` reported — the ledger uses it to
444 /// balance the memory budget (L0 quota discipline). The kernel does
445 /// not currently expose a back-channel for the runtime to look up
446 /// the prior size; callers pass it explicitly so the size delta in
447 /// the emitted `Op::RemoveComponent` is balanced against the
448 /// preceding `Op::SetComponent`.
449 pub fn remove_component<C: ArkheComponent>(
450 &mut self,
451 entity: EntityId,
452 prior_size: u64,
453 ) -> Result<(), ActionError> {
454 self.ops.push(Op::RemoveComponent {
455 entity,
456 type_code: TypeCode(C::TYPE_CODE),
457 size: prior_size,
458 });
459 Ok(())
460 }
461
462 /// Drain accumulated L0 `Op`s. The L2 service layer calls this after
463 /// `compute()` returns, wraps each `Op` in `Effect<'i, Unverified>`,
464 /// and submits through the authorize → dispatch path.
465 pub fn drain_ops(&mut self) -> Vec<Op> {
466 core::mem::take(&mut self.ops)
467 }
468
469 /// Borrow the accumulated `Op` buffer without draining — for tests
470 /// and debug tooling.
471 #[inline]
472 #[must_use]
473 pub fn ops(&self) -> &[Op] {
474 &self.ops
475 }
476
477 /// Idempotency-key lookup. Consults the attached `IdempotencyIndex`
478 /// (see [`ActionContext::with_idempotency_index`]) if one is bound;
479 /// otherwise returns `None` for forward-compat.
480 ///
481 /// The production path uses PG UNIQUE INDEX; the L0
482 /// kernel may layer a WAL-scan fallback beneath the same interface.
483 #[inline]
484 #[must_use]
485 pub fn idempotency_lookup(&self, key: &[u8; 16]) -> Option<(EntityId, Tick)> {
486 self.idempotency_index.and_then(|idx| idx.lookup(key))
487 }
488
489 /// Read a component from the attached `InstanceView` (NC2 contract).
490 ///
491 /// Returns:
492 /// * `Ok(Some(component))` — the view is bound and the component was
493 /// decoded successfully.
494 /// * `Ok(None)` — no view bound, or the component is not attached to
495 /// `entity` in the view.
496 /// * `Err(ActionError::InvalidInput)` — the view returned bytes but
497 /// postcard decoding failed (version drift or corrupt state).
498 pub fn read<C: ArkheComponent>(&self, entity: EntityId) -> Result<Option<C>, ActionError> {
499 let Some(view) = self.view else {
500 return Ok(None);
501 };
502 let Some(bytes) = view.component(entity, TypeCode(C::TYPE_CODE)) else {
503 return Ok(None);
504 };
505 let decoded = postcard::from_bytes::<C>(bytes)
506 .map_err(|_| ActionError::InvalidInput("component decode failed"))?;
507 Ok(Some(decoded))
508 }
509
510 /// Read a component, taking into account `Op` mutations staged so far in
511 /// the current `compute()` call. This is the staging-aware sibling of
512 /// [`ActionContext::read`] — same return shape, but a same-tick
513 /// `set_component` (or `remove_component`) earlier in the compute body
514 /// shadows the view's bytes.
515 ///
516 /// The Op accumulator is scanned in reverse, so the most-recent mutation
517 /// wins. A `RemoveComponent` makes the staged read return `Ok(None)`
518 /// even if the view still holds the prior value.
519 ///
520 /// Used by E-act-7 reassign rejection: a compute that issues a
521 /// `set_component::<EntityShellId>` for an entity already shadowed by a
522 /// staged op must still see the staged shell, not the stale view.
523 pub fn staged_read<C: ArkheComponent>(
524 &self,
525 entity: EntityId,
526 ) -> Result<Option<C>, ActionError> {
527 let target_tc = TypeCode(C::TYPE_CODE);
528 for op in self.ops.iter().rev() {
529 match op {
530 Op::SetComponent {
531 entity: e,
532 type_code,
533 bytes,
534 ..
535 } if *e == entity && *type_code == target_tc => {
536 let decoded = postcard::from_bytes::<C>(bytes)
537 .map_err(|_| ActionError::InvalidInput("staged component decode failed"))?;
538 return Ok(Some(decoded));
539 }
540 Op::RemoveComponent {
541 entity: e,
542 type_code,
543 ..
544 } if *e == entity && *type_code == target_tc => {
545 return Ok(None);
546 }
547 _ => {}
548 }
549 }
550 self.read::<C>(entity)
551 }
552
553 /// Resolve `(ShellId, BoundedString<32>) → ActorId` via the bound
554 /// [`ActorHandleIndex`]. Returns `None` if no index is attached or
555 /// the handle is unused.
556 ///
557 /// The compute path uses this to enforce E-actor-3 uniqueness before
558 /// spawning a fresh `ActorProfile`.
559 #[must_use]
560 pub fn actor_by_handle(&self, shell: ShellId, handle: &BoundedString<32>) -> Option<ActorId> {
561 self.actor_handle_index
562 .and_then(|idx| idx.lookup(shell, handle))
563 }
564
565 /// Resolve the backing [`UserId`] for an authenticated [`ActorId`] via
566 /// the staged-aware [`UserBinding`] read. Returns:
567 ///
568 /// * `Ok(Some(user_id))` — actor has an authenticated `UserBinding`.
569 /// * `Ok(None)` — no view or no binding (anonymous / pre-bind).
570 /// * `Err(...)` — view bytes failed to decode (state corruption).
571 pub fn authenticated_actor_user(&self, actor: ActorId) -> Result<Option<UserId>, ActionError> {
572 let binding = self.staged_read::<UserBinding>(actor.get())?;
573 Ok(binding.map(|b| b.user_id))
574 }
575
576 /// Resolve the GDPR status of `user` via the staged-aware
577 /// [`UserGdprState`] read. Returns `Ok(None)` when no lifecycle pointer
578 /// is reachable; [`ActionContext::ensure_actor_eligible`] treats that
579 /// as a hard reject for a resolved binding (registration always seeds
580 /// the pointer), while viewless contexts never reach this read.
581 pub fn user_gdpr_status(&self, user: UserId) -> Result<Option<GdprStatus>, ActionError> {
582 let state = self.staged_read::<UserGdprState>(user.get())?;
583 Ok(state.map(|s| s.status))
584 }
585
586 /// E-user-3 C3 helper — fail an actor-originated compute when the
587 /// actor's backing user is in an erasure-lifecycle state
588 /// (`GdprStatus::ErasurePending` or the terminal `Erased`). An
589 /// unresolved BINDING (no view bound, anonymous actor) is a soft pass
590 /// — the actor has no user scope to gate. A RESOLVED binding whose
591 /// user has no [`UserGdprState`] fails closed
592 /// ([`ActionError::UserLifecycleUnresolved`]): registration always
593 /// seeds the pointer, so its absence means an unregistered binding
594 /// target that erasure could never gate.
595 ///
596 /// The soft pass means this check is only effective when a view is
597 /// bound. Two paths bind one:
598 ///
599 /// * **Direct path** — callers that build the context via
600 /// `ActionContext::new(...).with_view(&view)` enforce the gate
601 /// in-compute.
602 /// * **Dispatch path** — actions driven through
603 /// `RuntimeService::dispatch` (forge-platform) run with a viewless
604 /// bridge context (see [`crate::bridge`]), so this in-compute call
605 /// soft-passes. For that path the gate is enforced at the L2
606 /// admission boundary on the authenticated acting actor: `dispatch`
607 /// injects the caller identity the auth layer resolved (it is the
608 /// single source of truth — there is no wire actor field to
609 /// substitute), binds the kernel `InstanceView`, and runs this same
610 /// check on that injected actor BEFORE `submit`, rejecting an
611 /// erasure-pending action before it reaches the WAL. The actor this
612 /// gate evaluates is therefore the authenticated caller, never a
613 /// trusted-by-default wire field — the gate is sound. Its liveness
614 /// rests on two production writes: the actor → user [`UserBinding`]
615 /// attached by [`RegisterActor`](crate::actor::RegisterActor) at
616 /// registration time (an actor with no binding has no resolvable user
617 /// scope, so the gate soft-passes it — system / anonymous actors), and
618 /// the [`GdprEraseUser`](crate::user::GdprEraseUser) transition of the
619 /// user's [`UserGdprState`] to `ErasurePending` (a blind write, valid
620 /// on the viewless path). For an actor registered through
621 /// `RegisterActor`, the gate rejects from the instant erasure is
622 /// requested.
623 pub fn ensure_actor_eligible(
624 &self,
625 actor: ActorId,
626 scheduled_tick: Tick,
627 ) -> Result<(), ActionError> {
628 let Some(user) = self.authenticated_actor_user(actor)? else {
629 return Ok(());
630 };
631 // Only an `Active` user may act. Any erasure-lifecycle state
632 // blocks: `ErasurePending` (erasure requested) AND the terminal
633 // `Erased` (crypto-erasure completed) — a user whose data is being
634 // or has been shredded must not originate further actions. A
635 // RESOLVED binding whose user has no lifecycle pointer also fails
636 // closed: `RegisterUser` always seeds one, so its absence means an
637 // unregistered binding target whose erasure could never arm the
638 // gate. Fail closed; a future `GdprStatus` variant forces an
639 // explicit decision here at compile time rather than silently
640 // soft-passing.
641 match self.user_gdpr_status(user)? {
642 Some(GdprStatus::Active) => Ok(()),
643 Some(GdprStatus::ErasurePending | GdprStatus::Erased) => {
644 Err(ActionError::UserErasurePending {
645 user,
646 scheduled_tick,
647 })
648 }
649 None => Err(ActionError::UserLifecycleUnresolved { user }),
650 }
651 }
652
653 /// Preview the `EntityId` that the next [`ActionContext::next_id`] /
654 /// [`ActionContext::spawn_entity_for`] call would produce, **without**
655 /// bumping the internal sequence counter.
656 ///
657 /// Useful for pre-spawn validation (e.g. E-act-5 Activity self-loop
658 /// rejection): compute the predicted id, compare against the target,
659 /// then commit the actual spawn.
660 pub fn preview_next_id_for<C: ArkheComponent>(&self) -> Result<EntityId, ActionError> {
661 derive_entity_id(
662 &self.world_seed,
663 self.instance_id,
664 TypeCode(C::TYPE_CODE),
665 self.tick,
666 self.id_seq,
667 )
668 .ok_or(ActionError::IdExhaustion)
669 }
670
671 /// Drain accumulated events. Called by the pipeline after `compute()`
672 /// returns so the L1 event consumer (projection observer / L2 event
673 /// sink) receives them in sequence order.
674 pub fn drain_events(&mut self) -> Vec<EventRecord> {
675 core::mem::take(&mut self.events)
676 }
677
678 /// Borrow the accumulated event buffer without draining — for tests
679 /// and debug tooling.
680 #[inline]
681 #[must_use]
682 pub fn events(&self) -> &[EventRecord] {
683 &self.events
684 }
685}
686
687#[cfg(test)]
688#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
689mod tests {
690 use super::*;
691 use crate::event::{ArkheEvent as _, UserErasureScheduled};
692 use crate::user::UserId;
693
694 fn fixture_ctx() -> ActionContext<'static> {
695 ActionContext::new(
696 [0x11u8; 32],
697 InstanceId::new(1).unwrap(),
698 Tick(100),
699 Principal::System,
700 CapabilityMask::SYSTEM,
701 )
702 }
703
704 #[test]
705 fn context_exposes_principal_and_caps() {
706 let ctx = fixture_ctx();
707 assert_eq!(*ctx.principal(), Principal::System);
708 assert!(ctx.caps().contains(CapabilityMask::SYSTEM));
709 assert_eq!(ctx.tick(), Tick(100));
710 }
711
712 #[test]
713 fn next_id_is_deterministic_and_monotone_within_context() {
714 let mut ctx = fixture_ctx();
715 let a = ctx.next_id(0x0003_0001).unwrap();
716 let b = ctx.next_id(0x0003_0001).unwrap();
717 assert_ne!(a, b, "sequential next_id calls must yield distinct ids");
718 }
719
720 #[test]
721 fn next_id_rejects_at_sequence_exhaustion_instead_of_wrapping() {
722 // Drive the per-context sequence to its u32 ceiling, then confirm the
723 // boundary call rejects with `IdExhaustion` rather than wrapping back
724 // to a previously-issued seq (the silent-duplicate regression).
725 let mut ctx = fixture_ctx();
726 ctx.id_seq = u32::MAX;
727 let err = ctx
728 .next_id(0x0003_0001)
729 .expect_err("u32::MAX boundary must reject, not wrap");
730 assert!(matches!(err, ActionError::IdExhaustion));
731 // Counter is unchanged — no silent wrap to 0 occurred.
732 assert_eq!(ctx.id_seq, u32::MAX);
733 }
734
735 #[test]
736 fn emit_event_appends_record_in_sequence() {
737 let mut ctx = fixture_ctx();
738 let user = UserId::new(arkhe_kernel::abi::EntityId::new(42).unwrap());
739 let ev = UserErasureScheduled {
740 schema_version: 1,
741 user,
742 scheduled_tick: Tick(100),
743 };
744 ctx.emit_event(&ev).unwrap();
745 ctx.emit_event(&ev).unwrap();
746 let drained = ctx.drain_events();
747 assert_eq!(drained.len(), 2);
748 assert_eq!(drained[0].sequence, 0);
749 assert_eq!(drained[1].sequence, 1);
750 assert_eq!(drained[0].type_code, UserErasureScheduled::TYPE_CODE);
751 assert!(ctx.events().is_empty());
752 }
753
754 #[test]
755 fn idempotency_lookup_returns_none_by_default() {
756 let ctx = fixture_ctx();
757 assert!(ctx.idempotency_lookup(&[0u8; 16]).is_none());
758 }
759
760 #[test]
761 fn ensure_schema_version_rejects_mismatch_with_both_versions() {
762 assert!(ensure_schema_version(1, 1).is_ok());
763 let err = ensure_schema_version(1, 0xBEEF).expect_err("mismatch must reject");
764 match err {
765 ActionError::SchemaMismatch { expected, got } => {
766 assert_eq!(expected, 1);
767 assert_eq!(got, 0xBEEF);
768 }
769 other => panic!("expected SchemaMismatch, got {:?}", other),
770 }
771 }
772
773 #[test]
774 fn ops_buffer_starts_empty_and_drains() {
775 let mut ctx = fixture_ctx();
776 assert!(ctx.ops().is_empty());
777 let user = UserId::new(arkhe_kernel::abi::EntityId::new(7).unwrap());
778 ctx.emit_event(&UserErasureScheduled {
779 schema_version: 1,
780 user,
781 scheduled_tick: Tick(100),
782 })
783 .unwrap();
784 assert_eq!(ctx.ops().len(), 1, "emit_event pushes Op::EmitEvent");
785 let drained = ctx.drain_ops();
786 assert_eq!(drained.len(), 1);
787 matches!(drained[0], Op::EmitEvent { .. });
788 assert!(ctx.ops().is_empty());
789 }
790
791 #[test]
792 fn spawn_entity_for_derives_id_and_pushes_spawn_op() {
793 use crate::user::UserProfile;
794 let mut ctx = fixture_ctx();
795 let id = ctx.spawn_entity_for::<UserProfile>().unwrap();
796 let ops = ctx.drain_ops();
797 assert_eq!(ops.len(), 1);
798 match &ops[0] {
799 Op::SpawnEntity {
800 id: spawn_id,
801 owner,
802 } => {
803 assert_eq!(*spawn_id, id);
804 assert!(matches!(owner, Principal::System));
805 }
806 other => panic!("expected SpawnEntity, got {:?}", other),
807 }
808 }
809
810 #[test]
811 fn set_component_encodes_via_postcard_and_tracks_size() {
812 use crate::user::{AuthKind, UserProfile};
813 let mut ctx = fixture_ctx();
814 let profile = UserProfile {
815 schema_version: 1,
816 created_tick: Tick(1),
817 primary_auth_kind: AuthKind::Passkey,
818 };
819 let entity = ctx.spawn_entity_for::<UserProfile>().unwrap();
820 ctx.set_component(entity, &profile).unwrap();
821 let ops = ctx.drain_ops();
822 assert_eq!(ops.len(), 2);
823 match &ops[1] {
824 Op::SetComponent {
825 entity: e,
826 type_code,
827 bytes,
828 size,
829 } => {
830 assert_eq!(*e, entity);
831 assert_eq!(*type_code, TypeCode(UserProfile::TYPE_CODE));
832 assert_eq!(*size, bytes.len() as u64);
833 let back: UserProfile = postcard::from_bytes(bytes).unwrap();
834 assert_eq!(back, profile);
835 }
836 other => panic!("expected SetComponent, got {:?}", other),
837 }
838 }
839
840 #[test]
841 fn remove_component_pushes_remove_op_with_reported_size() {
842 use crate::user::UserProfile;
843 let mut ctx = fixture_ctx();
844 let entity = ctx.spawn_entity_for::<UserProfile>().unwrap();
845 ctx.remove_component::<UserProfile>(entity, 128).unwrap();
846 let ops = ctx.drain_ops();
847 match &ops[1] {
848 Op::RemoveComponent {
849 entity: e,
850 type_code,
851 size,
852 } => {
853 assert_eq!(*e, entity);
854 assert_eq!(*type_code, TypeCode(UserProfile::TYPE_CODE));
855 assert_eq!(*size, 128);
856 }
857 other => panic!("expected RemoveComponent, got {:?}", other),
858 }
859 }
860
861 #[test]
862 fn emit_event_dual_path_event_record_and_op() {
863 let mut ctx = fixture_ctx();
864 let user = UserId::new(arkhe_kernel::abi::EntityId::new(9).unwrap());
865 ctx.emit_event(&UserErasureScheduled {
866 schema_version: 1,
867 user,
868 scheduled_tick: Tick(100),
869 })
870 .unwrap();
871 // Both buffers populated — drain independently.
872 let events = ctx.drain_events();
873 assert_eq!(events.len(), 1);
874 assert_eq!(events[0].type_code, UserErasureScheduled::TYPE_CODE);
875
876 let ops = ctx.drain_ops();
877 assert_eq!(ops.len(), 1);
878 match &ops[0] {
879 Op::EmitEvent {
880 actor,
881 event_type_code,
882 event_bytes: _,
883 } => {
884 assert!(actor.is_none());
885 assert_eq!(*event_type_code, TypeCode(UserErasureScheduled::TYPE_CODE));
886 }
887 other => panic!("expected EmitEvent, got {:?}", other),
888 }
889 }
890
891 /// A local `IdempotencyIndex` impl for unit-testing the
892 /// `with_idempotency_index` wiring without pulling in the
893 /// `arkhe-forge-platform` dedup crate.
894 struct FixedIndex {
895 key: [u8; 16],
896 binding: (arkhe_kernel::abi::EntityId, Tick),
897 }
898
899 impl IdempotencyIndex for FixedIndex {
900 fn lookup(&self, key: &[u8; 16]) -> Option<(arkhe_kernel::abi::EntityId, Tick)> {
901 if *key == self.key {
902 Some(self.binding)
903 } else {
904 None
905 }
906 }
907 }
908
909 #[test]
910 fn idempotency_lookup_consults_attached_index() {
911 let idx = FixedIndex {
912 key: [0x77u8; 16],
913 binding: (arkhe_kernel::abi::EntityId::new(5).unwrap(), Tick(42)),
914 };
915 let ctx = fixture_ctx().with_idempotency_index(&idx);
916 assert_eq!(
917 ctx.idempotency_lookup(&[0x77u8; 16]),
918 Some((arkhe_kernel::abi::EntityId::new(5).unwrap(), Tick(42))),
919 );
920 // Missing key still returns None.
921 assert!(ctx.idempotency_lookup(&[0x00u8; 16]).is_none());
922 }
923
924 #[test]
925 fn read_returns_none_when_no_view_is_bound() {
926 use crate::user::UserProfile;
927 let ctx = fixture_ctx();
928 let out: Option<UserProfile> = ctx
929 .read::<UserProfile>(arkhe_kernel::abi::EntityId::new(1).unwrap())
930 .unwrap();
931 assert!(out.is_none());
932 }
933
934 #[test]
935 fn preview_next_id_does_not_bump_sequence() {
936 use crate::user::UserProfile;
937 let mut ctx = fixture_ctx();
938 let a = ctx.preview_next_id_for::<UserProfile>().unwrap();
939 let b = ctx.preview_next_id_for::<UserProfile>().unwrap();
940 assert_eq!(a, b, "preview must not bump the id sequence");
941 let committed = ctx.next_id(UserProfile::TYPE_CODE).unwrap();
942 assert_eq!(
943 committed, a,
944 "the first committed next_id matches the prior preview",
945 );
946 }
947}