Task 01 changes bxcore's wire and log types for M5c-1: Turn.speaker and LogRecord::User.speaker (optional, skipped when None, so old frames and logs keep their bytes), SessionUpdated.turn (required, refused when missing), and the new SessionState / SessionStateReply messages. The other crates only construct the new fields (speaker: None, turn: 0) and brokerd's kind() names the two new kinds; later tasks give them values. 20 given files. Checks, against a reference at7b40686with its own build directory: - every "before" block found exactly once at7b40686(and at master, which changed no crate since); - step 2's failures as the task names them: two strict tests, fifteen test targets with E0559/E0560/E0432/E0599 only; - make gate: gate: ok, 1130 tests (1122 + 8); /tmp unchanged around it; - replay from the task text in a fresh worktree of7b40686with its own target/: crates/ byte-identical to the reference, gate ok, 1130; removed; - 23 mutations: 21 caught; the two that drop `default` from an Option field cannot change behaviour (serde reads a missing Option as None); - no chat-template tag whole in the task or the given files; lines over 100 characters only in tables, fixtures and verbatim strings. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
21 KiB
M5c-1 task 01: a turn's speaker, SessionUpdated's turn, and the session-state messages
Branch: m5c1 (run git switch m5c1; git status --short must be empty, otherwise stop)
Commit subject: bxcore: a turn's speaker, SessionUpdated's turn, the session-state messages
Log row: the Task cell is M5c1/01-wire-speaker-state.
Goal
M5c sends finished turns to Honcho, a memory server shared by a fleet of agents. Before any of
that can be built, the messages between the roles must carry three new things (M5c spec,
sections 5, 7 and 11). This task changes the types in bxcore only; the behaviour comes in later
tasks.
- Who spoke a turn.
Turngainsspeaker: Option<String>, and the session log'sUserrecord gains the same field. Both are written only when they areSome, and a frame or a log line without the field reads asNone.Noneis the safe side: a turn without a speaker is never sent to Honcho. Soturn.jsonand every log written before M5c keep their bytes. - Which turn ended.
SessionUpdated, whichloopdsends tomemorydafter each turn, gainsturn: u64. It is required: a frame without it is refused, never read as turn 0. - The session's state.
memorydwill askbrokerdfor a session's state on a new socket. Two new messages:SessionState { session }and the answerSessionStateReply { class, untrusted }. Both fields of the answer are required.
{"kind":"turn","body":{"session":"…","content":"…","resume":true,"speaker":"kyle"}}
{"type":"user","time":"…","content":"…","speaker":"kyle"}
{"kind":"session_updated","body":{"session":"…","turn":3}}
{"kind":"session_state","body":{"session":"…"}}
{"kind":"session_state_reply","body":{"class":"private","untrusted":true}}
The other crates build these types, so they must change to keep compiling, but only as far as
that. Where they build a Turn or a User record they write speaker: None; loopd sends
turn: 0; brokerd gives the two new kinds their names. Later tasks put real values there:
task 02 (loopd: the speaker in the log, the turn's number), task 03 (gatewayd), task 04
(bxctl chat --speaker), task 05 (brokerd answers SessionState), task 09 (memoryd reads
turn). Until then nothing reads the new fields, so nothing behaves differently.
Every place the changed types appear
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: Turn, Message, the use of crate::memory |
gains speaker; two new variants (edits W1 to W3) |
crates/bxcore/src/log.rs: LogRecord::User |
gains speaker (edit L1) |
crates/bxcore/src/memory.rs: SessionUpdated |
gains turn; the two new structs after it (edit M1) |
crates/bxcore/src/lib.rs |
exports the two new structs (edit X1) |
crates/brokerd/src/broker.rs, kind |
two new arms, and its comment's count (edits B1, B2) |
crates/gatewayd/src/deliver.rs, one_turn |
speaker: None (edit G1) |
crates/bxctl/src/chat.rs, run_turn |
speaker: None (edit C1) |
crates/loopd/src/turn.rs, run_turn |
speaker: None on the User record (edit T1) |
crates/loopd/src/memory.rs, MemoryPort::updated |
turn: 0 (edit U1) |
crates/memoryd/src/index.rs, the User arm |
the pattern gains ..: the speaker is not indexed (edit I1) |
crates/loopd/src/baseline.rs, crates/loopd/tests/support/turn.rs, crates/loopd/tests/neutralise.rs |
patterns with ..; no change |
crates/loopd/src/channel.rs, crates/memoryd/src/serve.rs, crates/gatewayd/tests/support/*, crates/bxctl/tests/support/mod.rs |
take a Turn or a SessionUpdated apart by name or pass it on; no change |
tests that build a Turn, a User record or a SessionUpdated, in bxcore, brokerd, bxctl, loopd, memoryd |
new versions are given |
Files
- Copy, from
docs/plans/M5c1/files/(the same path under it):- new:
crates/bxcore/tests/fixtures/wire/turn_speaker.json,crates/bxcore/tests/fixtures/wire/session_state.json,crates/bxcore/tests/fixtures/wire/session_state_reply.json,crates/bxcore/tests/fixtures/records/session_speaker.jsonl - new versions of (they replace the files in the tree):
crates/bxcore/tests/fixtures/wire/session_updated.json;crates/bxcore/tests/turn_wire.rs,memory_wire.rs,records.rs,strict.rs;crates/brokerd/tests/broker.rs;crates/bxctl/tests/chat.rs;crates/loopd/tests/baseline.rs,channel.rs,instance.rs,memory.rs,neutralise_replay.rs,session.rs,turn_done_untrusted.rs;crates/memoryd/tests/serve.rs,crates/memoryd/tests/support/logs.rs - Do not copy anything under
docs/plans/M5c1/files/crates/memoryd/tests/fixtures/honcho/: it is for later tasks.
- new:
- Modify:
crates/bxcore/src/wire.rs,crates/bxcore/src/log.rs,crates/bxcore/src/memory.rs,crates/bxcore/src/lib.rs,crates/brokerd/src/broker.rs,crates/gatewayd/src/deliver.rs,crates/bxctl/src/chat.rs,crates/loopd/src/turn.rs,crates/loopd/src/memory.rs,crates/memoryd/src/index.rs,docs/implementer-log.md. - Do not touch: any other source file, any
Cargo.toml, any file undercrates/gatewayd/tests.
Facts you need
- The optional fields.
#[serde(default, skip_serializing_if = "Option::is_none")]on anOptionfield does what it says: a missing field reads asNone, andNoneis not written. An unknown key is still refused underdeny_unknown_fields. It works the same on a field of a struct (Turn) and on a field of a variant of an internally tagged enum (LogRecord::User);ToolResponse::Denied'sdetailalready uses it. (serdereads a missingOptionfield asNoneeven withoutdefault; the attribute says so in so many words, asdetail's does.) - Why
speakeris optional. A missing speaker means "not synced", which is the safe way to be wrong. Andloopdreplays each session's log byte for byte to the inference server: a log written before M5c must read as it is, and a record without a speaker must be written with the bytes it had before. The givenneutralise_replay.rsandsession.rscheck those bytes. - Why
turnis required. Au64field without#[serde(default)]is required: decoding asession_updatedwithout it fails withmissing field `turn`. A default would read a missing turn as turn 0, andmemorydwould later record the wrong turn.loopdandmemorydare built from this one workspace and deployed together, andSessionUpdatedrefuses unknown fields, so an older reader refuses the new frame anyway. So: no#[serde(default)], noskip_serializing_if. The same holds forSessionStateReply'sclassanduntrusted: a default would read a missing field as a clean session.PROTOCOL_VERSIONdoes not change. - The kinds.
Messageis#[serde(tag = "kind", content = "body", rename_all = "snake_case")], so the variantsSessionStateandSessionStateReplyare written as"kind":"session_state"and"kind":"session_state_reply"with no attribute of their own. - No checks on the name.
bxcoredoes not check a speaker's name.memorydchecks it against Honcho's pattern in a later task (M5c spec, section 3). A given test sendsfirst.lastand expects it back as it is. - Two types named
SessionState.brokerdhas its ownbrokerd::policy::SessionState(taint,untrusted). The newbxcore::SessionStateis a message. They do not meet in this task:broker.rsnames the new one only as the patternMessage::SessionState(_), which needs no import. Do not addSessionStateto anyuseline inbrokerd. - Placeholders.
turn: 0inloopdis harmless until task 02:memorydanswersSessionUpdatedby reading the session's log, and nothing readsturnbefore task 09. - The calls and attributes the edits use:
Item What it does #[serde(default, skip_serializing_if = "Option::is_none")]on speaker(W3, L1): missing reads asNone;Noneis not written#[serde(deny_unknown_fields)]on each new struct (M1): an unknown key is refused, as on every message LogRecord::User { time, content, .. }a pattern that ignores the fields it does not name (I1)
The edits, exactly
Nothing else in these files changes. Each quoted "before" block occurs exactly once in its file. Keep the fields and variants in the order shown: it is the wire format.
crates/bxcore/src/wire.rs
W1. The lines
use crate::memory::{
MemoryAdmin, MemoryAdminReply, MemoryBlockReply, MemoryCall, Reindexed, SessionUpdated,
};
become
use crate::memory::{
MemoryAdmin, MemoryAdminReply, MemoryBlockReply, MemoryCall, Reindexed, SessionState,
SessionStateReply, SessionUpdated,
};
W2. At the end of enum Message, the lines
MemoryAdmin(MemoryAdmin),
MemoryAdminReply(MemoryAdminReply),
}
become
MemoryAdmin(MemoryAdmin),
MemoryAdminReply(MemoryAdminReply),
SessionState(SessionState),
SessionStateReply(SessionStateReply),
}
W3. The lines
// JSON: {"kind":"turn","body":{"session":"…","content":"…","resume":false}}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Turn {
pub session: SessionId,
pub content: String,
pub resume: bool,
}
become
// JSON: {"kind":"turn","body":{"session":"…","content":"…","resume":false,"speaker":"kyle"}}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Turn {
pub session: SessionId,
pub content: String,
pub resume: bool,
/// Who spoke the turn, when one person did (M5c spec, section 11). Written only when present,
/// and a frame without it reads as `None`, the safe side: a turn without a speaker is never
/// synced to Honcho.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speaker: Option<String>,
}
crates/bxcore/src/log.rs
L1. In enum LogRecord, the lines
User {
time: Timestamp,
content: String,
},
become
User {
time: Timestamp,
content: String,
/// The turn's speaker (M5c spec, section 11). Written only when present, so a log written
/// before M5c reads as it is, and a turn without a speaker is logged as before.
#[serde(default, skip_serializing_if = "Option::is_none")]
speaker: Option<String>,
},
crates/bxcore/src/memory.rs
M1. The lines
/// `loopd` → `memoryd`: this session's log has new records.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionUpdated {
pub session: SessionId,
}
become
/// `loopd` → `memoryd`: this session's log has new records, those of a turn that has ended.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionUpdated {
pub session: SessionId,
/// The ended turn's number in the session (M5c spec, section 5). It has no default: a frame
/// without it is refused, never read as turn 0.
pub turn: u64,
}
/// `memoryd` → `brokerd`, on `state.sock`: the session's state now (M5c spec, section 7).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionState {
pub session: SessionId,
}
/// `brokerd` → `memoryd`: the session's class, and whether it has read untrusted content. Neither
/// field has a default: a reply without one is refused, never read as a clean session.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionStateReply {
pub class: DataClass,
pub untrusted: bool,
}
DataClass and SessionId are imported at the top of the file already.
crates/bxcore/src/lib.rs
X1. The lines
pub use memory::{
ConsolidateEntry, EntryAction, MemoryAdmin, MemoryAdminReply, MemoryArgs, MemoryBlockReply,
MemoryCall, MemoryCommand, MemoryFile, RecallArgs, Reindexed, SessionUpdated,
};
become
pub use memory::{
ConsolidateEntry, EntryAction, MemoryAdmin, MemoryAdminReply, MemoryArgs, MemoryBlockReply,
MemoryCall, MemoryCommand, MemoryFile, RecallArgs, Reindexed, SessionState, SessionStateReply,
SessionUpdated,
};
crates/brokerd/src/broker.rs
B1. The line
/// The snake_case wire name of a message: all twenty-six kinds, none with a leading underscore.
becomes
/// The snake_case wire name of a message: all twenty-eight kinds, none with a leading underscore.
B2. At the end of kind, the lines
Message::MemoryAdminReply(_) => "memory_admin_reply",
}
become
Message::MemoryAdminReply(_) => "memory_admin_reply",
Message::SessionState(_) => "session_state",
Message::SessionStateReply(_) => "session_state_reply",
}
crates/gatewayd/src/deliver.rs
G1. In one_turn, the lines
content: batch.text.clone(),
resume,
};
become
content: batch.text.clone(),
resume,
// Set in M5c-1 task 03. Until then no turn has a speaker, and none is synced.
speaker: None,
};
crates/bxctl/src/chat.rs
C1. In run_turn, the lines
content: content.to_string(),
resume,
};
become
content: content.to_string(),
resume,
// Set in M5c-1 task 04, from `--speaker`. Until then no turn has a speaker.
speaker: None,
};
crates/loopd/src/turn.rs
T1. In run_turn, the lines
time: Timestamp::now(),
content: neutralise(content).into_owned(),
})
become
time: Timestamp::now(),
content: neutralise(content).into_owned(),
// Set in M5c-1 task 02, from the turn. `None` is not written: the record's bytes
// are as before.
speaker: None,
})
crates/loopd/src/memory.rs
U1. In MemoryPort::updated, the lines
match self.ask(Message::SessionUpdated(SessionUpdated {
session: session.clone(),
}))? {
become
match self.ask(Message::SessionUpdated(SessionUpdated {
session: session.clone(),
// Set in M5c-1 task 02. `memoryd` does not read it before M5c-1 task 09.
turn: 0,
}))? {
crates/memoryd/src/index.rs
I1. The line
LogRecord::User { time, content } if !content.trim().is_empty() => {
becomes
LogRecord::User { time, content, .. } if !content.trim().is_empty() => {
Steps
-
1. Copy the given files. From the repository root, twenty commands:
cp docs/plans/M5c1/files/crates/bxcore/tests/fixtures/wire/turn_speaker.json \ crates/bxcore/tests/fixtures/wire/ cp docs/plans/M5c1/files/crates/bxcore/tests/fixtures/wire/session_state.json \ crates/bxcore/tests/fixtures/wire/ cp docs/plans/M5c1/files/crates/bxcore/tests/fixtures/wire/session_state_reply.json \ crates/bxcore/tests/fixtures/wire/ cp docs/plans/M5c1/files/crates/bxcore/tests/fixtures/wire/session_updated.json \ crates/bxcore/tests/fixtures/wire/ cp docs/plans/M5c1/files/crates/bxcore/tests/fixtures/records/session_speaker.jsonl \ crates/bxcore/tests/fixtures/records/ cp docs/plans/M5c1/files/crates/bxcore/tests/turn_wire.rs crates/bxcore/tests/ cp docs/plans/M5c1/files/crates/bxcore/tests/memory_wire.rs crates/bxcore/tests/ cp docs/plans/M5c1/files/crates/bxcore/tests/records.rs crates/bxcore/tests/ cp docs/plans/M5c1/files/crates/bxcore/tests/strict.rs crates/bxcore/tests/ cp docs/plans/M5c1/files/crates/brokerd/tests/broker.rs crates/brokerd/tests/ cp docs/plans/M5c1/files/crates/bxctl/tests/chat.rs crates/bxctl/tests/ cp docs/plans/M5c1/files/crates/loopd/tests/baseline.rs crates/loopd/tests/ cp docs/plans/M5c1/files/crates/loopd/tests/channel.rs crates/loopd/tests/ cp docs/plans/M5c1/files/crates/loopd/tests/instance.rs crates/loopd/tests/ cp docs/plans/M5c1/files/crates/loopd/tests/memory.rs crates/loopd/tests/ cp docs/plans/M5c1/files/crates/loopd/tests/neutralise_replay.rs crates/loopd/tests/ cp docs/plans/M5c1/files/crates/loopd/tests/session.rs crates/loopd/tests/ cp docs/plans/M5c1/files/crates/loopd/tests/turn_done_untrusted.rs crates/loopd/tests/ cp docs/plans/M5c1/files/crates/memoryd/tests/serve.rs crates/memoryd/tests/ cp docs/plans/M5c1/files/crates/memoryd/tests/support/logs.rs crates/memoryd/tests/support/Then
git status --shortlists 16 modified files and 4 new ones, all undercrates/*/tests/. -
2. See them fail. Two commands:
cargo test -p bxcore --test strict: two tests fail:envelopes_reject_unknown_keys_at_every_depth, withturn_speaker.json: fixture must decode, andspeaker_log_records_reject_unknown_keys_at_every_depth, withsession_speaker.jsonl:2: fixture must decode.cargo build --tests --keep-going -p bxcore -p brokerd -p bxctl -p loopd -p memoryd: fifteen test targets do not compile:memory_wire,recordsandturn_wireinbxcore;brokerinbrokerd;chatinbxctl;baseline,channel,instance,memory,neutralise_replay,sessionandturn_done_untrustedinloopd;index,recallandserveinmemoryd. Every error is about a name this task adds:E0559(variant `LogRecord::User` has no field named `speaker`),E0560(struct `bxcore::Turn` has no field named `speaker`orstruct `bxcore::SessionUpdated` has no field named `turn`),E0432(unresolved importsbxcore::SessionState,bxcore::SessionStateReply) orE0599(no variantSessionStateorSessionStateReplyfor enumMessage).
-
3.
bxcore. Make edits W1, W2, W3, L1, M1 and X1.cargo test -p bxcore: every suite passes, among themturn_wire(9 tests),memory_wire(5),records(4) andstrict(6). -
4. The other crates.
cargo build --workspace --keep-goingnow fails in exactly six places:kindinbrokerd(E0004, non-exhaustive patterns);one_turningatewayd,run_turninbxctlandrun_turninloopd(E0063, missing fieldspeaker);MemoryPort::updatedinloopd(E0063, missing fieldturn); and theUserarm inmemoryd'sindex.rs(E0027, pattern does not mention fieldspeaker). Make edits B1, B2, G1, C1, T1, U1 and I1. Thencargo test -p brokerd -p gatewayd -p bxctl -p loopd -p memoryd: every suite passes. -
5. Check. Three commands:
grep -c 'serde(default' crates/bxcore/src/wire.rsprints2:detailofToolResponse::DeniedandspeakerofTurn. Nothing onturn,classoruntrusted.git diff --stat -- crates/gatewayd/tests crates/inferproxy crates/toolkitprints nothing.git diff --stat -- crates/*/srclists exactly the ten source files of "Modify".
-
6. Run the gate.
cargo fmt --all, thenmake gate, with a shell timeout of 600000 ms. Expected last line:gate: ok, with 1130 tests: 1122 before this task, plus 8 new ones (two inbxcore/tests/turn_wire.rs, three inmemory_wire.rs, one inrecords.rs, one instrict.rs, one inbrokerd/tests/broker.rs; the other new versions keep their counts). -
7. Log and commit. Add your row to
docs/implementer-log.md; its Task cell isM5c1/01-wire-speaker-state.git status --shortlists the ten sources, the sixteen replaced test files and your log as modified, and the four new fixtures. Stage withgit add crates/bxcore crates/brokerd crates/gatewayd crates/bxctl crates/loopd crates/memoryd 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 1130 tests.git diff HEAD~1 --statlists only the ten source files of "Modify", the twenty copied files anddocs/implementer-log.md.
Stop and report if
- A "before" block of any edit is not in its file exactly as shown, or is there more than once.
- Step 2 shows a different result: other tests failing in
strict, other test targets, or an error of another kind. - Step 4's build fails anywhere other than the six places it names.
- After step 3 or 4, 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: another source file, a test under
crates/gatewayd/tests, a default forturn,classoruntrusted, or a check on the speaker's name. It does not; stop and say why you think so. - A test in
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 1130.
- A comment in an edit does not fit the code under it. Quote it and stop.