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