Files
boxmaker/docs/plans/M1/03-proto-wire.md
kyleandClaude Fable 5.1 3e26c2e3c0 Add M1 plan, given tests and fixtures, AGENTS.md and implementer log
Seven task files for the implementing model under docs/plans/M1/, with
the test files, byte-exact fixtures, Makefile, deny.toml and gate-script
self-test they copy into place. All of it was verified against a private
reference implementation: the gate passes after every task in order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 01:22:43 -07:00

4.8 KiB

M1 task 03: proto IPC messages

Branch: m1 (run git switch m1; git status --short must be empty, otherwise stop) Commit subject: Add IPC envelope and tool messages to proto

Goal

Add the message types that roles send each other. They are defined by crates/proto/tests/wire.rs and the byte-exact JSON files in crates/proto/tests/fixtures/wire/. Each fixture must decode to the expected value, and the value must encode back to exactly the fixture's bytes.

Context

From the spec: "Unknown fields are rejected everywhere. Enums are tagged with explicit strings. No floats. No free-form maps." An envelope looks like this:

{"v":1,"id":7,"final":true,"msg":{"kind":"tool_request","body":{"session":"mm-thread-42","call":3,"tool":"read_file","arguments":"{\"path\":\"/etc/hosts\"}"}}}

arguments is the model's JSON object carried as a string. proto never parses it.

Files

  • Copy: crates/proto/tests/wire.rs, crates/proto/tests/fixtures/wire/ (6 files)
  • Create: crates/proto/src/wire.rs
  • Modify: crates/proto/src/lib.rs, docs/implementer-log.md

Interfaces

Consumes from task 02: proto::SessionId, proto::CallId, proto::Timestamp, proto::DataClass.

Produces, in crates/proto/src/wire.rs, re-exported from the crate root. Keep fields and variants in exactly this order; serde writes JSON in declaration order and the fixtures depend on it.

pub const PROTOCOL_VERSION: u32 = 1;

pub struct Envelope { pub v: u32, pub id: u64, pub r#final: bool, pub msg: Message }

// JSON: {"kind":"tool_request","body":{...}} ; kinds are snake_case
pub enum Message { ToolRequest(ToolRequest), ToolResponse(ToolResponse), Error(WireError) }

pub struct WireError { pub code: ErrorCode, pub detail: String }

// JSON: "bad_frame", "bad_version", "bad_message", "internal"
pub enum ErrorCode { BadFrame, BadVersion, BadMessage, Internal }

pub struct ToolRequest { pub session: SessionId, pub call: CallId, pub tool: String, pub arguments: String }

// JSON: {"status":"result","content":...} ; the tag sits beside the fields; statuses are snake_case
pub enum ToolResponse {
    PendingApproval { approval: String, expires: Timestamp },
    Result { content: String, class: DataClass, untrusted: bool, truncated: bool },
    Failed { message: String },
    Denied { reason: DenyReason },
}

// JSON: "no_grant", "grant_expired", "taint_too_high", "denied_by_grant", "approval_refused", "approval_expired"
pub enum DenyReason { NoGrant, GrantExpired, TaintTooHigh, DeniedByGrant, ApprovalRefused, ApprovalExpired }

Derives: every type gets Debug, Clone, PartialEq, Eq, Serialize, Deserialize. ErrorCode and DenyReason also get Copy.

Rules the tests check: an unknown field is an error in the envelope, beside kind and body, inside a body, and inside a ToolResponse variant. A missing field is an error, including final. An unknown kind or status is an error.

API notes (serde 1.0, verified 2026-09-17)

  • #[serde(deny_unknown_fields)] works on structs and on enums.
  • Adjacently tagged enum: #[serde(tag = "kind", content = "body")].
  • Internally tagged enum (tag beside the variant's own fields): #[serde(tag = "status")].
  • #[serde(rename_all = "snake_case")] renames variants: ToolRequest becomes tool_request.
  • Several attributes can share one #[serde(...)], separated by commas.
  • A field named r#final serializes as final. No rename is needed.

Steps

  • 1. Copy the test and fixtures.
git switch m1
mkdir -p crates/proto/tests/fixtures
cp docs/plans/M1/files/crates/proto/tests/wire.rs crates/proto/tests/
cp -r docs/plans/M1/files/crates/proto/tests/fixtures/wire crates/proto/tests/fixtures/
  • 2. See the test fail. cargo test -p proto --test wire. Expected: it does not compile.

  • 3. Write wire.rs, and add to lib.rs:

pub mod wire;

pub use wire::{
    DenyReason, Envelope, ErrorCode, Message, PROTOCOL_VERSION, ToolRequest, ToolResponse,
    WireError,
};

Keep the pub mod lines in lib.rs in alphabetical order, and the pub use lines after them in alphabetical order by module.

  • 4. See the test pass. cargo test -p proto --test wire. Expected: 9 passed; 0 failed.

  • 5. Run the gate. make gate. Expected last line: gate: ok.

  • 6. Log and commit.

git add crates/proto docs/implementer-log.md
git commit

Done when

  • cargo test -p proto --test wire reports 9 passed.
  • make gate prints gate: ok.
  • diff -r crates/proto/tests/fixtures/wire docs/plans/M1/files/crates/proto/tests/fixtures/wire and cmp crates/proto/tests/wire.rs docs/plans/M1/files/crates/proto/tests/wire.rs print nothing.

Stop and report if

  • A fixture cannot be matched byte for byte with the field order given above.
  • deny_unknown_fields does not reject one of the cases in the test with the enum representations named above.