Skip to main content

arkhe_forge_platform/observer_host/
mod.rs

1//! Observer host — capability-bounded WASM sandbox for L2 projection
2//! observers (E15 — Observer Capability Confinement).
3//!
4//! ## Spec anchor
5//!
6//! - **E15 Observer Capability Confinement** — Runtime axiom:
7//!   - **E15.a** observer panic is contained at the sandbox boundary; the
8//!     host catches the trap and emits an `ObserverQuarantine` event. No
9//!     native unwind reaches the L0 chain (L0 A22 strengthening).
10//!   - **E15.b** observer side-effects route exclusively through host-
11//!     declared capability tokens; direct syscalls and `wasi-{fs, sockets,
12//!     clocks, random}` are rejected at module-load.
13//! - Observer path — L2 projection observers operate *post-commit*
14//!   on already-chained data; they cannot mutate chain state.
15//!
16//! ## Chain-non-affecting invariant (cryptographer-anchored firm contract)
17//!
18//! Observer execution is *chain-non-affecting* by construction. Four
19//! clauses establish the invariant; together they ensure observer panic /
20//! capability-deny / module compromise cannot affect L0 chain integrity:
21//!
22//! 1. **No chain-mutation host-fn**: every binding under `arkhe:observer/*`
23//!    is a side-effect to a non-chain destination (PG projection, metric
24//!    sink, KMS rotation receipt). No binding calls `Op::EmitEvent`,
25//!    `Op::SpawnEntity`, or any chain-head-write primitive.
26//! 2. **Effect signature is chain-orthogonal**: every host-side
27//!    [`capability_linker::ObserverCapability`] impl carries its effect to
28//!    a layer *outside* the chain (projection / metric / vault). Enforced
29//!    by the trait signature shape — the impl receives no chain reference.
30//! 3. **Quarantine emission is host-supervised**: when an observer wasm
31//!    traps, the *host* generates the `ObserverQuarantine` event. The
32//!    observer *triggers* the emission via its trap, but does not
33//!    *generate* it — the cryptographic chain anchor is host-owned.
34//! 4. **Panic isolation preserves chain progression**: the wasmtime trap
35//!    is caught at the host's invoke boundary; chain progression
36//!    continues independently. The chain hash of the next tick is
37//!    unaffected by observer existence or panic-state.
38//!
39//! ## L0↔Runtime boundary surface
40//!
41//! `observer_host/` is a *Runtime-layer* concept. It uses no L0 source —
42//! the L0 [`KernelObserver`](arkhe_kernel::KernelObserver) trait is
43//! referenced only as a conceptual anchor (`observer_host` exposes the
44//! same `KernelEvent` observation pattern but inside a capability-bounded
45//! WASM sandbox).
46//!
47//! ## Surface
48//!
49//! - [`ObserverHost`] trait + [`NoopObserverHost`] (default when sandbox-
50//!   backed observer is not feature-gated).
51//! - [`ObserverContext`] / [`ObserverError`] / [`ObserverTrapClass`].
52//! - [`ObserverCapToken`] enum (`#[non_exhaustive]`) — currently a single
53//!   variant `PgWrite`; additional capabilities can be added without
54//!   breaking external matchers.
55//! - `WasmtimeObserverHost` (feature `tier-2-observer-host-v2`) —
56//!   wasmtime preview-2 sandbox with fuel-metered execution + capability-
57//!   bounded `arkhe:observer/*` host-fn dispatch.
58
59#[cfg(feature = "tier-2-observer-host-v2")]
60pub mod capability_linker;
61#[cfg(feature = "tier-2-observer-host-v2")]
62pub mod wasmtime_observer;
63
64/// Capability tokens an enabled observer may request from the host.
65///
66/// `#[non_exhaustive]` — additive expansion is non-breaking. Currently
67/// a single variant (`PgWrite`); additional capabilities (KMS / metric /
68/// etc.) can be added without breaking external matchers.
69///
70/// `Ord` / `PartialOrd` are required so `ObserverCapToken` can live in a
71/// deterministic [`std::collections::BTreeSet`] (the host's per-observer
72/// capability set — deterministic iteration matters for the call-time
73/// capability check audit log).
74#[non_exhaustive]
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
76pub enum ObserverCapToken {
77    /// `arkhe:observer/pg.write` — append a row to the operator's
78    /// projection PostgreSQL table. Side-effect routes outside the chain.
79    PgWrite,
80}
81
82// SealedCapToken safeguard. The observer `ObserverCapToken` enum
83// implements both the private `Sealed` marker (only reachable from
84// `arkhe-forge-platform`) and the public
85// `wasm_runtime_common::ObserverCapTokenSealed` trait. External crates
86// cannot satisfy the `Sealed` bound, so the observer-cap-token universe
87// is closed at the host-defining crate boundary — preserving the E15.b
88// chain-non-affecting clause at compile-time (observer side-effects
89// route exclusively through observer-declared capabilities, never
90// through hook surfaces or unknown external types).
91//
92// Compiled only when at least one wasmtime feature is enabled (matches
93// `wasm_runtime_common`'s feature gate).
94#[cfg(any(feature = "tier-2-hook-host-v2", feature = "tier-2-observer-host-v2"))]
95impl crate::wasm_runtime_common::sealed_impl::Sealed for ObserverCapToken {}
96#[cfg(any(feature = "tier-2-hook-host-v2", feature = "tier-2-observer-host-v2"))]
97impl crate::wasm_runtime_common::ObserverCapTokenSealed for ObserverCapToken {}
98
99/// Observer execution context — opaque to observers themselves; managed
100/// by the host. Carries only the capability set; observers are read-only
101/// sinks and do not mutate the submission pipeline (cf. hook host's
102/// `ExtraBytesBuilder` thread).
103///
104/// **Chain-non-affecting clause 2 enforcement** (cryptographer-anchored):
105/// this struct intentionally does NOT carry a reference to chain state,
106/// chain head, or any Op-emit primitive. The borrow-checker enforces
107/// that observer code can never construct a chain mutation through
108/// `ctx`. Future field additions must preserve this invariant.
109#[derive(Debug)]
110pub struct ObserverContext<'b> {
111    /// Capability tokens granted by the manifest for this observer
112    /// invocation.
113    pub capabilities: &'b [ObserverCapToken],
114}
115
116// `ObserverTrapClass` is re-exported from `arkhe-forge-core::event` —
117// single source of truth lives in core because the value enters the L0
118// chain via the `ObserverQuarantine` event. Re-export keeps the
119// platform-side API surface stable for callers that imported from
120// `arkhe_forge_platform::observer_host::ObserverTrapClass`.
121pub use arkhe_forge_core::event::ObserverTrapClass;
122
123/// Observer execution outcome.
124#[non_exhaustive]
125#[derive(Debug, thiserror::Error)]
126pub enum ObserverError {
127    /// Observer invocation exceeded the fuel budget. Surfaces only from
128    /// sandbox-backed hosts; the no-op pass-through never returns this.
129    #[error("observer budget exhausted")]
130    BudgetExceeded,
131    /// Observer tried to import a host-fn not in its capability set.
132    /// Rejected at module-load (linker pre-scan) or at the call site
133    /// (host-fn capability check).
134    #[error("observer capability denied: {0:?}")]
135    CapabilityDenied(ObserverCapToken),
136    /// Observer invocation trapped at the sandbox boundary — the host
137    /// catches and quarantines without propagating the unwind (E15.a
138    /// strengthening of L0 A22).
139    #[error("observer trap: {0}")]
140    Trapped(&'static str),
141    /// Observer was placed under quarantine. After repeated panic events
142    /// (host policy threshold), the observer is removed from the active
143    /// rotation and its module-digest blacklisted — terminal state
144    /// distinct from per-invocation [`Self::Trapped`].
145    #[error("observer quarantined")]
146    Quarantined,
147}
148
149/// Capability-bounded observer host — runs a registered observer module
150/// against a `KernelEvent` stream with side-effects gated by the
151/// configured capability set.
152///
153/// # Backends
154///
155/// The runtime selects between two backends:
156/// - [`NoopObserverHost`] (always available) — pass-through that
157///   returns `Ok(())` without invoking any wasm.
158/// - `WasmtimeObserverHost` (feature `tier-2-observer-host-v2`) —
159///   wasmtime preview-2 sandbox with fuel-metered execution +
160///   capability-bounded `arkhe:observer/*` host-fn dispatch.
161pub trait ObserverHost {
162    /// Invoke the observer bound to this host against the supplied
163    /// context. Implementations may inspect `ctx.capabilities`; they
164    /// may NOT — by construction — mutate any chain state. The return
165    /// is informational only; chain progression is unaffected.
166    fn invoke(&self, ctx: &mut ObserverContext<'_>) -> Result<(), ObserverError>;
167}
168
169/// Pass-through host — returns `Ok(())` without invoking any observer
170/// logic. Default when sandbox-backed observer is not feature-gated.
171#[derive(Debug, Default, Clone, Copy)]
172pub struct NoopObserverHost;
173
174impl NoopObserverHost {
175    /// Construct a new no-op observer host.
176    pub fn new() -> Self {
177        Self
178    }
179}
180
181impl ObserverHost for NoopObserverHost {
182    fn invoke(&self, _ctx: &mut ObserverContext<'_>) -> Result<(), ObserverError> {
183        Ok(())
184    }
185}
186
187#[cfg(test)]
188#[allow(clippy::expect_used, clippy::unwrap_used)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn noop_host_passes_through_ok() {
194        let host = NoopObserverHost::new();
195        let caps = [ObserverCapToken::PgWrite];
196        let mut ctx = ObserverContext {
197            capabilities: &caps,
198        };
199        assert!(host.invoke(&mut ctx).is_ok());
200    }
201
202    #[test]
203    fn observer_cap_token_set_is_ordered_for_btreeset() {
204        // PgWrite is currently the only variant; deterministic ordering
205        // matters once additional variants are added. Round-trip into
206        // a BTreeSet exercises the Ord / PartialOrd derives.
207        use std::collections::BTreeSet;
208        let mut set = BTreeSet::new();
209        set.insert(ObserverCapToken::PgWrite);
210        assert_eq!(set.len(), 1);
211        assert!(set.contains(&ObserverCapToken::PgWrite));
212    }
213
214    #[test]
215    fn observer_error_display_does_not_panic() {
216        let errors = [
217            ObserverError::BudgetExceeded,
218            ObserverError::CapabilityDenied(ObserverCapToken::PgWrite),
219            ObserverError::Trapped("test trap"),
220            ObserverError::Quarantined,
221        ];
222        for e in errors {
223            assert!(!format!("{e}").is_empty());
224        }
225    }
226
227    #[test]
228    fn observer_trap_class_variants_round_trip() {
229        let classes = [
230            ObserverTrapClass::Panic,
231            ObserverTrapClass::BudgetExceeded,
232            ObserverTrapClass::CapabilityDenied,
233            ObserverTrapClass::Other,
234        ];
235        for c in classes {
236            // Trivial round-trip — discriminator stays printable; copy
237            // semantics work; equality is reflexive.
238            assert_eq!(c, c);
239            assert!(!format!("{c:?}").is_empty());
240        }
241    }
242
243    /// Chain-non-affecting clause 2 (cryptographer-anchored): the
244    /// `ObserverContext` carries only the capability set — no chain
245    /// reference is reachable. The structural invariant ("no chain-
246    /// mutation surface") is enforced by the type shape + review at
247    /// PR time; this test is the runtime sanity check on the
248    /// `capabilities` slice round-trip. Future field additions must
249    /// not introduce a chain-mutation reference (PR review catch).
250    #[test]
251    fn observer_context_carries_only_capabilities() {
252        let caps = [ObserverCapToken::PgWrite];
253        let ctx = ObserverContext {
254            capabilities: &caps,
255        };
256        assert_eq!(ctx.capabilities.len(), 1);
257        assert_eq!(ctx.capabilities[0], ObserverCapToken::PgWrite);
258    }
259}