Skip to main content

brink_runtime/
external_policy.rs

1//! [`KindTieredHandler`] — a composable [`ExternalFnHandler`] wrapper that
2//! gates externals during speculative/watch evaluation by their effect
3//! kind, without the runtime knowing anything about a host manifest.
4//!
5//! The runtime stays **manifest-blind**: this module has no dependency on
6//! `brink-ir` or any analyzer type. [`PolicyKind`] is plain data — the
7//! consumer (e.g. celeris, bevy-brink) maps the analyzer's `ExternalKind`
8//! onto it (`Query` → [`PolicyKind::Query`]; `Effect`/`Presentation`/
9//! unclassified → [`PolicyKind::Effect`], conservative-by-default) and
10//! hands the resulting `name -> PolicyKind` table to
11//! [`KindTieredHandler::new`].
12//!
13//! Like [`crate::RecordingHandler`]/[`crate::ReplayHandler`], this *wraps*
14//! a real [`ExternalFnHandler`] rather than threading a policy through the
15//! stepping hot loop — the caller stacks it: e.g.
16//! `spec.advance(budget, &KindTieredHandler::new(&real_bindings, kinds, EvalContext::Watch, false))`.
17
18use std::cell::RefCell;
19use std::collections::HashMap;
20
21use brink_format::Value;
22
23use crate::story::{ExternalFnHandler, ExternalResult};
24
25/// The effect category a [`KindTieredHandler`] gates on. Plain data — the
26/// consumer maps its own (richer) external classification onto this
27/// two-way split.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum PolicyKind {
30    /// A read-only query (no side effects). Always delegated live.
31    Query,
32    /// A state-changing (or otherwise non-query) effect. Gated by
33    /// [`EvalContext`] and the handler's `live_effects` arming.
34    Effect,
35}
36
37/// Which evaluation regime a [`KindTieredHandler`] is gating for.
38///
39/// `Watch` is the conservative default: no effect ever fires live,
40/// regardless of arming. `Eval` additionally requires `live_effects` to be
41/// armed before an effect is allowed through — a deliberate two-key gate
42/// so effects don't fire just because the caller happens to be in an
43/// engine→ink eval.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum EvalContext {
46    /// Read-only inspection (e.g. a live-inspector "what would this show"
47    /// probe). Effects never fire live.
48    Watch,
49    /// An engine→ink function evaluation that may be permitted to run
50    /// live effects if `live_effects` is armed.
51    Eval,
52}
53
54/// Diagnostic record of which externals a [`KindTieredHandler`] let
55/// through live versus fell back, across the handler's lifetime.
56///
57/// Purely informational — nothing in the runtime reads this back, and its
58/// ordering (call order) has no bearing on story-visible behavior.
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct ExternalsReport {
61    /// Ink-declared names of externals delegated to the real handler
62    /// (live), in call order.
63    pub live: Vec<String>,
64    /// Ink-declared names of externals that fell back to the ink fallback
65    /// body (blocked by tiering, or a `Watch`/disarmed `Effect`), in call
66    /// order.
67    pub fallback: Vec<String>,
68}
69
70/// A stackable [`ExternalFnHandler`] that tiers externals by
71/// [`PolicyKind`] before delegating to a real handler.
72///
73/// - `Query` externals are always delegated live to `inner` (including a
74///   `Pending` result, passed straight through for async resolution).
75/// - `Effect` externals are delegated live only when `context ==
76///   EvalContext::Eval` *and* `live_effects` is armed; otherwise they
77///   resolve to [`ExternalResult::Fallback`] (the ink fallback body, or a
78///   named-external VM error if none is declared).
79/// - A name absent from `kinds` is treated as `Effect` — conservative by
80///   default.
81///
82/// No coupling to [`crate::Speculation`] — this is a plain composable
83/// handler, usable anywhere an `&dyn ExternalFnHandler` is accepted.
84pub struct KindTieredHandler<'h> {
85    inner: &'h dyn ExternalFnHandler,
86    kinds: HashMap<String, PolicyKind>,
87    context: EvalContext,
88    live_effects: bool,
89    report: RefCell<ExternalsReport>,
90}
91
92impl<'h> KindTieredHandler<'h> {
93    /// Build a handler gating calls to `inner` by `kinds`.
94    ///
95    /// `kinds` maps ink-declared external names to their [`PolicyKind`];
96    /// a name absent from the map is treated as `Effect`. `context` picks
97    /// the evaluation regime; `live_effects` arms `Effect` externals when
98    /// `context == EvalContext::Eval` (ignored under `Watch`, where
99    /// effects never fire live).
100    #[must_use]
101    pub fn new(
102        inner: &'h dyn ExternalFnHandler,
103        kinds: HashMap<String, PolicyKind>,
104        context: EvalContext,
105        live_effects: bool,
106    ) -> Self {
107        Self {
108            inner,
109            kinds,
110            context,
111            live_effects,
112            report: RefCell::new(ExternalsReport::default()),
113        }
114    }
115
116    /// Snapshot of which externals have run live versus fallen back so
117    /// far. Diagnostic only.
118    #[must_use]
119    pub fn report(&self) -> ExternalsReport {
120        self.report.borrow().clone()
121    }
122
123    /// Whether an external of the given kind is allowed to run live under
124    /// this handler's current context/arming.
125    fn armed(&self, kind: PolicyKind) -> bool {
126        match kind {
127            PolicyKind::Query => true,
128            PolicyKind::Effect => self.context == EvalContext::Eval && self.live_effects,
129        }
130    }
131}
132
133impl ExternalFnHandler for KindTieredHandler<'_> {
134    fn call(&self, name: &str, args: &[Value]) -> ExternalResult {
135        let kind = self.kinds.get(name).copied().unwrap_or(PolicyKind::Effect);
136        if self.armed(kind) {
137            self.report.borrow_mut().live.push(name.to_owned());
138            self.inner.call(name, args)
139        } else {
140            self.report.borrow_mut().fallback.push(name.to_owned());
141            ExternalResult::Fallback
142        }
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    /// A stub handler: `Resolved` for names in its table, `Pending` for
151    /// the sentinel name `"async"`, else `Fallback`.
152    struct Stub;
153    impl ExternalFnHandler for Stub {
154        fn call(&self, name: &str, _args: &[Value]) -> ExternalResult {
155            match name {
156                "async" => ExternalResult::Pending,
157                "get" => ExternalResult::Resolved(Value::Int(5)),
158                "act" => ExternalResult::Resolved(Value::Bool(true)),
159                _ => ExternalResult::Fallback,
160            }
161        }
162    }
163
164    fn kinds(pairs: &[(&str, PolicyKind)]) -> HashMap<String, PolicyKind> {
165        pairs.iter().map(|(n, k)| ((*n).to_owned(), *k)).collect()
166    }
167
168    #[test]
169    fn query_delegates_live_including_pending() {
170        let inner = Stub;
171        let h = KindTieredHandler::new(
172            &inner,
173            kinds(&[("get", PolicyKind::Query), ("async", PolicyKind::Query)]),
174            EvalContext::Watch,
175            false,
176        );
177        assert!(matches!(
178            h.call("get", &[]),
179            ExternalResult::Resolved(Value::Int(5))
180        ));
181        assert!(matches!(h.call("async", &[]), ExternalResult::Pending));
182        assert_eq!(h.report().live, vec!["get".to_owned(), "async".to_owned()]);
183        assert!(h.report().fallback.is_empty());
184    }
185
186    #[test]
187    fn effect_under_watch_falls_back_inner_not_called() {
188        let inner = Stub;
189        let h = KindTieredHandler::new(
190            &inner,
191            kinds(&[("act", PolicyKind::Effect)]),
192            EvalContext::Watch,
193            true, // even armed, Watch never lets effects through
194        );
195        assert!(matches!(h.call("act", &[]), ExternalResult::Fallback));
196        assert_eq!(h.report().fallback, vec!["act".to_owned()]);
197        assert!(h.report().live.is_empty());
198    }
199
200    #[test]
201    fn effect_under_eval_disarmed_falls_back() {
202        let inner = Stub;
203        let h = KindTieredHandler::new(
204            &inner,
205            kinds(&[("act", PolicyKind::Effect)]),
206            EvalContext::Eval,
207            false,
208        );
209        assert!(matches!(h.call("act", &[]), ExternalResult::Fallback));
210        assert_eq!(h.report().fallback, vec!["act".to_owned()]);
211    }
212
213    #[test]
214    fn effect_under_eval_armed_runs_live() {
215        let inner = Stub;
216        let h = KindTieredHandler::new(
217            &inner,
218            kinds(&[("act", PolicyKind::Effect)]),
219            EvalContext::Eval,
220            true,
221        );
222        assert!(matches!(
223            h.call("act", &[]),
224            ExternalResult::Resolved(Value::Bool(true))
225        ));
226        assert_eq!(h.report().live, vec!["act".to_owned()]);
227    }
228
229    #[test]
230    fn unclassified_name_treated_as_effect() {
231        let inner = Stub;
232        // "act" is absent from `kinds` entirely.
233        let h = KindTieredHandler::new(&inner, kinds(&[]), EvalContext::Watch, false);
234        assert!(matches!(h.call("act", &[]), ExternalResult::Fallback));
235        assert_eq!(h.report().fallback, vec!["act".to_owned()]);
236
237        // Even armed + Eval, an unclassified name is still conservative
238        // Effect tiering — it runs live only because Effect is armed here,
239        // proving it wasn't silently treated as Query.
240        let h2 = KindTieredHandler::new(&inner, kinds(&[]), EvalContext::Eval, true);
241        assert!(matches!(
242            h2.call("act", &[]),
243            ExternalResult::Resolved(Value::Bool(true))
244        ));
245        assert_eq!(h2.report().live, vec!["act".to_owned()]);
246    }
247
248    #[test]
249    fn report_reflects_mixed_live_and_fallback_in_call_order() {
250        let inner = Stub;
251        let h = KindTieredHandler::new(
252            &inner,
253            kinds(&[("get", PolicyKind::Query), ("act", PolicyKind::Effect)]),
254            EvalContext::Watch,
255            false,
256        );
257        let _ = h.call("get", &[]);
258        let _ = h.call("act", &[]);
259        let _ = h.call("get", &[]);
260        let report = h.report();
261        assert_eq!(report.live, vec!["get".to_owned(), "get".to_owned()]);
262        assert_eq!(report.fallback, vec!["act".to_owned()]);
263    }
264}