From 8c1852ec47a97601303fe23f71c3014ae724dfb0 Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Thu, 17 Sep 2026 09:05:48 -0700 Subject: [PATCH] Add Decision, decide and the runner stub to brokerd Implemented-By: Laguna S 2.1 (OpenCode) --- crates/brokerd/src/lib.rs | 3 ++ crates/brokerd/src/policy.rs | 95 ++++++++++++++++++++++++++++++++++++ crates/brokerd/src/runner.rs | 12 +++++ docs/implementer-log.md | 1 + 4 files changed, 111 insertions(+) create mode 100644 crates/brokerd/src/policy.rs create mode 100644 crates/brokerd/src/runner.rs diff --git a/crates/brokerd/src/lib.rs b/crates/brokerd/src/lib.rs index 330d8ce..8f4c101 100644 --- a/crates/brokerd/src/lib.rs +++ b/crates/brokerd/src/lib.rs @@ -1 +1,4 @@ //! The broker: the only role that holds authority. + +pub mod policy; +pub mod runner; diff --git a/crates/brokerd/src/policy.rs b/crates/brokerd/src/policy.rs new file mode 100644 index 0000000..f8464cd --- /dev/null +++ b/crates/brokerd/src/policy.rs @@ -0,0 +1,95 @@ +//! Policy decisions. `Decision` can only be constructed in this module. +//! +//! Code outside this module cannot build a `Decision` with a struct literal, because its fields +//! are private: +//! +//! ```compile_fail +//! let request = proto::ToolRequest { +//! session: proto::SessionId::new("s1").unwrap(), +//! call: proto::CallId(1), +//! tool: "read_file".to_string(), +//! arguments: "{}".to_string(), +//! }; +//! let _ = brokerd::policy::Decision { request, grant: "g".to_string() }; +//! ``` +//! +//! Nor with the constructor, because it is private to this module: +//! +//! ```compile_fail +//! let request = proto::ToolRequest { +//! session: proto::SessionId::new("s1").unwrap(), +//! call: proto::CallId(1), +//! tool: "read_file".to_string(), +//! arguments: "{}".to_string(), +//! }; +//! let _ = brokerd::policy::Decision::new(request, "g".to_string()); +//! ``` +//! +//! The same setup compiles when it goes through `decide`, which proves the two examples above +//! fail because of `Decision` and not because of a mistake in the setup: +//! +//! ``` +//! let request = proto::ToolRequest { +//! session: proto::SessionId::new("s1").unwrap(), +//! call: proto::CallId(1), +//! tool: "read_file".to_string(), +//! arguments: "{}".to_string(), +//! }; +//! assert_eq!(brokerd::policy::decide(request).unwrap_err(), proto::DenyReason::NoGrant); +//! ``` + +use proto::{DenyReason, ToolRequest}; + +#[derive(Debug)] +pub struct Decision { + request: ToolRequest, + grant: String, +} + +impl Decision { + #[cfg_attr(not(test), expect(dead_code, reason = "grant matching arrives in M3"))] + fn new(request: ToolRequest, grant: String) -> Self { + Decision { request, grant } + } + + pub fn request(&self) -> &ToolRequest { + &self.request + } + + pub fn grant(&self) -> &str { + &self.grant + } +} + +/// Until M3 there are no grants, so every request is denied with DenyReason::NoGrant. +pub fn decide(request: ToolRequest) -> Result { + let _ = request; + Err(DenyReason::NoGrant) +} + +#[cfg(test)] +mod tests { + use super::*; + use proto::{CallId, SessionId}; + + fn request() -> ToolRequest { + ToolRequest { + session: SessionId::new("s1").unwrap(), + call: CallId(1), + tool: "read_file".to_string(), + arguments: "{}".to_string(), + } + } + + #[test] + fn no_grants_means_deny() { + assert_eq!(decide(request()).unwrap_err(), DenyReason::NoGrant); + } + + #[test] + fn decision_exposes_request_and_grant() { + let d = Decision::new(request(), "g1".to_string()); + assert_eq!(d.request().tool, "read_file"); + assert_eq!(d.grant(), "g1"); + } +} diff --git a/crates/brokerd/src/runner.rs b/crates/brokerd/src/runner.rs new file mode 100644 index 0000000..c12c956 --- /dev/null +++ b/crates/brokerd/src/runner.rs @@ -0,0 +1,12 @@ +//! Tool runner stub. Real tool execution arrives in M3. + +use crate::policy::Decision; +use proto::ToolResponse; + +/// Takes the Decision by value, so one decision cannot run a tool twice. +pub fn run(decision: Decision) -> ToolResponse { + let _ = decision; + ToolResponse::Failed { + message: "no tool runner until M3".to_string(), + } +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 38b5377..98b8d96 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -11,5 +11,6 @@ reviewer adds findings under "Reviews" once per milestone. | M1/04-proto-frame | 2026-09-17 | done | 2 | fail | none | Added crates/proto/src/frame.rs (MAX_FRAME, FrameError, write_frame, read_frame) re-exported from lib.rs; 13 fixture tests pass. Two compile fixes: mapped read_bytes io::Error to FrameError::Io and annotated serde_json::from_slice::; cargo-fmt reordered the lib.rs re-export lines; `make gate` prints `gate: ok`. | | M1/05-proto-grant | 2026-09-17 | done | 2 | fail | none | Added crates/proto/src/grant.rs (Mode, Constraints with Default, Grant with serde defaults + deny_unknown_fields) re-exported from lib.rs and toml 1.1.6 as a proto dev-dependency (workspace dep + dependencies.md row); 4 fixture tests pass. cargo-fmt reordered the lib.rs re-exports before the gate. | | M1/06-proto-records | 2026-09-17 | done | 2 | fail | none | Added crates/proto/src/audit.rs (DecisionRecord, AuditRecord) and crates/proto/src/log.rs (ToolCall, LogRecord) re-exported from lib.rs; 3 fixture tests pass, 40 total across the five proto test files. cargo-fmt reordered the lib.rs re-exports before the gate. | +| M1/07-brokerd-decision | 2026-09-17 | done | 1 | pass | none | Added Decision (Debug only, private fields), decide (Err(NoGrant) until M3) and the run stub (ToolResponse::Failed) in crates/brokerd; Decision::new carries expect(dead_code). 2 unit + 3 doctests (2 compile_fail) pass; verified the compile_fail guards by temporarily making new pub. `make gate` prints `gate: ok`. | ## Reviews