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