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("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 })
}
_ => Err(DispatchError::ProbeViewCorrupt),
};
}
}
let bytes = action.canonical_bytes();
self.kernel.submit(
instance,
principal,
authenticated_actor.map(ActorId::get),
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> {
for record in &wal.records {
let bytes = postcard::to_allocvec(record)?;
sink.append_record(&bytes)?;
}
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_record() {
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(), 1);
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_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(), 3);
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_rejects_erasure_pending_actor_before_wal() {
use arkhe_forge_core::actor::{ActorId, UserBinding};
use arkhe_forge_core::brand::ShellId;
use arkhe_forge_core::component::{ArkheComponent, BoundedString};
use arkhe_forge_core::space::{CreateSpace, SpaceConfigDraft, SpaceKind, Visibility};
use arkhe_forge_core::user::{GdprStatus, UserGdprState, UserId};
use arkhe_kernel::abi::{EntityId, TypeCode};
use arkhe_kernel::state::{ActionCompute, ActionContext, Op};
use arkhe_kernel::ArkheAction;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, ArkheAction)]
#[arkhe(type_code = 0x0001_5105, schema_version = 1)]
struct SeedBinding {
actor: u64,
user: u64,
erasing: bool,
}
impl ActionCompute for SeedBinding {
fn compute(&self, _ctx: &ActionContext<'_>) -> Vec<Op> {
let binding = UserBinding {
schema_version: 1,
user_id: UserId::new(EntityId::new(self.user).unwrap()),
};
let state = UserGdprState {
schema_version: 1,
status: if self.erasing {
GdprStatus::ErasurePending
} else {
GdprStatus::Active
},
};
let bb = postcard::to_allocvec(&binding).unwrap();
let sb = postcard::to_allocvec(&state).unwrap();
vec![
Op::SetComponent {
entity: EntityId::new(self.actor).unwrap(),
type_code: TypeCode(UserBinding::TYPE_CODE),
size: bb.len() as u64,
bytes: bytes::Bytes::from(bb),
},
Op::SetComponent {
entity: EntityId::new(self.user).unwrap(),
type_code: TypeCode(UserGdprState::TYPE_CODE),
size: sb.len() as u64,
bytes: bytes::Bytes::from(sb),
},
]
}
}
fn create_space() -> CreateSpace {
CreateSpace {
schema_version: 1,
config: SpaceConfigDraft {
schema_version: 1,
shell_id: ShellId([0xC3; 16]),
slug: BoundedString::<32>::new("forbidden").unwrap(),
kind: SpaceKind::Flat,
visibility: Visibility::Public,
parent_space: None,
created_tick: Tick(100),
},
}
}
let mut svc = RuntimeService::new([0u8; 32], [0u8; 32]);
svc.register_action::<SeedBinding>();
svc.register_action::<CreateSpace>();
let inst = svc.create_instance(InstanceConfig::default());
svc.dispatch(
inst,
Principal::System,
&SeedBinding {
actor: 8,
user: 7,
erasing: true,
},
Tick(1),
CapabilityMask::SYSTEM,
None,
)
.expect("seed must succeed");
let after_seed = svc.kernel.wal_record_count();
assert_eq!(after_seed, Some(1), "seed action appends one record");
let rejected = svc.dispatch(
inst,
Principal::System,
&create_space(),
Tick(2),
CapabilityMask::SYSTEM,
Some(ActorId::new(EntityId::new(8).unwrap())),
);
match rejected {
Err(DispatchError::ErasurePending { user, tick }) => {
assert_eq!(user, UserId::new(EntityId::new(7).unwrap()));
assert_eq!(tick, Tick(2));
}
other => panic!("expected ErasurePending rejection, got {:?}", other),
}
assert_eq!(
svc.kernel.wal_record_count(),
Some(1),
"rejected action must NOT append a WAL record",
);
let mut svc2 = RuntimeService::new([0u8; 32], [0u8; 32]);
svc2.register_action::<SeedBinding>();
svc2.register_action::<CreateSpace>();
let inst2 = svc2.create_instance(InstanceConfig::default());
svc2.dispatch(
inst2,
Principal::System,
&SeedBinding {
actor: 8,
user: 7,
erasing: false,
},
Tick(1),
CapabilityMask::SYSTEM,
None,
)
.expect("seed must succeed");
let report = svc2
.dispatch(
inst2,
Principal::System,
&create_space(),
Tick(2),
CapabilityMask::SYSTEM,
Some(ActorId::new(EntityId::new(8).unwrap())),
)
.expect("Active user must proceed");
assert_eq!(report.actions_executed, 1);
assert_eq!(
svc2.kernel.wal_record_count(),
Some(2),
"Active-user action appends a second 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(1),
"authenticated user-scoped action appends a WAL record",
);
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(), 1);
assert_eq!(
wal.records[0].actor,
Some(EntityId::new(7).unwrap()),
"WAL must record 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,
wal.records[0].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",
);
}
}