Skip to main content

arkhe_kernel/runtime/
kernel.rs

1//! `Kernel` — top-level orchestrator.
2//!
3//! Step path: `pop_due → deserialize → compute → authorize → dispatch
4//! → apply_stage → observer.deliver`. Per-instance step ordering is
5//! `InstanceId` ascending (A23) — `BTreeMap` iteration delivers it for free.
6
7use std::collections::BTreeMap;
8
9use crate::abi::{ArkheError, CapabilityMask, EntityId, InstanceId, Principal, Tick, TypeCode};
10use crate::state::authz::authorize;
11use crate::state::{
12    Action, ActionContext, Effect, Instance, InstanceConfig, ScheduledActionId, Unverified,
13};
14
15use super::apply::{apply_stage, discard_stage};
16use super::dispatch::dispatch;
17use super::event::{KernelEvent, ObserverHandle};
18use super::observer::{KernelObserver, ObserverRegistry};
19use super::registry::ActionRegistry;
20use super::stage::StepStage;
21
22use crate::persist::{AuthDecisionAnnotation, Wal, WalWriter};
23
24/// Top-level kernel orchestrator.
25///
26/// Lifecycle: [`Kernel::new`] (or [`Kernel::new_with_wal`] /
27/// [`Kernel::new_with_wal_signed`]) → [`register_action`](Kernel::register_action)
28/// → [`create_instance`](Kernel::create_instance) →
29/// [`submit`](Kernel::submit) → [`step`](Kernel::step) (repeat) →
30/// optional [`snapshot`](Kernel::snapshot) / [`export_wal`](Kernel::export_wal).
31///
32/// `Kernel` is `!Sync` (A2 single-thread) and is owned by the caller —
33/// no internal locking, no async. All determinism guarantees depend on
34/// the caller driving a single kernel from one thread.
35pub struct Kernel {
36    instances: BTreeMap<InstanceId, Instance>,
37    action_registry: ActionRegistry,
38    observers: ObserverRegistry,
39    next_instance_id: u64,
40    wal: Option<WalWriter>,
41}
42
43/// Aggregated counters returned by [`Kernel::step`].
44#[derive(Debug, Clone, Default, PartialEq, Eq)]
45pub struct StepReport {
46    /// Number of scheduled actions whose `compute()` ran this step.
47    pub actions_executed: u32,
48    /// Number of effects (Ops) that committed.
49    pub effects_applied: u32,
50    /// Number of effects denied (authorize-deny or budget-deny).
51    pub effects_denied: u32,
52    /// Number of observers newly evicted (first-panic).
53    pub observers_evicted: u32,
54    /// Number of `KernelEvent::DomainEventEmitted` events produced.
55    pub domain_events_emitted: u32,
56}
57
58/// Cross-instance aggregate observability returned by [`Kernel::stats`].
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct Stats {
61    /// Total instances currently alive.
62    pub instance_count: usize,
63    /// Total scheduled actions pending across all instances.
64    pub scheduled_action_count: usize,
65    /// Total entities across all instances.
66    pub entity_count: u32,
67    /// Total component bytes across all ledgers.
68    pub component_byte_count: u64,
69    /// Live observer count (pre-eviction).
70    pub observer_count: usize,
71    /// WAL record count; `0` if no WAL is attached.
72    pub wal_record_count: usize,
73}
74
75impl Default for Kernel {
76    fn default() -> Self {
77        Self::new()
78    }
79}
80
81impl Kernel {
82    /// Construct a fresh kernel with no instances, no observers, and
83    /// no WAL. Use [`new_with_wal`](Kernel::new_with_wal) or
84    /// [`new_with_wal_signed`](Kernel::new_with_wal_signed) instead if
85    /// you want WAL recording from step zero.
86    pub fn new() -> Self {
87        Self {
88            instances: BTreeMap::new(),
89            action_registry: ActionRegistry::new(),
90            observers: ObserverRegistry::new(),
91            next_instance_id: 0,
92            wal: None,
93        }
94    }
95
96    /// Construct a Kernel with an attached WAL writer. Each successfully
97    /// committed step appends one record (A13/A14).
98    pub fn new_with_wal(world_id: [u8; 32], manifest_digest: [u8; 32]) -> Self {
99        Self {
100            instances: BTreeMap::new(),
101            action_registry: ActionRegistry::new(),
102            observers: ObserverRegistry::new(),
103            next_instance_id: 0,
104            wal: Some(WalWriter::new(world_id, manifest_digest)),
105        }
106    }
107
108    /// Construct a Kernel with a WAL writer that signs each record under
109    /// the supplied `SignatureClass` (A16 — Ed25519 (Tier 2) or Hybrid (Ed25519 + ML-DSA 65, CNSA 2.0)). The
110    /// verifying key is pinned in the WAL header so post-hoc verification
111    /// is self-contained.
112    pub fn new_with_wal_signed(
113        world_id: [u8; 32],
114        manifest_digest: [u8; 32],
115        sig_class: crate::persist::SignatureClass,
116    ) -> Self {
117        Self {
118            instances: BTreeMap::new(),
119            action_registry: ActionRegistry::new(),
120            observers: ObserverRegistry::new(),
121            next_instance_id: 0,
122            wal: Some(WalWriter::with_signature(
123                world_id,
124                manifest_digest,
125                sig_class,
126            )),
127        }
128    }
129
130    /// Current chain tip if the kernel has a WAL attached.
131    pub fn wal_chain_tip(&self) -> Option<[u8; 32]> {
132        self.wal.as_ref().map(|w| w.chain_tip())
133    }
134
135    /// Number of WAL records currently buffered (None if no WAL attached).
136    pub fn wal_record_count(&self) -> Option<usize> {
137        self.wal.as_ref().map(|w| w.record_count())
138    }
139
140    /// Consume the kernel and return the accumulated WAL (if any).
141    pub fn export_wal(self) -> Option<Wal> {
142        self.wal.map(Wal::from_writer)
143    }
144
145    /// Register a domain action type with the kernel's dispatch
146    /// registry. Required before [`submit`](Kernel::submit) accepts
147    /// the action's `TYPE_CODE`.
148    pub fn register_action<A: Action>(&mut self) {
149        self.action_registry.register::<A>();
150    }
151
152    /// Register an observer for every kernel event. Equivalent to
153    /// [`register_observer_filtered`](Kernel::register_observer_filtered)
154    /// with `EventMask::ALL`.
155    pub fn register_observer(&mut self, obs: Box<dyn KernelObserver>) -> ObserverHandle {
156        self.observers.register(obs)
157    }
158
159    /// Register an observer with an event-class filter. Only events
160    /// whose variant bit is set in `mask` are delivered to this observer
161    /// — useful when an observer cares about a narrow slice of the event
162    /// stream (e.g. only `DOMAIN_EVENT_EMITTED`). `EventMask::ALL` is
163    /// equivalent to `register_observer`.
164    pub fn register_observer_filtered(
165        &mut self,
166        obs: Box<dyn KernelObserver>,
167        mask: super::event::EventMask,
168    ) -> ObserverHandle {
169        self.observers.register_filtered(obs, mask)
170    }
171
172    /// Create a new instance with the supplied config. Returns the
173    /// freshly-allocated `InstanceId` (monotonic per kernel lifetime).
174    pub fn create_instance(&mut self, config: InstanceConfig) -> InstanceId {
175        self.next_instance_id = self.next_instance_id.saturating_add(1);
176        let id = InstanceId::new(self.next_instance_id).expect("instance id > 0");
177        self.instances.insert(id, Instance::new(id, config));
178        id
179    }
180
181    /// Number of live instances.
182    pub fn instances_len(&self) -> usize {
183        self.instances.len()
184    }
185
186    /// Read-only view of one instance's state. Returns `None` if `id`
187    /// does not exist. The borrow is `&self`, so callers cannot mutate
188    /// the kernel concurrently while a view is live.
189    pub fn instance_view(&self, id: InstanceId) -> Option<super::view::InstanceView<'_>> {
190        self.instances
191            .get(&id)
192            .map(|instance| super::view::InstanceView { instance })
193    }
194
195    /// Capture current kernel state as a serializable snapshot.
196    /// Excludes observers and action registry — caller re-registers those
197    /// after `Kernel::from_snapshot(...)`. WAL is independent and not
198    /// captured here.
199    pub fn snapshot(&self) -> crate::persist::KernelSnapshot {
200        let instances = self
201            .instances
202            .iter()
203            .map(|(id, inst)| (*id, inst.to_snapshot()))
204            .collect();
205        crate::persist::KernelSnapshot::__construct(instances, self.next_instance_id)
206    }
207
208    /// Restore a `Kernel` from a snapshot. The returned kernel has no
209    /// observers, an empty action registry, and no attached WAL — caller
210    /// must re-register everything before resuming `step()`.
211    pub fn from_snapshot(snap: crate::persist::KernelSnapshot) -> Self {
212        let (instances_in, next_instance_id) = snap.__into_parts();
213        let instances = instances_in
214            .into_iter()
215            .map(|(id, s)| (id, Instance::from_snapshot(s)))
216            .collect();
217        Self {
218            instances,
219            action_registry: ActionRegistry::new(),
220            observers: ObserverRegistry::new(),
221            next_instance_id,
222            wal: None,
223        }
224    }
225
226    /// Aggregate observability across all instances. See [`Stats`].
227    pub fn stats(&self) -> Stats {
228        let mut scheduled = 0usize;
229        let mut entities = 0u32;
230        let mut bytes = 0u64;
231        for inst in self.instances.values() {
232            scheduled = scheduled.saturating_add(inst.scheduler().len());
233            entities = entities.saturating_add(inst.ledger().total_entities());
234            bytes = bytes.saturating_add(inst.ledger().total_bytes());
235        }
236        Stats {
237            instance_count: self.instances.len(),
238            scheduled_action_count: scheduled,
239            entity_count: entities,
240            component_byte_count: bytes,
241            observer_count: self.observers.len(),
242            wal_record_count: self.wal.as_ref().map(|w| w.record_count()).unwrap_or(0),
243        }
244    }
245
246    /// Force-unload: drop every instance's inflight-refs entry for
247    /// `route_id` and emit `KernelEvent::ModuleForceUnloaded` with the
248    /// summed live-ref count for the audit trail. Requires `ADMIN_UNLOAD`.
249    ///
250    /// Returns the total live refs that were dropped (`Ok(0)` if no
251    /// instance held the route).
252    pub fn force_unload(
253        &mut self,
254        route_id: crate::abi::RouteId,
255        caps: CapabilityMask,
256    ) -> Result<usize, ArkheError> {
257        if !caps.contains(CapabilityMask::ADMIN_UNLOAD) {
258            return Err(ArkheError::CapabilityDenied);
259        }
260
261        let mut total_live_refs: u32 = 0;
262        for inst in self.instances.values_mut() {
263            if let Some(refs) = inst.inflight_refs_mut().remove(&route_id) {
264                total_live_refs = total_live_refs.saturating_add(refs);
265            }
266        }
267
268        let event = KernelEvent::ModuleForceUnloaded {
269            route_id,
270            live_refs_at_unload: total_live_refs,
271        };
272        let _ = self.observers.deliver(&event);
273
274        Ok(total_live_refs as usize)
275    }
276
277    /// Schedule a serialized action against an instance for execution
278    /// at tick `at`. The bytes must be the canonical postcard encoding
279    /// produced by `<A as Action>::canonical_bytes()` for some
280    /// previously-registered action type matching `action_type_code`.
281    /// Returns the freshly-allocated [`ScheduledActionId`].
282    ///
283    /// Errors with [`ArkheError::InstanceNotFound`] if `instance` is
284    /// unknown.
285    pub fn submit(
286        &mut self,
287        instance: InstanceId,
288        principal: Principal,
289        actor: Option<EntityId>,
290        at: Tick,
291        action_type_code: TypeCode,
292        action_bytes: Vec<u8>,
293    ) -> Result<ScheduledActionId, ArkheError> {
294        let inst = self
295            .instances
296            .get_mut(&instance)
297            .ok_or(ArkheError::InstanceNotFound)?;
298        // Back-pressure (#15): bound the scheduler so an external caller
299        // cannot flood `submit` into unbounded growth. `max_scheduled == 0`
300        // means unlimited (default `InstanceConfig`).
301        let max_scheduled = inst.config().max_scheduled;
302        if max_scheduled > 0 && inst.scheduler().len() >= max_scheduled as usize {
303            return Err(ArkheError::QuotaExceeded);
304        }
305        let counters = inst.id_counters_mut();
306        counters.next_scheduled = counters.next_scheduled.saturating_add(1);
307        let id = ScheduledActionId::new(counters.next_scheduled).expect("scheduled id > 0");
308        inst.scheduler_mut().schedule_with_id(
309            id,
310            at,
311            actor,
312            principal,
313            action_type_code,
314            action_bytes,
315        );
316        Ok(id)
317    }
318
319    /// Process at most one due action per instance, in ascending InstanceId
320    /// order (A23). Returns aggregated counters for the step.
321    pub fn step(&mut self, now: Tick, caps: CapabilityMask) -> StepReport {
322        let mut report = StepReport::default();
323
324        let instance_ids: Vec<InstanceId> = self.instances.keys().copied().collect();
325        for inst_id in instance_ids {
326            let entry = match self.instances.get_mut(&inst_id) {
327                Some(inst) => inst.scheduler_mut().pop_due(now),
328                None => continue,
329            };
330            let entry = match entry {
331                Some(e) => e,
332                None => continue,
333            };
334            report.actions_executed = report.actions_executed.saturating_add(1);
335
336            let reg = match self.action_registry.get(entry.action_type_code).cloned() {
337                Some(r) => r,
338                None => continue,
339            };
340
341            let action = match (reg.deserializer)(reg.schema_version, &entry.action_bytes) {
342                Ok(a) => a,
343                Err(_) => continue,
344            };
345
346            let inst_ref = self.instances.get(&inst_id).expect("instance present");
347            let ctx = ActionContext::new(entry.actor, now, inst_id, inst_ref);
348            let ops = action.compute_dyn(&ctx);
349
350            let mut stage = StepStage::default();
351            let mut next_scheduled_id = inst_ref.id_counters_snapshot().next_scheduled;
352            let budget = inst_ref.config().memory_budget_bytes;
353            let baseline_bytes: i64 =
354                i64::try_from(inst_ref.ledger().total_bytes()).unwrap_or(i64::MAX);
355            let mut any_denied = false;
356            for op in ops {
357                let principal_clone = match &entry.principal {
358                    Principal::Unauthenticated => Principal::Unauthenticated,
359                    Principal::External(e) => Principal::External(*e),
360                    Principal::System => Principal::System,
361                };
362                let eff: Effect<'_, Unverified> = Effect::new(inst_id, principal_clone, op);
363                match authorize(caps, eff) {
364                    Ok(authorized) => {
365                        // Budget enforcement (per-Op, post-authorize, pre-dispatch).
366                        // `budget == 0` means unlimited (default `InstanceConfig`).
367                        // Authorize-deny rolls back the whole stage (any_denied);
368                        // budget-deny is a per-Op skip that does NOT rollback.
369                        if budget > 0 {
370                            // `size` is a caller-supplied `u64`; clamp to
371                            // `i64::MAX` so a value above `i64::MAX` cannot wrap
372                            // negative and shrink the projection past the gate.
373                            let op_size: i64 = match &authorized.op {
374                                crate::state::Op::SetComponent { size, .. } => {
375                                    i64::try_from(*size).unwrap_or(i64::MAX)
376                                }
377                                crate::state::Op::RemoveComponent { size, .. } => {
378                                    i64::try_from(*size).unwrap_or(i64::MAX).saturating_neg()
379                                }
380                                _ => 0,
381                            };
382                            let projected = baseline_bytes
383                                .saturating_add(super::stage::bytes_delta(&stage))
384                                .saturating_add(op_size);
385                            if projected > i64::try_from(budget).unwrap_or(i64::MAX) {
386                                report.effects_denied = report.effects_denied.saturating_add(1);
387                                stage.events.push_back(KernelEvent::EffectFailed {
388                                    instance: inst_id,
389                                    reason: bytes::Bytes::from_static(b"budget_exceeded"),
390                                });
391                                continue;
392                            }
393                        }
394                        // Entity quota (#5; `max_entities == 0` = unlimited).
395                        // Conservative projection = committed live entities +
396                        // entity-spawns staged so far this step (favors
397                        // false-deny, like the byte-budget gate).
398                        let max_entities = inst_ref.config().max_entities;
399                        if max_entities > 0
400                            && matches!(&authorized.op, crate::state::Op::SpawnEntity { .. })
401                        {
402                            let projected_entities = (inst_ref.entities_len() as u64)
403                                .saturating_add(stage.id_counters.next_entity_advance);
404                            if projected_entities >= max_entities as u64 {
405                                report.effects_denied = report.effects_denied.saturating_add(1);
406                                stage.events.push_back(KernelEvent::EffectFailed {
407                                    instance: inst_id,
408                                    reason: bytes::Bytes::from_static(b"entity_quota_exceeded"),
409                                });
410                                continue;
411                            }
412                        }
413                        // Scheduled-action quota (#8; `max_scheduled == 0` =
414                        // unlimited). Projection = current scheduler depth +
415                        // schedule-adds staged so far this step.
416                        let max_scheduled = inst_ref.config().max_scheduled;
417                        if max_scheduled > 0
418                            && matches!(&authorized.op, crate::state::Op::ScheduleAction { .. })
419                        {
420                            let projected_scheduled = (inst_ref.scheduler().len() as u64)
421                                .saturating_add(stage.id_counters.next_scheduled_advance);
422                            if projected_scheduled >= max_scheduled as u64 {
423                                report.effects_denied = report.effects_denied.saturating_add(1);
424                                stage.events.push_back(KernelEvent::EffectFailed {
425                                    instance: inst_id,
426                                    reason: bytes::Bytes::from_static(b"scheduled_quota_exceeded"),
427                                });
428                                continue;
429                            }
430                        }
431                        dispatch(authorized, &mut stage, now, &mut next_scheduled_id);
432                        report.effects_applied = report.effects_applied.saturating_add(1);
433                    }
434                    Err(_) => {
435                        report.effects_denied = report.effects_denied.saturating_add(1);
436                        any_denied = true;
437                    }
438                }
439            }
440
441            if any_denied {
442                let inst_mut = self.instances.get_mut(&inst_id).expect("instance present");
443                discard_stage(inst_mut, stage);
444                continue;
445            }
446
447            // Domain emit count covers only `DomainEventEmitted`; other staged
448            // events (e.g. `EffectFailed` from budget deny) are kernel events.
449            let domain_emit_count = stage
450                .events
451                .iter()
452                .filter(|e| matches!(e, KernelEvent::DomainEventEmitted { .. }))
453                .count();
454            report.domain_events_emitted = report
455                .domain_events_emitted
456                .saturating_add(domain_emit_count as u32);
457            let events_to_deliver: Vec<KernelEvent> = stage.events.iter().cloned().collect();
458
459            // Snapshot record metadata before stage is consumed by apply.
460            let wal_stage = if self.wal.is_some() {
461                Some(stage.clone())
462            } else {
463                None
464            };
465            let principal_for_wal = match &entry.principal {
466                Principal::Unauthenticated => Principal::Unauthenticated,
467                Principal::External(e) => Principal::External(*e),
468                Principal::System => Principal::System,
469            };
470            let action_bytes_for_wal = entry.action_bytes.clone();
471            let action_type_for_wal = entry.action_type_code;
472            let actor_for_wal = entry.actor;
473
474            let inst_mut = self.instances.get_mut(&inst_id).expect("instance present");
475            apply_stage(inst_mut, stage);
476
477            if let (Some(wal), Some(s)) = (self.wal.as_mut(), wal_stage) {
478                let _ = wal.append(
479                    now,
480                    inst_id,
481                    principal_for_wal,
482                    actor_for_wal,
483                    action_type_for_wal,
484                    action_bytes_for_wal,
485                    caps.bits(),
486                    s,
487                    AuthDecisionAnnotation::AllAuthorized,
488                );
489            }
490
491            for event in events_to_deliver {
492                let evicted = self.observers.deliver(&event);
493                report.observers_evicted = report
494                    .observers_evicted
495                    .saturating_add(evicted.len() as u32);
496            }
497
498            let action_executed = KernelEvent::ActionExecuted {
499                instance: inst_id,
500                action_type: entry.action_type_code,
501                at: now,
502            };
503            let evicted = self.observers.deliver(&action_executed);
504            report.observers_evicted = report
505                .observers_evicted
506                .saturating_add(evicted.len() as u32);
507        }
508
509        report
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use bytes::Bytes;
517    use std::sync::atomic::{AtomicU32, Ordering};
518    use std::sync::Arc;
519
520    use crate::abi::{ExternalId, RouteId};
521    use crate::state::traits::_sealed::Sealed;
522    use crate::state::{ActionCompute, ActionDeriv, Op};
523    use serde::{Deserialize, Serialize};
524
525    // ---- Test Action: spawns a single entity with id=42 ----
526    #[derive(Serialize, Deserialize)]
527    struct SpawnOneAction;
528    impl Sealed for SpawnOneAction {}
529    impl ActionDeriv for SpawnOneAction {
530        const TYPE_CODE: TypeCode = TypeCode(100);
531        const SCHEMA_VERSION: u32 = 1;
532    }
533    impl ActionCompute for SpawnOneAction {
534        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
535            vec![Op::SpawnEntity {
536                id: EntityId::new(42).unwrap(),
537                owner: Principal::System,
538            }]
539        }
540    }
541
542    #[derive(Serialize, Deserialize)]
543    struct EmitAction;
544    impl Sealed for EmitAction {}
545    impl ActionDeriv for EmitAction {
546        const TYPE_CODE: TypeCode = TypeCode(101);
547        const SCHEMA_VERSION: u32 = 1;
548    }
549    impl ActionCompute for EmitAction {
550        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
551            vec![Op::EmitEvent {
552                actor: None,
553                event_type_code: TypeCode(7),
554                event_bytes: Bytes::from_static(b"hello"),
555            }]
556        }
557    }
558
559    #[derive(Serialize, Deserialize)]
560    struct SignalAction;
561    impl Sealed for SignalAction {}
562    impl ActionDeriv for SignalAction {
563        const TYPE_CODE: TypeCode = TypeCode(102);
564        const SCHEMA_VERSION: u32 = 1;
565    }
566    impl ActionCompute for SignalAction {
567        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
568            // SendSignal requires SYSTEM cap (state::authz policy).
569            vec![Op::SendSignal {
570                target: InstanceId::new(1).unwrap(),
571                route: RouteId(1),
572                payload: Bytes::new(),
573            }]
574        }
575    }
576
577    // Emits three SpawnEntity ops with distinct ids (max_entities quota test).
578    #[derive(Serialize, Deserialize)]
579    struct SpawnThreeAction;
580    impl Sealed for SpawnThreeAction {}
581    impl ActionDeriv for SpawnThreeAction {
582        const TYPE_CODE: TypeCode = TypeCode(103);
583        const SCHEMA_VERSION: u32 = 1;
584    }
585    impl ActionCompute for SpawnThreeAction {
586        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
587            (1..=3)
588                .map(|n| Op::SpawnEntity {
589                    id: EntityId::new(n).unwrap(),
590                    owner: Principal::System,
591                })
592                .collect()
593        }
594    }
595
596    // Emits a SetComponent with a declared size above i64::MAX (budget-bypass
597    // regression: the size must NOT wrap negative past the budget gate).
598    #[derive(Serialize, Deserialize)]
599    struct OversizedSetAction;
600    impl Sealed for OversizedSetAction {}
601    impl ActionDeriv for OversizedSetAction {
602        const TYPE_CODE: TypeCode = TypeCode(104);
603        const SCHEMA_VERSION: u32 = 1;
604    }
605    impl ActionCompute for OversizedSetAction {
606        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
607            vec![Op::SetComponent {
608                entity: EntityId::new(1).unwrap(),
609                type_code: TypeCode(7),
610                bytes: Bytes::from_static(b"x"),
611                size: u64::MAX,
612            }]
613        }
614    }
615
616    struct CountingObserver {
617        count: Arc<AtomicU32>,
618    }
619    impl KernelObserver for CountingObserver {
620        fn on_event(&self, _event: &KernelEvent) {
621            self.count.fetch_add(1, Ordering::SeqCst);
622        }
623    }
624
625    struct PanicObserver;
626    impl KernelObserver for PanicObserver {
627        fn on_event(&self, _event: &KernelEvent) {
628            panic!("observer intentional panic");
629        }
630    }
631
632    #[test]
633    fn create_instance_returns_monotonic_ids() {
634        let mut k = Kernel::new();
635        let i1 = k.create_instance(InstanceConfig::default());
636        let i2 = k.create_instance(InstanceConfig::default());
637        let i3 = k.create_instance(InstanceConfig::default());
638        assert!(i1 < i2);
639        assert!(i2 < i3);
640        assert_eq!(i1.get(), 1);
641        assert_eq!(i3.get(), 3);
642        assert_eq!(k.instances_len(), 3);
643    }
644
645    #[test]
646    fn submit_unknown_instance_returns_error() {
647        let mut k = Kernel::new();
648        let bogus = InstanceId::new(99).unwrap();
649        let result = k.submit(
650            bogus,
651            Principal::System,
652            None,
653            Tick(0),
654            TypeCode(100),
655            Vec::new(),
656        );
657        assert!(matches!(result, Err(ArkheError::InstanceNotFound)));
658    }
659
660    #[test]
661    fn submit_back_pressures_at_max_scheduled() {
662        // Regression (#15/#8): submit must bound the scheduler, returning
663        // QuotaExceeded rather than admitting unbounded growth.
664        let mut k = Kernel::new();
665        let inst = k.create_instance(InstanceConfig {
666            max_scheduled: 2,
667            ..Default::default()
668        });
669        let sub = |k: &mut Kernel| {
670            k.submit(inst, Principal::System, None, Tick(0), TypeCode(100), Vec::new())
671        };
672        assert!(sub(&mut k).is_ok());
673        assert!(sub(&mut k).is_ok());
674        assert!(matches!(sub(&mut k), Err(ArkheError::QuotaExceeded)));
675    }
676
677    #[test]
678    fn step_denies_spawns_past_max_entities() {
679        // Regression (#5): the entity quota is enforced per-Op in step();
680        // exactly max_entities commit, the rest deny without rollback.
681        let mut k = Kernel::new();
682        k.register_action::<SpawnThreeAction>();
683        let inst = k.create_instance(InstanceConfig {
684            max_entities: 2,
685            ..Default::default()
686        });
687        k.submit(inst, Principal::System, None, Tick(0), TypeCode(103), Vec::new())
688            .unwrap();
689        let report = k.step(Tick(0), CapabilityMask::SYSTEM);
690        assert_eq!(report.effects_applied, 2);
691        assert_eq!(report.effects_denied, 1);
692        assert_eq!(k.instances.get(&inst).unwrap().entities_len(), 2);
693    }
694
695    #[test]
696    fn step_denies_oversized_component_no_budget_bypass() {
697        // Regression (#4): a declared size > i64::MAX must trip the budget
698        // deny path, not wrap negative and slip past it.
699        let mut k = Kernel::new();
700        k.register_action::<OversizedSetAction>();
701        let inst = k.create_instance(InstanceConfig {
702            memory_budget_bytes: 1000,
703            ..Default::default()
704        });
705        k.submit(inst, Principal::System, None, Tick(0), TypeCode(104), Vec::new())
706            .unwrap();
707        let report = k.step(Tick(0), CapabilityMask::SYSTEM);
708        assert_eq!(report.effects_applied, 0);
709        assert_eq!(report.effects_denied, 1);
710    }
711
712    #[test]
713    fn submit_then_step_executes_action_and_spawns_entity() {
714        let mut k = Kernel::new();
715        k.register_action::<SpawnOneAction>();
716        let inst = k.create_instance(InstanceConfig::default());
717        k.submit(
718            inst,
719            Principal::System,
720            None,
721            Tick(0),
722            TypeCode(100),
723            Vec::new(),
724        )
725        .unwrap();
726
727        let report = k.step(Tick(5), CapabilityMask::SYSTEM);
728        assert_eq!(report.actions_executed, 1);
729        assert_eq!(report.effects_applied, 1);
730        assert_eq!(report.effects_denied, 0);
731        // Entity with id=42 added via SpawnEntity Op
732        let inst_ref = k.instances.get(&inst).unwrap();
733        assert_eq!(inst_ref.entities_len(), 1);
734    }
735
736    #[test]
737    fn step_with_unknown_type_code_skips_action() {
738        let mut k = Kernel::new();
739        // Don't register SpawnOneAction — submit with its type_code.
740        let inst = k.create_instance(InstanceConfig::default());
741        k.submit(
742            inst,
743            Principal::System,
744            None,
745            Tick(0),
746            TypeCode(999),
747            Vec::new(),
748        )
749        .unwrap();
750
751        let report = k.step(Tick(5), CapabilityMask::SYSTEM);
752        assert_eq!(report.actions_executed, 1);
753        assert_eq!(report.effects_applied, 0);
754        assert_eq!(k.instances.get(&inst).unwrap().entities_len(), 0);
755    }
756
757    #[test]
758    fn observer_receives_action_executed_event() {
759        let mut k = Kernel::new();
760        k.register_action::<SpawnOneAction>();
761        let count = Arc::new(AtomicU32::new(0));
762        let _h = k.register_observer(Box::new(CountingObserver {
763            count: count.clone(),
764        }));
765        let inst = k.create_instance(InstanceConfig::default());
766        k.submit(
767            inst,
768            Principal::System,
769            None,
770            Tick(0),
771            TypeCode(100),
772            Vec::new(),
773        )
774        .unwrap();
775        k.step(Tick(5), CapabilityMask::SYSTEM);
776        // Observer received ActionExecuted (1 event from this Spawn — no DomainEventEmitted).
777        assert_eq!(count.load(Ordering::SeqCst), 1);
778    }
779
780    #[test]
781    fn observer_receives_domain_event_emitted() {
782        let mut k = Kernel::new();
783        k.register_action::<EmitAction>();
784        let count = Arc::new(AtomicU32::new(0));
785        k.register_observer(Box::new(CountingObserver {
786            count: count.clone(),
787        }));
788        let inst = k.create_instance(InstanceConfig::default());
789        k.submit(
790            inst,
791            Principal::System,
792            None,
793            Tick(0),
794            TypeCode(101),
795            Vec::new(),
796        )
797        .unwrap();
798        let report = k.step(Tick(5), CapabilityMask::SYSTEM);
799        assert_eq!(report.domain_events_emitted, 1);
800        // DomainEventEmitted + ActionExecuted = 2.
801        assert_eq!(count.load(Ordering::SeqCst), 2);
802    }
803
804    #[test]
805    fn panic_observer_evicted_after_first_event() {
806        let mut k = Kernel::new();
807        k.register_action::<SpawnOneAction>();
808        let h = k.register_observer(Box::new(PanicObserver));
809        let inst = k.create_instance(InstanceConfig::default());
810        k.submit(
811            inst,
812            Principal::System,
813            None,
814            Tick(0),
815            TypeCode(100),
816            Vec::new(),
817        )
818        .unwrap();
819        let report = k.step(Tick(5), CapabilityMask::SYSTEM);
820        assert!(report.observers_evicted >= 1);
821        assert!(k.observers.is_evicted(h));
822    }
823
824    #[test]
825    fn unauthenticated_principal_denies_all_effects() {
826        let mut k = Kernel::new();
827        k.register_action::<SpawnOneAction>();
828        let inst = k.create_instance(InstanceConfig::default());
829        k.submit(
830            inst,
831            Principal::Unauthenticated,
832            None,
833            Tick(0),
834            TypeCode(100),
835            Vec::new(),
836        )
837        .unwrap();
838        let report = k.step(Tick(5), CapabilityMask::SYSTEM);
839        assert_eq!(report.effects_denied, 1);
840        assert_eq!(report.effects_applied, 0);
841        // Stage discarded; entity not spawned.
842        assert_eq!(k.instances.get(&inst).unwrap().entities_len(), 0);
843    }
844
845    #[test]
846    fn external_without_system_cap_denies_signal() {
847        let mut k = Kernel::new();
848        k.register_action::<SignalAction>();
849        let inst = k.create_instance(InstanceConfig::default());
850        k.submit(
851            inst,
852            Principal::External(ExternalId(7)),
853            None,
854            Tick(0),
855            TypeCode(102),
856            Vec::new(),
857        )
858        .unwrap();
859        let report = k.step(Tick(5), CapabilityMask::default());
860        assert_eq!(report.effects_denied, 1);
861        assert_eq!(report.effects_applied, 0);
862    }
863
864    #[test]
865    fn wal_attached_kernel_records_committed_step() {
866        let mut k = Kernel::new_with_wal([7u8; 32], [3u8; 32]);
867        k.register_action::<SpawnOneAction>();
868        let inst = k.create_instance(InstanceConfig::default());
869        k.submit(
870            inst,
871            Principal::System,
872            None,
873            Tick(0),
874            TypeCode(100),
875            Vec::new(),
876        )
877        .unwrap();
878        assert_eq!(k.wal_record_count(), Some(0));
879        let pre_tip = k.wal_chain_tip().unwrap();
880        k.step(Tick(5), CapabilityMask::SYSTEM);
881        assert_eq!(k.wal_record_count(), Some(1));
882        let post_tip = k.wal_chain_tip().unwrap();
883        assert_ne!(pre_tip, post_tip);
884    }
885
886    #[test]
887    fn wal_kernel_export_then_verify_chain() {
888        let mut k = Kernel::new_with_wal([11u8; 32], [0u8; 32]);
889        k.register_action::<SpawnOneAction>();
890        let inst = k.create_instance(InstanceConfig::default());
891        for _ in 0..3 {
892            k.submit(
893                inst,
894                Principal::System,
895                None,
896                Tick(0),
897                TypeCode(100),
898                Vec::new(),
899            )
900            .unwrap();
901            k.step(Tick(0), CapabilityMask::SYSTEM);
902        }
903        let wal = k.export_wal().expect("wal attached");
904        assert_eq!(wal.records.len(), 3);
905        wal.verify_chain([11u8; 32]).expect("chain verifies");
906    }
907
908    #[test]
909    fn replay_reconstructs_chain_tip() {
910        use crate::persist::replay_into;
911        // Original kernel: write WAL with several committed steps.
912        let mut k1 = Kernel::new_with_wal([42u8; 32], [0u8; 32]);
913        k1.register_action::<SpawnOneAction>();
914        let i1 = k1.create_instance(InstanceConfig::default());
915        for _ in 0..4 {
916            k1.submit(
917                i1,
918                Principal::System,
919                None,
920                Tick(0),
921                TypeCode(100),
922                Vec::new(),
923            )
924            .unwrap();
925            k1.step(Tick(0), CapabilityMask::SYSTEM);
926        }
927        let original_tip = k1.wal_chain_tip().unwrap();
928        let wal = k1.export_wal().unwrap();
929
930        // Reconstructed kernel: same WAL → same chain tip after replay.
931        let mut k2 = Kernel::new_with_wal([42u8; 32], [0u8; 32]);
932        k2.register_action::<SpawnOneAction>();
933        // Caller pre-creates instances; the integrated path is
934        // `Kernel::from_snapshot` (persist::snapshot).
935        let _i2 = k2.create_instance(InstanceConfig::default());
936        let report = replay_into(&mut k2, &wal).expect("replay ok");
937        assert_eq!(report.records_replayed, 4);
938        let replayed_tip = k2.wal_chain_tip().unwrap();
939        assert_eq!(replayed_tip, original_tip);
940        assert_eq!(report.final_chain_tip, original_tip);
941    }
942
943    #[test]
944    fn step_processes_instances_in_ascending_order() {
945        // Two instances; both submit a SpawnOneAction. After step,
946        // both should have an entity. Per A23, processing order is
947        // InstanceId ascending — observable via ActionExecuted event order.
948        let mut k = Kernel::new();
949        k.register_action::<SpawnOneAction>();
950        let i1 = k.create_instance(InstanceConfig::default());
951        let i2 = k.create_instance(InstanceConfig::default());
952        k.submit(
953            i2,
954            Principal::System,
955            None,
956            Tick(0),
957            TypeCode(100),
958            Vec::new(),
959        )
960        .unwrap();
961        k.submit(
962            i1,
963            Principal::System,
964            None,
965            Tick(0),
966            TypeCode(100),
967            Vec::new(),
968        )
969        .unwrap();
970        let report = k.step(Tick(5), CapabilityMask::SYSTEM);
971        assert_eq!(report.actions_executed, 2);
972        assert_eq!(report.effects_applied, 2);
973    }
974
975    #[test]
976    fn stats_aggregate_reflects_instances_and_scheduler() {
977        let mut k = Kernel::new();
978        k.register_action::<SpawnOneAction>();
979        assert_eq!(k.stats(), Stats::default());
980
981        let i1 = k.create_instance(InstanceConfig::default());
982        let i2 = k.create_instance(InstanceConfig::default());
983        let stats_pre = k.stats();
984        assert_eq!(stats_pre.instance_count, 2);
985        assert_eq!(stats_pre.scheduled_action_count, 0);
986        assert_eq!(stats_pre.entity_count, 0);
987
988        k.submit(
989            i1,
990            Principal::System,
991            None,
992            Tick(0),
993            TypeCode(100),
994            Vec::new(),
995        )
996        .unwrap();
997        k.submit(
998            i2,
999            Principal::System,
1000            None,
1001            Tick(0),
1002            TypeCode(100),
1003            Vec::new(),
1004        )
1005        .unwrap();
1006        let stats_queued = k.stats();
1007        assert_eq!(stats_queued.scheduled_action_count, 2);
1008
1009        let _ = k.step(Tick(1), CapabilityMask::SYSTEM);
1010        let stats_post = k.stats();
1011        assert_eq!(stats_post.scheduled_action_count, 0);
1012        assert_eq!(stats_post.entity_count, 2);
1013    }
1014
1015    #[test]
1016    fn stats_counts_observers() {
1017        struct NullObs;
1018        impl KernelObserver for NullObs {
1019            fn on_event(&self, _e: &KernelEvent) {}
1020        }
1021
1022        let mut k = Kernel::new();
1023        k.register_observer(Box::new(NullObs));
1024        k.register_observer(Box::new(NullObs));
1025        assert_eq!(k.stats().observer_count, 2);
1026    }
1027
1028    #[test]
1029    fn stats_wal_record_count_reflects_writer() {
1030        let mut k = Kernel::new_with_wal([1u8; 32], [0u8; 32]);
1031        k.register_action::<SpawnOneAction>();
1032        assert_eq!(k.stats().wal_record_count, 0);
1033
1034        let i = k.create_instance(InstanceConfig::default());
1035        k.submit(
1036            i,
1037            Principal::System,
1038            None,
1039            Tick(0),
1040            TypeCode(100),
1041            Vec::new(),
1042        )
1043        .unwrap();
1044        let _ = k.step(Tick(1), CapabilityMask::SYSTEM);
1045        assert_eq!(k.stats().wal_record_count, 1);
1046    }
1047
1048    // ---- force_unload ----
1049
1050    /// Observer that records every `ModuleForceUnloaded` event it sees.
1051    struct ForceUnloadCapture {
1052        seen: Arc<std::sync::Mutex<Vec<(RouteId, u32)>>>,
1053    }
1054    impl KernelObserver for ForceUnloadCapture {
1055        fn on_event(&self, event: &KernelEvent) {
1056            if let KernelEvent::ModuleForceUnloaded {
1057                route_id,
1058                live_refs_at_unload,
1059            } = event
1060            {
1061                self.seen
1062                    .lock()
1063                    .unwrap()
1064                    .push((*route_id, *live_refs_at_unload));
1065            }
1066        }
1067    }
1068
1069    #[test]
1070    fn force_unload_without_cap_denied() {
1071        let mut k = Kernel::new();
1072        let result = k.force_unload(RouteId(1), CapabilityMask::default());
1073        assert!(matches!(result, Err(ArkheError::CapabilityDenied)));
1074    }
1075
1076    #[test]
1077    fn force_unload_removes_inflight_refs() {
1078        let mut k = Kernel::new();
1079        k.register_action::<SignalAction>();
1080        let inst = k.create_instance(InstanceConfig::default());
1081        // SignalAction emits Op::SendSignal { route: RouteId(1) } — needs SYSTEM
1082        // cap to pass authorize, then dispatch increments inflight_refs[RouteId(1)].
1083        k.submit(
1084            inst,
1085            Principal::System,
1086            None,
1087            Tick(0),
1088            TypeCode(102),
1089            Vec::new(),
1090        )
1091        .unwrap();
1092        let report = k.step(Tick(0), CapabilityMask::SYSTEM);
1093        assert_eq!(report.effects_applied, 1);
1094        assert_eq!(
1095            k.instances
1096                .get(&inst)
1097                .unwrap()
1098                .inflight_refs_for(RouteId(1)),
1099            1
1100        );
1101
1102        let dropped = k
1103            .force_unload(RouteId(1), CapabilityMask::ADMIN_UNLOAD)
1104            .expect("admin_unload caps");
1105        assert_eq!(dropped, 1);
1106        assert_eq!(
1107            k.instances
1108                .get(&inst)
1109                .unwrap()
1110                .inflight_refs_for(RouteId(1)),
1111            0
1112        );
1113        assert_eq!(k.instances.get(&inst).unwrap().inflight_refs_len(), 0);
1114    }
1115
1116    #[test]
1117    fn force_unload_emits_module_unloaded_event() {
1118        let mut k = Kernel::new();
1119        k.register_action::<SignalAction>();
1120        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1121        k.register_observer(Box::new(ForceUnloadCapture { seen: seen.clone() }));
1122        let inst = k.create_instance(InstanceConfig::default());
1123        k.submit(
1124            inst,
1125            Principal::System,
1126            None,
1127            Tick(0),
1128            TypeCode(102),
1129            Vec::new(),
1130        )
1131        .unwrap();
1132        let _ = k.step(Tick(0), CapabilityMask::SYSTEM);
1133
1134        k.force_unload(RouteId(1), CapabilityMask::ADMIN_UNLOAD)
1135            .expect("admin_unload caps");
1136
1137        let captured = seen.lock().unwrap().clone();
1138        assert_eq!(captured, vec![(RouteId(1), 1)]);
1139    }
1140
1141    #[test]
1142    fn force_unload_no_live_refs_returns_zero() {
1143        let mut k = Kernel::new();
1144        let _ = k.create_instance(InstanceConfig::default());
1145        let dropped = k
1146            .force_unload(RouteId(99), CapabilityMask::ADMIN_UNLOAD)
1147            .expect("admin_unload caps");
1148        assert_eq!(dropped, 0);
1149    }
1150
1151    // ---- memory_budget_bytes enforcement (A21) ----
1152
1153    /// Test action: spawns entity `entity_id` and attaches one
1154    /// `SetComponent` of `size` bytes. The ledger tracks bytes only for
1155    /// registered entities, so the spawn must precede the set; this
1156    /// action emits both in one compute() — production-realistic.
1157    #[derive(Serialize, Deserialize)]
1158    struct SetCompAction {
1159        size: u64,
1160        entity_id: u64,
1161    }
1162    impl Sealed for SetCompAction {}
1163    impl ActionDeriv for SetCompAction {
1164        const TYPE_CODE: TypeCode = TypeCode(200);
1165        const SCHEMA_VERSION: u32 = 1;
1166    }
1167    impl ActionCompute for SetCompAction {
1168        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
1169            let entity = EntityId::new(self.entity_id).unwrap();
1170            vec![
1171                Op::SpawnEntity {
1172                    id: entity,
1173                    owner: Principal::System,
1174                },
1175                Op::SetComponent {
1176                    entity,
1177                    type_code: TypeCode(7),
1178                    bytes: Bytes::from(vec![0u8; self.size as usize]),
1179                    size: self.size,
1180                },
1181            ]
1182        }
1183    }
1184
1185    /// Test action: spawns entities 1 and 2, then attaches one
1186    /// `SetComponent` of size `a` to entity 1 and one of size `b` to
1187    /// entity 2 (4 ops total).
1188    #[derive(Serialize, Deserialize)]
1189    struct TwoSetCompAction {
1190        a: u64,
1191        b: u64,
1192    }
1193    impl Sealed for TwoSetCompAction {}
1194    impl ActionDeriv for TwoSetCompAction {
1195        const TYPE_CODE: TypeCode = TypeCode(201);
1196        const SCHEMA_VERSION: u32 = 1;
1197    }
1198    impl ActionCompute for TwoSetCompAction {
1199        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
1200            let e1 = EntityId::new(1).unwrap();
1201            let e2 = EntityId::new(2).unwrap();
1202            vec![
1203                Op::SpawnEntity {
1204                    id: e1,
1205                    owner: Principal::System,
1206                },
1207                Op::SpawnEntity {
1208                    id: e2,
1209                    owner: Principal::System,
1210                },
1211                Op::SetComponent {
1212                    entity: e1,
1213                    type_code: TypeCode(7),
1214                    bytes: Bytes::from(vec![0u8; self.a as usize]),
1215                    size: self.a,
1216                },
1217                Op::SetComponent {
1218                    entity: e2,
1219                    type_code: TypeCode(7),
1220                    bytes: Bytes::from(vec![0u8; self.b as usize]),
1221                    size: self.b,
1222                },
1223            ]
1224        }
1225    }
1226
1227    fn cfg_with_budget(budget: u64) -> InstanceConfig {
1228        InstanceConfig {
1229            memory_budget_bytes: budget,
1230            ..Default::default()
1231        }
1232    }
1233
1234    fn submit_set(k: &mut Kernel, inst: InstanceId, size: u64, entity_id: u64) {
1235        let action = SetCompAction { size, entity_id };
1236        let bytes = Action::canonical_bytes(&action);
1237        k.submit(
1238            inst,
1239            Principal::System,
1240            None,
1241            Tick(0),
1242            SetCompAction::TYPE_CODE,
1243            bytes,
1244        )
1245        .expect("submit ok");
1246    }
1247
1248    #[test]
1249    fn budget_zero_allows_unlimited() {
1250        // Default config has memory_budget_bytes = 0 → no enforcement.
1251        // SetCompAction emits Spawn + SetComponent (2 ops).
1252        let mut k = Kernel::new();
1253        k.register_action::<SetCompAction>();
1254        let inst = k.create_instance(InstanceConfig::default());
1255        submit_set(&mut k, inst, 1_000_000, 1);
1256        let report = k.step(Tick(0), CapabilityMask::SYSTEM);
1257        assert_eq!(report.effects_applied, 2);
1258        assert_eq!(report.effects_denied, 0);
1259        assert_eq!(k.instances.get(&inst).unwrap().components_len(), 1);
1260    }
1261
1262    #[test]
1263    fn budget_exceeded_denies_op() {
1264        // budget=100; Spawn passes (size 0), SetComponent denied (500 > 100).
1265        // Per-Op deny — Spawn still applies, no rollback (any_denied=false).
1266        let mut k = Kernel::new();
1267        k.register_action::<SetCompAction>();
1268        let inst = k.create_instance(cfg_with_budget(100));
1269        submit_set(&mut k, inst, 500, 1);
1270        let report = k.step(Tick(0), CapabilityMask::SYSTEM);
1271        assert_eq!(report.effects_applied, 1); // Spawn only
1272        assert_eq!(report.effects_denied, 1); // SetComponent
1273        assert_eq!(report.actions_executed, 1);
1274        assert_eq!(k.instances.get(&inst).unwrap().entities_len(), 1);
1275        assert_eq!(k.instances.get(&inst).unwrap().components_len(), 0);
1276    }
1277
1278    #[test]
1279    fn budget_at_edge_allows_equal() {
1280        // budget=500, projected = 0 + 0 (Spawn) + 500 (Set) = 500.
1281        // 500 == budget is allowed (only `>` denies).
1282        let mut k = Kernel::new();
1283        k.register_action::<SetCompAction>();
1284        let inst = k.create_instance(cfg_with_budget(500));
1285        submit_set(&mut k, inst, 500, 1);
1286        let report = k.step(Tick(0), CapabilityMask::SYSTEM);
1287        assert_eq!(report.effects_applied, 2);
1288        assert_eq!(report.effects_denied, 0);
1289        assert_eq!(k.instances.get(&inst).unwrap().components_len(), 1);
1290        assert_eq!(k.instances.get(&inst).unwrap().ledger().total_bytes(), 500);
1291    }
1292
1293    #[test]
1294    fn multi_op_stage_respects_running_delta() {
1295        // budget=600. TwoSetCompAction emits: Spawn(1), Spawn(2),
1296        // SetComp(1, size=400), SetComp(2, size=400).
1297        // Spawns fit (size 0). SetComp(1): projected=0+0+400=400 → allow.
1298        // SetComp(2): projected=0+400+400=800 > 600 → deny.
1299        // 3 applied, 1 denied; entity 2 spawned but uncomponented.
1300        let mut k = Kernel::new();
1301        k.register_action::<TwoSetCompAction>();
1302        let inst = k.create_instance(cfg_with_budget(600));
1303        let action = TwoSetCompAction { a: 400, b: 400 };
1304        let bytes = Action::canonical_bytes(&action);
1305        k.submit(
1306            inst,
1307            Principal::System,
1308            None,
1309            Tick(0),
1310            TwoSetCompAction::TYPE_CODE,
1311            bytes,
1312        )
1313        .unwrap();
1314        let report = k.step(Tick(0), CapabilityMask::SYSTEM);
1315        assert_eq!(report.effects_applied, 3);
1316        assert_eq!(report.effects_denied, 1);
1317        assert_eq!(k.instances.get(&inst).unwrap().entities_len(), 2);
1318        assert_eq!(k.instances.get(&inst).unwrap().components_len(), 1);
1319        assert_eq!(k.instances.get(&inst).unwrap().ledger().total_bytes(), 400);
1320    }
1321
1322    /// Observer that records every `EffectFailed` reason it sees.
1323    struct EffectFailedCapture {
1324        seen: Arc<std::sync::Mutex<Vec<Bytes>>>,
1325    }
1326    impl KernelObserver for EffectFailedCapture {
1327        fn on_event(&self, event: &KernelEvent) {
1328            if let KernelEvent::EffectFailed { reason, .. } = event {
1329                self.seen.lock().unwrap().push(reason.clone());
1330            }
1331        }
1332    }
1333
1334    #[test]
1335    fn effect_failed_event_on_budget_deny() {
1336        let mut k = Kernel::new();
1337        k.register_action::<SetCompAction>();
1338        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1339        k.register_observer(Box::new(EffectFailedCapture { seen: seen.clone() }));
1340        let inst = k.create_instance(cfg_with_budget(100));
1341        submit_set(&mut k, inst, 500, 1);
1342        let _ = k.step(Tick(0), CapabilityMask::SYSTEM);
1343
1344        let captured = seen.lock().unwrap().clone();
1345        assert_eq!(captured.len(), 1);
1346        assert_eq!(captured[0].as_ref(), b"budget_exceeded");
1347    }
1348
1349    // ---- EventMask filter ----
1350
1351    use crate::runtime::event::EventMask;
1352
1353    /// Per-variant counter observer — used to verify that only the
1354    /// expected variant arms increment.
1355    #[derive(Default)]
1356    struct VariantCounters {
1357        action_executed: AtomicU32,
1358        action_failed: AtomicU32,
1359        domain_event: AtomicU32,
1360        effect_failed: AtomicU32,
1361        other: AtomicU32,
1362    }
1363
1364    struct VariantTallyObserver {
1365        counters: Arc<VariantCounters>,
1366    }
1367    impl KernelObserver for VariantTallyObserver {
1368        fn on_event(&self, event: &KernelEvent) {
1369            match event {
1370                KernelEvent::ActionExecuted { .. } => {
1371                    self.counters.action_executed.fetch_add(1, Ordering::SeqCst);
1372                }
1373                KernelEvent::ActionFailed { .. } => {
1374                    self.counters.action_failed.fetch_add(1, Ordering::SeqCst);
1375                }
1376                KernelEvent::DomainEventEmitted { .. } => {
1377                    self.counters.domain_event.fetch_add(1, Ordering::SeqCst);
1378                }
1379                KernelEvent::EffectFailed { .. } => {
1380                    self.counters.effect_failed.fetch_add(1, Ordering::SeqCst);
1381                }
1382                KernelEvent::ObserverPanic { .. }
1383                | KernelEvent::ObserverEvicted { .. }
1384                | KernelEvent::SignalDropped { .. }
1385                | KernelEvent::ModuleForceUnloaded { .. }
1386                | KernelEvent::ActionDeferredToNextTick { .. }
1387                | KernelEvent::ObserversFlushed { .. } => {
1388                    self.counters.other.fetch_add(1, Ordering::SeqCst);
1389                }
1390            }
1391        }
1392    }
1393
1394    #[test]
1395    fn event_mask_default_is_all() {
1396        let m = EventMask::default();
1397        assert_eq!(m, EventMask::ALL);
1398        assert!(m.contains(EventMask::ACTION_EXECUTED));
1399        assert!(m.contains(EventMask::DOMAIN_EVENT_EMITTED));
1400        assert!(m.contains(EventMask::MODULE_FORCE_UNLOADED));
1401    }
1402
1403    #[test]
1404    fn register_observer_backward_compat_receives_all() {
1405        // EmitAction yields a DomainEventEmitted + an ActionExecuted —
1406        // a default-mask observer must see both.
1407        let mut k = Kernel::new();
1408        k.register_action::<EmitAction>();
1409        let counters = Arc::new(VariantCounters::default());
1410        k.register_observer(Box::new(VariantTallyObserver {
1411            counters: counters.clone(),
1412        }));
1413        let inst = k.create_instance(InstanceConfig::default());
1414        k.submit(
1415            inst,
1416            Principal::System,
1417            None,
1418            Tick(0),
1419            TypeCode(101),
1420            Vec::new(),
1421        )
1422        .unwrap();
1423        let _ = k.step(Tick(0), CapabilityMask::SYSTEM);
1424        assert_eq!(counters.action_executed.load(Ordering::SeqCst), 1);
1425        assert_eq!(counters.domain_event.load(Ordering::SeqCst), 1);
1426    }
1427
1428    #[test]
1429    fn filter_only_action_executed() {
1430        // Mask = ACTION_EXECUTED only — DomainEventEmitted should be muted.
1431        let mut k = Kernel::new();
1432        k.register_action::<EmitAction>();
1433        let counters = Arc::new(VariantCounters::default());
1434        k.register_observer_filtered(
1435            Box::new(VariantTallyObserver {
1436                counters: counters.clone(),
1437            }),
1438            EventMask::ACTION_EXECUTED,
1439        );
1440        let inst = k.create_instance(InstanceConfig::default());
1441        k.submit(
1442            inst,
1443            Principal::System,
1444            None,
1445            Tick(0),
1446            TypeCode(101),
1447            Vec::new(),
1448        )
1449        .unwrap();
1450        let _ = k.step(Tick(0), CapabilityMask::SYSTEM);
1451        assert_eq!(counters.action_executed.load(Ordering::SeqCst), 1);
1452        assert_eq!(counters.domain_event.load(Ordering::SeqCst), 0);
1453    }
1454
1455    #[test]
1456    fn filter_domain_event_only() {
1457        // Mask = DOMAIN_EVENT_EMITTED only — ActionExecuted should be muted.
1458        let mut k = Kernel::new();
1459        k.register_action::<EmitAction>();
1460        let counters = Arc::new(VariantCounters::default());
1461        k.register_observer_filtered(
1462            Box::new(VariantTallyObserver {
1463                counters: counters.clone(),
1464            }),
1465            EventMask::DOMAIN_EVENT_EMITTED,
1466        );
1467        let inst = k.create_instance(InstanceConfig::default());
1468        k.submit(
1469            inst,
1470            Principal::System,
1471            None,
1472            Tick(0),
1473            TypeCode(101),
1474            Vec::new(),
1475        )
1476        .unwrap();
1477        let _ = k.step(Tick(0), CapabilityMask::SYSTEM);
1478        assert_eq!(counters.action_executed.load(Ordering::SeqCst), 0);
1479        assert_eq!(counters.domain_event.load(Ordering::SeqCst), 1);
1480    }
1481
1482    #[test]
1483    fn multiple_observers_independent_masks() {
1484        // obs_a wants ACTION_EXECUTED, obs_b wants DOMAIN_EVENT_EMITTED.
1485        // After one EmitAction step, each observer sees exactly its slice.
1486        let mut k = Kernel::new();
1487        k.register_action::<EmitAction>();
1488        let ca = Arc::new(VariantCounters::default());
1489        let cb = Arc::new(VariantCounters::default());
1490        k.register_observer_filtered(
1491            Box::new(VariantTallyObserver {
1492                counters: ca.clone(),
1493            }),
1494            EventMask::ACTION_EXECUTED,
1495        );
1496        k.register_observer_filtered(
1497            Box::new(VariantTallyObserver {
1498                counters: cb.clone(),
1499            }),
1500            EventMask::DOMAIN_EVENT_EMITTED,
1501        );
1502        let inst = k.create_instance(InstanceConfig::default());
1503        k.submit(
1504            inst,
1505            Principal::System,
1506            None,
1507            Tick(0),
1508            TypeCode(101),
1509            Vec::new(),
1510        )
1511        .unwrap();
1512        let _ = k.step(Tick(0), CapabilityMask::SYSTEM);
1513        assert_eq!(ca.action_executed.load(Ordering::SeqCst), 1);
1514        assert_eq!(ca.domain_event.load(Ordering::SeqCst), 0);
1515        assert_eq!(cb.action_executed.load(Ordering::SeqCst), 0);
1516        assert_eq!(cb.domain_event.load(Ordering::SeqCst), 1);
1517    }
1518
1519    #[test]
1520    fn filter_empty_mask_receives_nothing() {
1521        // EventMask::empty() — observer is registered but receives zero events.
1522        let mut k = Kernel::new();
1523        k.register_action::<EmitAction>();
1524        let counters = Arc::new(VariantCounters::default());
1525        k.register_observer_filtered(
1526            Box::new(VariantTallyObserver {
1527                counters: counters.clone(),
1528            }),
1529            EventMask::empty(),
1530        );
1531        let inst = k.create_instance(InstanceConfig::default());
1532        k.submit(
1533            inst,
1534            Principal::System,
1535            None,
1536            Tick(0),
1537            TypeCode(101),
1538            Vec::new(),
1539        )
1540        .unwrap();
1541        let _ = k.step(Tick(0), CapabilityMask::SYSTEM);
1542        assert_eq!(counters.action_executed.load(Ordering::SeqCst), 0);
1543        assert_eq!(counters.domain_event.load(Ordering::SeqCst), 0);
1544        assert_eq!(counters.action_failed.load(Ordering::SeqCst), 0);
1545        assert_eq!(counters.effect_failed.load(Ordering::SeqCst), 0);
1546        assert_eq!(counters.other.load(Ordering::SeqCst), 0);
1547    }
1548}