Files
boxmaker/docs/plans/M5c1/01-wire-speaker-state.md
T
kyleandClaude Opus 5.5 82b7898e84 M5c-1 plan: task 01, the speaker, SessionUpdated's turn, the session-state messages
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 at 7b40686 with its own build directory:
- every "before" block found exactly once at 7b40686 (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 of 7b40686 with 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>
2026-09-27 23:22:37 -07:00

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.

  1. Who spoke a turn. Turn gains speaker: Option<String>, and the session log's User record gains the same field. Both are written only when they are Some, and a frame or a log line without the field reads as None. None is the safe side: a turn without a speaker is never sent to Honcho. So turn.json and every log written before M5c keep their bytes.
  2. Which turn ended. SessionUpdated, which loopd sends to memoryd after each turn, gains turn: u64. It is required: a frame without it is refused, never read as turn 0.
  3. The session's state. memoryd will ask brokerd for a session's state on a new socket. Two new messages: SessionState { session } and the answer SessionStateReply { 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.
  • 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 under crates/gatewayd/tests.

Facts you need

  • The optional fields. #[serde(default, skip_serializing_if = "Option::is_none")] on an Option field does what it says: a missing field reads as None, and None is not written. An unknown key is still refused under deny_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's detail already uses it. (serde reads a missing Option field as None even without default; the attribute says so in so many words, as detail's does.)
  • Why speaker is optional. A missing speaker means "not synced", which is the safe way to be wrong. And loopd replays 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 given neutralise_replay.rs and session.rs check those bytes.
  • Why turn is required. A u64 field without #[serde(default)] is required: decoding a session_updated without it fails with missing field `turn` . A default would read a missing turn as turn 0, and memoryd would later record the wrong turn. loopd and memoryd are built from this one workspace and deployed together, and SessionUpdated refuses unknown fields, so an older reader refuses the new frame anyway. So: no #[serde(default)], no skip_serializing_if. The same holds for SessionStateReply's class and untrusted: a default would read a missing field as a clean session. PROTOCOL_VERSION does not change.
  • The kinds. Message is #[serde(tag = "kind", content = "body", rename_all = "snake_case")], so the variants SessionState and SessionStateReply are written as "kind":"session_state" and "kind":"session_state_reply" with no attribute of their own.
  • No checks on the name. bxcore does not check a speaker's name. memoryd checks it against Honcho's pattern in a later task (M5c spec, section 3). A given test sends first.last and expects it back as it is.
  • Two types named SessionState. brokerd has its own brokerd::policy::SessionState (taint, untrusted). The new bxcore::SessionState is a message. They do not meet in this task: broker.rs names the new one only as the pattern Message::SessionState(_), which needs no import. Do not add SessionState to any use line in brokerd.
  • Placeholders. turn: 0 in loopd is harmless until task 02: memoryd answers SessionUpdated by reading the session's log, and nothing reads turn before 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 as None; None is 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 --short lists 16 modified files and 4 new ones, all under crates/*/tests/.

  • 2. See them fail. Two commands:

    • cargo test -p bxcore --test strict: two tests fail: envelopes_reject_unknown_keys_at_every_depth, with turn_speaker.json: fixture must decode, and speaker_log_records_reject_unknown_keys_at_every_depth, with session_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, records and turn_wire in bxcore; broker in brokerd; chat in bxctl; baseline, channel, instance, memory, neutralise_replay, session and turn_done_untrusted in loopd; index, recall and serve in memoryd. 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` or struct `bxcore::SessionUpdated` has no field named `turn` ), E0432 (unresolved imports bxcore::SessionState, bxcore::SessionStateReply) or E0599 (no variant SessionState or SessionStateReply for enum Message).
  • 3. bxcore. Make edits W1, W2, W3, L1, M1 and X1. cargo test -p bxcore: every suite passes, among them turn_wire (9 tests), memory_wire (5), records (4) and strict (6).

  • 4. The other crates. cargo build --workspace --keep-going now fails in exactly six places: kind in brokerd (E0004, non-exhaustive patterns); one_turn in gatewayd, run_turn in bxctl and run_turn in loopd (E0063, missing field speaker); MemoryPort::updated in loopd (E0063, missing field turn); and the User arm in memoryd's index.rs (E0027, pattern does not mention field speaker). Make edits B1, B2, G1, C1, T1, U1 and I1. Then cargo 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.rs prints 2: detail of ToolResponse::Denied and speaker of Turn. Nothing on turn, class or untrusted.
    • git diff --stat -- crates/gatewayd/tests crates/inferproxy crates/toolkit prints nothing.
    • git diff --stat -- crates/*/src lists exactly the ten source files of "Modify".
  • 6. Run the gate. cargo fmt --all, then make 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 in bxcore/tests/turn_wire.rs, three in memory_wire.rs, one in records.rs, one in strict.rs, one in brokerd/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 is M5c1/01-wire-speaker-state. git status --short lists the ten sources, the sixteen replaced test files and your log as modified, and the four new fixtures. Stage with git add crates/bxcore crates/brokerd crates/gatewayd crates/bxctl crates/loopd crates/memoryd 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 1130 tests.
  • git diff HEAD~1 --stat lists only the ten source files of "Modify", the twenty copied files and docs/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 for turn, class or untrusted, or a check on the speaker's name. It does not; stop and say why you think so.
  • A test in 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 1130.
  • A comment in an edit does not fit the code under it. Quote it and stop.