Files
boxmaker/docs/plans/M3a/02-proto-admin-wire.md
kyleandClaude Opus 5 e3f37da232 Hand over the M3a plan: 22 tasks, their files, and the check record
Task files, the files they copy in (byte-identical to the reference on
m3a-ref), each area's check record, and a README with the per-task
table of what each check exposed. The handoff note is done with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 23:45:43 -07:00

5.9 KiB

M3a task 02: the admin messages and the other wire additions

Branch: m3a (run git switch m3a; git status --short must be empty, otherwise stop) Commit subject: Add the admin messages, approval ids as numbers, and two turn events

Goal

Everything proto::wire needs for M3a: four more deny reasons, two more error codes, the eight admin messages of admin.sock with their body types, approval ids as u64, and two new turn events. This is a format we define, so every new struct and every new enum variant rejects unknown fields.

Files

  • Copy: crates/proto/tests/wire.rs, turn_wire.rs, strict.rs, admin_wire.rs, and the seventeen files in crates/proto/tests/fixtures/wire/ (sixteen new, and a changed tool_response_pending.json)
  • Modify: crates/proto/src/wire.rs, crates/proto/src/lib.rs, crates/bxctl/src/chat.rs, docs/implementer-log.md

Check first

Task 01 made DecisionRecord::Allowed and Ask empty struct variants (Allowed {}, Ask {}), because serde does not apply deny_unknown_fields to the unit variants of an internally tagged enum. Run grep -n 'Allowed {},' crates/proto/src/audit.rs. If it prints nothing, stop and report: the test an_outcome_rejects_unknown_and_misplaced_fields cannot pass without it. Write the braces wherever you name these two variants, as the tests do: DecisionRecord::Allowed {}.

Interfaces

Keep the order of variants and fields exactly as given. The order is the wire format.

In wire.rs, change the import to use crate::{CallId, DataClass, DecisionRecord, SessionId, Timestamp, Usage}; and then:

pub enum Message {
    // … the six existing variants, unchanged, then:
    Approvals(Empty),
    ApprovalList(ApprovalList),
    Approve(Approve),
    ApproveResult(ApproveResult),
    Refuse(Refuse),
    Ok(Empty),
    CheckGrants(Empty),
    GrantsReport(GrantsReport),
}

pub enum ErrorCode {
    // … the ten existing variants, then:
    Forbidden,
    NoSuchApproval,
}

pub enum ToolResponse {
    PendingApproval { approval: u64, expires: Timestamp },   // was `approval: String`
    // … the rest unchanged
}

pub enum DenyReason {
    // … the six existing variants, then:
    GrantsInvalid,
    AuditUnavailable,
    InvalidArguments,
    StateUnreadable,
}

pub enum TurnEvent {
    // … the ten existing variants, then:
    ApprovalPending { approval: u64, tool: String, expires: Timestamp },
    ToolDenied { name: String, reason: DenyReason },
}

New types, at the end of wire.rs. Every one of the eight gets #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] and #[serde(deny_unknown_fields)]; Empty also derives Default.

// The admin messages on admin.sock. JSON: {"kind":"approvals","body":{}}
pub struct Empty {}

pub struct PendingApproval {
    pub approval: u64,
    pub session: SessionId,
    pub call: CallId,
    pub tool: String,
    pub arguments: String,
    pub grant: String,
    pub taint: DataClass,
    pub created: Timestamp,
    pub expires: Timestamp,
}

pub struct ApprovalList { pub items: Vec<PendingApproval> }
pub struct Approve { pub approval: u64 }
pub struct ApproveResult { pub outcome: DecisionRecord }
pub struct Refuse { pub approval: u64, pub reason: Option<String> }
pub struct GrantProblem { pub file: String, pub line: Option<u64>, pub problem: String }
pub struct GrantsReport { pub problems: Vec<GrantProblem> }

Empty {} is written with braces, not as struct Empty;: a unit struct would encode as null, and the body must be {}.

In lib.rs the pub use wire::{…} line becomes:

pub use wire::{
    ApprovalList, Approve, ApproveResult, DenyReason, Empty, Envelope, ErrorCode, GrantProblem,
    GrantsReport, Message, PROTOCOL_VERSION, PendingApproval, Refuse, ToolRequest, ToolResponse,
    Turn, TurnDone, TurnEvent, WireError,
};

bxctl must still compile

crates/bxctl/src/chat.rs has two matches with no catch-all arm. Add arms; do not add _ =>.

  1. In code_name, after the Inference arm:
    ErrorCode::Forbidden => "forbidden",
    ErrorCode::NoSuchApproval => "no such approval",
    
  2. In Printer::event, after the Queued | Progress arm:
    // Printed from task 20 on; until then these events are not sent.
    TurnEvent::ApprovalPending { .. } | TurnEvent::ToolDenied { .. } => {}
    

loopd needs no change: its match on ToolResponse::PendingApproval uses { .. }.

Steps

  • 1. Copy. git switch m3a, then cp docs/plans/M3a/files/crates/proto/tests/*.rs crates/proto/tests/ only for the four files named above (wire.rs turn_wire.rs strict.rs admin_wire.rs), and cp docs/plans/M3a/files/crates/proto/tests/fixtures/wire/*.json crates/proto/tests/fixtures/wire/
  • 2. See the tests fail. cargo test -p proto --test admin_wire. Expected: it does not compile.
  • 3. Edit wire.rs and lib.rs, then the two arms in chat.rs. Run cargo fmt --all.
  • 4. Go through every new type one by one (eight structs, and the variants added to five enums) and check each has deny_unknown_fields, either its own or its enum's. The enums already have it; the eight structs each need their own.
  • 5. See the tests pass. cargo test -p proto --test wire --test turn_wire --test admin_wire --test strict. Expected: wire 10 passed, turn_wire 5 passed, admin_wire 10 passed, strict 5 passed.
  • 6. Run the gate. make gate. Expected last line: gate: ok.
  • 7. Log and commit. git add crates/proto crates/bxctl/src/chat.rs docs/implementer-log.md && git commit

Done when

  • The four test files report the counts in step 5, and make gate prints gate: ok.

Stop and report if

  • Allowed {}, is missing from audit.rs ("Check first").
  • A fixture cannot be matched byte for byte with the field order given here.
  • Any file outside proto other than bxctl/src/chat.rs stops compiling.