locode_engine/approve.rs
1//! The interactive approval seam at the engine's dispatch step (ADR-0017).
2//!
3//! The engine consults an injected [`Approver`] before every tool call, at the
4//! gate **in front of** the one dispatch door — the tools crate stays
5//! interaction-free (`Registry` is a first-class library surface whose
6//! consumers must not inherit an approval dependency). The core stays headless
7//! (ADR-0001): nothing here renders or waits on a terminal — the engine awaits
8//! a trait method that headless callers resolve instantly ([`AllowAll`]).
9//!
10//! An interactive frontend implements [`Approver`] with a oneshot + FIFO queue
11//! (the pattern all four studied harnesses share); prompt queueing and
12//! stickiness ("always allow") are the approver implementation's job,
13//! client-side — not core vocabulary.
14
15use async_trait::async_trait;
16use locode_tools::ToolKind;
17use serde_json::Value;
18
19/// Decides whether one tool call may run. Consulted by the engine per call,
20/// serially, before dispatch; the await suspends **only this call**.
21#[async_trait]
22pub trait Approver: Send + Sync {
23 /// Resolve one pre-dispatch approval request.
24 async fn decide(&self, request: &ApprovalRequest<'_>) -> Decision;
25}
26
27/// The pre-dispatch view of one tool call — what the studied UIs render their
28/// permission prompts from (request args, not host-resolved detail).
29///
30/// `#[non_exhaustive]` so richer context can be added without breaking
31/// downstream constructors — only the engine builds one.
32#[derive(Debug)]
33#[non_exhaustive]
34pub struct ApprovalRequest<'a> {
35 /// The `tool_use` id (pairs the decision to the call).
36 pub tool_use_id: &'a str,
37 /// The client-facing tool name the model called.
38 pub tool_name: &'a str,
39 /// The registry's cross-pack classification, when the tool is known —
40 /// lets an approver auto-allow read-only kinds without knowing names.
41 pub kind: Option<ToolKind>,
42 /// The raw arguments the model supplied.
43 pub input: &'a Value,
44}
45
46/// The approval vocabulary — deliberately minimal (ADR-0017 Option V1):
47/// stickiness and richer choices live in approver implementations.
48///
49/// `#[non_exhaustive]` so variants (e.g. `AllowModified`) are additive.
50#[derive(Debug, Clone, PartialEq, Eq)]
51#[non_exhaustive]
52pub enum Decision {
53 /// Run the call.
54 Allow,
55 /// Do not run the call: the model sees a paired `is_error` result carrying
56 /// the reason and the run continues — deny is **soft**, never fatal.
57 Deny {
58 /// Why the call was denied (shown to the model verbatim).
59 reason: String,
60 },
61}
62
63/// The default approver: allows everything, instantly — headless consumers
64/// (`locode-exec`, evals) are byte-for-byte unchanged in behavior.
65pub struct AllowAll;
66
67#[async_trait]
68impl Approver for AllowAll {
69 async fn decide(&self, _request: &ApprovalRequest<'_>) -> Decision {
70 Decision::Allow
71 }
72}