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>
131 lines
4.8 KiB
Markdown
131 lines
4.8 KiB
Markdown
# 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:
|
|
|
|
```json
|
|
{"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.
|
|
|
|
```rust
|
|
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.**
|
|
|
|
```sh
|
|
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`:
|
|
|
|
```rust
|
|
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.**
|
|
|
|
```sh
|
|
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.
|