# M5b task 09: `TurnDone` says whether the session has read untrusted content **Branch:** `m5b` (run `git switch m5b`; `git status --short` must be empty, otherwise stop) **Commit subject:** `bxcore, loopd: TurnDone says whether the session has read untrusted content` ## Goal `gatewayd` will post the links of a reply as code when the reply comes from a session that has read untrusted content, a fetched page or a recalled memory-tainted note (M5b spec, section 9; that is task 10). It learns that from `loopd`, in the frame that ends each turn. So `TurnDone` gains a field (M5b spec, sections 8 and 9): ```json {"kind":"turn_done","body":{"content":"…","usage":{…},"untrusted":true}} ``` - **`bxcore`**: `TurnDone` gains `untrusted: bool`, last. It is always written, and it is **required** when read: a frame without it is refused, never read as a reply from a clean session (see "Facts you need"). The fixture `turn_done.json` changes, and a second one, `turn_done_untrusted.json`, holds `true`. - **`loopd`**: a new method, `Session::untrusted`, is true when any tool result in the session's log is labelled untrusted. `channel::handle` puts it in the `TurnDone`. The log is the one source: it already holds each tool result with the label `brokerd` gave it, it is read back whole when a session is reopened, and records are only ever appended, so once a session has read untrusted content every later `TurnDone` of it says so, also after `loopd` restarts. - **Unchanged**: every source file of `gatewayd`, `bxctl` and `brokerd`. Only their tests build a `TurnDone`, and new versions of those are given. | The session's log holds | `TurnDone.untrusted` | |---|---| | no tool result, or only results labelled trusted | `false` | | a denial, or a failure (they are logged trusted) | `false` | | a result labelled untrusted, in this turn or any earlier one | `true` | | a `recall` result with a memory-tainted hit (`brokerd` labels it untrusted) | `true` | ## Every place `TurnDone` appears This is the complete list. Each one is covered by an edit below or by a given file. | Place | What happens there | |---|---| | `crates/bxcore/src/wire.rs`, the struct | gains `untrusted` (edit W1) | | `crates/loopd/src/session.rs` | new `Session::untrusted` (edit S1) | | `crates/loopd/src/channel.rs`, `handle` | sets `untrusted` from the session (edit C1) | | `crates/gatewayd/src/deliver.rs` | reads `content` only; no change (task 10 reads the flag) | | `crates/bxctl/src/chat.rs`, `crates/bxctl/src/main.rs` | pass the `TurnDone` on; `--json` prints it whole with `serde_json`, so the new field shows there; no change | | `crates/brokerd/src/broker.rs` | `Message::TurnDone(_)`, a pattern; no change | | `crates/loopd/tests/channel.rs`, `memory.rs` | patterns and field reads; no change | | tests that build a `TurnDone` in `bxcore`, `brokerd`, `bxctl`, `gatewayd` | new versions are given | ## Files - Copy, from `docs/plans/M5b/files/` (the same path under it): - new: `crates/bxcore/tests/fixtures/wire/turn_done_untrusted.json`, `crates/loopd/tests/turn_done_untrusted.rs` - **new versions** of: `crates/bxcore/tests/fixtures/wire/turn_done.json`, `crates/bxcore/tests/turn_wire.rs`; `crates/brokerd/tests/broker.rs`; `crates/bxctl/tests/chat.rs`, `crates/bxctl/tests/support/mod.rs`; `crates/gatewayd/tests/support/fake_loop.rs` (they replace the files in the tree). Do not copy `broker.rs-task01`: it is the older version. - Modify: `crates/bxcore/src/wire.rs` (W1), `crates/loopd/src/session.rs` (S1), `crates/loopd/src/channel.rs` (C1), `docs/implementer-log.md`. - Do not touch: any other source file, any `Cargo.toml`, `crates/bxcore/tests/strict.rs`. ## Facts you need - **Why the field is required.** `serde` treats a `bool` field without `#[serde(default)]` as required: decoding a `turn_done` without it fails with ``missing field `untrusted` ``. A default would read a missing field as `false`, "clean", which is the wrong way to fail: a reply from an untrusted session would keep its links live. `loopd`, `gatewayd` and `bxctl` are built from this one workspace and deployed together, and `TurnDone` already refuses unknown fields, so an older reader refuses the new frame anyway. So: no `#[serde(default)]`, no `skip_serializing_if`. `PROTOCOL_VERSION` does not change (task 01 added a field without changing it too). - **Where the labels come from.** `brokerd` labels every result it answers with (`ToolResponse::Result { untrusted, .. }`): from the grant, and for `recall` also from `memoryd`, which marks a result holding a memory-tainted hit untrusted; `brokerd` keeps the stricter of the two. `run_turn` (`crates/loopd/src/turn.rs`, unchanged) logs each tool result as `LogRecord::ToolResult { untrusted, .. }` with that label. A denial, a failure and `loopd`'s own tools (`clock`, `find_tool`, `call_tool`'s answers) are logged with `untrusted: false`, as `brokerd`'s own state does: a failure changes no state. - `Session::open` reads every record of the log back into `records`, so `Session::untrusted` on a reopened session sees every earlier turn's results. Every turn after a session's first reopens it (`resume`), in the same `loopd` or a restarted one. - In `channel::handle`, `session` is still in scope after `run_turn` returns: `run_turn` borrows it only for the call. `session.untrusted()` borrows it again, immutably. - The calls the edits make: | Call | Signature, and what it gives | |---|---| | `Iterator::any` | `self.records.iter().any(f) -> bool`: true when `f` is true for some record | | `matches!` | `matches!(record, LogRecord::ToolResult { untrusted: true, .. })`: true for a tool result whose field `untrusted` is `true` | | `Session::untrusted` | `pub fn untrusted(&self) -> bool` (new, S1) | ## The edits, exactly Nothing else in these files changes. Each quoted "before" block occurs exactly once in its file. ### `crates/bxcore/src/wire.rs` **W1.** The lines ```rust // JSON: {"kind":"turn_done","body":{"content":"…","usage":{…}}} #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct TurnDone { pub content: String, pub usage: Usage, } ``` become ```rust // JSON: {"kind":"turn_done","body":{"content":"…","usage":{…},"untrusted":false}} #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct TurnDone { pub content: String, pub usage: Usage, /// True once the session has read untrusted content, in this turn or an earlier one (M5b /// spec, section 9). It has no default: a frame without it is refused, never read as clean. pub untrusted: bool, } ``` ### `crates/loopd/src/session.rs` **S1.** The line ```rust pub fn last_usage(&self) -> Option { ``` becomes ```rust /// True once any tool result in the log is labelled untrusted: a result under an untrusted /// grant, or a recalled memory-tainted note, which `brokerd` labels so. The labels are in /// the log, so this holds after the session is reopened, and it never becomes false again. pub fn untrusted(&self) -> bool { self.records.iter().any(|record| { matches!( record, LogRecord::ToolResult { untrusted: true, .. } ) }) } pub fn last_usage(&self) -> Option { ``` `LogRecord` is imported at the top of the file already. ### `crates/loopd/src/channel.rs` **C1.** In `handle`, the lines ```rust msg: Message::TurnDone(TurnDone { content: outcome.content, usage: outcome.usage, }), ``` become ```rust msg: Message::TurnDone(TurnDone { content: outcome.content, usage: outcome.usage, // The whole log, this turn's results included (M5b spec, section 8). untrusted: session.untrusted(), }), ``` ## Steps - [ ] **1. Copy the given files.** From the repository root, eight commands: ```sh cp docs/plans/M5b/files/crates/bxcore/tests/fixtures/wire/turn_done.json \ crates/bxcore/tests/fixtures/wire/ cp docs/plans/M5b/files/crates/bxcore/tests/fixtures/wire/turn_done_untrusted.json \ crates/bxcore/tests/fixtures/wire/ cp docs/plans/M5b/files/crates/bxcore/tests/turn_wire.rs crates/bxcore/tests/ cp docs/plans/M5b/files/crates/brokerd/tests/broker.rs crates/brokerd/tests/ cp docs/plans/M5b/files/crates/bxctl/tests/chat.rs crates/bxctl/tests/ cp docs/plans/M5b/files/crates/bxctl/tests/support/mod.rs crates/bxctl/tests/support/ cp docs/plans/M5b/files/crates/gatewayd/tests/support/fake_loop.rs \ crates/gatewayd/tests/support/ cp docs/plans/M5b/files/crates/loopd/tests/turn_done_untrusted.rs crates/loopd/tests/ ``` Then `git status --short` lists 6 modified files and 2 new ones, all under `crates/*/tests/`. - [ ] **2. See them fail.** Two commands: - `cargo test -p bxcore --test strict`: one test fails, `envelopes_reject_unknown_keys_at_every_depth`, with `turn_done.json: fixture must decode`. - `cargo build --tests --keep-going -p bxcore -p brokerd -p loopd -p bxctl -p gatewayd`: thirteen test targets do not compile: `turn_wire` in `bxcore`; `broker` in `brokerd`; `turn_done_untrusted` in `loopd`; `admin`, `chat` and `chat_approvals` in `bxctl`; `deliver`, `deliver_notice`, `serve`, `serve_answers`, `serve_approvals`, `serve_redirect` and `serve_restart` in `gatewayd`. Every error is about the field this task adds: `E0560` (``struct `bxcore::TurnDone` has no field named `untrusted` ``) or, once, `E0609` (``no field `untrusted` on type `bxcore::TurnDone` ``). - [ ] **3. `bxcore`.** Make edit W1. `cargo test -p bxcore`: every suite passes, among them the seven tests of `turn_wire`. - [ ] **4. `loopd`.** Make edits S1 and C1. `cargo check -p loopd`: no error. Then `cargo test -p loopd --test turn_done_untrusted`: 6 passed, and `cargo test -p loopd`: every suite passes. - [ ] **5. The other crates.** `cargo test -p brokerd -p bxctl -p gatewayd`: every suite passes. You changed nothing in them but the copied tests. - [ ] **6. Check.** `git diff --stat -- crates/gatewayd/src crates/bxctl/src crates/brokerd/src` prints nothing, and `grep -n 'serde(default' crates/bxcore/src/wire.rs` prints only the line of `ToolResponse::Denied`'s `detail` (task 01), not one for `untrusted`. - [ ] **7. Run the gate.** `cargo fmt --all`, then `make gate`, with a shell timeout of 600000 ms. Expected last line: `gate: ok`, with 1058 tests: 1050 before this task, plus 8 new ones (two in `bxcore/tests/turn_wire.rs`, six in `loopd/tests/turn_done_untrusted.rs`; the new versions of the other files keep their counts). - [ ] **8. Log and commit.** `git status --short` lists the three sources, the six replaced test files and your log as modified, and `turn_done_untrusted.json` and `turn_done_untrusted.rs` as new. Stage with `git add crates/bxcore crates/loopd crates/brokerd crates/bxctl crates/gatewayd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md` says. `Cargo.lock` does not change in this task; the `git add` is harmless. ## Done when - `make gate` prints `gate: ok` with 1058 tests. - `git diff HEAD~1 --stat` lists only `crates/bxcore/src/wire.rs`, `crates/loopd/src/session.rs`, `crates/loopd/src/channel.rs`, the eight copied files and `docs/implementer-log.md`. ## Stop and report if - A "before" block of W1, S1 or C1 is not in its file exactly as shown, or is there more than once. - Step 2 shows a different result: another test failing in `strict`, other test targets, or an error of another kind. - After step 3, 4 or 5, a test fails. Do not change a test or a fixture: stop, and give the failing tests' names and their messages. - The fix seems to need a change anywhere else: in `turn.rs`, in `gatewayd`, `bxctl` or `brokerd` sources, or a default for the field. It does not; stop and say why you think so. - A test in `memoryd`, `inferproxy` or `toolkit` fails in the gate: check once whether it fails without your change, then stop and report both results. - The gate passes with a count other than 1058. - A comment in an edit does not fit the code under it. Quote it and stop.