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}