Skip to main content

runtime/
bench.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The tool bench: which door one call goes through, in which order,
7//! and what comes back when a door says no.
8//!
9//! Gate routing is turn-layer work (Handoff verdict 10), so the
10//! executor above stays thin: it hands the bench a call and receives an
11//! exhaustive [`BenchOutcome`] rather than deciding anything itself.
12//!
13//! **Three orderings are load-bearing.**
14//! - Dedup runs before any side effect, so a replayed call cannot bill
15//!   or write twice.
16//! - `exec` is forecast for discards *before* the Write door, because
17//!   "this command deletes things" is a stronger claim than "this
18//!   command writes somewhere" and deserves the stricter door.
19//! - A `Deny` comes back as a `tool_result` carrying the refusal rather
20//!   than ending the turn: the model that asked for something it may not
21//!   have should learn that, and continue.
22//!
23//! The tool itself arrives as a `Box<dyn Tool>` and the sandbox behind
24//! it is a port, so what this module owns is the ordering rather than
25//! the effect.
26
27use std::collections::BTreeMap;
28
29use kernel::{
30    Address, AxCode, AxError, DiscardForecast, Effect, EgressOutcome, EgressTarget, GateOutcome,
31    GateSubject, IdemKey, Locator, TaintSet, Tool, ToolCall, ToolName, ToolOutcome, WriteDomain,
32};
33use serde_json::Value;
34use storage::{Checkpoint, Provenance};
35
36mod admit;
37mod outside;
38
39/// The checkpoint net a run works under: the repository, what a checkpoint
40/// covers, and who is signing it.
41///
42/// Three values that are meaningless apart - a checkpoint with no scope
43/// stages what the run never held, and a checkpoint with no provenance
44/// commits under nobody's name - so they are handed over together
45/// rather than assembled inside the bench.
46pub struct CheckpointNet {
47    pub checkpoint: Checkpoint,
48    /// The prefixes a checkpoint stages, which are the run's write domain and
49    /// not its room. A checkpoint narrower than the domain leaves whatever
50    /// the run wrote in between outside every checkpoint, and therefore
51    /// outside `changes` and outside restoration.
52    pub scope: Vec<String>,
53    pub of: Provenance,
54}
55
56/// The tool bench: the turn layer's routing of a call through the gate
57/// its own declared Effect names, in the three orderings the module
58/// documentation states.
59pub struct ToolBench {
60    tools: BTreeMap<ToolName, Box<dyn Tool>>,
61    domain: WriteDomain,
62    taint: TaintSet,
63    /// The floor's limits, which decide whether a connector may reach
64    /// what nothing here can take back.
65    sandbox: kernel::SandboxLimits,
66    /// What each key already answered, success or failure. A key alone
67    /// would stop the second call's side effect and still owe the model
68    /// an answer; the answer is the first call's own, whichever way it
69    /// went (`crates/runtime/spec/Bench.lean` §8-35).
70    seen: BTreeMap<IdemKey, Result<ToolOutcome, AxError>>,
71    prior_public_egress: bool,
72    /// The building's `egress` list; `None` is a building that wrote none.
73    egress: Option<kernel::EgressAllowlist>,
74    /// The checkpoint net. A command the forecast suspects of deleting
75    /// things does not get refused — text prediction is obfuscatable, so
76    /// refusing on a substring would be security theatre that also
77    /// blocks honest work. It gets checkpointed instead: commit first, then
78    /// run, so whatever it deletes is restorable. Absent a net, such a
79    /// command is refused, because running it unprotected is the one
80    /// outcome nobody chose.
81    net: Option<CheckpointNet>,
82    /// What this run was given to do, as the approvals list refers to
83    /// it. An item that named no artifact would leave a person deciding
84    /// about a spawn with nothing to open.
85    job: Option<Locator>,
86    /// Where this run works, which is what a delegation approval
87    /// clusters by: the person is asked whether this resident may hand
88    /// work down, once.
89    asking: Option<Address>,
90}
91
92/// What the bench decided, alongside what the tool produced.
93#[derive(Debug)]
94pub enum BenchOutcome {
95    /// The tool ran; this is its result. `checkpointed` carries the commit
96    /// the wave was checkpointed against when the forecast suspected a
97    /// discard, so the post-wave sweep knows what to restore from.
98    /// `wrote` is the tool's own account of what the call may have
99    /// written, which the next checkpoint stages (`crates/runtime/spec/Run/Checkpoint.lean` §8-45).
100    Ran {
101        outcome: ToolOutcome,
102        checkpointed: Option<String>,
103        wrote: kernel::Writes,
104    },
105    /// A gate refused, or asked. Either way the answer travels back as
106    /// a tool_result, which keeps the turn alive and tells the model
107    /// what it may not do or what the person has to do; a pending
108    /// question carries `E_APPROVAL_PENDING` and its recovery sentence
109    /// is the question.
110    Refused { refusal: Box<AxError> },
111    /// The call was already made, and this is what it answered. A replay
112    /// is owed the first result: an error here would teach the model a
113    /// call failed when it succeeded.
114    Duplicate { outcome: ToolOutcome },
115}
116
117/// Each registered tool's answer to what it may write, by declared effect.
118pub struct DeclaredWrites(BTreeMap<ToolName, kernel::Writes>);
119
120impl DeclaredWrites {
121    /// A name the bench does not hold answers `Domain`: the call will be
122    /// refused, and a guess that it writes nothing is the one guess that
123    /// could leave a write outside every checkpoint.
124    pub fn of(&self, call: &ToolCall) -> kernel::Writes {
125        self.0
126            .get(&call.name)
127            .cloned()
128            .unwrap_or(kernel::Writes::Domain)
129    }
130}
131
132impl ToolBench {
133    pub fn new(domain: WriteDomain) -> ToolBench {
134        ToolBench {
135            tools: BTreeMap::new(),
136            domain,
137            taint: TaintSet::empty(),
138            sandbox: kernel::SandboxLimits::default(),
139            seen: BTreeMap::new(),
140            prior_public_egress: false,
141            egress: None,
142            net: None,
143            job: None,
144            asking: None,
145        }
146    }
147
148    /// Hands the bench the work it serves: where the run stands and what
149    /// it was given to do.
150    ///
151    /// Without it a spawn is refused rather than allowed, because an
152    /// approval item that named neither the asker nor an artifact would
153    /// reach a person as a question about nothing.
154    #[must_use]
155    pub fn for_job(mut self, asking: Address, job: Locator) -> ToolBench {
156        self.asking = Some(asking);
157        self.job = Some(job);
158        self
159    }
160
161    /// Hands the bench its checkpoint net. Without one, a suspected
162    /// discard is refused rather than run unprotected.
163    pub fn with_checkpoint(mut self, net: CheckpointNet) -> ToolBench {
164        self.net = Some(net);
165        self
166    }
167
168    /// Registers a tool under its own declared name. A second tool
169    /// claiming a taken name is refused rather than shadowing the first.
170    pub fn register(&mut self, tool: Box<dyn Tool>) -> Result<(), AxError> {
171        let name = tool.meta().name.clone();
172        if self.tools.contains_key(&name) {
173            return Err(AxError::failure(
174                AxCode::InvalidArgs,
175                "register tool",
176                format!("`{name}` is already registered"),
177            )
178            .with_recovery(format!(
179                "rename one of the two tools declaring the name `{name}` in its \
180                 `ToolMeta`, then register it again"
181            )));
182        }
183        self.tools.insert(name, tool);
184        Ok(())
185    }
186
187    /// What each registered tool may write, read off its declared
188    /// effect alone, taken out before the bench is lent to the run so the
189    /// checkpoint policy can ask it while a wave waits (`crates/runtime/spec/Run/Checkpoint.lean` §8-45).
190    pub fn declared_writes(&self) -> DeclaredWrites {
191        DeclaredWrites(
192            self.tools
193                .iter()
194                .map(|(name, tool)| (name.clone(), kernel::Writes::of(&tool.meta().effect)))
195                .collect(),
196        )
197    }
198
199    pub fn taint_mut(&mut self) -> &mut TaintSet {
200        &mut self.taint
201    }
202
203    /// The registered tool's declaration. Callers packaging a result
204    /// need its `temporal` to decide whether a clock line is due.
205    pub fn meta_of(&self, name: &ToolName) -> Option<&kernel::ToolMeta> {
206        self.tools.get(name).map(|tool| tool.meta())
207    }
208
209    /// Routes one call: dedup, then the door its Effect names, then the
210    /// tool itself: `clear`, then the tool `tool_for` names, then
211    /// `account`, in order, on one thread.
212    pub fn invoke(
213        &mut self,
214        call: &ToolCall,
215        key: &IdemKey,
216        now: kernel::TimeMs,
217    ) -> Result<BenchOutcome, AxError> {
218        match self.clear(call, key, now)? {
219            Clearance::Answered(outcome) => Ok(outcome),
220            Clearance::Cleared(ticket) => {
221                let answered = self.tool_for(&ticket).and_then(|tool| tool.invoke(call));
222                self.account(ticket, answered)
223            }
224        }
225    }
226
227    /// Dedup, the doors, and the discard forecast's checkpoint: everything
228    /// that decides whether a call may run, and reads or writes what
229    /// the calls before it left. A concurrent wave clears its calls one
230    /// at a time, in call order.
231    pub fn clear(
232        &mut self,
233        call: &ToolCall,
234        key: &IdemKey,
235        now: kernel::TimeMs,
236    ) -> Result<Clearance, AxError> {
237        // Before any unreplayable effect (8.2). `seen` holds a key only
238        // once the tool answered it, so its keys are the claimed set and
239        // a second copy of them would be a second authority.
240        if let Some(answered) = self.seen.get(key) {
241            return match answered {
242                Ok(outcome) => Ok(Clearance::Answered(BenchOutcome::Duplicate {
243                    outcome: outcome.clone(),
244                })),
245                Err(refused) => Err(refused.clone()),
246            };
247        }
248        let name = call.name.as_str();
249        let Some(tool) = self.tools.get(&call.name) else {
250            return Err(AxError::failure(
251                AxCode::ToolUnavailable,
252                "invoke tool",
253                format!("no tool named `{name}` is registered"),
254            )
255            .with_recovery("call one of the tools listed in your catalog"));
256        };
257        let effect = tool.effect_of(call)?;
258        let wrote = tool.writes(call);
259        // What the call is about, in the tool's own grammar.
260        // Read before any door: a subject this tool cannot read is a
261        // call no door may judge.
262        let subject = tool.subject(call)?;
263
264        // The command door stands before the forecast: a command a
265        // tainted run may not execute owes no checkpoint.
266        if name == ToolName::EXEC
267            && let Some(answered) = self.settled(kernel::gate::command(&self.taint))
268        {
269            return Ok(Clearance::Answered(answered));
270        }
271
272        // exec is forecast first. A hit does not refuse: it checkpoints.
273        let mut checkpointed = None;
274        if name == ToolName::EXEC
275            && let Ok(arm) = crate::tools::parse_arm(call.args.as_map())
276            && let DiscardForecast::Suspected { pattern } = kernel::discard::forecast(&arm)
277        {
278            let Some(net) = self.net.as_mut() else {
279                return Err(AxError::failure(
280                    AxCode::ToolUnavailable,
281                    "invoke tool",
282                    format!("`{pattern}` may discard files and no checkpoint net is configured"),
283                )
284                .with_recovery(
285                    "configure the checkpoint net, or run a command that does not delete",
286                ));
287            };
288            let payload = net
289                .checkpoint
290                .wave_pre(&net.scope, now, &net.of)
291                .map_err(storage::StorageError::into_ax)?;
292            checkpointed = payload
293                .as_map()
294                .get("oid")
295                .and_then(Value::as_str)
296                .map(str::to_owned);
297        }
298
299        if let Some(answered) = self.admit(call, name, &effect, &subject)? {
300            return Ok(Clearance::Answered(answered));
301        }
302        Ok(Clearance::Cleared(Ticket {
303            key: *key,
304            name: call.name.clone(),
305            effect,
306            checkpointed,
307            wrote,
308        }))
309    }
310
311    /// The tool a cleared call runs on. What comes back is the tool
312    /// rather than the bench, because the bench is not `Sync` (its
313    /// checkpoint holds a git repository handle) while a tool is: the
314    /// calls of one wave invoke their tools on several threads at once,
315    /// and none of them touches the dedup table or the taint.
316    ///
317    /// # Errors
318    /// A ticket naming a tool this bench does not hold.
319    pub fn tool_for(&self, ticket: &Ticket) -> Result<&dyn Tool, AxError> {
320        self.tool_named(&ticket.name).ok_or_else(|| {
321            AxError::failure(
322                AxCode::ToolUnavailable,
323                "invoke tool",
324                format!("no tool named `{}` is registered", ticket.name),
325            )
326            .with_nearby(self.tools.keys().map(ToString::to_string).collect())
327            .with_recovery(
328                "call one of the tools listed beside this error; those are the tools \
329                 this run holds",
330            )
331        })
332    }
333
334    /// The tool registered under `name`, lent out before any call to it
335    /// is cleared, so a read the model hands over can start while it is
336    /// still generating (`crates/runtime/spec/Turn.lean` §8-3). A name this bench does not
337    /// hold starts nothing early; admitting the call reports it.
338    pub fn tool_named(&self, name: &ToolName) -> Option<&dyn Tool> {
339        self.tools.get(name).map(AsRef::as_ref)
340    }
341
342    /// Records what a cleared call answered. A concurrent wave accounts
343    /// its calls in call order, so the dedup table and the taint grow
344    /// in the order a serial wave grows them.
345    ///
346    /// The key is recorded with the answer it earned, so a retry after
347    /// a gate refusal is not a replay, and a call the tool itself
348    /// failed answers its replay the same way it answered the first
349    /// time rather than running again. Outside content enters the run
350    /// here and nowhere else, so every later call's doors read the
351    /// taint it brought.
352    ///
353    /// # Errors
354    /// The tool's own refusal, and outside content whose source label
355    /// is empty.
356    pub fn account(
357        &mut self,
358        ticket: Ticket,
359        answered: Result<ToolOutcome, AxError>,
360    ) -> Result<BenchOutcome, AxError> {
361        let answered = answered.and_then(|outcome| {
362            self.taint = outside::entered(&ticket.effect, &self.taint)?;
363            Ok(outcome)
364        });
365        self.seen.insert(ticket.key, answered.clone());
366        let outcome = answered?;
367        Ok(BenchOutcome::Ran {
368            outcome,
369            checkpointed: ticket.checkpointed,
370            wrote: ticket.wrote,
371        })
372    }
373}
374
375/// What `clear` decided about one call.
376#[derive(Debug)]
377pub enum Clearance {
378    /// The bench answered without running the tool: a replay, or a door
379    /// that refused or asked.
380    Answered(BenchOutcome),
381    /// The call may run; `tool_for` and then `account` take the ticket.
382    Cleared(Ticket),
383}
384
385/// A call `clear` let through: the tool `tool_for` names, and what
386/// `account` records the answer under.
387#[derive(Debug)]
388pub struct Ticket {
389    key: IdemKey,
390    name: ToolName,
391    effect: Effect,
392    /// The commit the discard forecast checkpointed this call against.
393    checkpointed: Option<String>,
394    /// What the tool says the call may write, asked while the bench
395    /// still holds the tool; `account` hands it on in the outcome.
396    wrote: kernel::Writes,
397}
398
399#[cfg(test)]
400#[allow(
401    clippy::unwrap_used,
402    clippy::expect_used,
403    clippy::panic,
404    clippy::indexing_slicing,
405    clippy::wildcard_enum_match_arm,
406    clippy::let_underscore_must_use,
407    clippy::let_underscore_untyped,
408    reason = "test code"
409)]
410mod tests;