Files
boxmaker/docs/plans/M3a/12-brokerd-ledger.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

154 lines
8.4 KiB
Markdown

# M3a task 12: the ledger
**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `Add the ledger: the audit writer and session state behind one lock`
## Goal
`brokerd::ledger` holds the audit writer and every session's state behind **one** `Mutex`, and
has the three steps that hold it, each as a whole: decide and record; re-decide and record an
approval; raise the state and record a result. Without the one lock, two results for one session
could each read `private` and the second write would put the taint back down. After **any**
failed append, or a panic while the lock was held, the ledger refuses every later step: part of a
line may be on disk, and only the next start's check puts that right.
## Files
- Copy: `crates/brokerd/tests/ledger.rs`, `ledger_answer.rs`, and `support/rig.rs`,
`support/sink.rs` (under `crates/brokerd/tests/support/`)
- Create: `crates/brokerd/src/ledger.rs`
- Modify: `crates/brokerd/src/lib.rs` (add `pub mod ledger;`), `docs/implementer-log.md`
## Interfaces
```rust
pub const NOT_RECORDED: &str = "the result could not be recorded";
pub const POISONED: &str = "brokerd: a thread panicked while holding the ledger; every call is \
denied until brokerd is restarted\nsee docs/runbook.md#audit-unavailable";
pub const STOPPED: &str = "brokerd: an earlier audit write failed; every call is denied until \
brokerd is restarted\nsee docs/runbook.md#audit-unavailable";
pub type Grants = Result<GrantSet, Vec<proto::GrantProblem>>; // what grants::load returns
pub trait AuditSink: Send {
fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result<u64, AuditError>;
}
impl AuditSink for crate::audit::Writer { /* Writer::append(self, time, event) */ }
#[derive(Debug)]
pub enum Decided {
Allowed { decision: Decision, seq: u64 },
Ask { ask: Ask, seq: u64, state: SessionState },
Denied(DenyReason),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Answer {
Approved { by: Option<String> },
Refused { by: Option<String>, reason: Option<String> },
Expired,
}
#[derive(Debug)]
pub struct Answered { pub verdict: Verdict, pub outcome: DecisionRecord }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Call { pub session: SessionId, pub call: CallId, pub decision: u64, pub label: Label }
impl Call { pub fn of(decision: &Decision, seq: u64) -> Call; } // from request(), label()
pub struct Ledger { /* inner: Mutex<Inner>, log: Box<dyn Fn(&str) + Send + Sync> */ }
// struct Inner { audit: Box<dyn AuditSink>, state: StateStore, stopped: bool }
impl Ledger {
pub fn new(audit: Box<dyn AuditSink>, state: StateStore, log: Box<dyn Fn(&str) + Send + Sync>) -> Ledger;
pub fn decide(&self, request: ToolRequest, grants: &Grants, now: Timestamp) -> Decided;
pub fn answer(&self, ask: Ask, decision: u64, answer: Answer, grants: &Grants, now: Timestamp) -> Answered;
pub fn finish(&self, call: &Call, response: ToolResponse, now: Timestamp) -> ToolResponse;
}
```
`POISONED` and `STOPPED` are single string literals continued with `\` at the line end; the
pointer is written out in full, as `scripts/check-runbook.sh` requires. `Verdict` is task 11's.
## Shared rules (every step)
- **Getting the lock.** `self.inner.lock()`: `Err` (poisoned) → call `log(POISONED)`, step fails;
`Ok` with `stopped` true → `log(STOPPED)`, step fails. "Step fails" is: `decide` →
`Denied(AuditUnavailable)`; `answer` → verdict `Denied(AuditUnavailable)` and outcome
`Denied { reason: AuditUnavailable }`; `finish` → `Failed { NOT_RECORDED }`. Never
`into_inner` this lock.
- **Appending.** Through `inner.audit.append(now, event)`. On `Err(e)`: set `stopped = true`,
`log(&format!("brokerd: {e}"))`, and the step fails as above. Nothing is appended after that.
- **Reading state.** `inner.state.read(&session)`. On `Err(e)`: `log(&format!("brokerd: {e}"))`.
The state recorded is then `taint: Secret, untrusted: true` ("unknown" is the most sensitive).
- `now` is the record's time. Every record's `grant_sha256` is set exactly when `grant` is.
## `decide`: every exit
Copy `session`, `call`, `tool`, `arguments` out of the request first; `policy::decide` consumes it.
1. Lock fails → `Denied(AuditUnavailable)`, nothing written.
2. Read the state (keep the `Result`). Outcome, **first that applies**: `grants` is `Err` →
denied `GrantsInvalid`; the state is unreadable → denied `StateUnreadable`; otherwise
`policy::decide(request, set, state, now)`.
3. Write `AuditEvent::Decision { session, call, tool, arguments, outcome, grant, grant_sha256,
taint, untrusted }`: allowed → `Allowed {}` with the decision's `grant()`/`grant_sha256()`;
ask → `Ask {}` with the ask's; denied → `Denied { reason }` with the `Denial`'s `grant` and
`grant_sha256` (set only for `denied_by_grant`). `taint`/`untrusted`: the state read.
4. Append fails → `Denied(AuditUnavailable)`. Otherwise return `Allowed { decision, seq }`,
`Ask { ask, seq, state }` or `Denied(reason)`.
## `answer`: every exit
1. Lock fails → step fails.
2. Read the state of `ask.request().session`.
3. The re-decision: `Approved` → `grants` `Err` → `GrantsInvalid`; state unreadable →
`StateUnreadable`; else `policy::redecide(ask, set, state, now)`. `Refused` → denied
`ApprovalRefused`; `Expired` → denied `ApprovalExpired`.
4. For a `Decision` from `redecide`, find the mode of the grant it names:
`set.grants().iter().find(|g| g.id == decision.grant())`; `Mode::Ask` → outcome `Ask {}`,
anything else → `Allowed {}`. The grant fields are the decision's. A denial → `Denied { reason
}` with the `Denial`'s grant fields (none for refused and expired).
5. Write `AuditEvent::Approval { session, call, decision, answer, by, post: None, reason,
outcome, grant, grant_sha256, taint, untrusted }`: `answer` is `Approved`/`Refused`/`Expired`;
`by` from the `Answer` (`None` for `Expired`); `reason` only for `Refused`; the state read.
6. Append fails → step fails. Otherwise `Answered { verdict, outcome }`, verdict
`Run(Box::new(decision))` or `Denied(reason)`.
## `finish`: every exit
1. Lock fails → `Failed { NOT_RECORDED }`.
2. Read the state of `call.session`.
3. `response` is `Result { content, truncated, .. }`: state unreadable → `Failed { NOT_RECORDED
}`, nothing written. Else `inner.state.raise(&call.session, current, call.label)`; `Err(e)` →
`log("brokerd: {e}")`, `Failed { NOT_RECORDED }`, nothing written. `status: Result`,
`taint_after` is the raised taint, hashed text is `content`.
4. `Failed { message }`: no state change. `status: Failed`, `truncated: false`, `taint_after` is
the taint read (or `Secret` if unreadable), hashed text is `message`.
5. `Denied` or `PendingApproval` (`runner::run` never returns them) → `Failed { NOT_RECORDED }`.
6. `proto::sha256(text.as_bytes())` is `Err` → `Failed { NOT_RECORDED }`.
7. Write `AuditEvent::Result { session, call, decision: call.decision, status, class:
call.label.class, untrusted: call.label.untrusted, truncated, bytes, sha256, taint_after }`,
`bytes` = `u64::try_from(text.len())` (fall back to `u64::MAX`). Append fails →
`Failed { NOT_RECORDED }`. **Only now** return `response` unchanged. The content never goes
back unless the raised taint and the record are both on disk.
## Steps
- [ ] **1. Copy.** `git switch m3a`, then
`cp docs/plans/M3a/files/crates/brokerd/tests/{ledger,ledger_answer}.rs crates/brokerd/tests/`
and `cp docs/plans/M3a/files/crates/brokerd/tests/support/{rig,sink}.rs crates/brokerd/tests/support/`
- [ ] **2. See the tests fail.** `cargo test -p brokerd --test ledger`. Expected: no compile.
- [ ] **3. Write `ledger.rs`**, add `pub mod ledger;`. Run `cargo fmt --all`.
- [ ] **4. See the tests pass.** `cargo test -p brokerd --test ledger --test ledger_answer`.
Expected: `11 passed` and `9 passed`. One test panics on purpose and prints a panic message.
- [ ] **5. Walk the exits.** Point at the line for each numbered exit above, and check that every
`Err` from `append` goes through the one place that sets `stopped`.
- [ ] **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 two suites report 11 and 9 passed; step 5 is in the log's Notes; `make gate` prints
`gate: ok`.
## Stop and report if
- A test expects content to come back when the state or the record could not be written.
- You need to recover the poisoned ledger lock with `into_inner`.