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>
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:ToolRequestbecomestool_request.- Several attributes can share one
#[serde(...)], separated by commas. - A field named
r#finalserializes asfinal. 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 tolib.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 wirereports 9 passed.make gateprintsgate: ok.diff -r crates/proto/tests/fixtures/wire docs/plans/M1/files/crates/proto/tests/fixtures/wireandcmp crates/proto/tests/wire.rs docs/plans/M1/files/crates/proto/tests/wire.rsprint nothing.
Stop and report if
- A fixture cannot be matched byte for byte with the field order given above.
deny_unknown_fieldsdoes not reject one of the cases in the test with the enum representations named above.