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

12 KiB

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):

{"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

// 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

// 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

    pub fn last_usage(&self) -> Option<bxcore::Usage> {

becomes

    /// 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

                    msg: Message::TurnDone(TurnDone {
                        content: outcome.content,
                        usage: outcome.usage,
                    }),

become

                    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:

    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.