Files
boxmaker/docs/plans/M3a/08-brokerd-state.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

5.3 KiB

M3a task 08: session state files

Branch: m3a (run git switch m3a; git status --short must be empty, otherwise stop) Commit subject: Keep each session's taint and untrusted flag in a file

Goal

brokerd::state keeps what brokerd knows about a session in <home>/broker/sessions/<id>.json, one line:

{"taint":"private","untrusted":false}

Policy reads it to decide; a result raises it. If brokerd cannot read it, it does not know how sensitive the session is, so that is an error, never "a new session". Neither value ever goes down. Nothing here locks: the caller holds the ledger lock around read and raise (task 12).

Files

  • Copy: crates/brokerd/tests/state.rs (support/tmp.rs is already there from task 06)
  • Create: crates/brokerd/src/state.rs
  • Modify: crates/brokerd/src/lib.rs (add pub mod state;), docs/implementer-log.md

Interfaces

SessionState and Label come from crate::policy (task 07). Do not define them again.

pub const RUNBOOK: &str = "see docs/runbook.md#broker-state-damaged";

#[derive(Debug)]
pub enum StateError {
    Unreadable(PathBuf, String),        // the file exists and cannot be read, or is not a state
    Write(PathBuf, std::io::Error),     // the new state could not be put on disk
}

#[derive(Debug, Clone)]
pub struct StateStore { /* dir: PathBuf */ }
impl StateStore {
    pub fn new(dir: &Path) -> StateStore;                 // does not touch the disk
    pub fn path(&self, session: &SessionId) -> PathBuf;   // <dir>/<id>.json
    pub fn read(&self, session: &SessionId) -> Result<SessionState, StateError>;
    pub fn raise(&self, session: &SessionId, current: SessionState, label: Label)
        -> Result<SessionState, StateError>;
}

The file's format is a private struct, so SessionState itself stays free of serde:

#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StateFile { taint: DataClass, untrusted: bool }

StateError's Display, by hand: cannot read the session state <path>: <detail>; or cannot write the session state <path>: <error>; , and then RUNBOOK. Both messages end with RUNBOOK. Implement std::error::Error too.

read: every exit

  1. read_to_string fails with ErrorKind::NotFoundOk(SessionState::default()). This is the only case that counts as "no file". Reading creates nothing on disk.
  2. read_to_string fails any other way (no permission, not UTF-8, it is a directory) → Err(Unreadable(path, the error's text)).
  3. serde_json::from_str::<StateFile> fails (empty, cut short, unknown field, missing field, wrong type, text after the object) → Err(Unreadable(path, the error's text)). A final newline, or none, is fine.
  4. taint is PublicErr(Unreadable(path, "a session's taint is never below private")). brokerd never writes such a file, so someone else did.
  5. Otherwise Ok.

raise: every exit

The new state is taint = max(current.taint, label.class, Private) and untrusted = current.untrusted || label.untrusted. raise trusts current; it does not read the file again. It always writes, even when nothing changed, so the file exists from the session's first result on. Write atomically, and map an error from any of these steps to Err(Write(path of the .json file, error)):

  1. Create the directory and any missing parents with mode 0700: std::fs::DirBuilder::new().recursive(true).mode(0o700).create(&dir) (std::os::unix::fs::DirBuilderExt; an existing directory is not an error with recursive).
  2. Serialise StateFile with serde_json::to_string and add \n. Map a serde error with std::io::Error::other.
  3. Open <id>.json.tmp (path.with_extension("json.tmp")) with OpenOptions: write, create, truncate, and .mode(0o600) (std::os::unix::fs::OpenOptionsExt). truncate is what lets a leftover .tmp from a crash be replaced.
  4. write_all, then sync_all on the file.
  5. std::fs::rename the .tmp over <id>.json.
  6. std::fs::File::open(&dir)?.sync_all(), so the rename itself is on disk.

Only after all six return Ok(new state). On an error the old <id>.json is untouched, because nothing wrote to it.

Steps

  • 1. Copy. git switch m3a, then cp docs/plans/M3a/files/crates/brokerd/tests/state.rs crates/brokerd/tests/
  • 2. See the test fail. cargo test -p brokerd --test state. Expected: it does not compile.
  • 3. Write state.rs and add pub mod state; to lib.rs. Run cargo fmt --all.
  • 4. See the tests pass. cargo test -p brokerd --test state. Expected: 9 passed. Two of the nine print "skipped" and pass if you are root; you should not be.
  • 5. Walk the exits. Go down the two numbered lists above and point at the line of your code for each number. Check in particular that read has exactly one path that returns the default state.
  • 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

  • cargo test -p brokerd --test state reports 9 passed; make gate prints gate: ok.

Stop and report if

  • A test wants a damaged or unreadable file to be treated as a fresh session.
  • A test wants taint or the untrusted flag to go down.