Files
boxmaker/docs/plans/M3a/09-brokerd-audit-writer.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

139 lines
7.3 KiB
Markdown

# M3a task 09: the audit writer
**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `Add the audit writer with its startup check`
## Goal
`brokerd::audit::Writer` is the only thing that writes the audit log. It appends, never edits.
`open` checks the log it finds and refuses, recovers or accepts a break; `append` adds one
chained record and syncs it. The verifier from task 03 does all the judging.
## Files
- Copy: `crates/brokerd/tests/audit.rs`, `crates/brokerd/tests/audit_startup.rs`,
`crates/brokerd/tests/support/audit_dir.rs`
- Create: `crates/brokerd/src/audit.rs`
- Modify: `crates/brokerd/src/lib.rs` (add `pub mod audit;`), `docs/implementer-log.md`
The tests read the fixture logs of task 03 from `crates/proto/tests/fixtures/audit/`.
## Interfaces
```rust
pub const RECOVERED_NOTICE: &str =
"audit: recovered a torn final line\nsee docs/runbook.md#audit-recovered";
#[derive(Debug)]
pub enum AuditError {
Locked,
Broken(Box<proto::ChainFailure>), // boxed: clippy's result_large_err rejects it unboxed
NothingToAccept,
Io { what: String, source: std::io::Error },
Stopped,
}
#[derive(Debug)] pub struct Writer { /* private */ }
#[derive(Debug)] pub struct Opened {
pub writer: Writer,
pub recovered: bool, // a torn tail was recovered
pub accepted: Option<proto::ChainFailure>, // the failure --accept-break accepted
}
impl Writer {
pub fn open(dir: &Path, accept_break: bool) -> Result<Opened, AuditError>;
pub fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result<u64, AuditError>;
pub fn next_seq(&self) -> u64;
}
/// Verifies every log file in `dir`, in name order, from `ChainVerifier::new()`.
pub fn verify_dir(dir: &Path) -> std::io::Result<proto::ChainReport>;
```
`AuditError` implements `Display` and `Error` (`source()` for `Io`). `Display`, exactly:
| Variant | Text |
|---|---|
| `Locked` | `brokerd is already running\nsee docs/runbook.md#brokerd-already-running` |
| `Broken(f)` | `{f.file}:{f.line}: {f.what}\nsee docs/runbook.md#audit-chain-broken` |
| `NothingToAccept` | `nothing to accept: the audit log verifies` |
| `Io` | `audit: {what}: {source}\nsee docs/runbook.md#audit-unavailable` |
| `Stopped` | `audit: an earlier write failed; restart brokerd\nsee docs/runbook.md#audit-unavailable` |
Verified in the std docs of Rust 1.98.1 (`rust-version` is 1.95):
```rust
// std::fs::File, stable since 1.89. pub enum TryLockError { Error(io::Error), WouldBlock }
pub fn try_lock(&self) -> Result<(), TryLockError>; // released when the File is dropped
pub fn sync_all(&self) -> io::Result<()>; // File::open(dir)?.sync_all() syncs a directory
// std::os::unix::fs::{OpenOptionsExt, DirBuilderExt}: fn mode(&mut self, mode: u32) -> &mut Self
```
A log file is a name of the form `YYYY-MM-DD.jsonl`: ten characters, digits with `-` at
positions 4 and 7, then `.jsonl`. Everything else in the directory (`.lock`, `notes.txt`,
`x.jsonl.bak`) is ignored, in `open`, in `append` and in `verify_dir`.
## What `open` does, in order
1. Create `dir` and its parents if missing, mode 0700 (`DirBuilder`, `recursive(true)`).
2. Open `dir/.lock` (create, write, mode 0600, do not truncate) and `try_lock` it.
`WouldBlock` → `Err(Locked)`. `Error(e)` → `Err(Io)`. Keep the `File` in the `Writer`: the
lock lasts as long as the writer.
3. Verify. With `accept_break`: `verify_dir`. Without: **the short check.** If there are two or
more log files, read the one before the latest, take its last line (the bytes after the last
`\n`, once one trailing `\n` is removed), and if it parses as an `AuditRecord`, use
`ChainVerifier::resume(its seq + 1, its hash)` and feed only the latest file. If there is one
file, no file, or that last line does not parse, use `verify_dir`.
4. The report has a failure and `accept_break` is false → `Err(Broken)`. **Write nothing.**
5. The report has a failure and `accept_break` is true → append (rule 8) an
`AcceptedBreak { file, line, last_good }` from the failure, with `seq = break_seq` and
`prev = break_prev`; if `tail_torn`, write one `\n` first. `accepted` is the failure.
6. No failure and `accept_break` is true → `Err(NothingToAccept)`, even if there is a torn tail.
**Write nothing.**
7. No failure, a torn tail → append (rule 8) a `Recovery { torn_bytes: bytes, torn_sha256:
sha256 }` with `seq = recovery_seq` and `prev = recovery_prev`; if `has_newline` is false,
write one `\n` first. `recovered` is true.
8. Records written by `open` use `Timestamp::now()` and **always go in the latest log file,
whatever today's date is** (in today's file only if there is no log file at all). The `\n`
and the line go out in the same single write as in `append`.
9. Otherwise the writer continues from the report: `next_seq`, and `prev` is `head`, or the
resume hash if the latest file was empty, or zeros for an empty log.
10. Every I/O error on the way is `Err(Io)` with the path in `what`. A directory or file that
cannot be read is an error, never an empty log.
## What `append` does
1. If an earlier write failed → `Err(Stopped)`. Write nothing.
2. The file is `<first ten characters of time.to_rfc3339()>.jsonl`. **Never go back:** if that
name sorts before the latest log file's name, use the latest file. (A clock stepped back over
midnight must not put a record in an older file: files are verified in name order.)
3. The record is `AuditRecord { seq: next_seq, time, prev, event }`, serialised by `serde_json`.
Write the line and its `\n` with **one** `write_all`, then `sync_all`. A new file is created
with mode 0600, and the directory is synced after the create.
4. On success `prev` becomes the hash of the line (without `\n`), `next_seq` goes up by one, and
the record's `seq` is returned.
5. **On any error, from any step above, including the create and both syncs, set the stopped
flag before returning `Err(Io)`.** Part of a line may be on disk; only the next start deals
with that. The same holds for the writes `open` makes.
## Steps
- [ ] **1. Copy.** `mkdir -p crates/brokerd/tests/support`, then
`cp docs/plans/M3a/files/crates/brokerd/tests/audit.rs docs/plans/M3a/files/crates/brokerd/tests/audit_startup.rs crates/brokerd/tests/`
and `cp docs/plans/M3a/files/crates/brokerd/tests/support/audit_dir.rs crates/brokerd/tests/support/`
- [ ] **2. See the tests fail.** `cargo test -p brokerd --test audit`. Expected: does not compile.
- [ ] **3. Write `audit.rs`.** Run `cargo fmt --all`.
- [ ] **4. See the tests pass.** `cargo test -p brokerd --test audit --test audit_startup`, five
times. Expected: `9 passed` and `7 passed` every time.
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`.
- [ ] **6. Log and commit.** `git add crates/brokerd docs/implementer-log.md && git commit`
## Done when
- The two suites report 9 and 7 passed, five runs in a row; `make gate` prints `gate: ok`.
## Stop and report if
- `a_failed_write_stops_the_writer` prints `skipped`: the tests are running as root.
- `crates/brokerd/Cargo.toml` does not already have `serde_json.workspace = true` (task 04 adds
it). Do not add a dependency in this task.
- You need anything from `libc`, or `unsafe`, for the lock or the file modes.
- A test seems to need `open` to truncate, rewrite or delete anything. Nothing ever does.