diff --git a/crates/brokerd/src/lib.rs b/crates/brokerd/src/lib.rs
index dd2a3ef..1f809ee 100644
--- a/crates/brokerd/src/lib.rs
+++ b/crates/brokerd/src/lib.rs
@@ -5,3 +5,4 @@ pub mod config;
pub mod grants;
pub mod policy;
pub mod runner;
+pub mod state;
diff --git a/crates/brokerd/src/state.rs b/crates/brokerd/src/state.rs
new file mode 100644
index 0000000..67e276b
--- /dev/null
+++ b/crates/brokerd/src/state.rs
@@ -0,0 +1,149 @@
+//! Where the broker keeps what it knows about a session: one JSON line per session in
+//! `
/.json`. A read that fails is an error, never "a new session". Neither value ever
+//! goes down: `raise` only ever raises, and it writes atomically so a crash leaves the old file.
+//!
+//! The file's format is private (`StateFile`), so `SessionState` stays free of serde.
+
+use crate::policy::{Label, SessionState};
+use proto::{DataClass, SessionId};
+use serde::{Deserialize, Serialize};
+use std::fs;
+use std::io::{self, Write};
+use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
+use std::path::{Path, PathBuf};
+
+/// Where the owner looks when a session's state file cannot be read or written.
+pub const RUNBOOK: &str = "see docs/runbook.md#broker-state-damaged";
+
+/// A state file that exists but cannot be read, or a write that could not reach disk.
+#[derive(Debug)]
+pub enum StateError {
+ /// The file exists and cannot be read, or is not a state.
+ Unreadable(PathBuf, String),
+ /// The new state could not be put on disk.
+ Write(PathBuf, io::Error),
+}
+
+impl std::fmt::Display for StateError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ StateError::Unreadable(path, detail) => write!(
+ f,
+ "cannot read the session state {}: {}; {}",
+ path.display(),
+ detail,
+ RUNBOOK
+ ),
+ StateError::Write(path, err) => write!(
+ f,
+ "cannot write the session state {}: {}; {}",
+ path.display(),
+ err,
+ RUNBOOK
+ ),
+ }
+ }
+}
+
+impl std::error::Error for StateError {}
+
+/// The directory that holds one `.json` per session.
+#[derive(Debug, Clone)]
+pub struct StateStore {
+ dir: PathBuf,
+}
+
+impl StateStore {
+ /// A store for `dir`. This does not touch the disk.
+ pub fn new(dir: &Path) -> StateStore {
+ StateStore {
+ dir: dir.to_path_buf(),
+ }
+ }
+
+ /// The path of the file for this session: `/.json`.
+ pub fn path(&self, session: &SessionId) -> PathBuf {
+ self.dir.join(format!("{}.json", session.as_str()))
+ }
+
+ /// The session's current state. A missing file is a fresh session; anything else that cannot
+ /// be read is an error.
+ pub fn read(&self, session: &SessionId) -> Result {
+ let path = self.path(session);
+ let text = match fs::read_to_string(&path) {
+ Ok(text) => text,
+ Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(SessionState::default()),
+ Err(err) => return Err(StateError::Unreadable(path, err.to_string())),
+ };
+ let file = match serde_json::from_str::(&text) {
+ Ok(file) => file,
+ Err(err) => return Err(StateError::Unreadable(path, err.to_string())),
+ };
+ if file.taint == DataClass::Public {
+ return Err(StateError::Unreadable(
+ path,
+ "a session's taint is never below private".to_string(),
+ ));
+ }
+ Ok(SessionState {
+ taint: file.taint,
+ untrusted: file.untrusted,
+ })
+ }
+
+ /// The raised state and whether its results are untrusted. It trusts `current`, never reads the
+ /// file again, and always writes, so the file exists from the first result on.
+ pub fn raise(
+ &self,
+ session: &SessionId,
+ current: SessionState,
+ label: Label,
+ ) -> Result {
+ let next = SessionState {
+ taint: current.taint.max(label.class).max(DataClass::Private),
+ untrusted: current.untrusted || label.untrusted,
+ };
+ let path = self.path(session);
+ if let Err(err) = Self::persist(&self.dir, &path, next.taint, next.untrusted) {
+ return Err(StateError::Write(path, err));
+ }
+ Ok(next)
+ }
+
+ /// Write the new state to `` atomically, in six steps. Any failure maps to an `io` error
+ /// the caller turns into `Write`; an error leaves the old file untouched.
+ fn persist(dir: &Path, path: &Path, taint: DataClass, untrusted: bool) -> io::Result<()> {
+ // 1. The directory and any missing parents, mode 0700.
+ fs::DirBuilder::new()
+ .recursive(true)
+ .mode(0o700)
+ .create(dir)?;
+ // 2. Serialise and append the final newline.
+ let json =
+ serde_json::to_string(&StateFile { taint, untrusted }).map_err(io::Error::other)?;
+ let bytes = format!("{json}\n");
+ // 3. A fresh temporary file; `truncate` replaces a leftover one.
+ let tmp = path.with_extension("json.tmp");
+ let mut file = fs::OpenOptions::new()
+ .write(true)
+ .create(true)
+ .truncate(true)
+ .mode(0o600)
+ .open(&tmp)?;
+ // 4. Write it and force it to disk.
+ file.write_all(bytes.as_bytes())?;
+ file.sync_all()?;
+ // 5. Swap it into place.
+ fs::rename(&tmp, path)?;
+ // 6. Pin the rename itself to disk.
+ fs::File::open(dir)?.sync_all()
+ }
+}
+
+/// One line on disk: the taint and the untrusted flag.
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct StateFile {
+ taint: DataClass,
+ untrusted: bool,
+}
diff --git a/crates/brokerd/tests/state.rs b/crates/brokerd/tests/state.rs
new file mode 100644
index 0000000..dd16a3c
--- /dev/null
+++ b/crates/brokerd/tests/state.rs
@@ -0,0 +1,281 @@
+//! Tests for the session state files. Do not edit.
+
+#[path = "support/tmp.rs"]
+mod tmp;
+
+use brokerd::policy::{Label, SessionState};
+use brokerd::state::{RUNBOOK, StateError, StateStore};
+use proto::{DataClass, SessionId};
+use std::os::unix::fs::PermissionsExt;
+use tmp::TempDir;
+
+fn id(text: &str) -> SessionId {
+ SessionId::new(text).unwrap()
+}
+
+fn label(class: DataClass, untrusted: bool) -> Label {
+ Label { class, untrusted }
+}
+
+fn state(taint: DataClass, untrusted: bool) -> SessionState {
+ SessionState { taint, untrusted }
+}
+
+/// The store's directory is two levels below the temporary one and does not exist yet, as on a
+/// fresh install.
+fn store(home: &TempDir) -> StateStore {
+ StateStore::new(&home.path().join("broker/sessions"))
+}
+
+#[test]
+fn a_session_with_no_file_is_private_and_trusted() {
+ let home = TempDir::new("state");
+ let store = store(&home);
+ let fresh = store.read(&id("chat-1")).unwrap();
+ assert_eq!(fresh, state(DataClass::Private, false));
+ assert_eq!(fresh, SessionState::default());
+ // Reading creates nothing.
+ assert!(!home.path().join("broker").exists());
+}
+
+#[test]
+fn the_first_result_creates_the_file_and_its_directory() {
+ let home = TempDir::new("state");
+ let store = store(&home);
+ let session = id("chat-1");
+ let next = store
+ .raise(
+ &session,
+ SessionState::default(),
+ label(DataClass::Private, false),
+ )
+ .unwrap();
+ assert_eq!(next, state(DataClass::Private, false));
+
+ let path = home.path().join("broker/sessions/chat-1.json");
+ assert_eq!(store.path(&session), path);
+ assert_eq!(
+ std::fs::read_to_string(&path).unwrap(),
+ "{\"taint\":\"private\",\"untrusted\":false}\n"
+ );
+ let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
+ assert_eq!(mode(&path), 0o600);
+ assert_eq!(mode(&home.path().join("broker/sessions")), 0o700);
+ assert_eq!(mode(&home.path().join("broker")), 0o700);
+ // No temporary file is left behind.
+ assert!(!home.path().join("broker/sessions/chat-1.json.tmp").exists());
+ assert_eq!(store.read(&session).unwrap(), next);
+}
+
+#[test]
+fn taint_and_the_untrusted_flag_only_go_up() {
+ let home = TempDir::new("state");
+ let store = store(&home);
+ let session = id("s");
+ let steps = [
+ (
+ label(DataClass::Public, false),
+ state(DataClass::Private, false),
+ ),
+ (
+ label(DataClass::Private, true),
+ state(DataClass::Private, true),
+ ),
+ (
+ label(DataClass::Secret, false),
+ state(DataClass::Secret, true),
+ ),
+ (
+ label(DataClass::Public, false),
+ state(DataClass::Secret, true),
+ ),
+ (
+ label(DataClass::Private, false),
+ state(DataClass::Secret, true),
+ ),
+ ];
+ let mut current = store.read(&session).unwrap();
+ for (result, want) in steps {
+ current = store.raise(&session, current, result).unwrap();
+ assert_eq!(current, want);
+ assert_eq!(store.read(&session).unwrap(), want, "what is on disk");
+ }
+ assert_eq!(
+ std::fs::read_to_string(store.path(&session)).unwrap(),
+ "{\"taint\":\"secret\",\"untrusted\":true}\n"
+ );
+}
+
+#[test]
+fn sessions_do_not_share_state() {
+ let home = TempDir::new("state");
+ let store = store(&home);
+ store
+ .raise(
+ &id("a"),
+ SessionState::default(),
+ label(DataClass::Secret, true),
+ )
+ .unwrap();
+ assert_eq!(store.read(&id("b")).unwrap(), SessionState::default());
+ assert_eq!(
+ store.read(&id("a")).unwrap(),
+ state(DataClass::Secret, true)
+ );
+}
+
+/// A file that exists but does not hold a valid state is an error, never "no file".
+#[test]
+fn a_damaged_file_is_an_error_that_names_the_file_and_the_runbook() {
+ let home = TempDir::new("state");
+ let store = store(&home);
+ std::fs::create_dir_all(home.path().join("broker/sessions")).unwrap();
+ let session = id("hurt");
+ for text in [
+ "",
+ "{",
+ "null",
+ "[]",
+ "{\"taint\":\"secret\"}",
+ "{\"untrusted\":false}",
+ "{\"taint\":\"internal\",\"untrusted\":false}",
+ "{\"taint\":\"secret\",\"untrusted\":\"no\"}",
+ "{\"taint\":\"secret\",\"untrusted\":false,\"note\":1}",
+ "{\"taint\":\"secret\",\"untrusted\":false} trailing",
+ // A session is never below private, so this file was not written by brokerd.
+ "{\"taint\":\"public\",\"untrusted\":false}",
+ ] {
+ std::fs::write(store.path(&session), text).unwrap();
+ let err = store.read(&session).expect_err(text);
+ assert!(
+ matches!(err, StateError::Unreadable(..)),
+ "{text:?}: {err:?}"
+ );
+ let shown = err.to_string();
+ assert!(shown.contains("hurt.json"), "{shown}");
+ assert!(shown.ends_with(RUNBOOK), "{shown}");
+ }
+ assert_eq!(RUNBOOK, "see docs/runbook.md#broker-state-damaged");
+ // A good file with or without its final newline reads fine.
+ for text in [
+ "{\"taint\":\"secret\",\"untrusted\":true}\n",
+ "{\"taint\":\"secret\",\"untrusted\":true}",
+ ] {
+ std::fs::write(store.path(&session), text).unwrap();
+ assert_eq!(
+ store.read(&session).unwrap(),
+ state(DataClass::Secret, true)
+ );
+ }
+ // Not valid UTF-8, and a directory where the file should be.
+ std::fs::write(store.path(&session), b"\xff\xfe").unwrap();
+ assert!(matches!(
+ store.read(&session),
+ Err(StateError::Unreadable(..))
+ ));
+ std::fs::remove_file(store.path(&session)).unwrap();
+ std::fs::create_dir(store.path(&session)).unwrap();
+ assert!(matches!(
+ store.read(&session),
+ Err(StateError::Unreadable(..))
+ ));
+}
+
+#[test]
+fn a_file_without_read_permission_is_an_error_not_a_fresh_session() {
+ if tmp::running_as_root("a_file_without_read_permission_is_an_error_not_a_fresh_session") {
+ return;
+ }
+ let home = TempDir::new("state");
+ let store = store(&home);
+ let session = id("locked");
+ store
+ .raise(
+ &session,
+ SessionState::default(),
+ label(DataClass::Secret, false),
+ )
+ .unwrap();
+ let path = store.path(&session);
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
+ assert!(matches!(
+ store.read(&session),
+ Err(StateError::Unreadable(..))
+ ));
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
+}
+
+#[test]
+fn a_failed_write_is_an_error_and_leaves_the_old_state() {
+ if tmp::running_as_root("a_failed_write_is_an_error_and_leaves_the_old_state") {
+ return;
+ }
+ let home = TempDir::new("state");
+ let store = store(&home);
+ let session = id("s");
+ let before = store
+ .raise(
+ &session,
+ SessionState::default(),
+ label(DataClass::Private, true),
+ )
+ .unwrap();
+
+ let dir = home.path().join("broker/sessions");
+ std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
+ let err = store
+ .raise(&session, before, label(DataClass::Secret, false))
+ .expect_err("the directory is read-only");
+ std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
+
+ assert!(matches!(err, StateError::Write(..)), "{err:?}");
+ let shown = err.to_string();
+ assert!(shown.contains("s.json"), "{shown}");
+ assert!(shown.ends_with(RUNBOOK), "{shown}");
+ assert_eq!(store.read(&session).unwrap(), before);
+}
+
+/// A `.tmp` file beside the state is a write that did not finish. It is not the state, it does
+/// not stop the next write, and the next write replaces it.
+#[test]
+fn a_leftover_tmp_file_is_neither_read_nor_in_the_way() {
+ let home = TempDir::new("state");
+ let store = store(&home);
+ let session = id("s");
+ let dir = home.path().join("broker/sessions");
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("s.json.tmp"), "{\"taint\":\"secret\",\"untr").unwrap();
+
+ assert_eq!(store.read(&session).unwrap(), SessionState::default());
+ let next = store
+ .raise(
+ &session,
+ SessionState::default(),
+ label(DataClass::Secret, false),
+ )
+ .unwrap();
+ assert_eq!(store.read(&session).unwrap(), next);
+ assert!(!dir.join("s.json.tmp").exists());
+}
+
+#[test]
+fn raise_trusts_the_state_it_is_given_not_the_file() {
+ // The caller read the state under the ledger lock a moment ago; `raise` does not read again.
+ let home = TempDir::new("state");
+ let store = store(&home);
+ let session = id("s");
+ let given = state(DataClass::Secret, true);
+ let next = store
+ .raise(&session, given, label(DataClass::Public, false))
+ .unwrap();
+ assert_eq!(next, given);
+ // Even a state below private is lifted to private on the way to disk.
+ let low = store
+ .raise(
+ &id("low"),
+ state(DataClass::Public, false),
+ label(DataClass::Public, false),
+ )
+ .unwrap();
+ assert_eq!(low, state(DataClass::Private, false));
+}
diff --git a/docs/implementer-log.md b/docs/implementer-log.md
index 6009d16..a016039 100644
--- a/docs/implementer-log.md
+++ b/docs/implementer-log.md
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|---|---|---|---|---|---|---|---|
+| M3a/08-brokerd-state | 2026-09-18 | done | 1 | pass | none | Wrote crates/brokerd/src/state.rs: RUNBOOK, StateError (Unreadable/Write with hand-written Display ending in RUNBOOK and std::error::Error), StateStore (new does not touch disk, path joins /.json, read, raise) and the private StateFile with deny_unknown_fields. read has exactly one default path (ErrorKind::NotFound); Public taint is Unreadable; raise computes max(taint,label,Private) and ORs untrusted, always writes atomically in six steps mapping any error to Write(path, err). `cargo fmt` put `state` after `runner` in lib.rs. 9 tests pass; `make gate` prints `gate: ok`. | Laguna S 2.1 |
| M1/01-workspace-and-gate | 2026-09-17 | done | 1 | pass | none | Crate skeletons, Cargo files and the given Makefile/deny.toml/test-gate-scripts.sh were already present untracked from a prior attempt; I verified them against the plan and created only the missing gate scripts, dependencies.md, egress.md and this log row. | Laguna S 2.1 |
| M1/02-proto-values | 2026-09-17 | done | 1 | pass | none | Implemented ValueError, SessionId, Epoch, CallId, Hash32 and Timestamp in crates/proto/src/ids.rs and DataClass in class.rs, using serde try_from/into for string-backed JSON validation, a hand-written hex encoder and humantime for RFC 3339 parsing with canonical re-serialization. | Laguna S 2.1 |
| M1/03-proto-wire | 2026-09-17 | done | 2 | pass | none | Added Envelope, Message, WireError, ErrorCode, ToolRequest, ToolResponse and DenyReason in crates/proto/src/wire.rs, re-exported from lib.rs; all 9 fixture tests pass and `make gate` prints `gate: ok`. | Laguna S 2.1 |