Add M1 plan, given tests and fixtures, AGENTS.md and implementer log
Seven task files for the implementing model under docs/plans/M1/, with the test files, byte-exact fixtures, Makefile, deny.toml and gate-script self-test they copy into place. All of it was verified against a private reference implementation: the gate passes after every task in order. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
# M1 task 07: `Decision` in `brokerd`
|
||||
|
||||
**Branch:** `m1` (run `git switch m1`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `Add Decision, decide and the runner stub to brokerd`
|
||||
|
||||
## Goal
|
||||
|
||||
Put the project's central safety rule into types: a tool can only run if policy produced a
|
||||
`Decision`, and nothing outside `brokerd`'s policy module can make one. This task adds the type,
|
||||
a policy function that denies everything, and a runner stub. Grants are matched in M3, not here.
|
||||
|
||||
## Context
|
||||
|
||||
From the design brief: "Authority is encoded in types: a tool cannot execute without a `Decision`
|
||||
value that only `brokerd`'s policy module can construct. `Decision` is defined in `brokerd`, has a
|
||||
private field and does not implement `Deserialize`, so no other crate or wire message can produce
|
||||
one." Also: "No matching grant means deny."
|
||||
|
||||
The proof is two `compile_fail` doctests: examples that must **fail** to compile. A third doctest
|
||||
uses the same setup through the allowed path and must compile and pass. That third one shows the
|
||||
first two fail because of `Decision`'s privacy and not because of a mistake in the example.
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `crates/brokerd/src/policy.rs`, `crates/brokerd/src/runner.rs`
|
||||
- Modify: `crates/brokerd/src/lib.rs`, `docs/implementer-log.md`
|
||||
|
||||
## Interfaces
|
||||
|
||||
Consumes from `proto`: `ToolRequest`, `ToolResponse`, `DenyReason`, `SessionId`, `CallId`.
|
||||
|
||||
Produces:
|
||||
|
||||
```rust
|
||||
// crates/brokerd/src/policy.rs
|
||||
#[derive(Debug)] // and nothing else: not Clone, not Serialize, not Deserialize
|
||||
pub struct Decision { request: ToolRequest, grant: String } // both fields private
|
||||
|
||||
impl Decision {
|
||||
fn new(request: ToolRequest, grant: String) -> Self; // private: no `pub`
|
||||
pub fn request(&self) -> &ToolRequest;
|
||||
pub fn grant(&self) -> &str;
|
||||
}
|
||||
|
||||
/// Until M3 there are no grants, so every request is denied with DenyReason::NoGrant.
|
||||
pub fn decide(request: ToolRequest) -> Result<Decision, DenyReason>;
|
||||
|
||||
// crates/brokerd/src/runner.rs
|
||||
/// Takes the Decision by value, so one decision cannot run a tool twice.
|
||||
pub fn run(decision: Decision) -> ToolResponse; // returns Failed { message: "no tool runner until M3" }
|
||||
```
|
||||
|
||||
`Decision::new` is not called by non-test code until M3, so the compiler reports it as dead code.
|
||||
This is the one place in M1 where a lint attribute is allowed. Put exactly this on `new`:
|
||||
|
||||
```rust
|
||||
#[cfg_attr(not(test), expect(dead_code, reason = "grant matching arrives in M3"))]
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Write the module documentation first.** `crates/brokerd/src/policy.rs` starts with
|
||||
exactly this doc comment. It contains the three doctests.
|
||||
|
||||
````rust
|
||||
//! 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);
|
||||
//! ```
|
||||
````
|
||||
|
||||
- [ ] **2. Write the unit tests** at the end of `policy.rs`, exactly:
|
||||
|
||||
```rust
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **3. Register the modules and see the tests fail.** Add to `crates/brokerd/src/lib.rs`, below
|
||||
the doc comment:
|
||||
|
||||
```rust
|
||||
pub mod policy;
|
||||
pub mod runner;
|
||||
```
|
||||
|
||||
Create `runner.rs` as an empty file for now. Run `cargo test -p brokerd`. Expected: it does not
|
||||
compile, because `Decision`, `decide`, `ToolRequest` and `DenyReason` are not defined in `policy.rs`.
|
||||
|
||||
- [ ] **4. Write `Decision`, `decide` and `run`** as described under Interfaces.
|
||||
|
||||
- [ ] **5. See the tests pass.** `cargo test -p brokerd`. Expected: 2 unit tests pass, and under
|
||||
`Doc-tests brokerd` 3 pass, two of them marked `compile fail`.
|
||||
|
||||
- [ ] **6. Check that the doctests guard what they claim.** Temporarily change `fn new` to
|
||||
`pub fn new` and run `cargo test -p brokerd --doc`. Expected: one failure saying
|
||||
`Test compiled successfully, but it's marked compile_fail`. Change it back and run the command
|
||||
again. Expected: 3 pass. Write in your log row that you did this check and what you saw.
|
||||
|
||||
- [ ] **7. Run the gate.** `make gate`. Expected last line: `gate: ok`.
|
||||
|
||||
- [ ] **8. Log and commit.**
|
||||
|
||||
```sh
|
||||
git add crates/brokerd docs/implementer-log.md
|
||||
git commit
|
||||
```
|
||||
|
||||
## Done when
|
||||
|
||||
- `cargo test -p brokerd` passes 2 unit tests and 3 doctests.
|
||||
- `Decision` derives only `Debug`, and `new` has no `pub`.
|
||||
- `make gate` prints `gate: ok`.
|
||||
- `git log --oneline master..m1` shows one commit per task.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- The `expect(dead_code)` attribute itself causes a warning or an error.
|
||||
- A `compile_fail` doctest passes in step 6 even with `pub fn new`.
|
||||
Reference in New Issue
Block a user