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}