Files
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

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.