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

120 lines
5.3 KiB
Markdown

# 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:
```json
{"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.
```rust
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:
```rust
#[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::NotFound` → `Ok(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 `Public` → `Err(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.