Files
boxmaker/docs/plans/M3a/13-brokerd-broker.md
T
kyleandClaude Opus 5 e3f37da232 Hand over the M3a plan: 22 tasks, their files, and the check record
Task files, the files they copy in (byte-identical to the reference on
m3a-ref), each area's check record, and a README with the per-task
table of what each check exposed. The handoff note is done with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 23:45:43 -07:00

130 lines
7.1 KiB
Markdown

# M3a task 13: one tool request on `broker.sock`
**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `Handle a tool request from decision to answer`
## Goal
`brokerd::broker::handle` serves one `broker.sock` connection: `loopd` sends one `tool_request`;
`brokerd` decides, records, runs if allowed, records the result, and answers with one final
`tool_response`. For an `ask` call it first sends one `pending_approval` frame (`final: false`)
and waits for whoever takes the table entry to send the verdict. Every frame carries the
request's `id`. The tests answer entries by hand, as task 14's `admin` will.
## Files
- Copy: `crates/brokerd/tests/broker.rs`, `broker_pending.rs`, `broker_sequence.rs`, and
`crates/brokerd/tests/support/client.rs`
- Create: `crates/brokerd/src/broker.rs`
- Modify: `crates/brokerd/src/lib.rs` (add `pub mod broker;`), `docs/implementer-log.md`
## Interfaces
```rust
pub const GONE: &str = "the requester went away";
pub type Log = Box<dyn Fn(&str) + Send + Sync>;
pub struct Broker { /* cfg: Config, ledger: Ledger, table: Table, runtime: Box<dyn Runtime>,
log: Log, printed: Mutex<Option<Vec<GrantProblem>>> */ }
impl Broker {
pub fn new(cfg: Config, ledger: Ledger, runtime: Box<dyn Runtime>, log: Log) -> Broker; // Table::new()
pub fn cfg(&self) -> &Config;
pub fn ledger(&self) -> &Ledger;
pub fn table(&self) -> &Table;
pub fn log(&self, line: &str);
pub fn grants(&self) -> Grants;
}
pub fn kind(msg: &Message) -> &'static str; // snake_case wire name, all 14 kinds, no `_`
pub fn send(stream: &mut UnixStream, id: u64, last: bool, msg: Message) -> bool; // write_frame(..).is_ok()
pub fn read_request(stream: &mut UnixStream) -> Option<Envelope>;
pub fn forbid(broker: &Broker, stream: &mut UnixStream, id: u64, msg: &Message, socket: &str);
pub fn alive(stream: &UnixStream) -> bool;
pub fn handle(stream: UnixStream, broker: &Broker);
```
Task 14 uses `send`, `read_request`, `forbid` and `grants` too; they are `pub` for that.
## The helpers
- `grants()`: `grants::load(&self.cfg.paths.grants)`. `Ok` → set `printed` to `None`. `Err(p)`
and `printed` is not `Some(p)``log(grants::render(&p).trim_end())`, then `printed =
Some(p.clone())`. So each distinct set of problems is printed once. Recover a poisoned `printed`
lock with `into_inner`. Return what `load` returned.
- `read_request`: `read_frame`. `Ok``Some`. `Closed``None`. Any other error → send an
error frame (`id` 0, `final: true`) with code `BadVersion` for `FrameError::BadVersion(_)`,
`BadMessage` for `FrameError::Json(_)`, `BadFrame` otherwise, detail the error's text; `None`.
- `forbid`: `log(&format!("brokerd: refused the message kind {kind} on {socket}\nsee docs/runbook.md#socket-forbidden"))`,
then send `Error { code: Forbidden, detail: format!("{kind} is not accepted on {socket}") }`,
`final: true`, the request's `id`.
- `alive`: `set_read_timeout(Some(Duration::from_millis(10)))` (`Err``false`; a zero duration
is an error in std). Then `read` one byte through `&UnixStream` (`Read` is implemented for it):
`Ok(0)``false` (gone); `Ok(_)``false` (bytes break the protocol); `Err` with kind
`WouldBlock` or `TimedOut``true`; any other `Err``false`. `loopd` never half-closes and
sends nothing more, so waiting means it is there.
## `handle`: every exit
1. `read_request` is `None` → return.
2. The message is not `Message::ToolRequest``forbid(.., "broker.sock")`, return. Nothing is
written to the audit log.
3. `now = Timestamp::now()`, `grants = broker.grants()`, `broker.ledger.decide(request, &grants,
now)`:
- `Denied(reason)` → answer `ToolResponse::Denied { reason }`.
- `Allowed { decision, seq }` → answer `run(decision, seq)` (below).
- `Ask { .. }` → the pending path (below). `None` from it → return, sending nothing more.
4. Send the answer, `final: true`, the request's `id`. A failed send is ignored: the records are
already written.
`run(decision, seq)`: `let call = Call::of(&decision, seq)`, then `runner::run(decision,
runtime)`, then return `ledger.finish(&call, response, Timestamp::now())`.
## The pending path: every exit
1. `expires` = `Timestamp::from_unix_millis(now.unix_millis().saturating_add(ttl_ms))`, or
`Timestamp::MAX` if that is `Err`; then the earlier of that and `ask.expires()` if the grant
has one.
2. `info = PendingApproval { approval: seq, session, call, tool: ask.request().tool, arguments:
ask.args().canonical_json(), grant: ask.grant(), taint: state.taint, created: now, expires }`,
then `let verdict = table.insert(info, ask)`.
3. Send `PendingApproval { approval: seq, expires }` with **`final: false`**. If the send fails
and `table.take(seq)` is `Some` → return `None` (nothing written: the log shows it abandoned).
If the send fails and the entry is already gone, someone is answering it: go on to 4.
4. Wait: loop on `verdict.recv_timeout(Duration::from_secs(1))`:
- `Ok(v)` → go to 5 with `v`.
- `Err(Timeout)`: `alive(stream)` → loop. Gone and `table.take(seq)` is `Some` → return `None`.
Gone and the entry is already taken → `verdict.recv()`: `Ok(v)` → 5; `Err` →
`Denied(AuditUnavailable)`.
- `Err(Disconnected)` (the taker dropped it unanswered) → `Denied(AuditUnavailable)`.
5. `Denied(reason)` → answer `Denied { reason }`. `Run(decision)` → unbox it; **one more look**:
if `!alive(stream)`, `ledger.finish(&Call::of(&decision, seq), Failed { GONE }, now)` and
return `None` without running. Otherwise answer `run(decision, seq)`.
Verified in the std docs: `Receiver::recv_timeout(Duration) -> Result<T, RecvTimeoutError>` with
variants `Timeout` and `Disconnected`; `UnixStream::set_read_timeout(Option<Duration>)`.
## Steps
- [ ] **1. Copy.** `git switch m3a`, then
`cp docs/plans/M3a/files/crates/brokerd/tests/{broker,broker_pending,broker_sequence}.rs crates/brokerd/tests/`
and `cp docs/plans/M3a/files/crates/brokerd/tests/support/client.rs crates/brokerd/tests/support/`
- [ ] **2. See the tests fail.** `cargo test -p brokerd --test broker`. Expected: no compile.
- [ ] **3. Write `broker.rs`**, add `pub mod broker;`. Run `cargo fmt --all`.
- [ ] **4. See the tests pass.**
`cargo test -p brokerd --test broker --test broker_pending --test broker_sequence`, five times.
Expected: `9 passed`, `5 passed`, `2 passed` every time. `broker_pending` takes about a second:
it waits for the one-second look.
- [ ] **5. Walk the exits.** Point at the line of each numbered exit in both lists above.
- [ ] **6. Run the gate.** `make gate`. Expected last line: `gate: ok`.
- [ ] **7. Log and commit.** `git add crates/brokerd docs/implementer-log.md && git commit`
## Done when
- The three suites pass five runs in a row with the counts in step 4; step 5 is in the log's
Notes; `make gate` prints `gate: ok`.
## Stop and report if
- A test expects a call to run without a `Decision` record allowing it or an `Approval` record
whose re-decision allows it.
- A test needs `handle` to write an audit record itself: every record goes through the ledger.