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 in v0.13, 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. Kernel v0.13 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, UserId, UserProfile};
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 `GdprStatus::ErasurePending`,
124    /// so any actor-originated Action is blocked until the cascade finalises.
125    #[error("user erasure pending: {user:?} scheduled at {scheduled_tick:?}")]
126    UserErasurePending {
127        /// Backing user whose erasure is in flight.
128        user: UserId,
129        /// Tick at which the cascade was scheduled.
130        scheduled_tick: Tick,
131    },
132
133    /// E-act-7 — an Action attempted to overwrite an entity's existing
134    /// `EntityShellId` with a different shell. The runtime refuses the
135    /// reassignment to defend against type-erased-id brand bypass (spec
136    /// E-act-2 Extension MC).
137    #[error("EntityShellId reassign rejected for {entity:?}: {old_shell:?} → {new_shell:?}")]
138    EntityShellIdReassign {
139        /// Target entity.
140        entity: EntityId,
141        /// Currently bound shell.
142        old_shell: ShellId,
143        /// Attempted new shell.
144        new_shell: ShellId,
145    },
146
147    /// E-actor-3 — `(shell_id, handle)` is already held by another Actor.
148    /// Spawning or renaming with a colliding handle is rejected (spec
149    /// E-actor-3 invariant).
150    #[error("actor handle collision in shell {shell_id:?}: {handle:?}")]
151    ActorHandleCollision {
152        /// Shell scope of the collision.
153        shell_id: ShellId,
154        /// Offending handle.
155        handle: BoundedString<32>,
156    },
157}
158
159/// Deterministic event record — what `emit_event` accumulates per tick
160/// before the pipeline drains them into the WAL `Op::EmitEvent` stream.
161#[derive(Debug, Clone, Eq, PartialEq)]
162pub struct EventRecord {
163    /// Event TypeCode (runtime registry pin).
164    pub type_code: u32,
165    /// Per-context monotone sequence — establishes emission order within
166    /// the same tick.
167    pub sequence: u64,
168    /// Tick at which the event was emitted.
169    pub tick: Tick,
170    /// Postcard-serialized event payload.
171    pub payload: Bytes,
172}
173
174/// Per-Action compute context.
175///
176/// The `'i` lifetime is currently a phantom on the public API surface.
177/// Kernel v0.13 keeps `Effect<'i, _>` private, so the forge-side `'i`
178/// brand cannot be wired to a kernel-side brand cross-call; the
179/// position is preserved so a future L0 expansion can switch to a
180/// real-brand binding without breaking the forge signature.
181pub struct ActionContext<'i> {
182    world_seed: [u8; 32],
183    instance_id: InstanceId,
184    tick: Tick,
185    principal: Principal,
186    caps: CapabilityMask,
187    id_seq: u32,
188    event_seq: u64,
189    events: Vec<EventRecord>,
190    ops: Vec<Op>,
191    view: Option<&'i InstanceView<'i>>,
192    idempotency_index: Option<&'i dyn IdempotencyIndex>,
193    actor_handle_index: Option<&'i dyn ActorHandleIndex>,
194    _phantom: PhantomData<&'i ()>,
195}
196
197impl<'i> ActionContext<'i> {
198    /// Construct a fresh context. The `world_seed` is a non-exportable
199    /// L0 configuration secret; tests may pass any fixed
200    /// 32-byte value.
201    #[must_use]
202    pub fn new(
203        world_seed: [u8; 32],
204        instance_id: InstanceId,
205        tick: Tick,
206        principal: Principal,
207        caps: CapabilityMask,
208    ) -> Self {
209        Self {
210            world_seed,
211            instance_id,
212            tick,
213            principal,
214            caps,
215            id_seq: 0,
216            event_seq: 0,
217            events: Vec::new(),
218            ops: Vec::new(),
219            view: None,
220            idempotency_index: None,
221            actor_handle_index: None,
222            _phantom: PhantomData,
223        }
224    }
225
226    /// Attach an L0 `InstanceView` snapshot — enables
227    /// [`ActionContext::read`] lookups. Method-chain builder style so
228    /// callers can write `ActionContext::new(...).with_view(&view)`.
229    #[inline]
230    #[must_use]
231    pub fn with_view(mut self, view: &'i InstanceView<'i>) -> Self {
232        self.view = Some(view);
233        self
234    }
235
236    /// Attach an `IdempotencyIndex` backend — enables
237    /// [`ActionContext::idempotency_lookup`] to consult the L2 PG UNIQUE
238    /// INDEX (or equivalent). Without this call the lookup always returns
239    /// `None` (forward-compat stub).
240    #[inline]
241    #[must_use]
242    pub fn with_idempotency_index(mut self, index: &'i dyn IdempotencyIndex) -> Self {
243        self.idempotency_index = Some(index);
244        self
245    }
246
247    /// Attach an [`ActorHandleIndex`] backend — enables
248    /// [`ActionContext::actor_by_handle`] to enforce E-actor-3 uniqueness
249    /// Without this call `actor_by_handle` always returns
250    /// `None`, so handle-collision rejection only fires when an L2
251    /// implementation is bound.
252    #[inline]
253    #[must_use]
254    pub fn with_actor_handle_index(mut self, index: &'i dyn ActorHandleIndex) -> Self {
255        self.actor_handle_index = Some(index);
256        self
257    }
258
259    /// Current tick.
260    #[inline]
261    #[must_use]
262    pub fn tick(&self) -> Tick {
263        self.tick
264    }
265
266    /// Caller principal (authorization subject).
267    #[inline]
268    #[must_use]
269    pub fn principal(&self) -> &Principal {
270        &self.principal
271    }
272
273    /// Caller capability mask (L2-granted authority set).
274    #[inline]
275    #[must_use]
276    pub fn caps(&self) -> CapabilityMask {
277        self.caps
278    }
279
280    /// Instance identifier.
281    #[inline]
282    #[must_use]
283    pub fn instance_id(&self) -> InstanceId {
284        self.instance_id
285    }
286
287    /// Derive the next `EntityId` for a given `type_code`. Increments the
288    /// internal per-context sequence so repeated calls within the same
289    /// tick produce distinct entities.
290    pub fn next_id(&mut self, type_code: u32) -> Result<EntityId, ActionError> {
291        let seq = self.id_seq;
292        self.id_seq = self.id_seq.wrapping_add(1);
293        derive_entity_id(
294            &self.world_seed,
295            self.instance_id,
296            TypeCode(type_code),
297            self.tick,
298            seq,
299        )
300        .ok_or(ActionError::IdExhaustion)
301    }
302
303    /// Emit an event. Dual-path — the payload is appended to the
304    /// [`EventRecord`] buffer (drained by [`drain_events`](Self::drain_events),
305    /// the L1 [`pipeline::process_action`](crate::pipeline::process_action)
306    /// surface) **and** pushed onto the L0 `Op::EmitEvent` accumulator
307    /// (drained via [`ActionContext::drain_ops`] by the L2 service
308    /// layer's bridge into the kernel).
309    pub fn emit_event<E>(&mut self, event: &E) -> Result<(), ActionError>
310    where
311        E: Serialize + crate::event::ArkheEvent,
312    {
313        let payload = postcard::to_stdvec(event)
314            .map_err(|_| ActionError::InvalidInput("event serialization failed"))?;
315        let payload_bytes: Bytes = Bytes::from(payload);
316
317        let record = EventRecord {
318            type_code: E::TYPE_CODE,
319            sequence: self.event_seq,
320            tick: self.tick,
321            payload: payload_bytes.clone(),
322        };
323        self.event_seq = self.event_seq.saturating_add(1);
324        self.events.push(record);
325
326        self.ops.push(Op::EmitEvent {
327            actor: None,
328            event_type_code: TypeCode(E::TYPE_CODE),
329            event_bytes: payload_bytes,
330        });
331        Ok(())
332    }
333
334    /// Spawn a fresh entity in the namespace of `C`. Allocates an
335    /// `EntityId` via [`ActionContext::next_id`] using `C::TYPE_CODE` as
336    /// the id-derivation namespace, and pushes a matching
337    /// `Op::SpawnEntity` into the accumulator.
338    ///
339    /// The component itself is **not** attached here — follow with
340    /// [`ActionContext::set_component`] to attach the payload.
341    pub fn spawn_entity_for<C: ArkheComponent>(&mut self) -> Result<EntityId, ActionError> {
342        let id = self.next_id(C::TYPE_CODE)?;
343        let owner = self.principal.clone();
344        self.ops.push(Op::SpawnEntity { id, owner });
345        Ok(id)
346    }
347
348    /// Attach (or replace) component `component` on `entity`. Serializes
349    /// the payload via postcard and pushes a `Op::SetComponent`. The
350    /// runtime ledger uses the encoded size for quota accounting (L0
351    /// memory budget enforcement).
352    pub fn set_component<C: ArkheComponent>(
353        &mut self,
354        entity: EntityId,
355        component: &C,
356    ) -> Result<(), ActionError> {
357        let bytes = postcard::to_stdvec(component)
358            .map_err(|_| ActionError::InvalidInput("component serialization failed"))?;
359        let size = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
360        self.ops.push(Op::SetComponent {
361            entity,
362            type_code: TypeCode(C::TYPE_CODE),
363            bytes: Bytes::from(bytes),
364            size,
365        });
366        Ok(())
367    }
368
369    /// Detach component `C` from `entity`. `prior_size` must match the
370    /// size the matching `SetComponent` reported — the ledger uses it to
371    /// balance the memory budget (L0 quota discipline). The kernel does
372    /// not currently expose a back-channel for the runtime to look up
373    /// the prior size; callers pass it explicitly so the size delta in
374    /// the emitted `Op::RemoveComponent` is balanced against the
375    /// preceding `Op::SetComponent`.
376    pub fn remove_component<C: ArkheComponent>(
377        &mut self,
378        entity: EntityId,
379        prior_size: u64,
380    ) -> Result<(), ActionError> {
381        self.ops.push(Op::RemoveComponent {
382            entity,
383            type_code: TypeCode(C::TYPE_CODE),
384            size: prior_size,
385        });
386        Ok(())
387    }
388
389    /// Drain accumulated L0 `Op`s. The L2 service layer calls this after
390    /// `compute()` returns, wraps each `Op` in `Effect<'i, Unverified>`,
391    /// and submits through the authorize → dispatch path.
392    pub fn drain_ops(&mut self) -> Vec<Op> {
393        core::mem::take(&mut self.ops)
394    }
395
396    /// Borrow the accumulated `Op` buffer without draining — for tests
397    /// and debug tooling.
398    #[inline]
399    #[must_use]
400    pub fn ops(&self) -> &[Op] {
401        &self.ops
402    }
403
404    /// Idempotency-key lookup. Consults the attached `IdempotencyIndex`
405    /// (see [`ActionContext::with_idempotency_index`]) if one is bound;
406    /// otherwise returns `None` for forward-compat.
407    ///
408    /// The production path uses PG UNIQUE INDEX; the L0
409    /// kernel may layer a WAL-scan fallback beneath the same interface.
410    #[inline]
411    #[must_use]
412    pub fn idempotency_lookup(&self, key: &[u8; 16]) -> Option<(EntityId, Tick)> {
413        self.idempotency_index.and_then(|idx| idx.lookup(key))
414    }
415
416    /// Read a component from the attached `InstanceView` (NC2 contract).
417    ///
418    /// Returns:
419    /// * `Ok(Some(component))` — the view is bound and the component was
420    ///   decoded successfully.
421    /// * `Ok(None)` — no view bound, or the component is not attached to
422    ///   `entity` in the view.
423    /// * `Err(ActionError::InvalidInput)` — the view returned bytes but
424    ///   postcard decoding failed (version drift or corrupt state).
425    pub fn read<C: ArkheComponent>(&self, entity: EntityId) -> Result<Option<C>, ActionError> {
426        let Some(view) = self.view else {
427            return Ok(None);
428        };
429        let Some(bytes) = view.component(entity, TypeCode(C::TYPE_CODE)) else {
430            return Ok(None);
431        };
432        let decoded = postcard::from_bytes::<C>(bytes)
433            .map_err(|_| ActionError::InvalidInput("component decode failed"))?;
434        Ok(Some(decoded))
435    }
436
437    /// Read a component, taking into account `Op` mutations staged so far in
438    /// the current `compute()` call. This is the staging-aware sibling of
439    /// [`ActionContext::read`] — same return shape, but a same-tick
440    /// `set_component` (or `remove_component`) earlier in the compute body
441    /// shadows the view's bytes.
442    ///
443    /// The Op accumulator is scanned in reverse, so the most-recent mutation
444    /// wins. A `RemoveComponent` makes the staged read return `Ok(None)`
445    /// even if the view still holds the prior value.
446    ///
447    /// Used by E-act-7 reassign rejection: a compute that issues a
448    /// `set_component::<EntityShellId>` for an entity already shadowed by a
449    /// staged op must still see the staged shell, not the stale view.
450    pub fn staged_read<C: ArkheComponent>(
451        &self,
452        entity: EntityId,
453    ) -> Result<Option<C>, ActionError> {
454        let target_tc = TypeCode(C::TYPE_CODE);
455        for op in self.ops.iter().rev() {
456            match op {
457                Op::SetComponent {
458                    entity: e,
459                    type_code,
460                    bytes,
461                    ..
462                } if *e == entity && *type_code == target_tc => {
463                    let decoded = postcard::from_bytes::<C>(bytes)
464                        .map_err(|_| ActionError::InvalidInput("staged component decode failed"))?;
465                    return Ok(Some(decoded));
466                }
467                Op::RemoveComponent {
468                    entity: e,
469                    type_code,
470                    ..
471                } if *e == entity && *type_code == target_tc => {
472                    return Ok(None);
473                }
474                _ => {}
475            }
476        }
477        self.read::<C>(entity)
478    }
479
480    /// Resolve `(ShellId, BoundedString<32>) → ActorId` via the bound
481    /// [`ActorHandleIndex`]. Returns `None` if no index is attached or
482    /// the handle is unused.
483    ///
484    /// The compute path uses this to enforce E-actor-3 uniqueness before
485    /// spawning a fresh `ActorProfile`.
486    #[must_use]
487    pub fn actor_by_handle(&self, shell: ShellId, handle: &BoundedString<32>) -> Option<ActorId> {
488        self.actor_handle_index
489            .and_then(|idx| idx.lookup(shell, handle))
490    }
491
492    /// Resolve the backing [`UserId`] for an authenticated [`ActorId`] via
493    /// the staged-aware [`UserBinding`] read. Returns:
494    ///
495    /// * `Ok(Some(user_id))` — actor has an authenticated `UserBinding`.
496    /// * `Ok(None)` — no view or no binding (anonymous / pre-bind).
497    /// * `Err(...)` — view bytes failed to decode (state corruption).
498    pub fn authenticated_actor_user(&self, actor: ActorId) -> Result<Option<UserId>, ActionError> {
499        let binding = self.staged_read::<UserBinding>(actor.get())?;
500        Ok(binding.map(|b| b.user_id))
501    }
502
503    /// Resolve the GDPR status of `user` via the staged-aware
504    /// [`UserProfile`] read. Returns `Ok(None)` when no profile is
505    /// reachable; the compute caller decides whether that is a soft
506    /// pass (Tier-0 / pre-Bootstrap dev) or a hard reject.
507    pub fn user_gdpr_status(&self, user: UserId) -> Result<Option<GdprStatus>, ActionError> {
508        let profile = self.staged_read::<UserProfile>(user.get())?;
509        Ok(profile.map(|p| p.gdpr_status))
510    }
511
512    /// E-user-3 C3 helper — fail an actor-originated compute when the
513    /// actor's backing user is in `GdprStatus::ErasurePending`. `Ok(None)`
514    /// from the underlying reads (no view bound, anonymous actor, etc.)
515    /// is treated as a soft pass — the L2 service layer guarantees the
516    /// view is bound for production paths.
517    pub fn ensure_actor_eligible(
518        &self,
519        actor: ActorId,
520        scheduled_tick: Tick,
521    ) -> Result<(), ActionError> {
522        let Some(user) = self.authenticated_actor_user(actor)? else {
523            return Ok(());
524        };
525        if matches!(
526            self.user_gdpr_status(user)?,
527            Some(GdprStatus::ErasurePending)
528        ) {
529            return Err(ActionError::UserErasurePending {
530                user,
531                scheduled_tick,
532            });
533        }
534        Ok(())
535    }
536
537    /// Preview the `EntityId` that the next [`ActionContext::next_id`] /
538    /// [`ActionContext::spawn_entity_for`] call would produce, **without**
539    /// bumping the internal sequence counter.
540    ///
541    /// Useful for pre-spawn validation (e.g. E-act-5 Activity self-loop
542    /// rejection): compute the predicted id, compare against the target,
543    /// then commit the actual spawn.
544    pub fn preview_next_id_for<C: ArkheComponent>(&self) -> Result<EntityId, ActionError> {
545        derive_entity_id(
546            &self.world_seed,
547            self.instance_id,
548            TypeCode(C::TYPE_CODE),
549            self.tick,
550            self.id_seq,
551        )
552        .ok_or(ActionError::IdExhaustion)
553    }
554
555    /// Drain accumulated events. Called by the pipeline after `compute()`
556    /// returns so the WAL append path can stream them in sequence order.
557    pub fn drain_events(&mut self) -> Vec<EventRecord> {
558        core::mem::take(&mut self.events)
559    }
560
561    /// Borrow the accumulated event buffer without draining — for tests
562    /// and debug tooling.
563    #[inline]
564    #[must_use]
565    pub fn events(&self) -> &[EventRecord] {
566        &self.events
567    }
568}
569
570#[cfg(test)]
571#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
572mod tests {
573    use super::*;
574    use crate::event::{ArkheEvent as _, UserErasureScheduled};
575    use crate::user::UserId;
576
577    fn fixture_ctx() -> ActionContext<'static> {
578        ActionContext::new(
579            [0x11u8; 32],
580            InstanceId::new(1).unwrap(),
581            Tick(100),
582            Principal::System,
583            CapabilityMask::SYSTEM,
584        )
585    }
586
587    #[test]
588    fn context_exposes_principal_and_caps() {
589        let ctx = fixture_ctx();
590        assert_eq!(*ctx.principal(), Principal::System);
591        assert!(ctx.caps().contains(CapabilityMask::SYSTEM));
592        assert_eq!(ctx.tick(), Tick(100));
593    }
594
595    #[test]
596    fn next_id_is_deterministic_and_monotone_within_context() {
597        let mut ctx = fixture_ctx();
598        let a = ctx.next_id(0x0003_0001).unwrap();
599        let b = ctx.next_id(0x0003_0001).unwrap();
600        assert_ne!(a, b, "sequential next_id calls must yield distinct ids");
601    }
602
603    #[test]
604    fn emit_event_appends_record_in_sequence() {
605        let mut ctx = fixture_ctx();
606        let user = UserId::new(arkhe_kernel::abi::EntityId::new(42).unwrap());
607        let ev = UserErasureScheduled {
608            schema_version: 1,
609            user,
610            scheduled_tick: Tick(100),
611        };
612        ctx.emit_event(&ev).unwrap();
613        ctx.emit_event(&ev).unwrap();
614        let drained = ctx.drain_events();
615        assert_eq!(drained.len(), 2);
616        assert_eq!(drained[0].sequence, 0);
617        assert_eq!(drained[1].sequence, 1);
618        assert_eq!(drained[0].type_code, UserErasureScheduled::TYPE_CODE);
619        assert!(ctx.events().is_empty());
620    }
621
622    #[test]
623    fn idempotency_lookup_returns_none_by_default() {
624        let ctx = fixture_ctx();
625        assert!(ctx.idempotency_lookup(&[0u8; 16]).is_none());
626    }
627
628    #[test]
629    fn ops_buffer_starts_empty_and_drains() {
630        let mut ctx = fixture_ctx();
631        assert!(ctx.ops().is_empty());
632        let user = UserId::new(arkhe_kernel::abi::EntityId::new(7).unwrap());
633        ctx.emit_event(&UserErasureScheduled {
634            schema_version: 1,
635            user,
636            scheduled_tick: Tick(100),
637        })
638        .unwrap();
639        assert_eq!(ctx.ops().len(), 1, "emit_event pushes Op::EmitEvent");
640        let drained = ctx.drain_ops();
641        assert_eq!(drained.len(), 1);
642        matches!(drained[0], Op::EmitEvent { .. });
643        assert!(ctx.ops().is_empty());
644    }
645
646    #[test]
647    fn spawn_entity_for_derives_id_and_pushes_spawn_op() {
648        use crate::user::UserProfile;
649        let mut ctx = fixture_ctx();
650        let id = ctx.spawn_entity_for::<UserProfile>().unwrap();
651        let ops = ctx.drain_ops();
652        assert_eq!(ops.len(), 1);
653        match &ops[0] {
654            Op::SpawnEntity {
655                id: spawn_id,
656                owner,
657            } => {
658                assert_eq!(*spawn_id, id);
659                assert!(matches!(owner, Principal::System));
660            }
661            other => panic!("expected SpawnEntity, got {:?}", other),
662        }
663    }
664
665    #[test]
666    fn set_component_encodes_via_postcard_and_tracks_size() {
667        use crate::user::{AuthKind, GdprStatus, UserProfile};
668        let mut ctx = fixture_ctx();
669        let profile = UserProfile {
670            schema_version: 1,
671            created_tick: Tick(1),
672            primary_auth_kind: AuthKind::Passkey,
673            gdpr_status: GdprStatus::Active,
674        };
675        let entity = ctx.spawn_entity_for::<UserProfile>().unwrap();
676        ctx.set_component(entity, &profile).unwrap();
677        let ops = ctx.drain_ops();
678        assert_eq!(ops.len(), 2);
679        match &ops[1] {
680            Op::SetComponent {
681                entity: e,
682                type_code,
683                bytes,
684                size,
685            } => {
686                assert_eq!(*e, entity);
687                assert_eq!(*type_code, TypeCode(UserProfile::TYPE_CODE));
688                assert_eq!(*size, bytes.len() as u64);
689                let back: UserProfile = postcard::from_bytes(bytes).unwrap();
690                assert_eq!(back, profile);
691            }
692            other => panic!("expected SetComponent, got {:?}", other),
693        }
694    }
695
696    #[test]
697    fn remove_component_pushes_remove_op_with_reported_size() {
698        use crate::user::UserProfile;
699        let mut ctx = fixture_ctx();
700        let entity = ctx.spawn_entity_for::<UserProfile>().unwrap();
701        ctx.remove_component::<UserProfile>(entity, 128).unwrap();
702        let ops = ctx.drain_ops();
703        match &ops[1] {
704            Op::RemoveComponent {
705                entity: e,
706                type_code,
707                size,
708            } => {
709                assert_eq!(*e, entity);
710                assert_eq!(*type_code, TypeCode(UserProfile::TYPE_CODE));
711                assert_eq!(*size, 128);
712            }
713            other => panic!("expected RemoveComponent, got {:?}", other),
714        }
715    }
716
717    #[test]
718    fn emit_event_dual_path_event_record_and_op() {
719        let mut ctx = fixture_ctx();
720        let user = UserId::new(arkhe_kernel::abi::EntityId::new(9).unwrap());
721        ctx.emit_event(&UserErasureScheduled {
722            schema_version: 1,
723            user,
724            scheduled_tick: Tick(100),
725        })
726        .unwrap();
727        // Both buffers populated — drain independently.
728        let events = ctx.drain_events();
729        assert_eq!(events.len(), 1);
730        assert_eq!(events[0].type_code, UserErasureScheduled::TYPE_CODE);
731
732        let ops = ctx.drain_ops();
733        assert_eq!(ops.len(), 1);
734        match &ops[0] {
735            Op::EmitEvent {
736                actor,
737                event_type_code,
738                event_bytes: _,
739            } => {
740                assert!(actor.is_none());
741                assert_eq!(*event_type_code, TypeCode(UserErasureScheduled::TYPE_CODE));
742            }
743            other => panic!("expected EmitEvent, got {:?}", other),
744        }
745    }
746
747    /// A local `IdempotencyIndex` impl for unit-testing the
748    /// `with_idempotency_index` wiring without pulling in the
749    /// `arkhe-forge-platform` dedup crate.
750    struct FixedIndex {
751        key: [u8; 16],
752        binding: (arkhe_kernel::abi::EntityId, Tick),
753    }
754
755    impl IdempotencyIndex for FixedIndex {
756        fn lookup(&self, key: &[u8; 16]) -> Option<(arkhe_kernel::abi::EntityId, Tick)> {
757            if *key == self.key {
758                Some(self.binding)
759            } else {
760                None
761            }
762        }
763    }
764
765    #[test]
766    fn idempotency_lookup_consults_attached_index() {
767        let idx = FixedIndex {
768            key: [0x77u8; 16],
769            binding: (arkhe_kernel::abi::EntityId::new(5).unwrap(), Tick(42)),
770        };
771        let ctx = fixture_ctx().with_idempotency_index(&idx);
772        assert_eq!(
773            ctx.idempotency_lookup(&[0x77u8; 16]),
774            Some((arkhe_kernel::abi::EntityId::new(5).unwrap(), Tick(42))),
775        );
776        // Missing key still returns None.
777        assert!(ctx.idempotency_lookup(&[0x00u8; 16]).is_none());
778    }
779
780    #[test]
781    fn read_returns_none_when_no_view_is_bound() {
782        use crate::user::UserProfile;
783        let ctx = fixture_ctx();
784        let out: Option<UserProfile> = ctx
785            .read::<UserProfile>(arkhe_kernel::abi::EntityId::new(1).unwrap())
786            .unwrap();
787        assert!(out.is_none());
788    }
789
790    #[test]
791    fn preview_next_id_does_not_bump_sequence() {
792        use crate::user::UserProfile;
793        let mut ctx = fixture_ctx();
794        let a = ctx.preview_next_id_for::<UserProfile>().unwrap();
795        let b = ctx.preview_next_id_for::<UserProfile>().unwrap();
796        assert_eq!(a, b, "preview must not bump the id sequence");
797        let committed = ctx.next_id(UserProfile::TYPE_CODE).unwrap();
798        assert_eq!(
799            committed, a,
800            "the first committed next_id matches the prior preview",
801        );
802    }
803}