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 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}