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>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user