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

7.3 KiB

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

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):

// 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. WouldBlockErr(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.