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>
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:TurnDonegainsuntrusted: 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 fixtureturn_done.jsonchanges, and a second one,turn_done_untrusted.json, holdstrue.loopd: a new method,Session::untrusted, is true when any tool result in the session's log is labelled untrusted.channel::handleputs it in theTurnDone. The log is the one source: it already holds each tool result with the labelbrokerdgave 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 laterTurnDoneof it says so, also afterloopdrestarts.- Unchanged: every source file of
gatewayd,bxctlandbrokerd. Only their tests build aTurnDone, 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 copybroker.rs-task01: it is the older version.
- new:
- 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.
serdetreats aboolfield without#[serde(default)]as required: decoding aturn_donewithout it fails withmissing field `untrusted`. A default would read a missing field asfalse, "clean", which is the wrong way to fail: a reply from an untrusted session would keep its links live.loopd,gatewaydandbxctlare built from this one workspace and deployed together, andTurnDonealready refuses unknown fields, so an older reader refuses the new frame anyway. So: no#[serde(default)], noskip_serializing_if.PROTOCOL_VERSIONdoes not change (task 01 added a field without changing it too). - Where the labels come from.
brokerdlabels every result it answers with (ToolResponse::Result { untrusted, .. }): from the grant, and forrecallalso frommemoryd, which marks a result holding a memory-tainted hit untrusted;brokerdkeeps the stricter of the two.run_turn(crates/loopd/src/turn.rs, unchanged) logs each tool result asLogRecord::ToolResult { untrusted, .. }with that label. A denial, a failure andloopd's own tools (clock,find_tool,call_tool's answers) are logged withuntrusted: false, asbrokerd's own state does: a failure changes no state. Session::openreads every record of the log back intorecords, soSession::untrustedon a reopened session sees every earlier turn's results. Every turn after a session's first reopens it (resume), in the sameloopdor a restarted one.- In
channel::handle,sessionis still in scope afterrun_turnreturns:run_turnborrows it only for the call.session.untrusted()borrows it again, immutably. - The calls the edits make:
Call Signature, and what it gives Iterator::anyself.records.iter().any(f) -> bool: true whenfis true for some recordmatches!matches!(record, LogRecord::ToolResult { untrusted: true, .. }): true for a tool result whose fielduntrustedistrueSession::untrustedpub 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 --shortlists 6 modified files and 2 new ones, all undercrates/*/tests/. -
2. See them fail. Two commands:
cargo test -p bxcore --test strict: one test fails,envelopes_reject_unknown_keys_at_every_depth, withturn_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_wireinbxcore;brokerinbrokerd;turn_done_untrustedinloopd;admin,chatandchat_approvalsinbxctl;deliver,deliver_notice,serve,serve_answers,serve_approvals,serve_redirectandserve_restartingatewayd. 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 ofturn_wire. -
4.
loopd. Make edits S1 and C1.cargo check -p loopd: no error. Thencargo test -p loopd --test turn_done_untrusted: 6 passed, andcargo 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/srcprints nothing, andgrep -n 'serde(default' crates/bxcore/src/wire.rsprints only the line ofToolResponse::Denied'sdetail(task 01), not one foruntrusted. -
7. Run the gate.
cargo fmt --all, thenmake 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 inbxcore/tests/turn_wire.rs, six inloopd/tests/turn_done_untrusted.rs; the new versions of the other files keep their counts). -
8. Log and commit.
git status --shortlists the three sources, the six replaced test files and your log as modified, andturn_done_untrusted.jsonandturn_done_untrusted.rsas new. Stage withgit add crates/bxcore crates/loopd crates/brokerd crates/bxctl crates/gatewayd docs/implementer-log.md Cargo.lock, then commit asAGENTS.mdsays.Cargo.lockdoes not change in this task; thegit addis harmless.
Done when
make gateprintsgate: okwith 1058 tests.git diff HEAD~1 --statlists onlycrates/bxcore/src/wire.rs,crates/loopd/src/session.rs,crates/loopd/src/channel.rs, the eight copied files anddocs/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, ingatewayd,bxctlorbrokerdsources, or a default for the field. It does not; stop and say why you think so. - A test in
memoryd,inferproxyortoolkitfails 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.