Files
boxmaker/docs/plans/M5b/09-turn-done-untrusted.md
T
kyleandClaude Opus 5.5 8658af58b7 M5b plan: task 09, TurnDone.untrusted from the session's log
bxcore's TurnDone gains untrusted: bool, always written and required
when read (no serde default: a missing field would read as a clean
session, the wrong way to fail). loopd's new Session::untrusted is true
once any ToolResult in the log is labelled untrusted, and channel::handle
puts it in the TurnDone; the log is the one source, so it holds across
resumes and restarts. gatewayd, bxctl and brokerd sources are unchanged;
their tests that build a TurnDone get new versions. Task 01's broker.rs
is kept as broker.rs-task01, and task 01 now copies it.

Checks: the given tests fail at ref 08 as the task says (strict's
turn_done.json, and 13 test targets with E0560/E0609 only); the task's
edits applied from its text to a fresh ref 08 tree are byte-identical to
the reference; the gate passes with 1058 tests; 12 mutations caught;
/private/tmp and TMPDIR unchanged around the runs; no tag in plan or
given files.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 06:37:03 -07:00

251 lines
12 KiB
Markdown

# 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<bxcore::Usage> {
```
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<bxcore::Usage> {
```
`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.