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>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user