Skip to main content

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