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

8.4 KiB

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

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: decideDenied(AuditUnavailable); answer → verdict Denied(AuditUnavailable) and outcome Denied { reason: AuditUnavailable }; finishFailed { 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: Approvedgrants ErrGrantsInvalid; 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 ErrFailed { 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.