# 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>; // what grants::load returns pub trait AuditSink: Send { fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result; } 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 }, Refused { by: Option, reason: Option }, 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, log: Box */ } // struct Inner { audit: Box, state: StateStore, stopped: bool } impl Ledger { pub fn new(audit: Box, state: StateStore, log: Box) -> 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`.