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 → digest → observer.deliver → signal router`. Per-instance
5//! step ordering is `InstanceId` ascending (A23).
6//!
7//! Under the Canonical Input Log model the kernel emits exactly two record
8//! kinds: a `Submit` when an external action is admitted ([`Kernel::submit`])
9//! and one `Step` per pop ([`Kernel::step`]). Every deterministic effect —
10//! child schedules, cross-instance signal routing, internal ids — is left
11//! unlogged and re-derived on replay by re-executing `compute()`.
12
13use std::collections::BTreeMap;
14
15use crate::abi::{ArkheError, CapabilityMask, EntityId, InstanceId, Principal, Tick, TypeCode};
16use crate::state::authz::{authorize, effective_caps};
17use crate::state::{
18    Action, ActionContext, Effect, InboundSignal, Instance, InstanceConfig, ScheduledActionId,
19    Unverified,
20};
21
22use super::apply::apply_stage;
23use super::dispatch::dispatch;
24use super::event::{KernelEvent, ObserverHandle, SignalDropReason};
25use super::observer::{KernelObserver, ObserverRegistry};
26use super::registry::ActionRegistry;
27use super::stage::{PendingSignal, StepStage};
28
29use crate::persist::{SkipReason, StepVerdict, Wal, WalWriter};
30
31/// Top-level kernel orchestrator.
32///
33/// Lifecycle: [`Kernel::new`] (or [`Kernel::new_with_wal`] /
34/// [`Kernel::new_with_wal_signed`]) → [`register_action`](Kernel::register_action)
35/// → [`create_instance`](Kernel::create_instance) →
36/// [`submit`](Kernel::submit) → [`step`](Kernel::step) (repeat) →
37/// optional [`snapshot`](Kernel::snapshot) / [`export_wal`](Kernel::export_wal).
38///
39/// `Kernel` is `!Sync` (A2 single-thread) and is owned by the caller —
40/// no internal locking, no async. All determinism guarantees depend on
41/// the caller driving a single kernel from one thread.
42pub struct Kernel {
43    instances: BTreeMap<InstanceId, Instance>,
44    action_registry: ActionRegistry,
45    observers: ObserverRegistry,
46    next_instance_id: u64,
47    /// The kernel's declared `ModuleManifest` digest (A14). Pinned in any
48    /// attached WAL's header and checked by `replay_into` against the WAL it
49    /// replays — closing the otherwise-vacuous default-path manifest gate.
50    /// `[0u8; 32]` for a kernel constructed without one.
51    manifest_digest: [u8; 32],
52    wal: Option<WalWriter>,
53    /// Per-step staging scratch, reused across actions to avoid reallocating
54    /// the `StepStage` buckets every step. `clear()`ed before each action;
55    /// never serialized (not part of `KernelSnapshot`) and never read across
56    /// `step()` calls, so it carries no observable state.
57    step_scratch: StepStage,
58}
59
60/// Aggregated counters returned by [`Kernel::step`].
61#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct StepReport {
63    /// Number of scheduled actions whose `compute()` ran this step.
64    pub actions_executed: u32,
65    /// Number of effects (Ops) that committed.
66    pub effects_applied: u32,
67    /// Number of effects denied (authorize-deny or budget-deny).
68    pub effects_denied: u32,
69    /// Number of observers newly evicted (first-panic).
70    pub observers_evicted: u32,
71    /// Number of `KernelEvent::DomainEventEmitted` events produced.
72    pub domain_events_emitted: u32,
73}
74
75/// Cross-instance aggregate observability returned by [`Kernel::stats`].
76#[derive(Debug, Clone, Default, PartialEq, Eq)]
77pub struct Stats {
78    /// Total instances currently alive.
79    pub instance_count: usize,
80    /// Total scheduled actions pending across all instances.
81    pub scheduled_action_count: usize,
82    /// Total entities across all instances.
83    pub entity_count: u32,
84    /// Total component bytes across all ledgers.
85    pub component_byte_count: u64,
86    /// Live observer count (pre-eviction).
87    pub observer_count: usize,
88    /// WAL record count; `0` if no WAL is attached.
89    pub wal_record_count: usize,
90}
91
92impl Default for Kernel {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98impl Kernel {
99    /// Construct a fresh kernel with no instances, no observers, and
100    /// no WAL. Use [`new_with_wal`](Kernel::new_with_wal) or
101    /// [`new_with_wal_signed`](Kernel::new_with_wal_signed) instead if
102    /// you want WAL recording from step zero.
103    pub fn new() -> Self {
104        Self {
105            instances: BTreeMap::new(),
106            action_registry: ActionRegistry::new(),
107            observers: ObserverRegistry::new(),
108            next_instance_id: 0,
109            manifest_digest: [0u8; 32],
110            wal: None,
111            step_scratch: StepStage::default(),
112        }
113    }
114
115    /// Construct a Kernel with an attached WAL writer. Each successfully
116    /// committed step appends one record (A13/A14).
117    pub fn new_with_wal(world_id: [u8; 32], manifest_digest: [u8; 32]) -> Self {
118        Self {
119            instances: BTreeMap::new(),
120            action_registry: ActionRegistry::new(),
121            observers: ObserverRegistry::new(),
122            next_instance_id: 0,
123            manifest_digest,
124            wal: Some(WalWriter::new(world_id, manifest_digest)),
125            step_scratch: StepStage::default(),
126        }
127    }
128
129    /// Construct a Kernel with a WAL writer that signs each record under
130    /// the supplied `SignatureClass` (A16 — Ed25519 (Tier 2) or Hybrid (Ed25519 + ML-DSA 65, CNSA 2.0)). The
131    /// verifying key is pinned in the WAL header so post-hoc verification
132    /// is self-contained.
133    pub fn new_with_wal_signed(
134        world_id: [u8; 32],
135        manifest_digest: [u8; 32],
136        sig_class: crate::persist::SignatureClass,
137    ) -> Self {
138        Self {
139            instances: BTreeMap::new(),
140            action_registry: ActionRegistry::new(),
141            observers: ObserverRegistry::new(),
142            next_instance_id: 0,
143            manifest_digest,
144            wal: Some(WalWriter::with_signature(
145                world_id,
146                manifest_digest,
147                sig_class,
148            )),
149            step_scratch: StepStage::default(),
150        }
151    }
152
153    /// The kernel's declared `ModuleManifest` digest (A14). `[0u8; 32]` if the
154    /// kernel was built without one. `replay_into` rejects a WAL whose header
155    /// pins a different digest.
156    pub fn manifest_digest(&self) -> [u8; 32] {
157        self.manifest_digest
158    }
159
160    /// Current chain tip if the kernel has a WAL attached.
161    pub fn wal_chain_tip(&self) -> Option<[u8; 32]> {
162        self.wal.as_ref().map(|w| w.chain_tip())
163    }
164
165    /// Number of WAL records currently buffered (None if no WAL attached).
166    pub fn wal_record_count(&self) -> Option<usize> {
167        self.wal.as_ref().map(|w| w.record_count())
168    }
169
170    /// Consume the kernel and return the accumulated WAL (if any).
171    pub fn export_wal(self) -> Option<Wal> {
172        self.wal.map(Wal::from_writer)
173    }
174
175    /// Register a domain action type with the kernel's dispatch
176    /// registry. Required before [`submit`](Kernel::submit) accepts
177    /// the action's `TYPE_CODE`.
178    pub fn register_action<A: Action>(&mut self) {
179        self.action_registry.register::<A>();
180    }
181
182    /// Register an observer for every kernel event. Equivalent to
183    /// [`register_observer_filtered`](Kernel::register_observer_filtered)
184    /// with `EventMask::ALL`.
185    pub fn register_observer(&mut self, obs: Box<dyn KernelObserver>) -> ObserverHandle {
186        self.observers.register(obs)
187    }
188
189    /// Register an observer with an event-class filter. Only events
190    /// whose variant bit is set in `mask` are delivered to this observer
191    /// — useful when an observer cares about a narrow slice of the event
192    /// stream (e.g. only `DOMAIN_EVENT_EMITTED`). `EventMask::ALL` is
193    /// equivalent to `register_observer`.
194    pub fn register_observer_filtered(
195        &mut self,
196        obs: Box<dyn KernelObserver>,
197        mask: super::event::EventMask,
198    ) -> ObserverHandle {
199        self.observers.register_filtered(obs, mask)
200    }
201
202    /// Create a new instance with the supplied config. Returns the
203    /// freshly-allocated `InstanceId` (monotonic per kernel lifetime).
204    pub fn create_instance(&mut self, config: InstanceConfig) -> InstanceId {
205        self.next_instance_id = self.next_instance_id.saturating_add(1);
206        let id = InstanceId::new(self.next_instance_id).expect("instance id > 0");
207        self.instances.insert(id, Instance::new(id, config));
208        id
209    }
210
211    /// Number of live instances.
212    pub fn instances_len(&self) -> usize {
213        self.instances.len()
214    }
215
216    /// Read-only view of one instance's state. Returns `None` if `id`
217    /// does not exist. The borrow is `&self`, so callers cannot mutate
218    /// the kernel concurrently while a view is live.
219    pub fn instance_view(&self, id: InstanceId) -> Option<super::view::InstanceView<'_>> {
220        self.instances
221            .get(&id)
222            .map(|instance| super::view::InstanceView { instance })
223    }
224
225    /// Capture current kernel state as a serializable snapshot.
226    /// Excludes observers and action registry — caller re-registers those
227    /// after `Kernel::from_snapshot(...)`. WAL is independent and not
228    /// captured here.
229    pub fn snapshot(&self) -> crate::persist::KernelSnapshot {
230        let instances = self
231            .instances
232            .iter()
233            .map(|(id, inst)| (*id, inst.to_snapshot()))
234            .collect();
235        crate::persist::KernelSnapshot::__construct(instances, self.next_instance_id)
236    }
237
238    /// Restore a `Kernel` from a snapshot. The returned kernel has no
239    /// observers, an empty action registry, and no attached WAL — caller
240    /// must re-register everything before resuming `step()`.
241    pub fn from_snapshot(snap: crate::persist::KernelSnapshot) -> Self {
242        let (instances_in, next_instance_id) = snap.__into_parts();
243        let instances = instances_in
244            .into_iter()
245            .map(|(id, s)| (id, Instance::from_snapshot(s)))
246            .collect();
247        Self {
248            instances,
249            action_registry: ActionRegistry::new(),
250            observers: ObserverRegistry::new(),
251            next_instance_id,
252            manifest_digest: [0u8; 32],
253            wal: None,
254            step_scratch: StepStage::default(),
255        }
256    }
257
258    /// Aggregate observability across all instances. See [`Stats`].
259    pub fn stats(&self) -> Stats {
260        let mut scheduled = 0usize;
261        let mut entities = 0u32;
262        let mut bytes = 0u64;
263        for inst in self.instances.values() {
264            scheduled = scheduled.saturating_add(inst.scheduler().len());
265            entities = entities.saturating_add(inst.ledger().total_entities());
266            bytes = bytes.saturating_add(inst.ledger().total_bytes());
267        }
268        Stats {
269            instance_count: self.instances.len(),
270            scheduled_action_count: scheduled,
271            entity_count: entities,
272            component_byte_count: bytes,
273            observer_count: self.observers.len(),
274            wal_record_count: self.wal.as_ref().map(|w| w.record_count()).unwrap_or(0),
275        }
276    }
277
278    /// Force-unload: drop every instance's inflight-refs entry for
279    /// `route_id` and emit `KernelEvent::ModuleForceUnloaded` with the
280    /// summed live-ref count for the audit trail. Requires `ADMIN_UNLOAD`.
281    ///
282    /// Returns the total live refs that were dropped (`Ok(0)` if no
283    /// instance held the route).
284    pub fn force_unload(
285        &mut self,
286        route_id: crate::abi::RouteId,
287        caps: CapabilityMask,
288    ) -> Result<usize, ArkheError> {
289        if !caps.contains(CapabilityMask::ADMIN_UNLOAD) {
290            return Err(ArkheError::CapabilityDenied);
291        }
292
293        let mut total_live_refs: u32 = 0;
294        for inst in self.instances.values_mut() {
295            if let Some(refs) = inst.inflight_refs_mut().remove(&route_id) {
296                total_live_refs = total_live_refs.saturating_add(refs);
297            }
298        }
299
300        let event = KernelEvent::ModuleForceUnloaded {
301            route_id,
302            live_refs_at_unload: total_live_refs,
303        };
304        let _ = self.observers.deliver(&event);
305
306        Ok(total_live_refs as usize)
307    }
308
309    /// Schedule a serialized action against an instance for execution
310    /// at tick `at`. The bytes must be the canonical postcard encoding
311    /// produced by `<A as Action>::canonical_bytes()` for some
312    /// previously-registered action type matching `action_type_code`.
313    /// Returns the freshly-allocated [`ScheduledActionId`].
314    ///
315    /// `caps` is the capability ceiling granted to this submission — it is
316    /// pinned on the scheduled entry and bounds the action's effective caps
317    /// at execution (see [`effective_caps`]). It is the only non-reproducible
318    /// scheduling input, so an attached WAL records one `Submit` here (a
319    /// CIL admission); the resulting `ScheduledActionId` is recorded with it
320    /// so replay re-injects the action under the exact same id.
321    ///
322    /// Errors with [`ArkheError::InstanceNotFound`] if `instance` is
323    /// unknown.
324    #[allow(clippy::too_many_arguments)]
325    pub fn submit(
326        &mut self,
327        instance: InstanceId,
328        principal: Principal,
329        actor: Option<EntityId>,
330        caps: CapabilityMask,
331        at: Tick,
332        action_type_code: TypeCode,
333        action_bytes: Vec<u8>,
334    ) -> Result<ScheduledActionId, ArkheError> {
335        // Clone the bytes for the WAL record only when a WAL is attached
336        // (the scheduler takes ownership of the original).
337        let wal_bytes = if self.wal.is_some() {
338            Some(action_bytes.clone())
339        } else {
340            None
341        };
342        let id = {
343            let inst = self
344                .instances
345                .get_mut(&instance)
346                .ok_or(ArkheError::InstanceNotFound)?;
347            // Back-pressure (#15): bound the scheduler so an external caller
348            // cannot flood `submit` into unbounded growth. `max_scheduled == 0`
349            // means unlimited (default `InstanceConfig`).
350            let max_scheduled = inst.config().max_scheduled;
351            if max_scheduled > 0 && inst.scheduler().len() >= max_scheduled as usize {
352                return Err(ArkheError::QuotaExceeded);
353            }
354            let counters = inst.id_counters_mut();
355            counters.next_scheduled = counters.next_scheduled.saturating_add(1);
356            let id = ScheduledActionId::new(counters.next_scheduled).expect("scheduled id > 0");
357            inst.scheduler_mut().schedule_with_id(
358                id,
359                at,
360                actor,
361                principal.clone(),
362                caps,
363                action_type_code,
364                action_bytes,
365            );
366            id
367        };
368        if let (Some(wal), Some(bytes)) = (self.wal.as_mut(), wal_bytes) {
369            let _ = wal.append_submit(
370                instance,
371                principal,
372                actor,
373                caps.bits(),
374                at,
375                action_type_code,
376                bytes,
377                id,
378            );
379        }
380        Ok(id)
381    }
382
383    /// Replay-side admission: inject a previously-recorded `Submit` under its
384    /// exact `id`, capability ceiling, and inputs. No WAL is written (replay
385    /// re-measures the chain through its own writer) and the scheduler/id
386    /// counters are advanced so subsequent internal id allocation reproduces
387    /// the original sequence bit-for-bit. The back-pressure check is skipped
388    /// — a recorded submit already passed it at original-admission time.
389    #[allow(clippy::too_many_arguments)]
390    pub(crate) fn submit_with_id(
391        &mut self,
392        instance: InstanceId,
393        principal: Principal,
394        actor: Option<EntityId>,
395        caps: CapabilityMask,
396        id: ScheduledActionId,
397        at: Tick,
398        action_type_code: TypeCode,
399        action_bytes: Vec<u8>,
400    ) -> Result<(), ArkheError> {
401        let inst = self
402            .instances
403            .get_mut(&instance)
404            .ok_or(ArkheError::InstanceNotFound)?;
405        // Mirror `submit`'s counter advance so a re-executed `Op::ScheduleAction`
406        // mints the same next id (the kernel-level `next_scheduled` seeds
407        // dispatch's allocator; the scheduler's own `next_id` is bumped by
408        // `schedule_with_id`).
409        let counters = inst.id_counters_mut();
410        if id.get() > counters.next_scheduled {
411            counters.next_scheduled = id.get();
412        }
413        inst.scheduler_mut().schedule_with_id(
414            id,
415            at,
416            actor,
417            principal,
418            caps,
419            action_type_code,
420            action_bytes,
421        );
422        Ok(())
423    }
424
425    /// Process at most one due action per instance, in ascending InstanceId
426    /// order (A23). `caps` is the operator SESSION CEILING — it intersects
427    /// (never widens) each action's resolved capabilities. Returns aggregated
428    /// counters and, with a WAL attached, appends one `Step` record per pop.
429    pub fn step(&mut self, now: Tick, caps: CapabilityMask) -> StepReport {
430        let mut report = StepReport::default();
431        // The digest is the per-step bit-identity witness; it is only needed
432        // when it will be recorded (WAL attached) — skip the full-state hash
433        // on the plain stepping path so it costs nothing there.
434        let measure_digest = self.wal.is_some();
435
436        // Ascending-InstanceId key snapshot (A23). A snapshot is required —
437        // not `iter_mut` — because the post-step signal router needs the whole
438        // instance map (a sender routes into *other* instances' inboxes), so
439        // the per-instance borrow cannot be held across the round.
440        let ids: Vec<InstanceId> = self.instances.keys().copied().collect();
441        for inst_id in ids {
442            let Some(result) = self.process_instance_record(inst_id, now, caps, measure_digest)
443            else {
444                continue;
445            };
446            report.actions_executed = report.actions_executed.saturating_add(1);
447            report.effects_applied = report
448                .effects_applied
449                .saturating_add(result.effects_applied);
450            report.effects_denied = report.effects_denied.saturating_add(result.effects_denied);
451            report.domain_events_emitted = report
452                .domain_events_emitted
453                .saturating_add(result.domain_events_emitted);
454            report.observers_evicted = report
455                .observers_evicted
456                .saturating_add(result.observers_evicted);
457            // CIL: one `Step` record per pop — Committed / AuthDenied /
458            // BudgetPartial / Skipped alike (the verdict + post-state digest
459            // are the only non-reproducible facts; routing/scheduling effects
460            // are re-derived on replay).
461            if let Some(wal) = self.wal.as_mut() {
462                let _ = wal.append_step(
463                    inst_id,
464                    result.popped_id,
465                    now,
466                    caps.bits(),
467                    result.verdict,
468                    result.digest,
469                );
470            }
471        }
472
473        report
474    }
475
476    /// Run one due action for `inst_id` end-to-end: pop → gate → dispatch →
477    /// apply → digest → deliver observer events → route outbound signals.
478    /// Returns `None` if nothing was due. Shared verbatim by the live `step`
479    /// loop and `replay` so both reach bit-identical state and verdicts.
480    ///
481    /// The signal router runs here, immediately after this instance's own
482    /// apply and digest, so a signal this instance sends is delivered into the
483    /// target's inbox before any later instance steps this round — and is
484    /// witnessed by that target's next `Step` digest. The ordering is
485    /// identical on replay (records are re-driven in the same order), so the
486    /// digests match.
487    pub(crate) fn process_instance_record(
488        &mut self,
489        inst_id: InstanceId,
490        now: Tick,
491        session_caps: CapabilityMask,
492        measure_digest: bool,
493    ) -> Option<InstanceStepResult> {
494        // ---- 1. step_one core (partial borrow: one instance + registry + scratch) ----
495        // A missing instance returns `None` (not a panic): the live `step` loop
496        // only ever passes ids drawn from the live key set, but `replay` passes
497        // the `instance` from a (possibly malformed / adversarial) WAL Step
498        // record — which `replay_records` then surfaces as a graceful
499        // `ReplayError::StepUnderflow` rather than a panic (A12 panic-free
500        // discipline holds for untrusted input).
501        let core = {
502            let Self {
503                instances,
504                action_registry,
505                step_scratch,
506                ..
507            } = self;
508            let inst = instances.get_mut(&inst_id)?;
509            step_one_core(
510                inst_id,
511                inst,
512                action_registry,
513                step_scratch,
514                now,
515                session_caps,
516                measure_digest,
517            )
518        }?;
519
520        let mut result = InstanceStepResult {
521            popped_id: core.popped_id,
522            verdict: core.verdict,
523            digest: core.digest,
524            effects_applied: core.effects_applied,
525            effects_denied: core.effects_denied,
526            domain_events_emitted: core.domain_events_emitted,
527            observers_evicted: 0,
528        };
529
530        // ---- 2. observer delivery (staged events, then ActionExecuted) ----
531        for event in &core.events {
532            let evicted = self.observers.deliver(event);
533            result.observers_evicted = result
534                .observers_evicted
535                .saturating_add(evicted.len() as u32);
536        }
537        // `ActionExecuted` fires only when the action actually committed (fully
538        // or partially) — exactly the pre-epoch contract, where a rolled-back
539        // step (`any_denied`) and an unexecuted skip both delivered no
540        // `ActionExecuted`. AuthDenied (full rollback) and Skipped are excluded.
541        if matches!(
542            core.verdict,
543            StepVerdict::Committed | StepVerdict::BudgetPartial { .. }
544        ) {
545            let action_executed = KernelEvent::ActionExecuted {
546                instance: inst_id,
547                action_type: core.action_type,
548                at: now,
549            };
550            let evicted = self.observers.deliver(&action_executed);
551            result.observers_evicted = result
552                .observers_evicted
553                .saturating_add(evicted.len() as u32);
554        }
555
556        // ---- 3. signal router (cross-instance; needs the full map) ----
557        // A signal's in-flight refcount is a conserved quantity: it is
558        // credited on the TARGET's route here, at delivery (a delivered
559        // signal increments; a dropped one does not) — never speculatively at
560        // dispatch. Delivery is unlogged; replay re-derives it identically.
561        for sig in core.pending_signals {
562            let route = sig.route;
563            let target_id = sig.target;
564            let event = match self.instances.get_mut(&target_id) {
565                Some(target) => {
566                    let max_inbox = target.config().max_inbox_per_route;
567                    if max_inbox > 0 && target.inbox_len(route) >= max_inbox as usize {
568                        KernelEvent::SignalDropped {
569                            target: target_id,
570                            route,
571                            reason: SignalDropReason::QueueFull,
572                        }
573                    } else {
574                        target.deliver_signal(
575                            route,
576                            InboundSignal {
577                                from: inst_id,
578                                principal: sig.principal,
579                                payload: sig.payload,
580                                // Assigned by `deliver_signal` from the target's
581                                // own monotonic `inbox_seq`.
582                                seq: 0,
583                            },
584                        );
585                        let entry = target.inflight_refs_mut().entry(route).or_insert(0);
586                        *entry = entry.saturating_add(1);
587                        KernelEvent::SignalDelivered {
588                            from: inst_id,
589                            target: target_id,
590                            route,
591                        }
592                    }
593                }
594                None => KernelEvent::SignalDropped {
595                    target: target_id,
596                    route,
597                    reason: SignalDropReason::TargetNotFound,
598                },
599            };
600            let evicted = self.observers.deliver(&event);
601            result.observers_evicted = result
602                .observers_evicted
603                .saturating_add(evicted.len() as u32);
604        }
605
606        Some(result)
607    }
608}
609
610/// Per-pop outcome carried out of [`Kernel::process_instance_record`] to the
611/// live `step` loop / `replay` driver: the WAL `Step` fields plus the
612/// observability counters.
613pub(crate) struct InstanceStepResult {
614    /// ScheduledActionId popped this step (scheduler-order witness).
615    pub popped_id: ScheduledActionId,
616    /// Step outcome verdict.
617    pub verdict: StepVerdict,
618    /// Full post-step state digest (`[0u8; 32]` when `measure_digest` was
619    /// false — the plain no-WAL stepping path, where it is unused).
620    pub digest: [u8; 32],
621    /// Effects committed this step.
622    pub effects_applied: u32,
623    /// Effects denied (authorize / budget / quota) this step.
624    pub effects_denied: u32,
625    /// `DomainEventEmitted` events produced this step.
626    pub domain_events_emitted: u32,
627    /// Observers newly evicted while delivering this step's events.
628    pub observers_evicted: u32,
629}
630
631/// Internal result of the pop → gate → dispatch → apply → digest core, before
632/// observer delivery and signal routing (which need kernel-wide state).
633struct StepCore {
634    popped_id: ScheduledActionId,
635    action_type: TypeCode,
636    verdict: StepVerdict,
637    digest: [u8; 32],
638    pending_signals: Vec<PendingSignal>,
639    events: Vec<KernelEvent>,
640    effects_applied: u32,
641    effects_denied: u32,
642    domain_events_emitted: u32,
643}
644
645/// Pop one due action for `inst` and run it through the gate loop, producing
646/// a [`StepCore`] (verdict + post-state digest + staged outputs). Pure with
647/// respect to other instances — observer delivery and the cross-instance
648/// signal router run in [`Kernel::process_instance_record`], which owns the
649/// kernel-wide borrows. Returns `None` if no action was due.
650#[allow(clippy::too_many_arguments)]
651fn step_one_core(
652    inst_id: InstanceId,
653    inst: &mut Instance,
654    registry: &ActionRegistry,
655    scratch: &mut StepStage,
656    now: Tick,
657    session_caps: CapabilityMask,
658    measure_digest: bool,
659) -> Option<StepCore> {
660    let entry = inst.scheduler_mut().pop_due(now)?;
661    let popped_id = entry.id;
662    let action_type = entry.action_type_code;
663
664    // A digest helper closure that elides the full-state hash when unneeded.
665    let digest_of = |inst: &Instance| -> [u8; 32] {
666        if measure_digest {
667            inst.state_digest()
668        } else {
669            [0u8; 32]
670        }
671    };
672
673    // Skipped: no registry entry for the popped type. The pop already mutated
674    // the scheduler (the entry is gone), which the digest witnesses.
675    let reg = match registry.get(entry.action_type_code).cloned() {
676        Some(r) => r,
677        None => {
678            return Some(StepCore {
679                popped_id,
680                action_type,
681                verdict: StepVerdict::Skipped {
682                    reason: SkipReason::Unregistered,
683                },
684                digest: digest_of(inst),
685                pending_signals: Vec::new(),
686                events: Vec::new(),
687                effects_applied: 0,
688                effects_denied: 0,
689                domain_events_emitted: 0,
690            });
691        }
692    };
693
694    // Skipped: bytes fail to deserialize under the registered schema.
695    let action = match (reg.deserializer)(reg.schema_version, &entry.action_bytes) {
696        Ok(a) => a,
697        Err(_) => {
698            return Some(StepCore {
699                popped_id,
700                action_type,
701                verdict: StepVerdict::Skipped {
702                    reason: SkipReason::DeserFailed,
703                },
704                digest: digest_of(inst),
705                pending_signals: Vec::new(),
706                events: Vec::new(),
707                effects_applied: 0,
708                effects_denied: 0,
709                domain_events_emitted: 0,
710            });
711        }
712    };
713
714    // Resolve the capabilities this action runs under: the unified ceiling
715    // model (config default_caps bounded by the entry's inherited ceiling per
716    // principal) intersected with the operator session ceiling. There is no
717    // System blanket bypass — a System action is privileged only insofar as
718    // `default_caps` grants and the session permits.
719    let eff = effective_caps(inst.config().default_caps, &entry.principal, entry.caps_ceiling)
720        & session_caps;
721
722    let ctx = ActionContext::new(entry.actor, now, inst_id, &*inst);
723    let ops = action.compute_dyn(&ctx);
724
725    // Immutable read view for the gate loop; its borrow ends before the
726    // mutable `apply_stage` below (NLL).
727    let inst_ref = &*inst;
728    // Reuse the kernel-owned scratch (capacity retained across actions)
729    // rather than allocating a fresh StepStage each step. `clear()`
730    // makes it identical to `StepStage::default()`.
731    scratch.clear();
732    let stage = &mut *scratch;
733    let mut next_scheduled_id = inst_ref.id_counters_snapshot().next_scheduled;
734    let budget = inst_ref.config().memory_budget_bytes;
735    let baseline_bytes: u64 = inst_ref.ledger().total_bytes();
736    let mut any_denied = false;
737    // Provisional applied count — folded in only on the commit path. If the
738    // step rolls back (`any_denied`), these ops are discarded.
739    let mut applied_this_action: u32 = 0;
740    // Per-Op budget/quota skips (do NOT roll back). A committed step with one
741    // or more of these records `BudgetPartial { denied }`.
742    let mut denied_this_action: u32 = 0;
743    for op in ops {
744        let unverified: Effect<'_, Unverified> = Effect::new(inst_id, entry.principal.clone(), op);
745        match authorize(eff, unverified) {
746            Ok(authorized) => {
747                // Budget enforcement (per-Op, post-authorize, pre-dispatch).
748                // `budget == 0` means unlimited (default `InstanceConfig`).
749                // Authorize-deny rolls back the whole stage (any_denied);
750                // budget-deny is a per-Op skip that does NOT rollback.
751                if budget > 0 {
752                    // Project the post-commit component-byte total in
753                    // saturating u64 (matching ResourceLedger's own
754                    // arithmetic): the baseline + every already-staged
755                    // component delta + this Op. A SetComponent adds its
756                    // caller-declared `size`; a RemoveComponent credits
757                    // only the bytes the ledger will actually free (its
758                    // stored size), never the untrusted caller-declared
759                    // `size` — an inflated remove would otherwise poison
760                    // the projection and disable the budget for the rest
761                    // of the step. Working in u64 (not i64) means an
762                    // oversized add saturates to u64::MAX and trips any
763                    // budget below u64::MAX — including budgets above
764                    // i64::MAX, which the old i64 threshold silently
765                    // disabled.
766                    let staged = super::stage::projected_component_bytes(
767                        baseline_bytes,
768                        &*stage,
769                        inst_ref.ledger(),
770                    );
771                    let projected = match &authorized.op {
772                        crate::state::Op::SetComponent { size, .. } => staged.saturating_add(*size),
773                        crate::state::Op::RemoveComponent {
774                            entity, type_code, ..
775                        } => {
776                            let freed = inst_ref
777                                .ledger()
778                                .component_size(*entity, *type_code)
779                                .unwrap_or(0);
780                            staged.saturating_sub(freed)
781                        }
782                        crate::state::Op::SpawnEntity { .. }
783                        | crate::state::Op::DespawnEntity { .. }
784                        | crate::state::Op::EmitEvent { .. }
785                        | crate::state::Op::ScheduleAction { .. }
786                        | crate::state::Op::SendSignal { .. } => staged,
787                    };
788                    if projected > budget {
789                        denied_this_action = denied_this_action.saturating_add(1);
790                        stage.events.push_back(KernelEvent::EffectFailed {
791                            instance: inst_id,
792                            reason: bytes::Bytes::from_static(b"budget_exceeded"),
793                        });
794                        continue;
795                    }
796                }
797                // Entity quota (#5; `max_entities == 0` = unlimited).
798                // Conservative projection = committed live entities +
799                // entity-spawns staged so far this step (favors
800                // false-deny, like the byte-budget gate).
801                let max_entities = inst_ref.config().max_entities;
802                if max_entities > 0
803                    && matches!(&authorized.op, crate::state::Op::SpawnEntity { .. })
804                {
805                    let projected_entities = (inst_ref.entities_len() as u64)
806                        .saturating_add(stage.id_counters.next_entity_advance);
807                    if projected_entities >= max_entities as u64 {
808                        denied_this_action = denied_this_action.saturating_add(1);
809                        stage.events.push_back(KernelEvent::EffectFailed {
810                            instance: inst_id,
811                            reason: bytes::Bytes::from_static(b"entity_quota_exceeded"),
812                        });
813                        continue;
814                    }
815                }
816                // Scheduled-action quota (#8; `max_scheduled == 0` =
817                // unlimited). Projection = current scheduler depth +
818                // schedule-adds staged so far this step.
819                let max_scheduled = inst_ref.config().max_scheduled;
820                if max_scheduled > 0
821                    && matches!(&authorized.op, crate::state::Op::ScheduleAction { .. })
822                {
823                    let projected_scheduled = (inst_ref.scheduler().len() as u64)
824                        .saturating_add(stage.id_counters.next_scheduled_advance);
825                    if projected_scheduled >= max_scheduled as u64 {
826                        denied_this_action = denied_this_action.saturating_add(1);
827                        stage.events.push_back(KernelEvent::EffectFailed {
828                            instance: inst_id,
829                            reason: bytes::Bytes::from_static(b"scheduled_quota_exceeded"),
830                        });
831                        continue;
832                    }
833                }
834                dispatch(authorized, stage, now, &mut next_scheduled_id, eff);
835                applied_this_action = applied_this_action.saturating_add(1);
836            }
837            Err(_) => {
838                denied_this_action = denied_this_action.saturating_add(1);
839                any_denied = true;
840            }
841        }
842    }
843
844    if any_denied {
845        // Rollback: skip apply entirely (no effects, no signals, no events).
846        // The pop still happened, so the digest reflects the scheduler with
847        // the entry removed. The scratch is `clear()`ed at the top of the
848        // next action, so nothing staged here leaks.
849        return Some(StepCore {
850            popped_id,
851            action_type,
852            verdict: StepVerdict::AuthDenied,
853            digest: digest_of(inst),
854            pending_signals: Vec::new(),
855            events: Vec::new(),
856            effects_applied: 0,
857            effects_denied: denied_this_action,
858            domain_events_emitted: 0,
859        });
860    }
861
862    // Commit path. Domain emit count covers only `DomainEventEmitted`; other
863    // staged events (e.g. `EffectFailed` from a budget deny) are kernel events.
864    let domain_emit_count = stage
865        .events
866        .iter()
867        .filter(|e| matches!(e, KernelEvent::DomainEventEmitted { .. }))
868        .count() as u32;
869    let events_to_deliver: Vec<KernelEvent> = stage.events.iter().cloned().collect();
870    let pending_signals = apply_stage(inst, stage);
871    let digest = digest_of(inst);
872
873    let verdict = if denied_this_action > 0 {
874        StepVerdict::BudgetPartial {
875            denied: denied_this_action,
876        }
877    } else {
878        StepVerdict::Committed
879    };
880
881    Some(StepCore {
882        popped_id,
883        action_type,
884        verdict,
885        digest,
886        pending_signals,
887        events: events_to_deliver,
888        effects_applied: applied_this_action,
889        effects_denied: denied_this_action,
890        domain_events_emitted: domain_emit_count,
891    })
892}
893
894#[cfg(test)]
895mod tests {
896    use super::*;
897    use bytes::Bytes;
898    use std::sync::atomic::{AtomicU32, Ordering};
899    use std::sync::Arc;
900
901    use crate::abi::{ExternalId, RouteId};
902    use crate::state::traits::_sealed::Sealed;
903    use crate::state::{ActionCompute, ActionDeriv, Op};
904    use serde::{Deserialize, Serialize};
905
906    // ---- Test Action: spawns a single entity with id=42 ----
907    #[derive(Serialize, Deserialize)]
908    struct SpawnOneAction;
909    impl Sealed for SpawnOneAction {}
910    impl ActionDeriv for SpawnOneAction {
911        const TYPE_CODE: TypeCode = TypeCode(100);
912        const SCHEMA_VERSION: u32 = 1;
913    }
914    impl ActionCompute for SpawnOneAction {
915        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
916            vec![Op::SpawnEntity {
917                id: EntityId::new(42).unwrap(),
918                owner: Principal::System,
919            }]
920        }
921    }
922
923    #[derive(Serialize, Deserialize)]
924    struct EmitAction;
925    impl Sealed for EmitAction {}
926    impl ActionDeriv for EmitAction {
927        const TYPE_CODE: TypeCode = TypeCode(101);
928        const SCHEMA_VERSION: u32 = 1;
929    }
930    impl ActionCompute for EmitAction {
931        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
932            vec![Op::EmitEvent {
933                actor: None,
934                event_type_code: TypeCode(7),
935                event_bytes: Bytes::from_static(b"hello"),
936            }]
937        }
938    }
939
940    #[derive(Serialize, Deserialize)]
941    struct SignalAction;
942    impl Sealed for SignalAction {}
943    impl ActionDeriv for SignalAction {
944        const TYPE_CODE: TypeCode = TypeCode(102);
945        const SCHEMA_VERSION: u32 = 1;
946    }
947    impl ActionCompute for SignalAction {
948        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
949            // SendSignal requires SYSTEM cap (state::authz policy).
950            vec![Op::SendSignal {
951                target: InstanceId::new(1).unwrap(),
952                route: RouteId(1),
953                payload: Bytes::new(),
954            }]
955        }
956    }
957
958    // Emits three SpawnEntity ops with distinct ids (max_entities quota test).
959    #[derive(Serialize, Deserialize)]
960    struct SpawnThreeAction;
961    impl Sealed for SpawnThreeAction {}
962    impl ActionDeriv for SpawnThreeAction {
963        const TYPE_CODE: TypeCode = TypeCode(103);
964        const SCHEMA_VERSION: u32 = 1;
965    }
966    impl ActionCompute for SpawnThreeAction {
967        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
968            (1..=3)
969                .map(|n| Op::SpawnEntity {
970                    id: EntityId::new(n).unwrap(),
971                    owner: Principal::System,
972                })
973                .collect()
974        }
975    }
976
977    // Emits a SetComponent with a declared size above i64::MAX (budget-bypass
978    // regression: the size must NOT wrap negative past the budget gate).
979    #[derive(Serialize, Deserialize)]
980    struct OversizedSetAction;
981    impl Sealed for OversizedSetAction {}
982    impl ActionDeriv for OversizedSetAction {
983        const TYPE_CODE: TypeCode = TypeCode(104);
984        const SCHEMA_VERSION: u32 = 1;
985    }
986    impl ActionCompute for OversizedSetAction {
987        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
988            vec![Op::SetComponent {
989                entity: EntityId::new(1).unwrap(),
990                type_code: TypeCode(7),
991                bytes: Bytes::from_static(b"x"),
992                size: u64::MAX,
993            }]
994        }
995    }
996
997    // Spawns an entity, issues a phantom RemoveComponent with an inflated
998    // caller `size` against a component that does not exist, then two 90-byte
999    // SetComponents (budget-poison regression: the phantom remove must NOT
1000    // credit its declared size into the projection and disable the budget).
1001    #[derive(Serialize, Deserialize)]
1002    struct PhantomRemovePoisonAction;
1003    impl Sealed for PhantomRemovePoisonAction {}
1004    impl ActionDeriv for PhantomRemovePoisonAction {
1005        const TYPE_CODE: TypeCode = TypeCode(105);
1006        const SCHEMA_VERSION: u32 = 1;
1007    }
1008    impl ActionCompute for PhantomRemovePoisonAction {
1009        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
1010            let e = EntityId::new(1).unwrap();
1011            vec![
1012                Op::SpawnEntity {
1013                    id: e,
1014                    owner: Principal::System,
1015                },
1016                Op::RemoveComponent {
1017                    entity: e,
1018                    type_code: TypeCode(99), // never set — phantom
1019                    size: u64::MAX,          // inflated free — must be ignored
1020                },
1021                Op::SetComponent {
1022                    entity: e,
1023                    type_code: TypeCode(8),
1024                    bytes: Bytes::from_static(b"a"),
1025                    size: 90,
1026                },
1027                Op::SetComponent {
1028                    entity: e,
1029                    type_code: TypeCode(9),
1030                    bytes: Bytes::from_static(b"b"),
1031                    size: 90,
1032                },
1033            ]
1034        }
1035    }
1036
1037    // Emits a SpawnEntity (allowed for External) then a ScheduleAction (needs
1038    // SYSTEM). Under non-SYSTEM caps the schedule authorize-denies, rolling
1039    // back the whole step — the earlier spawn must not count as applied.
1040    #[derive(Serialize, Deserialize)]
1041    struct SpawnThenScheduleAction;
1042    impl Sealed for SpawnThenScheduleAction {}
1043    impl ActionDeriv for SpawnThenScheduleAction {
1044        const TYPE_CODE: TypeCode = TypeCode(106);
1045        const SCHEMA_VERSION: u32 = 1;
1046    }
1047    impl ActionCompute for SpawnThenScheduleAction {
1048        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
1049            vec![
1050                Op::SpawnEntity {
1051                    id: EntityId::new(1).unwrap(),
1052                    owner: Principal::System,
1053                },
1054                Op::ScheduleAction {
1055                    at: Tick(10),
1056                    actor: None,
1057                    action_type_code: TypeCode(100),
1058                    action_bytes: Bytes::from_static(b""),
1059                },
1060            ]
1061        }
1062    }
1063
1064    struct CountingObserver {
1065        count: Arc<AtomicU32>,
1066    }
1067    impl KernelObserver for CountingObserver {
1068        fn on_event(&self, _event: &KernelEvent) {
1069            self.count.fetch_add(1, Ordering::SeqCst);
1070        }
1071    }
1072
1073    struct PanicObserver;
1074    impl KernelObserver for PanicObserver {
1075        fn on_event(&self, _event: &KernelEvent) {
1076            panic!("observer intentional panic");
1077        }
1078    }
1079
1080    #[test]
1081    fn create_instance_returns_monotonic_ids() {
1082        let mut k = Kernel::new();
1083        let i1 = k.create_instance(InstanceConfig::default());
1084        let i2 = k.create_instance(InstanceConfig::default());
1085        let i3 = k.create_instance(InstanceConfig::default());
1086        assert!(i1 < i2);
1087        assert!(i2 < i3);
1088        assert_eq!(i1.get(), 1);
1089        assert_eq!(i3.get(), 3);
1090        assert_eq!(k.instances_len(), 3);
1091    }
1092
1093    #[test]
1094    fn submit_unknown_instance_returns_error() {
1095        let mut k = Kernel::new();
1096        let bogus = InstanceId::new(99).unwrap();
1097        let result = k.submit(
1098            bogus,
1099            Principal::System,
1100            None,
1101            CapabilityMask::SYSTEM,
1102            Tick(0),
1103            TypeCode(100),
1104            Vec::new(),
1105        );
1106        assert!(matches!(result, Err(ArkheError::InstanceNotFound)));
1107    }
1108
1109    #[test]
1110    fn submit_back_pressures_at_max_scheduled() {
1111        // Regression (#15/#8): submit must bound the scheduler, returning
1112        // QuotaExceeded rather than admitting unbounded growth.
1113        let mut k = Kernel::new();
1114        let inst = k.create_instance(InstanceConfig {
1115            max_scheduled: 2,
1116            ..Default::default()
1117        });
1118        let sub = |k: &mut Kernel| {
1119            k.submit(inst, Principal::System, None, CapabilityMask::SYSTEM, Tick(0), TypeCode(100), Vec::new())
1120        };
1121        assert!(sub(&mut k).is_ok());
1122        assert!(sub(&mut k).is_ok());
1123        assert!(matches!(sub(&mut k), Err(ArkheError::QuotaExceeded)));
1124    }
1125
1126    #[test]
1127    fn step_denies_spawns_past_max_entities() {
1128        // Regression (#5): the entity quota is enforced per-Op in step();
1129        // exactly max_entities commit, the rest deny without rollback.
1130        let mut k = Kernel::new();
1131        k.register_action::<SpawnThreeAction>();
1132        let inst = k.create_instance(InstanceConfig {
1133            max_entities: 2,
1134            ..Default::default()
1135        });
1136        k.submit(inst, Principal::System, None, CapabilityMask::SYSTEM, Tick(0), TypeCode(103), Vec::new())
1137            .unwrap();
1138        let report = k.step(Tick(0), CapabilityMask::SYSTEM);
1139        assert_eq!(report.effects_applied, 2);
1140        assert_eq!(report.effects_denied, 1);
1141        assert_eq!(k.instances.get(&inst).unwrap().entities_len(), 2);
1142    }
1143
1144    #[test]
1145    fn step_denies_oversized_component_no_budget_bypass() {
1146        // Regression (#4): a declared size > i64::MAX must trip the budget
1147        // deny path, not wrap negative and slip past it.
1148        let mut k = Kernel::new();
1149        k.register_action::<OversizedSetAction>();
1150        let inst = k.create_instance(InstanceConfig {
1151            memory_budget_bytes: 1000,
1152            ..Default::default()
1153        });
1154        k.submit(inst, Principal::System, None, CapabilityMask::SYSTEM, Tick(0), TypeCode(104), Vec::new())
1155            .unwrap();
1156        let report = k.step(Tick(0), CapabilityMask::SYSTEM);
1157        assert_eq!(report.effects_applied, 0);
1158        assert_eq!(report.effects_denied, 1);
1159    }
1160
1161    #[test]
1162    fn step_budget_above_i64_max_still_enforced() {
1163        // Regression: a memory_budget_bytes above i64::MAX must NOT silently
1164        // disable enforcement. The old i64 threshold round-trip collapsed to
1165        // i64::MAX, so an oversized add (which projects to i64::MAX) never
1166        // exceeded it and always committed. In u64 space, a u64::MAX add
1167        // saturates the projection above the (still-finite) budget and denies.
1168        let mut k = Kernel::new();
1169        k.register_action::<OversizedSetAction>();
1170        let inst = k.create_instance(InstanceConfig {
1171            memory_budget_bytes: 1u64 << 63, // i64::MAX + 1, above i64::MAX
1172            ..Default::default()
1173        });
1174        k.submit(inst, Principal::System, None, CapabilityMask::SYSTEM, Tick(0), TypeCode(104), Vec::new())
1175            .unwrap();
1176        let report = k.step(Tick(0), CapabilityMask::SYSTEM);
1177        assert_eq!(report.effects_applied, 0);
1178        assert_eq!(
1179            report.effects_denied, 1,
1180            "a u64::MAX add must trip a budget above i64::MAX"
1181        );
1182    }
1183
1184    #[test]
1185    fn step_phantom_remove_does_not_bypass_budget() {
1186        // Regression: a RemoveComponent with an inflated caller `size` against
1187        // a nonexistent component must NOT credit phantom freed bytes into the
1188        // budget projection. Spawn + phantom-remove + the first 90-byte
1189        // SetComponent commit (90 <= 100); the second 90-byte SetComponent
1190        // would push the total to 180 > 100 and is denied.
1191        let mut k = Kernel::new();
1192        k.register_action::<PhantomRemovePoisonAction>();
1193        let inst = k.create_instance(InstanceConfig {
1194            memory_budget_bytes: 100,
1195            ..Default::default()
1196        });
1197        k.submit(inst, Principal::System, None, CapabilityMask::SYSTEM, Tick(0), TypeCode(105), Vec::new())
1198            .unwrap();
1199        let report = k.step(Tick(0), CapabilityMask::SYSTEM);
1200        assert_eq!(
1201            report.effects_denied, 1,
1202            "the second SetComponent must be denied, not slipped past a poisoned budget"
1203        );
1204        let total = k.instances.get(&inst).unwrap().ledger().total_bytes();
1205        assert_eq!(
1206            total, 90,
1207            "exactly the first 90-byte component is accounted (budget held at 100)"
1208        );
1209    }
1210
1211    #[test]
1212    fn step_rollback_does_not_count_applied_effects() {
1213        // Regression: when a later Op authorize-denies and the whole step
1214        // rolls back, an earlier dispatched Op must NOT be counted in
1215        // effects_applied (it was discarded), and instance state is unchanged.
1216        let mut k = Kernel::new();
1217        k.register_action::<SpawnThenScheduleAction>();
1218        let counters = Arc::new(VariantCounters::default());
1219        k.register_observer(Box::new(VariantTallyObserver {
1220            counters: counters.clone(),
1221        }));
1222        let inst = k.create_instance(InstanceConfig::default());
1223        k.submit(
1224            inst,
1225            Principal::External(ExternalId(7)),
1226            None,
1227            CapabilityMask::SYSTEM,
1228            Tick(0),
1229            TypeCode(106),
1230            Vec::new(),
1231        )
1232        .unwrap();
1233        // Non-SYSTEM caps: External may SpawnEntity but not ScheduleAction →
1234        // the schedule authorize-denies → any_denied → rollback.
1235        let report = k.step(Tick(0), CapabilityMask::empty());
1236        assert_eq!(
1237            report.effects_applied, 0,
1238            "a rolled-back spawn must not count as applied"
1239        );
1240        assert!(report.effects_denied >= 1);
1241        assert_eq!(
1242            k.instances.get(&inst).unwrap().entities_len(),
1243            0,
1244            "rollback restores instance state"
1245        );
1246        // A rolled-back step delivers NO ActionExecuted (the action did not
1247        // commit) — preserving the pre-epoch observer contract.
1248        assert_eq!(
1249            counters.action_executed.load(Ordering::SeqCst),
1250            0,
1251            "AuthDenied rollback must not emit ActionExecuted"
1252        );
1253    }
1254
1255    #[test]
1256    fn submit_then_step_executes_action_and_spawns_entity() {
1257        let mut k = Kernel::new();
1258        k.register_action::<SpawnOneAction>();
1259        let inst = k.create_instance(InstanceConfig::default());
1260        k.submit(
1261            inst,
1262            Principal::System,
1263            None,
1264            CapabilityMask::SYSTEM,
1265            Tick(0),
1266            TypeCode(100),
1267            Vec::new(),
1268        )
1269        .unwrap();
1270
1271        let report = k.step(Tick(5), CapabilityMask::SYSTEM);
1272        assert_eq!(report.actions_executed, 1);
1273        assert_eq!(report.effects_applied, 1);
1274        assert_eq!(report.effects_denied, 0);
1275        // Entity with id=42 added via SpawnEntity Op
1276        let inst_ref = k.instances.get(&inst).unwrap();
1277        assert_eq!(inst_ref.entities_len(), 1);
1278    }
1279
1280    #[test]
1281    fn step_with_unknown_type_code_skips_action() {
1282        let mut k = Kernel::new();
1283        // Don't register SpawnOneAction — submit with its type_code.
1284        let inst = k.create_instance(InstanceConfig::default());
1285        k.submit(
1286            inst,
1287            Principal::System,
1288            None,
1289            CapabilityMask::SYSTEM,
1290            Tick(0),
1291            TypeCode(999),
1292            Vec::new(),
1293        )
1294        .unwrap();
1295
1296        let report = k.step(Tick(5), CapabilityMask::SYSTEM);
1297        assert_eq!(report.actions_executed, 1);
1298        assert_eq!(report.effects_applied, 0);
1299        assert_eq!(k.instances.get(&inst).unwrap().entities_len(), 0);
1300    }
1301
1302    #[test]
1303    fn observer_receives_action_executed_event() {
1304        let mut k = Kernel::new();
1305        k.register_action::<SpawnOneAction>();
1306        let count = Arc::new(AtomicU32::new(0));
1307        let _h = k.register_observer(Box::new(CountingObserver {
1308            count: count.clone(),
1309        }));
1310        let inst = k.create_instance(InstanceConfig::default());
1311        k.submit(
1312            inst,
1313            Principal::System,
1314            None,
1315            CapabilityMask::SYSTEM,
1316            Tick(0),
1317            TypeCode(100),
1318            Vec::new(),
1319        )
1320        .unwrap();
1321        k.step(Tick(5), CapabilityMask::SYSTEM);
1322        // Observer received ActionExecuted (1 event from this Spawn — no DomainEventEmitted).
1323        assert_eq!(count.load(Ordering::SeqCst), 1);
1324    }
1325
1326    #[test]
1327    fn observer_receives_domain_event_emitted() {
1328        let mut k = Kernel::new();
1329        k.register_action::<EmitAction>();
1330        let count = Arc::new(AtomicU32::new(0));
1331        k.register_observer(Box::new(CountingObserver {
1332            count: count.clone(),
1333        }));
1334        let inst = k.create_instance(InstanceConfig::default());
1335        k.submit(
1336            inst,
1337            Principal::System,
1338            None,
1339            CapabilityMask::SYSTEM,
1340            Tick(0),
1341            TypeCode(101),
1342            Vec::new(),
1343        )
1344        .unwrap();
1345        let report = k.step(Tick(5), CapabilityMask::SYSTEM);
1346        assert_eq!(report.domain_events_emitted, 1);
1347        // DomainEventEmitted + ActionExecuted = 2.
1348        assert_eq!(count.load(Ordering::SeqCst), 2);
1349    }
1350
1351    #[test]
1352    fn panic_observer_evicted_after_first_event() {
1353        let mut k = Kernel::new();
1354        k.register_action::<SpawnOneAction>();
1355        let h = k.register_observer(Box::new(PanicObserver));
1356        let inst = k.create_instance(InstanceConfig::default());
1357        k.submit(
1358            inst,
1359            Principal::System,
1360            None,
1361            CapabilityMask::SYSTEM,
1362            Tick(0),
1363            TypeCode(100),
1364            Vec::new(),
1365        )
1366        .unwrap();
1367        let report = k.step(Tick(5), CapabilityMask::SYSTEM);
1368        assert!(report.observers_evicted >= 1);
1369        assert!(k.observers.is_evicted(h));
1370    }
1371
1372    #[test]
1373    fn unauthenticated_principal_denies_all_effects() {
1374        let mut k = Kernel::new();
1375        k.register_action::<SpawnOneAction>();
1376        let inst = k.create_instance(InstanceConfig::default());
1377        k.submit(
1378            inst,
1379            Principal::Unauthenticated,
1380            None,
1381            CapabilityMask::SYSTEM,
1382            Tick(0),
1383            TypeCode(100),
1384            Vec::new(),
1385        )
1386        .unwrap();
1387        let report = k.step(Tick(5), CapabilityMask::SYSTEM);
1388        assert_eq!(report.effects_denied, 1);
1389        assert_eq!(report.effects_applied, 0);
1390        // Stage discarded; entity not spawned.
1391        assert_eq!(k.instances.get(&inst).unwrap().entities_len(), 0);
1392    }
1393
1394    #[test]
1395    fn external_without_system_cap_denies_signal() {
1396        let mut k = Kernel::new();
1397        k.register_action::<SignalAction>();
1398        let inst = k.create_instance(InstanceConfig::default());
1399        k.submit(
1400            inst,
1401            Principal::External(ExternalId(7)),
1402            None,
1403            CapabilityMask::SYSTEM,
1404            Tick(0),
1405            TypeCode(102),
1406            Vec::new(),
1407        )
1408        .unwrap();
1409        let report = k.step(Tick(5), CapabilityMask::default());
1410        assert_eq!(report.effects_denied, 1);
1411        assert_eq!(report.effects_applied, 0);
1412    }
1413
1414    #[test]
1415    fn wal_attached_kernel_records_submit_then_step() {
1416        // CIL: `submit` appends one `Submit` record; the committed `step`
1417        // appends one `Step` record (2 records total for one admitted action).
1418        let mut k = Kernel::new_with_wal([7u8; 32], [3u8; 32]);
1419        k.register_action::<SpawnOneAction>();
1420        let inst = k.create_instance(InstanceConfig::default());
1421        assert_eq!(k.wal_record_count(), Some(0));
1422        k.submit(
1423            inst,
1424            Principal::System,
1425            None,
1426            CapabilityMask::SYSTEM,
1427            Tick(0),
1428            TypeCode(100),
1429            Vec::new(),
1430        )
1431        .unwrap();
1432        assert_eq!(k.wal_record_count(), Some(1));
1433        let pre_tip = k.wal_chain_tip().unwrap();
1434        k.step(Tick(5), CapabilityMask::SYSTEM);
1435        assert_eq!(k.wal_record_count(), Some(2));
1436        let post_tip = k.wal_chain_tip().unwrap();
1437        assert_ne!(pre_tip, post_tip);
1438    }
1439
1440    #[test]
1441    fn wal_kernel_export_then_verify_chain() {
1442        let mut k = Kernel::new_with_wal([11u8; 32], [0u8; 32]);
1443        k.register_action::<SpawnOneAction>();
1444        let inst = k.create_instance(InstanceConfig::default());
1445        for _ in 0..3 {
1446            k.submit(
1447                inst,
1448                Principal::System,
1449                None,
1450                CapabilityMask::SYSTEM,
1451                Tick(0),
1452                TypeCode(100),
1453                Vec::new(),
1454            )
1455            .unwrap();
1456            k.step(Tick(0), CapabilityMask::SYSTEM);
1457        }
1458        let wal = k.export_wal().expect("wal attached");
1459        // 3 admitted actions → 3 Submit + 3 Step records.
1460        assert_eq!(wal.records.len(), 6);
1461        wal.verify_chain([11u8; 32]).expect("chain verifies");
1462    }
1463
1464    #[test]
1465    fn replay_reconstructs_chain_tip() {
1466        use crate::persist::replay_into;
1467        // Original kernel: write WAL with several committed steps.
1468        let mut k1 = Kernel::new_with_wal([42u8; 32], [0u8; 32]);
1469        k1.register_action::<SpawnOneAction>();
1470        let i1 = k1.create_instance(InstanceConfig::default());
1471        for _ in 0..4 {
1472            k1.submit(
1473                i1,
1474                Principal::System,
1475                None,
1476                CapabilityMask::SYSTEM,
1477                Tick(0),
1478                TypeCode(100),
1479                Vec::new(),
1480            )
1481            .unwrap();
1482            k1.step(Tick(0), CapabilityMask::SYSTEM);
1483        }
1484        let original_tip = k1.wal_chain_tip().unwrap();
1485        let wal = k1.export_wal().unwrap();
1486
1487        // Reconstructed kernel: same WAL → same chain tip after replay.
1488        let mut k2 = Kernel::new_with_wal([42u8; 32], [0u8; 32]);
1489        k2.register_action::<SpawnOneAction>();
1490        // Caller pre-creates instances; the integrated path is
1491        // `Kernel::from_snapshot` (persist::snapshot).
1492        let _i2 = k2.create_instance(InstanceConfig::default());
1493        let report = replay_into(&mut k2, &wal).expect("replay ok");
1494        // 4 admitted actions → 4 Submit + 4 Step records re-driven.
1495        assert_eq!(report.submits_replayed, 4);
1496        assert_eq!(report.steps_replayed, 4);
1497        // The tip is MEASURED by replay's own header-rebuilt writer (replay
1498        // does not write back into k2's attached WAL), and must equal the
1499        // original sealed tip — the A1 D1-Total bit-identity witness.
1500        assert_eq!(report.final_chain_tip, original_tip);
1501    }
1502
1503    #[test]
1504    fn step_processes_instances_in_ascending_order() {
1505        // Two instances; both submit a SpawnOneAction. After step,
1506        // both should have an entity. Per A23, processing order is
1507        // InstanceId ascending — observable via ActionExecuted event order.
1508        let mut k = Kernel::new();
1509        k.register_action::<SpawnOneAction>();
1510        let i1 = k.create_instance(InstanceConfig::default());
1511        let i2 = k.create_instance(InstanceConfig::default());
1512        k.submit(
1513            i2,
1514            Principal::System,
1515            None,
1516            CapabilityMask::SYSTEM,
1517            Tick(0),
1518            TypeCode(100),
1519            Vec::new(),
1520        )
1521        .unwrap();
1522        k.submit(
1523            i1,
1524            Principal::System,
1525            None,
1526            CapabilityMask::SYSTEM,
1527            Tick(0),
1528            TypeCode(100),
1529            Vec::new(),
1530        )
1531        .unwrap();
1532        let report = k.step(Tick(5), CapabilityMask::SYSTEM);
1533        assert_eq!(report.actions_executed, 2);
1534        assert_eq!(report.effects_applied, 2);
1535    }
1536
1537    #[test]
1538    fn stats_aggregate_reflects_instances_and_scheduler() {
1539        let mut k = Kernel::new();
1540        k.register_action::<SpawnOneAction>();
1541        assert_eq!(k.stats(), Stats::default());
1542
1543        let i1 = k.create_instance(InstanceConfig::default());
1544        let i2 = k.create_instance(InstanceConfig::default());
1545        let stats_pre = k.stats();
1546        assert_eq!(stats_pre.instance_count, 2);
1547        assert_eq!(stats_pre.scheduled_action_count, 0);
1548        assert_eq!(stats_pre.entity_count, 0);
1549
1550        k.submit(
1551            i1,
1552            Principal::System,
1553            None,
1554            CapabilityMask::SYSTEM,
1555            Tick(0),
1556            TypeCode(100),
1557            Vec::new(),
1558        )
1559        .unwrap();
1560        k.submit(
1561            i2,
1562            Principal::System,
1563            None,
1564            CapabilityMask::SYSTEM,
1565            Tick(0),
1566            TypeCode(100),
1567            Vec::new(),
1568        )
1569        .unwrap();
1570        let stats_queued = k.stats();
1571        assert_eq!(stats_queued.scheduled_action_count, 2);
1572
1573        let _ = k.step(Tick(1), CapabilityMask::SYSTEM);
1574        let stats_post = k.stats();
1575        assert_eq!(stats_post.scheduled_action_count, 0);
1576        assert_eq!(stats_post.entity_count, 2);
1577    }
1578
1579    #[test]
1580    fn stats_counts_observers() {
1581        struct NullObs;
1582        impl KernelObserver for NullObs {
1583            fn on_event(&self, _e: &KernelEvent) {}
1584        }
1585
1586        let mut k = Kernel::new();
1587        k.register_observer(Box::new(NullObs));
1588        k.register_observer(Box::new(NullObs));
1589        assert_eq!(k.stats().observer_count, 2);
1590    }
1591
1592    #[test]
1593    fn stats_wal_record_count_reflects_writer() {
1594        let mut k = Kernel::new_with_wal([1u8; 32], [0u8; 32]);
1595        k.register_action::<SpawnOneAction>();
1596        assert_eq!(k.stats().wal_record_count, 0);
1597
1598        let i = k.create_instance(InstanceConfig::default());
1599        k.submit(
1600            i,
1601            Principal::System,
1602            None,
1603            CapabilityMask::SYSTEM,
1604            Tick(0),
1605            TypeCode(100),
1606            Vec::new(),
1607        )
1608        .unwrap();
1609        let _ = k.step(Tick(1), CapabilityMask::SYSTEM);
1610        // One Submit + one Step record.
1611        assert_eq!(k.stats().wal_record_count, 2);
1612    }
1613
1614    // ---- force_unload ----
1615
1616    /// Observer that records every `ModuleForceUnloaded` event it sees.
1617    struct ForceUnloadCapture {
1618        seen: Arc<std::sync::Mutex<Vec<(RouteId, u32)>>>,
1619    }
1620    impl KernelObserver for ForceUnloadCapture {
1621        fn on_event(&self, event: &KernelEvent) {
1622            if let KernelEvent::ModuleForceUnloaded {
1623                route_id,
1624                live_refs_at_unload,
1625            } = event
1626            {
1627                self.seen
1628                    .lock()
1629                    .unwrap()
1630                    .push((*route_id, *live_refs_at_unload));
1631            }
1632        }
1633    }
1634
1635    #[test]
1636    fn force_unload_without_cap_denied() {
1637        let mut k = Kernel::new();
1638        let result = k.force_unload(RouteId(1), CapabilityMask::default());
1639        assert!(matches!(result, Err(ArkheError::CapabilityDenied)));
1640    }
1641
1642    #[test]
1643    fn force_unload_removes_inflight_refs() {
1644        let mut k = Kernel::new();
1645        k.register_action::<SignalAction>();
1646        // SendSignal needs SYSTEM in the action's effective caps. Under the
1647        // unified model a System principal is bounded by `default_caps`, so the
1648        // instance must grant it (no blanket bypass).
1649        let inst = k.create_instance(InstanceConfig {
1650            default_caps: CapabilityMask::SYSTEM,
1651            ..Default::default()
1652        });
1653        // SignalAction emits Op::SendSignal { target: self, route: RouteId(1) };
1654        // the post-step router delivers it into the inbox and credits
1655        // inflight_refs[RouteId(1)] at delivery (not speculatively at dispatch).
1656        k.submit(
1657            inst,
1658            Principal::System,
1659            None,
1660            CapabilityMask::SYSTEM,
1661            Tick(0),
1662            TypeCode(102),
1663            Vec::new(),
1664        )
1665        .unwrap();
1666        let report = k.step(Tick(0), CapabilityMask::SYSTEM);
1667        assert_eq!(report.effects_applied, 1);
1668        assert_eq!(
1669            k.instances
1670                .get(&inst)
1671                .unwrap()
1672                .inflight_refs_for(RouteId(1)),
1673            1
1674        );
1675
1676        let dropped = k
1677            .force_unload(RouteId(1), CapabilityMask::ADMIN_UNLOAD)
1678            .expect("admin_unload caps");
1679        assert_eq!(dropped, 1);
1680        assert_eq!(
1681            k.instances
1682                .get(&inst)
1683                .unwrap()
1684                .inflight_refs_for(RouteId(1)),
1685            0
1686        );
1687        assert_eq!(k.instances.get(&inst).unwrap().inflight_refs_len(), 0);
1688    }
1689
1690    #[test]
1691    fn force_unload_emits_module_unloaded_event() {
1692        let mut k = Kernel::new();
1693        k.register_action::<SignalAction>();
1694        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1695        k.register_observer(Box::new(ForceUnloadCapture { seen: seen.clone() }));
1696        let inst = k.create_instance(InstanceConfig {
1697            default_caps: CapabilityMask::SYSTEM,
1698            ..Default::default()
1699        });
1700        k.submit(
1701            inst,
1702            Principal::System,
1703            None,
1704            CapabilityMask::SYSTEM,
1705            Tick(0),
1706            TypeCode(102),
1707            Vec::new(),
1708        )
1709        .unwrap();
1710        let _ = k.step(Tick(0), CapabilityMask::SYSTEM);
1711
1712        k.force_unload(RouteId(1), CapabilityMask::ADMIN_UNLOAD)
1713            .expect("admin_unload caps");
1714
1715        let captured = seen.lock().unwrap().clone();
1716        assert_eq!(captured, vec![(RouteId(1), 1)]);
1717    }
1718
1719    #[test]
1720    fn force_unload_no_live_refs_returns_zero() {
1721        let mut k = Kernel::new();
1722        let _ = k.create_instance(InstanceConfig::default());
1723        let dropped = k
1724            .force_unload(RouteId(99), CapabilityMask::ADMIN_UNLOAD)
1725            .expect("admin_unload caps");
1726        assert_eq!(dropped, 0);
1727    }
1728
1729    // ---- CIL signal routing + unified capability model ----
1730
1731    /// Sends two signals to instance 1 on route 1 (queue-full exercise).
1732    #[derive(Serialize, Deserialize)]
1733    struct SignalTwiceAction;
1734    impl Sealed for SignalTwiceAction {}
1735    impl ActionDeriv for SignalTwiceAction {
1736        const TYPE_CODE: TypeCode = TypeCode(110);
1737        const SCHEMA_VERSION: u32 = 1;
1738    }
1739    impl ActionCompute for SignalTwiceAction {
1740        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
1741            let one = || Op::SendSignal {
1742                target: InstanceId::new(1).unwrap(),
1743                route: RouteId(1),
1744                payload: Bytes::new(),
1745            };
1746            vec![one(), one()]
1747        }
1748    }
1749
1750    /// Sends a signal to a nonexistent instance (target-not-found exercise).
1751    #[derive(Serialize, Deserialize)]
1752    struct SignalMissingAction;
1753    impl Sealed for SignalMissingAction {}
1754    impl ActionDeriv for SignalMissingAction {
1755        const TYPE_CODE: TypeCode = TypeCode(111);
1756        const SCHEMA_VERSION: u32 = 1;
1757    }
1758    impl ActionCompute for SignalMissingAction {
1759        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
1760            vec![Op::SendSignal {
1761                target: InstanceId::new(99).unwrap(),
1762                route: RouteId(1),
1763                payload: Bytes::new(),
1764            }]
1765        }
1766    }
1767
1768    /// Schedules a `SignalAction` child for tick 1 (cap time-shift exercise).
1769    #[derive(Serialize, Deserialize)]
1770    struct ScheduleSignalChildAction;
1771    impl Sealed for ScheduleSignalChildAction {}
1772    impl ActionDeriv for ScheduleSignalChildAction {
1773        const TYPE_CODE: TypeCode = TypeCode(112);
1774        const SCHEMA_VERSION: u32 = 1;
1775    }
1776    impl ActionCompute for ScheduleSignalChildAction {
1777        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
1778            vec![Op::ScheduleAction {
1779                at: Tick(1),
1780                actor: None,
1781                action_type_code: SignalAction::TYPE_CODE,
1782                action_bytes: Bytes::from_static(b""),
1783            }]
1784        }
1785    }
1786
1787    #[derive(Default)]
1788    struct SignalEventCounters {
1789        delivered: AtomicU32,
1790        dropped_not_found: AtomicU32,
1791        dropped_queue_full: AtomicU32,
1792    }
1793    struct SignalEventCapture {
1794        c: Arc<SignalEventCounters>,
1795    }
1796    impl KernelObserver for SignalEventCapture {
1797        fn on_event(&self, event: &KernelEvent) {
1798            match event {
1799                KernelEvent::SignalDelivered { .. } => {
1800                    self.c.delivered.fetch_add(1, Ordering::SeqCst);
1801                }
1802                KernelEvent::SignalDropped { reason, .. } => match reason {
1803                    crate::runtime::event::SignalDropReason::TargetNotFound => {
1804                        self.c.dropped_not_found.fetch_add(1, Ordering::SeqCst);
1805                    }
1806                    crate::runtime::event::SignalDropReason::QueueFull => {
1807                        self.c.dropped_queue_full.fetch_add(1, Ordering::SeqCst);
1808                    }
1809                    crate::runtime::event::SignalDropReason::Cancelled => {}
1810                },
1811                // Exhaustive (no catch-all `_`) so a new KernelEvent variant
1812                // forces a conscious decision here — the file's convention.
1813                KernelEvent::ActionExecuted { .. }
1814                | KernelEvent::ActionFailed { .. }
1815                | KernelEvent::EffectFailed { .. }
1816                | KernelEvent::ObserverPanic { .. }
1817                | KernelEvent::ObserverEvicted { .. }
1818                | KernelEvent::ModuleForceUnloaded { .. }
1819                | KernelEvent::ActionDeferredToNextTick { .. }
1820                | KernelEvent::ObserversFlushed { .. }
1821                | KernelEvent::DomainEventEmitted { .. } => {}
1822            }
1823        }
1824    }
1825
1826    fn cfg_sys() -> InstanceConfig {
1827        InstanceConfig {
1828            default_caps: CapabilityMask::SYSTEM,
1829            max_inbox_per_route: 8,
1830            ..Default::default()
1831        }
1832    }
1833
1834    #[test]
1835    fn system_no_longer_blanket_bypasses() {
1836        // A System action whose instance does NOT grant SYSTEM in default_caps
1837        // cannot SendSignal — System is gated by effective_caps, not bypassed.
1838        let mut k = Kernel::new();
1839        k.register_action::<SignalAction>();
1840        let inst = k.create_instance(InstanceConfig::default()); // default_caps empty
1841        k.submit(
1842            inst,
1843            Principal::System,
1844            None,
1845            CapabilityMask::SYSTEM,
1846            Tick(0),
1847            TypeCode(102),
1848            Vec::new(),
1849        )
1850        .unwrap();
1851        let report = k.step(Tick(0), CapabilityMask::SYSTEM);
1852        assert_eq!(
1853            report.effects_denied, 1,
1854            "System SendSignal denied when default_caps withholds SYSTEM"
1855        );
1856        assert_eq!(report.effects_applied, 0);
1857    }
1858
1859    #[test]
1860    fn signal_delivered_routes_to_inbox() {
1861        let mut k = Kernel::new();
1862        k.register_action::<SignalAction>();
1863        let counters = Arc::new(SignalEventCounters::default());
1864        k.register_observer(Box::new(SignalEventCapture {
1865            c: counters.clone(),
1866        }));
1867        let inst = k.create_instance(cfg_sys());
1868        k.submit(
1869            inst,
1870            Principal::System,
1871            None,
1872            CapabilityMask::SYSTEM,
1873            Tick(0),
1874            TypeCode(102),
1875            Vec::new(),
1876        )
1877        .unwrap();
1878        let report = k.step(Tick(0), CapabilityMask::SYSTEM);
1879        assert_eq!(report.effects_applied, 1);
1880        let target = k.instances.get(&inst).unwrap();
1881        assert_eq!(target.inbox_len(RouteId(1)), 1);
1882        assert_eq!(target.inflight_refs_for(RouteId(1)), 1);
1883        assert_eq!(counters.delivered.load(Ordering::SeqCst), 1);
1884    }
1885
1886    #[test]
1887    fn signal_dropped_target_not_found() {
1888        let mut k = Kernel::new();
1889        k.register_action::<SignalMissingAction>();
1890        let counters = Arc::new(SignalEventCounters::default());
1891        k.register_observer(Box::new(SignalEventCapture {
1892            c: counters.clone(),
1893        }));
1894        let inst = k.create_instance(cfg_sys());
1895        k.submit(
1896            inst,
1897            Principal::System,
1898            None,
1899            CapabilityMask::SYSTEM,
1900            Tick(0),
1901            TypeCode(111),
1902            Vec::new(),
1903        )
1904        .unwrap();
1905        let report = k.step(Tick(0), CapabilityMask::SYSTEM);
1906        // The op is applied (authorized + dispatched); the DROP happens at the
1907        // delivery router (no target), so no inbox and no refcount anywhere.
1908        assert_eq!(report.effects_applied, 1);
1909        assert_eq!(counters.dropped_not_found.load(Ordering::SeqCst), 1);
1910        assert_eq!(counters.delivered.load(Ordering::SeqCst), 0);
1911        assert_eq!(
1912            k.instances.get(&inst).unwrap().inflight_refs_for(RouteId(1)),
1913            0
1914        );
1915    }
1916
1917    #[test]
1918    fn signal_dropped_queue_full() {
1919        let mut k = Kernel::new();
1920        k.register_action::<SignalTwiceAction>();
1921        let counters = Arc::new(SignalEventCounters::default());
1922        k.register_observer(Box::new(SignalEventCapture {
1923            c: counters.clone(),
1924        }));
1925        // Inbox capacity 1: first signal delivered, second dropped (QueueFull).
1926        let inst = k.create_instance(InstanceConfig {
1927            default_caps: CapabilityMask::SYSTEM,
1928            max_inbox_per_route: 1,
1929            ..Default::default()
1930        });
1931        k.submit(
1932            inst,
1933            Principal::System,
1934            None,
1935            CapabilityMask::SYSTEM,
1936            Tick(0),
1937            TypeCode(110),
1938            Vec::new(),
1939        )
1940        .unwrap();
1941        let _ = k.step(Tick(0), CapabilityMask::SYSTEM);
1942        assert_eq!(counters.delivered.load(Ordering::SeqCst), 1);
1943        assert_eq!(counters.dropped_queue_full.load(Ordering::SeqCst), 1);
1944        let target = k.instances.get(&inst).unwrap();
1945        assert_eq!(target.inbox_len(RouteId(1)), 1, "exactly one fit the inbox");
1946        assert_eq!(target.inflight_refs_for(RouteId(1)), 1);
1947    }
1948
1949    #[test]
1950    fn cap_time_shift_unrepresentable() {
1951        // A scheduling parent cannot bake elevated caps into a future child:
1952        // the child's effective caps are re-resolved at execution under the
1953        // CURRENT operator session ceiling. Parent schedules a SendSignal child
1954        // while the session grants SYSTEM; when the child later runs under an
1955        // empty session, its SendSignal is denied — no time-shifted escalation.
1956        let mut k = Kernel::new();
1957        k.register_action::<ScheduleSignalChildAction>();
1958        k.register_action::<SignalAction>();
1959        let inst = k.create_instance(cfg_sys());
1960        k.submit(
1961            inst,
1962            Principal::System,
1963            None,
1964            CapabilityMask::SYSTEM,
1965            Tick(0),
1966            TypeCode(112),
1967            Vec::new(),
1968        )
1969        .unwrap();
1970        // tick 0, full session: parent schedules the child.
1971        let r0 = k.step(Tick(0), CapabilityMask::SYSTEM);
1972        assert_eq!(r0.effects_applied, 1);
1973        // tick 1, EMPTY session: the child SendSignal is denied.
1974        let r1 = k.step(Tick(1), CapabilityMask::empty());
1975        assert_eq!(r1.actions_executed, 1, "child popped");
1976        assert_eq!(r1.effects_applied, 0, "child SendSignal denied under narrowed session");
1977        assert_eq!(
1978            k.instances.get(&inst).unwrap().inbox_len(RouteId(1)),
1979            0,
1980            "no signal delivered — caps could not be time-shifted"
1981        );
1982    }
1983
1984    // ---- memory_budget_bytes enforcement (A21) ----
1985
1986    /// Test action: spawns entity `entity_id` and attaches one
1987    /// `SetComponent` of `size` bytes. The ledger tracks bytes only for
1988    /// registered entities, so the spawn must precede the set; this
1989    /// action emits both in one compute() — production-realistic.
1990    #[derive(Serialize, Deserialize)]
1991    struct SetCompAction {
1992        size: u64,
1993        entity_id: u64,
1994    }
1995    impl Sealed for SetCompAction {}
1996    impl ActionDeriv for SetCompAction {
1997        const TYPE_CODE: TypeCode = TypeCode(200);
1998        const SCHEMA_VERSION: u32 = 1;
1999    }
2000    impl ActionCompute for SetCompAction {
2001        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
2002            let entity = EntityId::new(self.entity_id).unwrap();
2003            vec![
2004                Op::SpawnEntity {
2005                    id: entity,
2006                    owner: Principal::System,
2007                },
2008                Op::SetComponent {
2009                    entity,
2010                    type_code: TypeCode(7),
2011                    bytes: Bytes::from(vec![0u8; self.size as usize]),
2012                    size: self.size,
2013                },
2014            ]
2015        }
2016    }
2017
2018    /// Test action: spawns entities 1 and 2, then attaches one
2019    /// `SetComponent` of size `a` to entity 1 and one of size `b` to
2020    /// entity 2 (4 ops total).
2021    #[derive(Serialize, Deserialize)]
2022    struct TwoSetCompAction {
2023        a: u64,
2024        b: u64,
2025    }
2026    impl Sealed for TwoSetCompAction {}
2027    impl ActionDeriv for TwoSetCompAction {
2028        const TYPE_CODE: TypeCode = TypeCode(201);
2029        const SCHEMA_VERSION: u32 = 1;
2030    }
2031    impl ActionCompute for TwoSetCompAction {
2032        fn compute(&self, _ctx: &ActionContext) -> Vec<Op> {
2033            let e1 = EntityId::new(1).unwrap();
2034            let e2 = EntityId::new(2).unwrap();
2035            vec![
2036                Op::SpawnEntity {
2037                    id: e1,
2038                    owner: Principal::System,
2039                },
2040                Op::SpawnEntity {
2041                    id: e2,
2042                    owner: Principal::System,
2043                },
2044                Op::SetComponent {
2045                    entity: e1,
2046                    type_code: TypeCode(7),
2047                    bytes: Bytes::from(vec![0u8; self.a as usize]),
2048                    size: self.a,
2049                },
2050                Op::SetComponent {
2051                    entity: e2,
2052                    type_code: TypeCode(7),
2053                    bytes: Bytes::from(vec![0u8; self.b as usize]),
2054                    size: self.b,
2055                },
2056            ]
2057        }
2058    }
2059
2060    fn cfg_with_budget(budget: u64) -> InstanceConfig {
2061        InstanceConfig {
2062            memory_budget_bytes: budget,
2063            ..Default::default()
2064        }
2065    }
2066
2067    fn submit_set(k: &mut Kernel, inst: InstanceId, size: u64, entity_id: u64) {
2068        let action = SetCompAction { size, entity_id };
2069        let bytes = Action::canonical_bytes(&action);
2070        k.submit(
2071            inst,
2072            Principal::System,
2073            None,
2074            CapabilityMask::SYSTEM,
2075            Tick(0),
2076            SetCompAction::TYPE_CODE,
2077            bytes,
2078        )
2079        .expect("submit ok");
2080    }
2081
2082    #[test]
2083    fn budget_zero_allows_unlimited() {
2084        // Default config has memory_budget_bytes = 0 → no enforcement.
2085        // SetCompAction emits Spawn + SetComponent (2 ops).
2086        let mut k = Kernel::new();
2087        k.register_action::<SetCompAction>();
2088        let inst = k.create_instance(InstanceConfig::default());
2089        submit_set(&mut k, inst, 1_000_000, 1);
2090        let report = k.step(Tick(0), CapabilityMask::SYSTEM);
2091        assert_eq!(report.effects_applied, 2);
2092        assert_eq!(report.effects_denied, 0);
2093        assert_eq!(k.instances.get(&inst).unwrap().components_len(), 1);
2094    }
2095
2096    #[test]
2097    fn budget_exceeded_denies_op() {
2098        // budget=100; Spawn passes (size 0), SetComponent denied (500 > 100).
2099        // Per-Op deny — Spawn still applies, no rollback (any_denied=false).
2100        let mut k = Kernel::new();
2101        k.register_action::<SetCompAction>();
2102        let inst = k.create_instance(cfg_with_budget(100));
2103        submit_set(&mut k, inst, 500, 1);
2104        let report = k.step(Tick(0), CapabilityMask::SYSTEM);
2105        assert_eq!(report.effects_applied, 1); // Spawn only
2106        assert_eq!(report.effects_denied, 1); // SetComponent
2107        assert_eq!(report.actions_executed, 1);
2108        assert_eq!(k.instances.get(&inst).unwrap().entities_len(), 1);
2109        assert_eq!(k.instances.get(&inst).unwrap().components_len(), 0);
2110    }
2111
2112    #[test]
2113    fn budget_at_edge_allows_equal() {
2114        // budget=500, projected = 0 + 0 (Spawn) + 500 (Set) = 500.
2115        // 500 == budget is allowed (only `>` denies).
2116        let mut k = Kernel::new();
2117        k.register_action::<SetCompAction>();
2118        let inst = k.create_instance(cfg_with_budget(500));
2119        submit_set(&mut k, inst, 500, 1);
2120        let report = k.step(Tick(0), CapabilityMask::SYSTEM);
2121        assert_eq!(report.effects_applied, 2);
2122        assert_eq!(report.effects_denied, 0);
2123        assert_eq!(k.instances.get(&inst).unwrap().components_len(), 1);
2124        assert_eq!(k.instances.get(&inst).unwrap().ledger().total_bytes(), 500);
2125    }
2126
2127    #[test]
2128    fn multi_op_stage_respects_running_delta() {
2129        // budget=600. TwoSetCompAction emits: Spawn(1), Spawn(2),
2130        // SetComp(1, size=400), SetComp(2, size=400).
2131        // Spawns fit (size 0). SetComp(1): projected=0+0+400=400 → allow.
2132        // SetComp(2): projected=0+400+400=800 > 600 → deny.
2133        // 3 applied, 1 denied; entity 2 spawned but uncomponented.
2134        let mut k = Kernel::new();
2135        k.register_action::<TwoSetCompAction>();
2136        let inst = k.create_instance(cfg_with_budget(600));
2137        let action = TwoSetCompAction { a: 400, b: 400 };
2138        let bytes = Action::canonical_bytes(&action);
2139        k.submit(
2140            inst,
2141            Principal::System,
2142            None,
2143            CapabilityMask::SYSTEM,
2144            Tick(0),
2145            TwoSetCompAction::TYPE_CODE,
2146            bytes,
2147        )
2148        .unwrap();
2149        let report = k.step(Tick(0), CapabilityMask::SYSTEM);
2150        assert_eq!(report.effects_applied, 3);
2151        assert_eq!(report.effects_denied, 1);
2152        assert_eq!(k.instances.get(&inst).unwrap().entities_len(), 2);
2153        assert_eq!(k.instances.get(&inst).unwrap().components_len(), 1);
2154        assert_eq!(k.instances.get(&inst).unwrap().ledger().total_bytes(), 400);
2155    }
2156
2157    /// Observer that records every `EffectFailed` reason it sees.
2158    struct EffectFailedCapture {
2159        seen: Arc<std::sync::Mutex<Vec<Bytes>>>,
2160    }
2161    impl KernelObserver for EffectFailedCapture {
2162        fn on_event(&self, event: &KernelEvent) {
2163            if let KernelEvent::EffectFailed { reason, .. } = event {
2164                self.seen.lock().unwrap().push(reason.clone());
2165            }
2166        }
2167    }
2168
2169    #[test]
2170    fn effect_failed_event_on_budget_deny() {
2171        let mut k = Kernel::new();
2172        k.register_action::<SetCompAction>();
2173        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
2174        k.register_observer(Box::new(EffectFailedCapture { seen: seen.clone() }));
2175        let inst = k.create_instance(cfg_with_budget(100));
2176        submit_set(&mut k, inst, 500, 1);
2177        let _ = k.step(Tick(0), CapabilityMask::SYSTEM);
2178
2179        let captured = seen.lock().unwrap().clone();
2180        assert_eq!(captured.len(), 1);
2181        assert_eq!(captured[0].as_ref(), b"budget_exceeded");
2182    }
2183
2184    // ---- EventMask filter ----
2185
2186    use crate::runtime::event::EventMask;
2187
2188    /// Per-variant counter observer — used to verify that only the
2189    /// expected variant arms increment.
2190    #[derive(Default)]
2191    struct VariantCounters {
2192        action_executed: AtomicU32,
2193        action_failed: AtomicU32,
2194        domain_event: AtomicU32,
2195        effect_failed: AtomicU32,
2196        other: AtomicU32,
2197    }
2198
2199    struct VariantTallyObserver {
2200        counters: Arc<VariantCounters>,
2201    }
2202    impl KernelObserver for VariantTallyObserver {
2203        fn on_event(&self, event: &KernelEvent) {
2204            match event {
2205                KernelEvent::ActionExecuted { .. } => {
2206                    self.counters.action_executed.fetch_add(1, Ordering::SeqCst);
2207                }
2208                KernelEvent::ActionFailed { .. } => {
2209                    self.counters.action_failed.fetch_add(1, Ordering::SeqCst);
2210                }
2211                KernelEvent::DomainEventEmitted { .. } => {
2212                    self.counters.domain_event.fetch_add(1, Ordering::SeqCst);
2213                }
2214                KernelEvent::EffectFailed { .. } => {
2215                    self.counters.effect_failed.fetch_add(1, Ordering::SeqCst);
2216                }
2217                KernelEvent::ObserverPanic { .. }
2218                | KernelEvent::ObserverEvicted { .. }
2219                | KernelEvent::SignalDropped { .. }
2220                | KernelEvent::SignalDelivered { .. }
2221                | KernelEvent::ModuleForceUnloaded { .. }
2222                | KernelEvent::ActionDeferredToNextTick { .. }
2223                | KernelEvent::ObserversFlushed { .. } => {
2224                    self.counters.other.fetch_add(1, Ordering::SeqCst);
2225                }
2226            }
2227        }
2228    }
2229
2230    #[test]
2231    fn event_mask_default_is_all() {
2232        let m = EventMask::default();
2233        assert_eq!(m, EventMask::ALL);
2234        assert!(m.contains(EventMask::ACTION_EXECUTED));
2235        assert!(m.contains(EventMask::DOMAIN_EVENT_EMITTED));
2236        assert!(m.contains(EventMask::MODULE_FORCE_UNLOADED));
2237    }
2238
2239    #[test]
2240    fn register_observer_backward_compat_receives_all() {
2241        // EmitAction yields a DomainEventEmitted + an ActionExecuted —
2242        // a default-mask observer must see both.
2243        let mut k = Kernel::new();
2244        k.register_action::<EmitAction>();
2245        let counters = Arc::new(VariantCounters::default());
2246        k.register_observer(Box::new(VariantTallyObserver {
2247            counters: counters.clone(),
2248        }));
2249        let inst = k.create_instance(InstanceConfig::default());
2250        k.submit(
2251            inst,
2252            Principal::System,
2253            None,
2254            CapabilityMask::SYSTEM,
2255            Tick(0),
2256            TypeCode(101),
2257            Vec::new(),
2258        )
2259        .unwrap();
2260        let _ = k.step(Tick(0), CapabilityMask::SYSTEM);
2261        assert_eq!(counters.action_executed.load(Ordering::SeqCst), 1);
2262        assert_eq!(counters.domain_event.load(Ordering::SeqCst), 1);
2263    }
2264
2265    #[test]
2266    fn filter_only_action_executed() {
2267        // Mask = ACTION_EXECUTED only — DomainEventEmitted should be muted.
2268        let mut k = Kernel::new();
2269        k.register_action::<EmitAction>();
2270        let counters = Arc::new(VariantCounters::default());
2271        k.register_observer_filtered(
2272            Box::new(VariantTallyObserver {
2273                counters: counters.clone(),
2274            }),
2275            EventMask::ACTION_EXECUTED,
2276        );
2277        let inst = k.create_instance(InstanceConfig::default());
2278        k.submit(
2279            inst,
2280            Principal::System,
2281            None,
2282            CapabilityMask::SYSTEM,
2283            Tick(0),
2284            TypeCode(101),
2285            Vec::new(),
2286        )
2287        .unwrap();
2288        let _ = k.step(Tick(0), CapabilityMask::SYSTEM);
2289        assert_eq!(counters.action_executed.load(Ordering::SeqCst), 1);
2290        assert_eq!(counters.domain_event.load(Ordering::SeqCst), 0);
2291    }
2292
2293    #[test]
2294    fn filter_domain_event_only() {
2295        // Mask = DOMAIN_EVENT_EMITTED only — ActionExecuted should be muted.
2296        let mut k = Kernel::new();
2297        k.register_action::<EmitAction>();
2298        let counters = Arc::new(VariantCounters::default());
2299        k.register_observer_filtered(
2300            Box::new(VariantTallyObserver {
2301                counters: counters.clone(),
2302            }),
2303            EventMask::DOMAIN_EVENT_EMITTED,
2304        );
2305        let inst = k.create_instance(InstanceConfig::default());
2306        k.submit(
2307            inst,
2308            Principal::System,
2309            None,
2310            CapabilityMask::SYSTEM,
2311            Tick(0),
2312            TypeCode(101),
2313            Vec::new(),
2314        )
2315        .unwrap();
2316        let _ = k.step(Tick(0), CapabilityMask::SYSTEM);
2317        assert_eq!(counters.action_executed.load(Ordering::SeqCst), 0);
2318        assert_eq!(counters.domain_event.load(Ordering::SeqCst), 1);
2319    }
2320
2321    #[test]
2322    fn multiple_observers_independent_masks() {
2323        // obs_a wants ACTION_EXECUTED, obs_b wants DOMAIN_EVENT_EMITTED.
2324        // After one EmitAction step, each observer sees exactly its slice.
2325        let mut k = Kernel::new();
2326        k.register_action::<EmitAction>();
2327        let ca = Arc::new(VariantCounters::default());
2328        let cb = Arc::new(VariantCounters::default());
2329        k.register_observer_filtered(
2330            Box::new(VariantTallyObserver {
2331                counters: ca.clone(),
2332            }),
2333            EventMask::ACTION_EXECUTED,
2334        );
2335        k.register_observer_filtered(
2336            Box::new(VariantTallyObserver {
2337                counters: cb.clone(),
2338            }),
2339            EventMask::DOMAIN_EVENT_EMITTED,
2340        );
2341        let inst = k.create_instance(InstanceConfig::default());
2342        k.submit(
2343            inst,
2344            Principal::System,
2345            None,
2346            CapabilityMask::SYSTEM,
2347            Tick(0),
2348            TypeCode(101),
2349            Vec::new(),
2350        )
2351        .unwrap();
2352        let _ = k.step(Tick(0), CapabilityMask::SYSTEM);
2353        assert_eq!(ca.action_executed.load(Ordering::SeqCst), 1);
2354        assert_eq!(ca.domain_event.load(Ordering::SeqCst), 0);
2355        assert_eq!(cb.action_executed.load(Ordering::SeqCst), 0);
2356        assert_eq!(cb.domain_event.load(Ordering::SeqCst), 1);
2357    }
2358
2359    #[test]
2360    fn filter_empty_mask_receives_nothing() {
2361        // EventMask::empty() — observer is registered but receives zero events.
2362        let mut k = Kernel::new();
2363        k.register_action::<EmitAction>();
2364        let counters = Arc::new(VariantCounters::default());
2365        k.register_observer_filtered(
2366            Box::new(VariantTallyObserver {
2367                counters: counters.clone(),
2368            }),
2369            EventMask::empty(),
2370        );
2371        let inst = k.create_instance(InstanceConfig::default());
2372        k.submit(
2373            inst,
2374            Principal::System,
2375            None,
2376            CapabilityMask::SYSTEM,
2377            Tick(0),
2378            TypeCode(101),
2379            Vec::new(),
2380        )
2381        .unwrap();
2382        let _ = k.step(Tick(0), CapabilityMask::SYSTEM);
2383        assert_eq!(counters.action_executed.load(Ordering::SeqCst), 0);
2384        assert_eq!(counters.domain_event.load(Ordering::SeqCst), 0);
2385        assert_eq!(counters.action_failed.load(Ordering::SeqCst), 0);
2386        assert_eq!(counters.effect_failed.load(Ordering::SeqCst), 0);
2387        assert_eq!(counters.other.load(Ordering::SeqCst), 0);
2388    }
2389}