use arkhe_forge_core::actor::ActorId;
use arkhe_forge_core::context::{ActionContext, ActionError};
use arkhe_forge_core::user::UserId;
use arkhe_kernel::abi::{ArkheError, CapabilityMask, InstanceId, Principal, Tick};
use arkhe_kernel::state::traits::Action;
use arkhe_kernel::state::InstanceConfig;
use arkhe_kernel::{Kernel, StepReport, Wal};
use crate::wal_export::{BufferedWalSink, WalExportError, WalRecordSink};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum DispatchError {
#[error("kernel error: {0}")]
Kernel(#[from] ArkheError),
#[error("user erasure pending: {user:?} scheduled at {tick:?}")]
ErasurePending {
user: UserId,
tick: Tick,
},
#[error("actor bound to user without GDPR lifecycle state: {user:?}")]
UnboundUserLifecycle {
user: UserId,
},
#[error("GDPR admission probe failed: corrupt view state")]
ProbeViewCorrupt,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum WalSinkError {
#[error("WalRecord postcard encode failed: {0}")]
Encode(#[from] postcard::Error),
#[error("BufferedWalSink rejected record: {0}")]
Sink(#[from] WalExportError),
}
pub struct RuntimeService {
kernel: Kernel,
}
impl RuntimeService {
#[must_use]
pub fn new(world_id: [u8; 32], manifest_digest: [u8; 32]) -> Self {
Self {
kernel: Kernel::new_with_wal(world_id, manifest_digest),
}
}
pub fn register_action<A: Action>(&mut self) {
self.kernel.register_action::<A>();
}
pub fn create_instance(&mut self, config: InstanceConfig) -> InstanceId {
self.kernel.create_instance(config)
}
pub fn dispatch<A>(
&mut self,
instance: InstanceId,
principal: Principal,
action: &A,
at: Tick,
caps: CapabilityMask,
authenticated_actor: Option<ActorId>,
) -> Result<StepReport, DispatchError>
where
A: Action,
{
if let Some(actor) = authenticated_actor {
let view = self
.kernel
.instance_view(instance)
.ok_or(ArkheError::InstanceNotFound)?;
let probe = ActionContext::new([0u8; 32], instance, at, principal.clone(), caps)
.with_view(&view);
if let Err(err) = probe.ensure_actor_eligible(actor, at) {
return match err {
ActionError::UserErasurePending { user, .. } => {
Err(DispatchError::ErasurePending { user, tick: at })
}
ActionError::UserLifecycleUnresolved { user } => {
Err(DispatchError::UnboundUserLifecycle { user })
}
_ => Err(DispatchError::ProbeViewCorrupt),
};
}
}
let bytes = action.canonical_bytes();
self.kernel.submit(
instance,
principal,
authenticated_actor.map(ActorId::get),
caps,
at,
A::TYPE_CODE,
bytes,
)?;
Ok(self.kernel.step(at, caps))
}
#[must_use]
pub fn export_wal(self) -> Option<Wal> {
self.kernel.export_wal()
}
}
pub fn wal_to_sink<W: std::io::Write>(
wal: &Wal,
sink: &mut BufferedWalSink<W>,
) -> Result<(), WalSinkError> {
let mut scratch: Vec<u8> = Vec::with_capacity(256);
for record in &wal.records {
scratch.clear();
scratch = postcard::to_extend(record, scratch)?;
match sink.append_record(record.seq(), &scratch) {
Ok(()) => {}
Err(WalExportError::BufferOverflow { .. }) => {
sink.flush()?;
sink.append_record(record.seq(), &scratch)?;
}
Err(e) => return Err(e.into()),
}
}
sink.flush()?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use arkhe_kernel::abi::{Principal, Tick};
#[test]
fn fresh_service_has_zero_wal_records() {
let svc = RuntimeService::new([0x11u8; 32], [0x22u8; 32]);
assert_eq!(svc.kernel.wal_record_count(), Some(0));
}
#[test]
fn create_instance_grows_kernel() {
let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
let _id = svc.create_instance(InstanceConfig::default());
assert_eq!(svc.kernel.instances_len(), 1);
}
#[test]
fn dispatch_unknown_instance_returns_instance_not_found() {
use arkhe_kernel::abi::EntityId;
use arkhe_kernel::state::{ActionCompute, ActionContext, Op};
use arkhe_kernel::ArkheAction;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, ArkheAction)]
#[arkhe(type_code = 0x0001_5101, schema_version = 1)]
struct NoopAction;
impl ActionCompute for NoopAction {
fn compute(&self, _ctx: &ActionContext<'_>) -> Vec<Op> {
vec![Op::SpawnEntity {
id: EntityId::new(1).unwrap(),
owner: Principal::System,
}]
}
}
let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
svc.register_action::<NoopAction>();
let bogus = InstanceId::new(99).unwrap();
let result = svc.dispatch(
bogus,
Principal::System,
&NoopAction,
Tick(1),
CapabilityMask::SYSTEM,
None,
);
assert!(matches!(
result,
Err(DispatchError::Kernel(ArkheError::InstanceNotFound))
));
}
#[test]
fn dispatch_happy_path_executes_one_action() {
use arkhe_kernel::abi::EntityId;
use arkhe_kernel::state::{ActionCompute, ActionContext, Op};
use arkhe_kernel::ArkheAction;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, ArkheAction)]
#[arkhe(type_code = 0x0001_5102, schema_version = 1)]
struct SpawnOne;
impl ActionCompute for SpawnOne {
fn compute(&self, _ctx: &ActionContext<'_>) -> Vec<Op> {
vec![Op::SpawnEntity {
id: EntityId::new(1).unwrap(),
owner: Principal::System,
}]
}
}
let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
svc.register_action::<SpawnOne>();
let inst = svc.create_instance(InstanceConfig::default());
let report = svc
.dispatch(
inst,
Principal::System,
&SpawnOne,
Tick(0),
CapabilityMask::SYSTEM,
None,
)
.expect("dispatch must succeed for live instance");
assert_eq!(report.actions_executed, 1);
assert_eq!(report.effects_applied, 1);
assert_eq!(report.effects_denied, 0);
}
#[test]
fn wal_to_sink_round_trips_single_dispatch() {
use arkhe_kernel::abi::EntityId;
use arkhe_kernel::state::{ActionCompute, ActionContext, Op};
use arkhe_kernel::ArkheAction;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, ArkheAction)]
#[arkhe(type_code = 0x0001_5103, schema_version = 1)]
struct SpawnOne;
impl ActionCompute for SpawnOne {
fn compute(&self, _ctx: &ActionContext<'_>) -> Vec<Op> {
vec![Op::SpawnEntity {
id: EntityId::new(1).unwrap(),
owner: Principal::System,
}]
}
}
let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
svc.register_action::<SpawnOne>();
let inst = svc.create_instance(InstanceConfig::default());
let _ = svc
.dispatch(
inst,
Principal::System,
&SpawnOne,
Tick(0),
CapabilityMask::SYSTEM,
None,
)
.unwrap();
let wal = svc.export_wal().expect("WAL is configured");
assert_eq!(wal.records.len(), 2, "one dispatch = Submit + Step pair");
let mut buffer: Vec<u8> = Vec::new();
let mut sink = BufferedWalSink::new(&mut buffer);
wal_to_sink(&wal, &mut sink).expect("wal_to_sink must succeed");
assert!(!buffer.is_empty(), "sink writer must hold framed bytes");
assert!(
buffer.starts_with(&crate::wal_export::STREAM_HEADER_MAGIC),
"sink stream must begin with ARKHEXP1 magic",
);
}
#[test]
fn wal_to_sink_drains_mid_stream_when_capacity_is_tight() {
use arkhe_kernel::abi::EntityId;
use arkhe_kernel::state::{ActionCompute, ActionContext, Op};
use arkhe_kernel::ArkheAction;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, ArkheAction)]
#[arkhe(type_code = 0x0001_5106, schema_version = 1)]
struct SpawnAt(u64);
impl ActionCompute for SpawnAt {
fn compute(&self, _ctx: &ActionContext<'_>) -> Vec<Op> {
vec![Op::SpawnEntity {
id: EntityId::new(self.0.max(1)).unwrap(),
owner: Principal::System,
}]
}
}
fn export(records_capacity: Option<usize>, wal: &Wal) -> Vec<u8> {
let mut buffer: Vec<u8> = Vec::new();
{
let mut sink = match records_capacity {
Some(cap) => BufferedWalSink::with_capacity(&mut buffer, cap),
None => BufferedWalSink::new(&mut buffer),
};
wal_to_sink(wal, &mut sink).expect("wal_to_sink succeeds");
}
buffer
}
let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
svc.register_action::<SpawnAt>();
let inst = svc.create_instance(InstanceConfig::default());
for i in 1..=4 {
svc.dispatch(
inst,
Principal::System,
&SpawnAt(i),
Tick(i),
CapabilityMask::SYSTEM,
None,
)
.unwrap();
}
let wal = svc.export_wal().expect("WAL configured");
assert_eq!(wal.records.len(), 8);
let roomy = export(None, &wal);
let largest_frame = wal
.records
.iter()
.map(|r| 8 + postcard::to_allocvec(r).unwrap().len())
.max()
.unwrap();
let tight = export(Some(8 + largest_frame), &wal);
assert_eq!(
tight, roomy,
"drain-and-retry export must be byte-identical to a roomy export"
);
}
#[test]
fn wal_to_sink_handles_multi_record_stream() {
use arkhe_kernel::abi::EntityId;
use arkhe_kernel::state::{ActionCompute, ActionContext, Op};
use arkhe_kernel::ArkheAction;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, ArkheAction)]
#[arkhe(type_code = 0x0001_5104, schema_version = 1)]
struct SpawnAt(u64);
impl ActionCompute for SpawnAt {
fn compute(&self, _ctx: &ActionContext<'_>) -> Vec<Op> {
vec![Op::SpawnEntity {
id: EntityId::new(self.0.max(1)).unwrap(),
owner: Principal::System,
}]
}
}
let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
svc.register_action::<SpawnAt>();
let inst = svc.create_instance(InstanceConfig::default());
for i in 1..=3 {
svc.dispatch(
inst,
Principal::System,
&SpawnAt(i),
Tick(i),
CapabilityMask::SYSTEM,
None,
)
.unwrap();
}
let wal = svc.export_wal().expect("WAL configured");
assert_eq!(wal.records.len(), 6, "3 dispatches = 3 Submit + Step pairs");
let mut buffer: Vec<u8> = Vec::new();
let mut sink = BufferedWalSink::new(&mut buffer);
wal_to_sink(&wal, &mut sink).unwrap();
assert!(!buffer.is_empty());
assert!(buffer.starts_with(&crate::wal_export::STREAM_HEADER_MAGIC));
}
#[test]
fn dispatch_gdpr_gate_is_live_through_production_binding_path() {
use arkhe_forge_core::actor::{ActorKind, ActorProfile, RegisterActor, UserBinding};
use arkhe_forge_core::user::{
AuthCredential, AuthKind, GdprEraseUser, KdfKind, KdfParams, RegisterUser, UserId,
UserProfile,
};
fn create_space(slug: &str) -> CreateSpace {
CreateSpace {
schema_version: 1,
config: SpaceConfigDraft {
schema_version: 1,
shell_id: ShellId([0xC3; 16]),
slug: BoundedString::<32>::new(slug).unwrap(),
kind: SpaceKind::Flat,
visibility: Visibility::Public,
parent_space: None,
created_tick: Tick(100),
},
}
}
fn single_entity_with<C: arkhe_forge_core::component::ArkheComponent>(
svc: &RuntimeService,
inst: InstanceId,
) -> Option<EntityId> {
let view = svc.kernel.instance_view(inst)?;
let mut found = view
.components_by_type(TypeCode(C::TYPE_CODE))
.map(|(eid, _)| eid);
let first = found.next();
assert!(found.next().is_none(), "expected exactly one component");
first
}
let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
svc.register_action::<RegisterUser>();
svc.register_action::<RegisterActor>();
svc.register_action::<GdprEraseUser>();
svc.register_action::<CreateSpace>();
let inst = svc.create_instance(InstanceConfig::default());
svc.dispatch(
inst,
Principal::System,
&RegisterUser {
schema_version: 1,
profile: UserProfile {
schema_version: 1,
created_tick: Tick(1),
primary_auth_kind: AuthKind::Passkey,
},
credential: AuthCredential {
schema_version: 1,
kind: AuthKind::Passkey,
kdf: KdfKind::Argon2id,
salt: [0u8; 16],
credential_hash: [0u8; 32],
kdf_params: KdfParams {
m_cost: AuthCredential::MIN_ARGON2ID_M_COST,
t_cost: AuthCredential::MIN_ARGON2ID_T_COST,
p_cost: AuthCredential::MIN_ARGON2ID_P_COST,
},
expires_tick: None,
bound_tick: Tick(1),
},
},
Tick(1),
CapabilityMask::SYSTEM,
None,
)
.expect("RegisterUser must succeed");
let user = UserId::new(
single_entity_with::<UserProfile>(&svc, inst).expect("user entity spawned"),
);
svc.dispatch(
inst,
Principal::System,
&RegisterActor {
schema_version: 1,
profile: ActorProfile {
schema_version: 1,
shell_id: ShellId([0xC3; 16]),
handle: BoundedString::<32>::new("alice").unwrap(),
kind: ActorKind::Human,
created_tick: Tick(2),
},
user,
},
Tick(2),
CapabilityMask::SYSTEM,
None,
)
.expect("RegisterActor must succeed");
let actor = ActorId::new(
single_entity_with::<UserBinding>(&svc, inst).expect("actor entity spawned"),
);
let report = svc
.dispatch(
inst,
Principal::System,
&create_space("welcome"),
Tick(3),
CapabilityMask::SYSTEM,
Some(actor),
)
.expect("Active user's actor must proceed");
assert_eq!(report.actions_executed, 1);
svc.dispatch(
inst,
Principal::System,
&GdprEraseUser {
schema_version: 1,
user,
},
Tick(4),
CapabilityMask::SYSTEM,
None,
)
.expect("GdprEraseUser must succeed");
let wal_before = svc.kernel.wal_record_count();
let rejected = svc.dispatch(
inst,
Principal::System,
&create_space("forbidden"),
Tick(5),
CapabilityMask::SYSTEM,
Some(actor),
);
match rejected {
Err(DispatchError::ErasurePending { user: u, tick }) => {
assert_eq!(u, user, "rejection must name the backing user");
assert_eq!(tick, Tick(5));
}
other => panic!("expected ErasurePending rejection, got {:?}", other),
}
assert_eq!(
svc.kernel.wal_record_count(),
wal_before,
"rejected action must NOT append a WAL record",
);
}
#[test]
fn dispatch_rejects_actor_bound_to_unregistered_user() {
use arkhe_forge_core::actor::{ActorKind, ActorProfile, RegisterActor, UserBinding};
use arkhe_forge_core::user::UserId;
let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
svc.register_action::<RegisterActor>();
svc.register_action::<CreateSpace>();
let inst = svc.create_instance(InstanceConfig::default());
let phantom_user = UserId::new(EntityId::new(999).unwrap());
svc.dispatch(
inst,
Principal::System,
&RegisterActor {
schema_version: 1,
profile: ActorProfile {
schema_version: 1,
shell_id: ShellId([0xC3; 16]),
handle: BoundedString::<32>::new("ghost").unwrap(),
kind: ActorKind::Human,
created_tick: Tick(1),
},
user: phantom_user,
},
Tick(1),
CapabilityMask::SYSTEM,
None,
)
.expect("RegisterActor itself is system-scoped and succeeds");
let actor_entity = svc
.kernel
.instance_view(inst)
.expect("instance live")
.components_by_type(TypeCode(UserBinding::TYPE_CODE))
.map(|(eid, _)| eid)
.next()
.expect("actor entity spawned with binding");
let wal_before = svc.kernel.wal_record_count();
let rejected = svc.dispatch(
inst,
Principal::System,
&user_create_space(),
Tick(2),
CapabilityMask::SYSTEM,
Some(ActorId::new(actor_entity)),
);
match rejected {
Err(DispatchError::UnboundUserLifecycle { user }) => {
assert_eq!(user, phantom_user, "rejection names the phantom user");
}
other => panic!("expected UnboundUserLifecycle, got {:?}", other),
}
assert_eq!(
svc.kernel.wal_record_count(),
wal_before,
"rejected action must NOT append a WAL record",
);
}
use arkhe_forge_core::actor::ActorId;
use arkhe_forge_core::brand::ShellId;
use arkhe_forge_core::component::{ArkheComponent as _, BoundedString};
use arkhe_forge_core::space::{
CreateSpace, SpaceConfig, SpaceConfigDraft, SpaceKind, Visibility,
};
use arkhe_kernel::abi::{EntityId, TypeCode};
fn user_create_space() -> CreateSpace {
CreateSpace {
schema_version: 1,
config: SpaceConfigDraft {
schema_version: 1,
shell_id: ShellId([0xC3; 16]),
slug: BoundedString::<32>::new("space").unwrap(),
kind: SpaceKind::Flat,
visibility: Visibility::Public,
parent_space: None,
created_tick: Tick(100),
},
}
}
fn actor(id: u64) -> ActorId {
ActorId::new(EntityId::new(id).unwrap())
}
fn stored_space_creator(svc: &RuntimeService, inst: InstanceId) -> Option<ActorId> {
let view = svc.kernel.instance_view(inst)?;
view.components_by_type(TypeCode(SpaceConfig::TYPE_CODE))
.find_map(|(_eid, bytes)| postcard::from_bytes::<SpaceConfig>(bytes).ok())
.map(|cfg| cfg.creator)
}
#[test]
fn dispatch_records_injected_actor_as_creator() {
let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
svc.register_action::<CreateSpace>();
let inst = svc.create_instance(InstanceConfig::default());
let report = svc
.dispatch(
inst,
Principal::System,
&user_create_space(),
Tick(1),
CapabilityMask::SYSTEM,
Some(actor(7)),
)
.expect("authenticated actor must proceed");
assert_eq!(report.actions_executed, 1);
assert_eq!(
svc.kernel.wal_record_count(),
Some(2),
"authenticated user-scoped action appends a Submit + Step pair",
);
assert_eq!(
stored_space_creator(&svc, inst),
Some(actor(7)),
"stored creator must equal the injected authenticated actor",
);
}
#[test]
fn dispatch_creator_follows_injected_identity() {
let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
svc.register_action::<CreateSpace>();
let inst = svc.create_instance(InstanceConfig::default());
svc.dispatch(
inst,
Principal::System,
&user_create_space(),
Tick(1),
CapabilityMask::SYSTEM,
Some(actor(42)),
)
.expect("authenticated actor must proceed");
assert_eq!(
stored_space_creator(&svc, inst),
Some(actor(42)),
"stored creator equals the injected actor, whatever it is",
);
}
#[test]
fn dispatch_unauthenticated_user_action_creates_no_space() {
let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
svc.register_action::<CreateSpace>();
let inst = svc.create_instance(InstanceConfig::default());
let report = svc
.dispatch(
inst,
Principal::System,
&user_create_space(),
Tick(1),
CapabilityMask::SYSTEM,
None,
)
.expect("dispatch returns Ok — compute self-rejects, no error surface");
assert_eq!(report.effects_applied, 0);
assert_eq!(
stored_space_creator(&svc, inst),
None,
"no Space may be created without an injected actor",
);
}
#[test]
fn wal_replay_reproduces_injected_creator() {
let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
svc.register_action::<CreateSpace>();
let inst = svc.create_instance(InstanceConfig::default());
svc.dispatch(
inst,
Principal::System,
&user_create_space(),
Tick(1),
CapabilityMask::SYSTEM,
Some(actor(7)),
)
.expect("authenticated actor proceeds");
let wal = svc.export_wal().expect("WAL configured");
assert_eq!(wal.records.len(), 2, "one dispatch = Submit + Step pair");
let arkhe_kernel::persist::WalRecordContent::Submit {
actor: recorded_actor,
..
} = wal.records[0].content
else {
panic!("record 0 of a dispatch must be the Submit record");
};
assert_eq!(
recorded_actor,
Some(EntityId::new(7).unwrap()),
"WAL Submit record must carry the injected acting actor as canonical input",
);
let mut replay = RuntimeService::new([0u8; 32], [0u8; 32]);
replay.register_action::<CreateSpace>();
let rinst = replay.create_instance(InstanceConfig::default());
replay
.dispatch(
rinst,
Principal::System,
&user_create_space(),
Tick(1),
CapabilityMask::SYSTEM,
recorded_actor.map(ActorId::new),
)
.expect("replay proceeds");
assert_eq!(
stored_space_creator(&replay, rinst),
Some(actor(7)),
"replay reproduces the WAL-recorded acting actor as creator",
);
}
}