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}