169 lines
7.1 KiB
Markdown
169 lines
7.1 KiB
Markdown
# 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-<parent>-<n>` 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<SessionState, StateError>` |
|
|
| `StateStore::raise` | `raise(&self, session: &SessionId, current: SessionState, label: Label) -> Result<SessionState, StateError>` |
|
|
| `SessionState::default` | private + trusted |
|
|
| `Label` | `{ class: DataClass, untrusted: bool }` |
|
|
| `SessionId::new` | `new(s: &str) -> Result<SessionId, ValueError>` |
|
|
| `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-<parent>-<n>` file copies the parent's taint and untrusted flag (M6 spec 3.4).
|
|
fn inherit_or_default(store: &StateStore, session: &SessionId) -> Result<SessionState, StateError> {
|
|
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<SessionId> {
|
|
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.
|