Skip to main content

brainos_intent/
verbs.rs

1//! Canonical verb vocabulary for Brain's capability kernel.
2//!
3//! Every verb that flows through identity authorization, confirmation
4//! gating, and intent routing must appear here. A verb is the
5//! `(namespace, action)` pair Brain uses to address a capability — e.g.
6//! `memory.store`, `shell.exec`, `mcp.mount`. Verbs are part of the
7//! kernel's stable contract: adding one is a code change (new
8//! [`crate::IntentToken`] consumer, new authz mapping in
9//! `signal::authz::intent_to_auth`, new pipeline handler), not a config
10//! edit. External plugins extend Brain by mounting MCP servers whose
11//! tools land at `mcp.{tool_name}` — they do not invent new verbs.
12//!
13//! ## Why constants instead of `verbs.toml`
14//!
15//! The v1.0.0 RFC §158 nominally called for a `verbs.toml` registry +
16//! startup validation that every authz-mapped verb appears in it. We
17//! ship the same guarantee at *compile time* instead — `VERBS` is a
18//! `&[VerbSpec]` and `signal::authz::tests::every_static_verb_is_in_registry`
19//! asserts that every typed-Intent → AuthorizationRequest mapping
20//! resolves through [`lookup`]. A typo in `intent_to_auth` therefore
21//! fails the test suite rather than the runtime. Operators do not need
22//! to edit a verb file because verbs are kernel surface, not
23//! configuration.
24
25/// Documentation hint for a verb's confirmation tier. The authoritative
26/// tier mapping lives in `signal::authz::tier_for_verb`; this enum
27/// mirrors the five `identity::Tier` variants without depending on the
28/// `identity` crate so this module stays dependency-light. The
29/// `signal::authz::tests::tier_hints_match_authz` cross-check asserts
30/// the two stay in sync.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum TierHint {
33    Read,
34    Write,
35    Execute,
36    Destructive,
37    External,
38}
39
40impl TierHint {
41    pub fn as_str(&self) -> &'static str {
42        match self {
43            Self::Read => "read",
44            Self::Write => "write",
45            Self::Execute => "execute",
46            Self::Destructive => "destructive",
47            Self::External => "external",
48        }
49    }
50}
51
52/// A single capability verb. The `tier_hint` is the conservative
53/// confirmation tier the kernel applies if the typed-Intent path
54/// doesn't override it; it matches `signal::authz::tier_for_verb`'s
55/// fallback for the same `(ns, action)` pair.
56#[derive(Debug, Clone, Copy)]
57pub struct VerbSpec {
58    pub ns: &'static str,
59    pub action: &'static str,
60    pub tier_hint: TierHint,
61    pub summary: &'static str,
62}
63
64impl VerbSpec {
65    /// Dotted-string rendering for log/audit lines (`memory.store`).
66    pub fn dotted(&self) -> String {
67        format!("{}.{}", self.ns, self.action)
68    }
69}
70
71/// The kernel's full verb vocabulary as of v0.4.0.
72///
73/// Adding a new verb requires:
74/// 1. Appending an entry here with a non-empty `summary`.
75/// 2. Mapping the producing [`crate::Intent`] variant in
76///    `signal::authz::intent_to_auth` (or extending `tier_for_verb`
77///    if the verb arrives via `Intent::ToolCall`).
78/// 3. Routing the variant to a handler in `signal::pipeline`.
79///
80/// The unique-pair invariant is enforced by [`Self::check_invariants`]
81/// at test time, not by a HashMap — the list is small enough that
82/// linear scan during boot is cheaper than a static-init map.
83pub const VERBS: &[VerbSpec] = &[
84    // ── Memory ────────────────────────────────────────────────────────
85    VerbSpec {
86        ns: "memory",
87        action: "store",
88        tier_hint: TierHint::Write,
89        summary: "Store a subject-predicate-object fact in semantic memory.",
90    },
91    VerbSpec {
92        ns: "memory",
93        action: "delete",
94        tier_hint: TierHint::Destructive,
95        summary: "Delete matching semantic facts (irreversible).",
96    },
97    VerbSpec {
98        ns: "memory",
99        action: "import",
100        tier_hint: TierHint::Write,
101        summary: "Bulk-import memory entries from a filesystem path.",
102    },
103    VerbSpec {
104        ns: "memory",
105        action: "export",
106        tier_hint: TierHint::Write,
107        summary: "Bulk-export memory entries to a filesystem path.",
108    },
109    // ── Shell / Terminal ─────────────────────────────────────────────
110    VerbSpec {
111        ns: "shell",
112        action: "exec",
113        tier_hint: TierHint::Execute,
114        summary: "Run a one-shot sandboxed command via the action dispatcher.",
115    },
116    VerbSpec {
117        ns: "terminal",
118        action: "open",
119        tier_hint: TierHint::Execute,
120        summary: "Open a persistent PTY session through the terminal bridge.",
121    },
122    VerbSpec {
123        ns: "terminal",
124        action: "close",
125        tier_hint: TierHint::Write,
126        summary: "Close a previously-opened PTY session.",
127    },
128    // ── Network egress ────────────────────────────────────────────────
129    VerbSpec {
130        ns: "net",
131        action: "http",
132        tier_hint: TierHint::External,
133        summary: "Perform an outbound HTTP request (web search, fetch).",
134    },
135    VerbSpec {
136        ns: "net",
137        action: "check",
138        tier_hint: TierHint::External,
139        summary: "Check reachability of a host: DNS resolution + timed TCP connect.",
140    },
141    VerbSpec {
142        ns: "net",
143        action: "trace",
144        tier_hint: TierHint::External,
145        summary: "Trace the network route (hops) to a host.",
146    },
147    VerbSpec {
148        ns: "net",
149        action: "cert",
150        tier_hint: TierHint::External,
151        summary: "Inspect the TLS certificate chain a host presents (validity, SANs).",
152    },
153    VerbSpec {
154        ns: "notify",
155        action: "send",
156        tier_hint: TierHint::External,
157        summary: "Send a message through the channel dispatcher.",
158    },
159    // ── Scheduling / tasks ───────────────────────────────────────────
160    VerbSpec {
161        // Gates up-front (fires later, possibly unattended) but reversible via
162        // schedule.cancel — External, not Destructive/Write. Must stay in sync
163        // with `signal::authz::tier_for_verb` and `pipeline/lifecycle.rs`.
164        ns: "schedule",
165        action: "create",
166        tier_hint: TierHint::External,
167        summary: "Persist a scheduled-intent row.",
168    },
169    VerbSpec {
170        ns: "schedule",
171        action: "cancel",
172        tier_hint: TierHint::Write,
173        summary: "Cancel a scheduled-intent row.",
174    },
175    VerbSpec {
176        ns: "task",
177        action: "decompose",
178        tier_hint: TierHint::Execute,
179        summary: "Decompose a request into an orchestrator task plan.",
180    },
181    VerbSpec {
182        ns: "task",
183        action: "cancel",
184        tier_hint: TierHint::Write,
185        summary: "Abort a running orchestrator task.",
186    },
187    VerbSpec {
188        ns: "signal",
189        action: "cancel",
190        tier_hint: TierHint::Write,
191        summary: "Cancel an in-flight signal pipeline run.",
192    },
193    // ── Agents ────────────────────────────────────────────────────────
194    VerbSpec {
195        ns: "agent",
196        action: "delegate",
197        tier_hint: TierHint::Execute,
198        summary: "Hand a subtask to a discovered subprocess agent.",
199    },
200    // ── Approvals / audit ─────────────────────────────────────────────
201    VerbSpec {
202        ns: "approval",
203        action: "respond",
204        tier_hint: TierHint::Write,
205        summary: "Approve or reject a pending confirmation by nonce.",
206    },
207    VerbSpec {
208        ns: "approval",
209        action: "revoke",
210        tier_hint: TierHint::Write,
211        summary: "Revoke a standing approval grant.",
212    },
213    VerbSpec {
214        ns: "audit",
215        action: "prune",
216        tier_hint: TierHint::Destructive,
217        summary: "Prune audit-log rows older than the given duration.",
218    },
219    VerbSpec {
220        ns: "security",
221        action: "audit",
222        tier_hint: TierHint::Read,
223        summary: "Audit the security posture of the current configuration.",
224    },
225    // ── System inspection (logs + baselines) ──────────────────────────
226    VerbSpec {
227        ns: "logs",
228        action: "analyze",
229        tier_hint: TierHint::Read,
230        summary: "Analyse recent logs for recurring error/warning patterns.",
231    },
232    VerbSpec {
233        ns: "baseline",
234        action: "capture",
235        tier_hint: TierHint::Write,
236        summary: "Capture a system-baseline snapshot for later drift detection.",
237    },
238    VerbSpec {
239        ns: "baseline",
240        action: "diff",
241        tier_hint: TierHint::Read,
242        summary: "Diff a baseline against another snapshot or the live state.",
243    },
244    VerbSpec {
245        ns: "baseline",
246        action: "list",
247        tier_hint: TierHint::Read,
248        summary: "List stored baseline snapshots.",
249    },
250    // ── Channel / preferences ─────────────────────────────────────────
251    VerbSpec {
252        ns: "channel",
253        action: "configure",
254        tier_hint: TierHint::Write,
255        summary: "Adjust a channel-preference weight or pin.",
256    },
257    VerbSpec {
258        ns: "proactivity",
259        action: "configure",
260        tier_hint: TierHint::Write,
261        summary: "Toggle or rate-limit the proactivity engine.",
262    },
263    // ── Filesystem (path-scoped) ──────────────────────────────────────
264    VerbSpec {
265        ns: "fs",
266        action: "read",
267        tier_hint: TierHint::Read,
268        summary: "Read a filesystem path inside security.allowed_paths.",
269    },
270    // ── MCP host ──────────────────────────────────────────────────────
271    VerbSpec {
272        ns: "mcp",
273        action: "mount",
274        tier_hint: TierHint::External,
275        summary: "Mount an external MCP server (stdio or HTTP transport).",
276    },
277    VerbSpec {
278        ns: "mcp",
279        action: "unmount",
280        tier_hint: TierHint::Write,
281        summary: "Unmount a previously-mounted MCP server.",
282    },
283];
284
285/// Look up a verb by namespace + action. Returns `None` for verbs not
286/// in the kernel vocabulary — callers should treat this as a hard
287/// invariant violation for typed Intent variants, and as expected for
288/// dynamically-discovered MCP tool verbs (`mcp.{tool_name}` is *not*
289/// in [`VERBS`]; only the bare `mcp.mount` / `mcp.unmount` host-control
290/// verbs are).
291pub fn lookup(ns: &str, action: &str) -> Option<&'static VerbSpec> {
292    VERBS.iter().find(|v| v.ns == ns && v.action == action)
293}
294
295/// Every distinct namespace currently in the vocabulary, deduped.
296/// Useful for telemetry and startup banners.
297pub fn namespaces() -> Vec<&'static str> {
298    let mut out: Vec<&'static str> = VERBS.iter().map(|v| v.ns).collect();
299    out.sort_unstable();
300    out.dedup();
301    out
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use std::collections::HashSet;
308
309    /// Every `(ns, action)` pair in [`VERBS`] must be unique. Linear
310    /// scan during lookup is fine; silent duplicates would mask routing
311    /// bugs.
312    #[test]
313    fn verb_pairs_are_unique() {
314        let mut seen: HashSet<(&str, &str)> = HashSet::new();
315        for v in VERBS {
316            assert!(
317                seen.insert((v.ns, v.action)),
318                "duplicate verb pair: {}.{}",
319                v.ns,
320                v.action
321            );
322        }
323    }
324
325    /// Every entry must carry a non-empty human summary; the summary
326    /// is what `brain doctor` / observability surfaces show.
327    #[test]
328    fn every_verb_has_a_summary() {
329        for v in VERBS {
330            assert!(
331                !v.summary.trim().is_empty(),
332                "{}.{} has empty summary",
333                v.ns,
334                v.action
335            );
336        }
337    }
338
339    #[test]
340    fn lookup_finds_known_pair() {
341        let v = lookup("memory", "store").expect("memory.store registered");
342        assert_eq!(v.tier_hint, TierHint::Write);
343        assert_eq!(v.dotted(), "memory.store");
344    }
345
346    #[test]
347    fn tier_hint_string_form_is_stable() {
348        assert_eq!(TierHint::Read.as_str(), "read");
349        assert_eq!(TierHint::Destructive.as_str(), "destructive");
350    }
351
352    #[test]
353    fn lookup_returns_none_for_unknown_pair() {
354        assert!(lookup("not", "real").is_none());
355        // mcp.{tool_name} is dynamic — only mount/unmount are static.
356        assert!(lookup("mcp", "any_tool_name").is_none());
357    }
358
359    #[test]
360    fn namespaces_are_deduped_and_sorted() {
361        let ns = namespaces();
362        let mut sorted = ns.clone();
363        sorted.sort_unstable();
364        assert_eq!(ns, sorted);
365        let mut deduped = ns.clone();
366        deduped.dedup();
367        assert_eq!(ns, deduped);
368        assert!(ns.contains(&"memory"));
369        assert!(ns.contains(&"mcp"));
370    }
371
372    #[test]
373    fn vocabulary_contains_expected_size() {
374        // Spot-check: 23 verbs through v0.4.0, +3 net diagnostics
375        // (net.check/trace/cert), +1 security.audit, +1 logs.analyze,
376        // +3 baseline.* (capture/diff/list) = 31. Bumping this intentionally
377        // is fine — the test exists so silent additions (or removals)
378        // surface in review.
379        assert_eq!(VERBS.len(), 31);
380    }
381}