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

4.5 KiB

M1 task 04: proto frame codec

Branch: m1 (run git switch m1; git status --short must be empty, otherwise stop) Commit subject: Add length-prefixed frame codec to proto

Goal

Read and write one envelope on a byte stream. This is the only I/O in proto. It is the first code that touches bytes from another process, so it must never trust a length it has not checked.

Context

From the spec: "A 4-byte big-endian unsigned length N, then N bytes of UTF-8 JSON holding one envelope. MAX_FRAME is 1,048,576. A reader that sees N == 0 or N > MAX_FRAME returns an error without reading or allocating the body." And: "v is the protocol version, 1. A reader that sees another value" rejects the frame.

Files

  • Copy: crates/proto/tests/frame.rs, crates/proto/tests/fixtures/frame/tool_request.bin
  • Create: crates/proto/src/frame.rs
  • Modify: crates/proto/src/lib.rs, docs/implementer-log.md

Interfaces

Consumes from task 03: proto::Envelope, proto::PROTOCOL_VERSION.

Produces, in crates/proto/src/frame.rs, re-exported from the crate root:

pub const MAX_FRAME: usize = 1_048_576;

#[derive(Debug)]
pub enum FrameError {
    Closed,                     // the stream ended before the first byte of a frame
    Empty,                      // the length prefix was 0
    TooLarge(usize),            // the length prefix, or an outgoing body, was larger than MAX_FRAME
    BadVersion(u32),            // the envelope's v was not PROTOCOL_VERSION
    Json(serde_json::Error),    // the body was not a valid envelope, or could not be encoded
    Io(std::io::Error),         // any other I/O failure, including a stream that ends mid-frame
}
// FrameError implements Display (a short message per variant) and std::error::Error.

pub fn write_frame<W: std::io::Write>(w: &mut W, env: &Envelope) -> Result<(), FrameError>;
pub fn read_frame<R: std::io::Read>(r: &mut R) -> Result<Envelope, FrameError>;

Rules the tests check:

  • write_frame encodes first. If the body is larger than MAX_FRAME it returns TooLarge(len) and writes nothing. Otherwise it writes the 4-byte length, the body, and flushes.
  • read_frame reads exactly 4 length bytes. Read::read may return fewer bytes than asked for, even one at a time; keep reading until you have all 4. If the stream ends before the first of them, return Closed. If it ends after 1 to 3 of them, return Io.
  • It checks N == 0 (Empty) and N > MAX_FRAME (TooLarge(N)) before it allocates a buffer or reads the body. One test uses a reader that panics if the body is read.
  • A body of exactly MAX_FRAME bytes is allowed.
  • A stream that ends inside the body is Io. A body that is not a valid envelope is Json.
  • After decoding, v != PROTOCOL_VERSION is BadVersion(v).

API notes

  • u32::from_be_bytes([u8; 4]) and u32::to_be_bytes().
  • serde_json::to_vec(&T) -> Result<Vec<u8>, serde_json::Error> and serde_json::from_slice::<T>(&[u8]).
  • Read::read_exact returns ErrorKind::UnexpectedEof when the stream ends early, which is what you want for the body. It cannot tell a clean close from a cut-off header, so do not use it for the 4 length bytes.
  • A read that fails with ErrorKind::Interrupted should be retried.
  • Build an io::Error from a kind with std::io::Error::from(std::io::ErrorKind::UnexpectedEof).

Steps

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

  • 3. Write frame.rs, and add to lib.rs (alphabetical order, as before):

pub mod frame;

pub use frame::{FrameError, MAX_FRAME, read_frame, write_frame};
  • 4. See the test pass. cargo test -p proto --test frame. Expected: 13 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 frame reports 13 passed.
  • make gate prints gate: ok.
  • cmp crates/proto/tests/frame.rs docs/plans/M1/files/crates/proto/tests/frame.rs prints nothing.
  • frame.rs contains no unwrap, expect, panic! or unchecked indexing.

Stop and report if

  • write_matches_the_fixture_byte_for_byte fails although cargo test -p proto --test wire passes.