# M6a task 05: `brokerd` copies parent taint onto a new `sub-*` session **Branch:** `m6a`. Check it: `git branch --show-current` prints `m6a` and `git status --short` is empty; otherwise stop. Never switch branch. **Commit subject:** `brokerd: inherit parent taint for a new subagent` **Log row:** the Task cell is `M6a/05-brokerd-state-inherit`. **First action.** Run this before any Read of crate source. Do not `ls` `crates/brokerd/tests/`. Do not Read `policy.rs`, `loopd`, or `docs/implementer-log.md` (except the first 12 lines at the log step). The quoted block is the only edit in `read`. ```sh cp docs/plans/M6a/files/crates/brokerd/tests/state_subagent.rs crates/brokerd/tests/ ``` ## Goal `brokerd` does not see the spawn (spec 3.4). The child's later tool calls, and `memoryd`'s session-state question, go through `StateStore::read`. A missing file for a `sub--` id copies the parent's taint and `untrusted` flag via `raise` (so the child file exists). Nested ids recurse through a missing middle child. A missing file for any other id is still a fresh private trusted session and still creates nothing. Spawn is not a grant. `loopd` does not write `{home}/state/`. ## Every place that changes | Place | What happens there | |---|---| | `crates/brokerd/src/state.rs` | missing `sub-*` inherits; other missing ids unchanged | | `crates/brokerd/tests/state_subagent.rs` | new, given | | everything else | **no change**: not `loopd`, not `raise`'s merge rules | ## Files - Copy, from `docs/plans/M6a/files/` (the same path under it): the new test `crates/brokerd/tests/state_subagent.rs`. - Modify: `crates/brokerd/src/state.rs`, `docs/implementer-log.md`. - Do not touch: any other file. Do not edit `crates/brokerd/tests/state.rs`. ## Facts you need - **`parent_of_sub`.** `session.as_str().strip_prefix("sub-")`, then `rsplit_once('-')`. The right piece must be non-empty and all ASCII digits. `SessionId::new(parent)` on the left piece. Anything else (`chat-1`, `sub-x` with no trailing digits, an empty parent) is `None` and `read` returns `SessionState::default()` without writing. - **Recursion.** `sub-sub-mm-thread-1-2` → parent `sub-mm-thread-1` → parent `mm-thread`. `read` of a missing middle child inherits from the live parent the same way. Do not special-case depth. - **`raise`, not a silent struct.** `inherit_or_default` calls `store.read(&parent)?` then `store.raise(session, SessionState::default(), Label { class: inherited.taint, untrusted: inherited.untrusted })`. `raise` already maxes class at `Private` and ORs `untrusted`, and creates the file. A later `read` of the child hits the file. - **Plain sessions.** `a_session_with_no_file_is_private_and_trusted` in `state.rs` still requires that `read("chat-1")` creates no `broker/` directory. Only a `sub-*` miss writes. - **`Label` and `SessionState`** are already imported in `state.rs`. - The items the edits use. This table is complete. | Item | Signature | |---|---| | `StateStore::read` | `read(&self, session: &SessionId) -> Result` | | `StateStore::raise` | `raise(&self, session: &SessionId, current: SessionState, label: Label) -> Result` | | `SessionState::default` | private + trusted | | `Label` | `{ class: DataClass, untrusted: bool }` | | `SessionId::new` | `new(s: &str) -> Result` | | `SessionId::as_str` | `as_str(&self) -> &str` | ## The edits, exactly Each quoted "before" block occurs exactly once after task 04 (the file is unchanged since master). ### `crates/brokerd/src/state.rs` **I1.** The line (exactly once, inside `read`) ```rust Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(SessionState::default()), ``` becomes ```rust Err(err) if err.kind() == io::ErrorKind::NotFound => { return inherit_or_default(self, session); } ``` **I2.** After `impl StateStore` (after `persist`'s closing `}` and the `impl`'s closing `}`, before `/// One line on disk`), add: ```rust /// A missing `sub--` file copies the parent's taint and untrusted flag (M6 spec 3.4). fn inherit_or_default(store: &StateStore, session: &SessionId) -> Result { let Some(parent) = parent_of_sub(session) else { return Ok(SessionState::default()); }; let inherited = store.read(&parent)?; store.raise( session, SessionState::default(), Label { class: inherited.taint, untrusted: inherited.untrusted, }, ) } fn parent_of_sub(session: &SessionId) -> Option { let rest = session.as_str().strip_prefix("sub-")?; let (parent, n) = rest.rsplit_once('-')?; if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) { return None; } SessionId::new(parent).ok() } ``` Do not change `raise`. Do not change `persist`. ## Steps - [ ] **1. Copy the given test.** ```sh cp docs/plans/M6a/files/crates/brokerd/tests/state_subagent.rs crates/brokerd/tests/ ``` - [ ] **2. See it fail.** `cargo test -p brokerd --test state_subagent`, timeout 600000 ms. The tests compile (`StateStore::read` exists). `a_new_subagent_copies_the_parents_untrusted_flag` fails: the child is the default (private, trusted) and `sub-mm-thread-3.json` does not exist. `a_nested_subagent_copies_through_a_missing_middle_child` fails: the leaf is not `Secret` / untrusted. `a_plain_session_still_creates_nothing_on_read` passes. - [ ] **3. The edits.** I1, I2. `cargo check -p brokerd` prints no error and no warning. `wc -l crates/brokerd/src/state.rs` ≤ 500. - [ ] **4. The tests.** `cargo test -p brokerd --test state_subagent --test state`: every test passes, 3 in `state_subagent`. `a_session_with_no_file_is_private_and_trusted` still passes. - [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line `gate: ok`. Test count **1590** (1587 before, plus 3). After `gate: ok`, do not add up `test result:` lines. - [ ] **6. Log and commit.** Task cell `M6a/05-brokerd-state-inherit`. Read only the first 12 lines of `docs/implementer-log.md`; insert the row under the header. Stage with `git add crates/brokerd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md` says. ## Done when - `make gate` prints `gate: ok` with 1590 tests. - `git diff HEAD~1 --stat` lists only `crates/brokerd/src/state.rs`, `crates/brokerd/tests/state_subagent.rs` and the log. ## Stop and report if - The branch check fails; a "before" block is missing or duplicated; step 2 does not compile, or the plain-session test fails, or a different assertion fails; `cargo check` warns; `state.rs` existing tests fail; you write a state file from `loopd`; a test outside the named files fails in the gate (`git stash -u`, rerun, `git stash pop`, report both). ## What the given tests pin `read("sub-mm-thread-3")` of a parent raised to private+untrusted returns that pair and creates the child file; `read("sub-sub-mm-thread-1-2")` with only `mm-thread` on disk returns secret+untrusted; `read("chat-1")` still creates no `broker/` directory.