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>
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, andsupport/rig.rs,support/sink.rs(undercrates/brokerd/tests/support/) - Create:
crates/brokerd/src/ledger.rs - Modify:
crates/brokerd/src/lib.rs(addpub 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) → calllog(POISONED), step fails;Okwithstoppedtrue →log(STOPPED), step fails. "Step fails" is:decide→Denied(AuditUnavailable);answer→ verdictDenied(AuditUnavailable)and outcomeDenied { reason: AuditUnavailable };finish→Failed { NOT_RECORDED }. Neverinto_innerthis lock. - Appending. Through
inner.audit.append(now, event). OnErr(e): setstopped = true,log(&format!("brokerd: {e}")), and the step fails as above. Nothing is appended after that. - Reading state.
inner.state.read(&session). OnErr(e):log(&format!("brokerd: {e}")). The state recorded is thentaint: Secret, untrusted: true("unknown" is the most sensitive). nowis the record's time. Every record'sgrant_sha256is set exactly whengrantis.
decide: every exit
Copy session, call, tool, arguments out of the request first; policy::decide consumes it.
- Lock fails →
Denied(AuditUnavailable), nothing written. - Read the state (keep the
Result). Outcome, first that applies:grantsisErr→ deniedGrantsInvalid; the state is unreadable → deniedStateUnreadable; otherwisepolicy::decide(request, set, state, now). - Write
AuditEvent::Decision { session, call, tool, arguments, outcome, grant, grant_sha256, taint, untrusted }: allowed →Allowed {}with the decision'sgrant()/grant_sha256(); ask →Ask {}with the ask's; denied →Denied { reason }with theDenial'sgrantandgrant_sha256(set only fordenied_by_grant).taint/untrusted: the state read. - Append fails →
Denied(AuditUnavailable). Otherwise returnAllowed { decision, seq },Ask { ask, seq, state }orDenied(reason).
answer: every exit
- Lock fails → step fails.
- Read the state of
ask.request().session. - The re-decision:
Approved→grantsErr→GrantsInvalid; state unreadable →StateUnreadable; elsepolicy::redecide(ask, set, state, now).Refused→ deniedApprovalRefused;Expired→ deniedApprovalExpired. - For a
Decisionfromredecide, find the mode of the grant it names:set.grants().iter().find(|g| g.id == decision.grant());Mode::Ask→ outcomeAsk {}, anything else →Allowed {}. The grant fields are the decision's. A denial →Denied { reason }with theDenial's grant fields (none for refused and expired). - Write
AuditEvent::Approval { session, call, decision, answer, by, post: None, reason, outcome, grant, grant_sha256, taint, untrusted }:answerisApproved/Refused/Expired;byfrom theAnswer(NoneforExpired);reasononly forRefused; the state read. - Append fails → step fails. Otherwise
Answered { verdict, outcome }, verdictRun(Box::new(decision))orDenied(reason).
finish: every exit
- Lock fails →
Failed { NOT_RECORDED }. - Read the state of
call.session. responseisResult { content, truncated, .. }: state unreadable →Failed { NOT_RECORDED }, nothing written. Elseinner.state.raise(&call.session, current, call.label);Err(e)→log("brokerd: {e}"),Failed { NOT_RECORDED }, nothing written.status: Result,taint_afteris the raised taint, hashed text iscontent.Failed { message }: no state change.status: Failed,truncated: false,taint_afteris the taint read (orSecretif unreadable), hashed text ismessage.DeniedorPendingApproval(runner::runnever returns them) →Failed { NOT_RECORDED }.proto::sha256(text.as_bytes())isErr→Failed { NOT_RECORDED }.- 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 tou64::MAX). Append fails →Failed { NOT_RECORDED }. Only now returnresponseunchanged. The content never goes back unless the raised taint and the record are both on disk.
Steps
- 1. Copy.
git switch m3a, thencp docs/plans/M3a/files/crates/brokerd/tests/{ledger,ledger_answer}.rs crates/brokerd/tests/andcp 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, addpub mod ledger;. Runcargo fmt --all. - 4. See the tests pass.
cargo test -p brokerd --test ledger --test ledger_answer. Expected:11 passedand9 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
Errfromappendgoes through the one place that setsstopped. - 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 gateprintsgate: 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.