use core::marker::PhantomData;
use arkhe_kernel::abi::{CapabilityMask, EntityId, InstanceId, Principal, Tick, TypeCode};
use arkhe_kernel::state::Op;
use arkhe_kernel::InstanceView;
use bytes::Bytes;
use serde::Serialize;
use crate::actor::{ActorId, UserBinding};
use crate::brand::ShellId;
use crate::component::{ArkheComponent, BoundedString};
use crate::derive_entity_id;
use crate::user::{GdprStatus, UserId, UserProfile};
pub trait IdempotencyIndex: Send + Sync {
fn lookup(&self, key: &[u8; 16]) -> Option<(EntityId, Tick)>;
}
pub trait ActorHandleIndex: Send + Sync {
fn lookup(&self, shell: ShellId, handle: &BoundedString<32>) -> Option<ActorId>;
}
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum ActionError {
#[error("authorization failed: {0}")]
AuthorizationFailed(&'static str),
#[error("idempotency conflict")]
IdempotencyConflict([u8; 16]),
#[error("capability denied: {0}")]
CapabilityDenied(&'static str),
#[error("schema version mismatch: expected {expected}, got {got}")]
SchemaMismatch {
expected: u16,
got: u16,
},
#[error("cross-shell activity")]
CrossShellActivity,
#[error("GDPR policy violation")]
GdprPolicyViolation,
#[error("id exhaustion")]
IdExhaustion,
#[error("invalid input: {0}")]
InvalidInput(&'static str),
#[error("user erasure pending: {user:?} scheduled at {scheduled_tick:?}")]
UserErasurePending {
user: UserId,
scheduled_tick: Tick,
},
#[error("EntityShellId reassign rejected for {entity:?}: {old_shell:?} → {new_shell:?}")]
EntityShellIdReassign {
entity: EntityId,
old_shell: ShellId,
new_shell: ShellId,
},
#[error("actor handle collision in shell {shell_id:?}: {handle:?}")]
ActorHandleCollision {
shell_id: ShellId,
handle: BoundedString<32>,
},
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct EventRecord {
pub type_code: u32,
pub sequence: u64,
pub tick: Tick,
pub payload: Bytes,
}
pub struct ActionContext<'i> {
world_seed: [u8; 32],
instance_id: InstanceId,
tick: Tick,
principal: Principal,
caps: CapabilityMask,
id_seq: u32,
event_seq: u64,
events: Vec<EventRecord>,
ops: Vec<Op>,
view: Option<&'i InstanceView<'i>>,
idempotency_index: Option<&'i dyn IdempotencyIndex>,
actor_handle_index: Option<&'i dyn ActorHandleIndex>,
_phantom: PhantomData<&'i ()>,
}
impl<'i> ActionContext<'i> {
#[must_use]
pub fn new(
world_seed: [u8; 32],
instance_id: InstanceId,
tick: Tick,
principal: Principal,
caps: CapabilityMask,
) -> Self {
Self {
world_seed,
instance_id,
tick,
principal,
caps,
id_seq: 0,
event_seq: 0,
events: Vec::new(),
ops: Vec::new(),
view: None,
idempotency_index: None,
actor_handle_index: None,
_phantom: PhantomData,
}
}
#[inline]
#[must_use]
pub fn with_view(mut self, view: &'i InstanceView<'i>) -> Self {
self.view = Some(view);
self
}
#[inline]
#[must_use]
pub fn with_idempotency_index(mut self, index: &'i dyn IdempotencyIndex) -> Self {
self.idempotency_index = Some(index);
self
}
#[inline]
#[must_use]
pub fn with_actor_handle_index(mut self, index: &'i dyn ActorHandleIndex) -> Self {
self.actor_handle_index = Some(index);
self
}
#[inline]
#[must_use]
pub fn tick(&self) -> Tick {
self.tick
}
#[inline]
#[must_use]
pub fn principal(&self) -> &Principal {
&self.principal
}
#[inline]
#[must_use]
pub fn caps(&self) -> CapabilityMask {
self.caps
}
#[inline]
#[must_use]
pub fn instance_id(&self) -> InstanceId {
self.instance_id
}
pub fn next_id(&mut self, type_code: u32) -> Result<EntityId, ActionError> {
let seq = self.id_seq;
self.id_seq = self.id_seq.wrapping_add(1);
derive_entity_id(
&self.world_seed,
self.instance_id,
TypeCode(type_code),
self.tick,
seq,
)
.ok_or(ActionError::IdExhaustion)
}
pub fn emit_event<E>(&mut self, event: &E) -> Result<(), ActionError>
where
E: Serialize + crate::event::ArkheEvent,
{
let payload = postcard::to_stdvec(event)
.map_err(|_| ActionError::InvalidInput("event serialization failed"))?;
let payload_bytes: Bytes = Bytes::from(payload);
let record = EventRecord {
type_code: E::TYPE_CODE,
sequence: self.event_seq,
tick: self.tick,
payload: payload_bytes.clone(),
};
self.event_seq = self.event_seq.saturating_add(1);
self.events.push(record);
self.ops.push(Op::EmitEvent {
actor: None,
event_type_code: TypeCode(E::TYPE_CODE),
event_bytes: payload_bytes,
});
Ok(())
}
pub fn spawn_entity_for<C: ArkheComponent>(&mut self) -> Result<EntityId, ActionError> {
let id = self.next_id(C::TYPE_CODE)?;
let owner = self.principal.clone();
self.ops.push(Op::SpawnEntity { id, owner });
Ok(id)
}
pub fn set_component<C: ArkheComponent>(
&mut self,
entity: EntityId,
component: &C,
) -> Result<(), ActionError> {
let bytes = postcard::to_stdvec(component)
.map_err(|_| ActionError::InvalidInput("component serialization failed"))?;
let size = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
self.ops.push(Op::SetComponent {
entity,
type_code: TypeCode(C::TYPE_CODE),
bytes: Bytes::from(bytes),
size,
});
Ok(())
}
pub fn remove_component<C: ArkheComponent>(
&mut self,
entity: EntityId,
prior_size: u64,
) -> Result<(), ActionError> {
self.ops.push(Op::RemoveComponent {
entity,
type_code: TypeCode(C::TYPE_CODE),
size: prior_size,
});
Ok(())
}
pub fn drain_ops(&mut self) -> Vec<Op> {
core::mem::take(&mut self.ops)
}
#[inline]
#[must_use]
pub fn ops(&self) -> &[Op] {
&self.ops
}
#[inline]
#[must_use]
pub fn idempotency_lookup(&self, key: &[u8; 16]) -> Option<(EntityId, Tick)> {
self.idempotency_index.and_then(|idx| idx.lookup(key))
}
pub fn read<C: ArkheComponent>(&self, entity: EntityId) -> Result<Option<C>, ActionError> {
let Some(view) = self.view else {
return Ok(None);
};
let Some(bytes) = view.component(entity, TypeCode(C::TYPE_CODE)) else {
return Ok(None);
};
let decoded = postcard::from_bytes::<C>(bytes)
.map_err(|_| ActionError::InvalidInput("component decode failed"))?;
Ok(Some(decoded))
}
pub fn staged_read<C: ArkheComponent>(
&self,
entity: EntityId,
) -> Result<Option<C>, ActionError> {
let target_tc = TypeCode(C::TYPE_CODE);
for op in self.ops.iter().rev() {
match op {
Op::SetComponent {
entity: e,
type_code,
bytes,
..
} if *e == entity && *type_code == target_tc => {
let decoded = postcard::from_bytes::<C>(bytes)
.map_err(|_| ActionError::InvalidInput("staged component decode failed"))?;
return Ok(Some(decoded));
}
Op::RemoveComponent {
entity: e,
type_code,
..
} if *e == entity && *type_code == target_tc => {
return Ok(None);
}
_ => {}
}
}
self.read::<C>(entity)
}
#[must_use]
pub fn actor_by_handle(&self, shell: ShellId, handle: &BoundedString<32>) -> Option<ActorId> {
self.actor_handle_index
.and_then(|idx| idx.lookup(shell, handle))
}
pub fn authenticated_actor_user(&self, actor: ActorId) -> Result<Option<UserId>, ActionError> {
let binding = self.staged_read::<UserBinding>(actor.get())?;
Ok(binding.map(|b| b.user_id))
}
pub fn user_gdpr_status(&self, user: UserId) -> Result<Option<GdprStatus>, ActionError> {
let profile = self.staged_read::<UserProfile>(user.get())?;
Ok(profile.map(|p| p.gdpr_status))
}
pub fn ensure_actor_eligible(
&self,
actor: ActorId,
scheduled_tick: Tick,
) -> Result<(), ActionError> {
let Some(user) = self.authenticated_actor_user(actor)? else {
return Ok(());
};
if matches!(
self.user_gdpr_status(user)?,
Some(GdprStatus::ErasurePending)
) {
return Err(ActionError::UserErasurePending {
user,
scheduled_tick,
});
}
Ok(())
}
pub fn preview_next_id_for<C: ArkheComponent>(&self) -> Result<EntityId, ActionError> {
derive_entity_id(
&self.world_seed,
self.instance_id,
TypeCode(C::TYPE_CODE),
self.tick,
self.id_seq,
)
.ok_or(ActionError::IdExhaustion)
}
pub fn drain_events(&mut self) -> Vec<EventRecord> {
core::mem::take(&mut self.events)
}
#[inline]
#[must_use]
pub fn events(&self) -> &[EventRecord] {
&self.events
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::event::{ArkheEvent as _, UserErasureScheduled};
use crate::user::UserId;
fn fixture_ctx() -> ActionContext<'static> {
ActionContext::new(
[0x11u8; 32],
InstanceId::new(1).unwrap(),
Tick(100),
Principal::System,
CapabilityMask::SYSTEM,
)
}
#[test]
fn context_exposes_principal_and_caps() {
let ctx = fixture_ctx();
assert_eq!(*ctx.principal(), Principal::System);
assert!(ctx.caps().contains(CapabilityMask::SYSTEM));
assert_eq!(ctx.tick(), Tick(100));
}
#[test]
fn next_id_is_deterministic_and_monotone_within_context() {
let mut ctx = fixture_ctx();
let a = ctx.next_id(0x0003_0001).unwrap();
let b = ctx.next_id(0x0003_0001).unwrap();
assert_ne!(a, b, "sequential next_id calls must yield distinct ids");
}
#[test]
fn emit_event_appends_record_in_sequence() {
let mut ctx = fixture_ctx();
let user = UserId::new(arkhe_kernel::abi::EntityId::new(42).unwrap());
let ev = UserErasureScheduled {
schema_version: 1,
user,
scheduled_tick: Tick(100),
};
ctx.emit_event(&ev).unwrap();
ctx.emit_event(&ev).unwrap();
let drained = ctx.drain_events();
assert_eq!(drained.len(), 2);
assert_eq!(drained[0].sequence, 0);
assert_eq!(drained[1].sequence, 1);
assert_eq!(drained[0].type_code, UserErasureScheduled::TYPE_CODE);
assert!(ctx.events().is_empty());
}
#[test]
fn idempotency_lookup_returns_none_by_default() {
let ctx = fixture_ctx();
assert!(ctx.idempotency_lookup(&[0u8; 16]).is_none());
}
#[test]
fn ops_buffer_starts_empty_and_drains() {
let mut ctx = fixture_ctx();
assert!(ctx.ops().is_empty());
let user = UserId::new(arkhe_kernel::abi::EntityId::new(7).unwrap());
ctx.emit_event(&UserErasureScheduled {
schema_version: 1,
user,
scheduled_tick: Tick(100),
})
.unwrap();
assert_eq!(ctx.ops().len(), 1, "emit_event pushes Op::EmitEvent");
let drained = ctx.drain_ops();
assert_eq!(drained.len(), 1);
matches!(drained[0], Op::EmitEvent { .. });
assert!(ctx.ops().is_empty());
}
#[test]
fn spawn_entity_for_derives_id_and_pushes_spawn_op() {
use crate::user::UserProfile;
let mut ctx = fixture_ctx();
let id = ctx.spawn_entity_for::<UserProfile>().unwrap();
let ops = ctx.drain_ops();
assert_eq!(ops.len(), 1);
match &ops[0] {
Op::SpawnEntity {
id: spawn_id,
owner,
} => {
assert_eq!(*spawn_id, id);
assert!(matches!(owner, Principal::System));
}
other => panic!("expected SpawnEntity, got {:?}", other),
}
}
#[test]
fn set_component_encodes_via_postcard_and_tracks_size() {
use crate::user::{AuthKind, GdprStatus, UserProfile};
let mut ctx = fixture_ctx();
let profile = UserProfile {
schema_version: 1,
created_tick: Tick(1),
primary_auth_kind: AuthKind::Passkey,
gdpr_status: GdprStatus::Active,
};
let entity = ctx.spawn_entity_for::<UserProfile>().unwrap();
ctx.set_component(entity, &profile).unwrap();
let ops = ctx.drain_ops();
assert_eq!(ops.len(), 2);
match &ops[1] {
Op::SetComponent {
entity: e,
type_code,
bytes,
size,
} => {
assert_eq!(*e, entity);
assert_eq!(*type_code, TypeCode(UserProfile::TYPE_CODE));
assert_eq!(*size, bytes.len() as u64);
let back: UserProfile = postcard::from_bytes(bytes).unwrap();
assert_eq!(back, profile);
}
other => panic!("expected SetComponent, got {:?}", other),
}
}
#[test]
fn remove_component_pushes_remove_op_with_reported_size() {
use crate::user::UserProfile;
let mut ctx = fixture_ctx();
let entity = ctx.spawn_entity_for::<UserProfile>().unwrap();
ctx.remove_component::<UserProfile>(entity, 128).unwrap();
let ops = ctx.drain_ops();
match &ops[1] {
Op::RemoveComponent {
entity: e,
type_code,
size,
} => {
assert_eq!(*e, entity);
assert_eq!(*type_code, TypeCode(UserProfile::TYPE_CODE));
assert_eq!(*size, 128);
}
other => panic!("expected RemoveComponent, got {:?}", other),
}
}
#[test]
fn emit_event_dual_path_event_record_and_op() {
let mut ctx = fixture_ctx();
let user = UserId::new(arkhe_kernel::abi::EntityId::new(9).unwrap());
ctx.emit_event(&UserErasureScheduled {
schema_version: 1,
user,
scheduled_tick: Tick(100),
})
.unwrap();
let events = ctx.drain_events();
assert_eq!(events.len(), 1);
assert_eq!(events[0].type_code, UserErasureScheduled::TYPE_CODE);
let ops = ctx.drain_ops();
assert_eq!(ops.len(), 1);
match &ops[0] {
Op::EmitEvent {
actor,
event_type_code,
event_bytes: _,
} => {
assert!(actor.is_none());
assert_eq!(*event_type_code, TypeCode(UserErasureScheduled::TYPE_CODE));
}
other => panic!("expected EmitEvent, got {:?}", other),
}
}
struct FixedIndex {
key: [u8; 16],
binding: (arkhe_kernel::abi::EntityId, Tick),
}
impl IdempotencyIndex for FixedIndex {
fn lookup(&self, key: &[u8; 16]) -> Option<(arkhe_kernel::abi::EntityId, Tick)> {
if *key == self.key {
Some(self.binding)
} else {
None
}
}
}
#[test]
fn idempotency_lookup_consults_attached_index() {
let idx = FixedIndex {
key: [0x77u8; 16],
binding: (arkhe_kernel::abi::EntityId::new(5).unwrap(), Tick(42)),
};
let ctx = fixture_ctx().with_idempotency_index(&idx);
assert_eq!(
ctx.idempotency_lookup(&[0x77u8; 16]),
Some((arkhe_kernel::abi::EntityId::new(5).unwrap(), Tick(42))),
);
assert!(ctx.idempotency_lookup(&[0x00u8; 16]).is_none());
}
#[test]
fn read_returns_none_when_no_view_is_bound() {
use crate::user::UserProfile;
let ctx = fixture_ctx();
let out: Option<UserProfile> = ctx
.read::<UserProfile>(arkhe_kernel::abi::EntityId::new(1).unwrap())
.unwrap();
assert!(out.is_none());
}
#[test]
fn preview_next_id_does_not_bump_sequence() {
use crate::user::UserProfile;
let mut ctx = fixture_ctx();
let a = ctx.preview_next_id_for::<UserProfile>().unwrap();
let b = ctx.preview_next_id_for::<UserProfile>().unwrap();
assert_eq!(a, b, "preview must not bump the id sequence");
let committed = ctx.next_id(UserProfile::TYPE_CODE).unwrap();
assert_eq!(
committed, a,
"the first committed next_id matches the prior preview",
);
}
}