Areas A to D (audit, policy side, loopd, bxctl) are written on branches in ~/src/boxmaker-ref-a to -d; area E (broker and approval handoff) is not started. HANDOFF.md records each area's result, the contract conflicts between them, the spec defects they found and the remaining steps in order. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
20 KiB
M3a plan: handoff, 2026-09-18
The M3a plan is half built. This file says what exists, where, and what is left. Delete it when
the plan is complete and README.md replaces it.
Read first: CLAUDE.md, docs/specs/2026-09-18-m3a-decision-path.md (revised after review; the
"Spec review of M3a" rows in docs/decisions.md say what changed and why), the decision of
2026-09-18 on reference implementations (top row of docs/decisions.md, tip T17 in
docs/implementer-lessons.md), and docs/plans/M2b/ as the model for format
(README.md, 07-loopd-channel.md).
How the plan is being built
The given tests are checked before hand-over at one of three levels: a full reference
implementation (audit chain, approval handoff), the oracle inside the property test (policy), or
a skeleton (real signatures, todo!() bodies, tests must compile and be clippy-clean). Each area
records what its check exposed in docs/plans/M3a/checks-<x>.md; the final README.md folds
those into one table per task. That record decides what M3b gets.
Work happens in git worktrees on unpushed branches, never in /tmp:
| Worktree | Branch | Holds |
|---|---|---|
~/src/boxmaker-ref |
m3a-ref |
Integration. Commit 3ba5f83: the proto contract (new audit types, admin wire messages, chain.rs skeleton) and an empty file for every new module, so that areas never share a file. |
~/src/boxmaker-ref-a |
m3a-ref-a |
Area A, audit: tasks 01, 03, 09, 19 |
~/src/boxmaker-ref-b |
m3a-ref-b |
Area B, policy side: tasks 02, 04 to 08 |
~/src/boxmaker-ref-c |
m3a-ref-c |
Area C, loopd: tasks 16, 17 |
~/src/boxmaker-ref-d |
m3a-ref-d |
Area D, bxctl and the runbook script: tasks 18, 20, 21 |
Each area branch holds, under its worktree: the tests in place under crates/, the reference or
skeleton source, copies of everything the implementer must copy under
docs/plans/M3a/files/<same path>, its task files docs/plans/M3a/NN-name.md, and
docs/plans/M3a/checks-<x>.md.
Task numbering (fixed; task files use these names)
| # | Task | Area | Check |
|---|---|---|---|
| 01 | proto-audit-types |
A | reference (types; generates fixtures) |
| 02 | proto-admin-wire |
B | reference (types) |
| 03 | proto-chain-verifier |
A | reference |
| 04 | brokerd-config |
B | minimal reference (needed under E) |
| 05 | brokerd-args |
B | minimal reference |
| 06 | brokerd-grants |
B | minimal reference |
| 07 | brokerd-policy |
B | oracle, plus minimal reference |
| 08 | brokerd-state |
B | minimal reference |
| 09 | brokerd-audit-writer |
A | reference |
| 10 | brokerd-runner |
E | reference |
| 11 | brokerd-approvals |
E | reference |
| 12 | brokerd-ledger |
E | reference |
| 13 | brokerd-broker |
E | reference |
| 14 | brokerd-admin |
E | reference |
| 15 | brokerd-serve |
E | reference |
| 16 | loopd-tools |
C | skeleton |
| 17 | loopd-broker-port |
C | skeleton |
| 18 | bxctl-admin |
D | skeleton |
| 19 | bxctl-audit-verify |
A | reference |
| 20 | bxctl-chat-approvals |
D | skeleton |
| 21 | runbook-check |
D | run for real |
| 22 | end-to-end |
E | compiles only, unless C's BrokerPort gets a body |
Area E: not started
Tasks 10 to 15 and 22: runner, approvals, ledger, broker, admin, serve, the
two-process end-to-end test, and the scripted check on straylight. It builds on A's
brokerd::audit::Writer and B's config, args, grants, policy, state, so it starts from
a merge of those two branches. The design it must follow, beyond the spec:
ledger.rs:pub trait AuditSink: Send { fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result<u64, AuditError>; }implemented byaudit::Writer, so tests can wrap a real writer in one that fails on demand.Ledger { audit: Box<dyn AuditSink>, state: StateStore }behind oneMutex, with the three locked steps as functions: decide and record; re-decide and record the approval; raise state and record the result. A poisoned ledger lock or any failed append meansaudit_unavailablefor every later call.approvals.rs:Verdict { Run(Decision), Denied(DenyReason) };TableoverMutex<BTreeMap<u64, Entry>>withinsert(info, ask) -> Receiver<Verdict>,take(id),take_expired(now),list(). Whoever takes the entry answers it.broker.rs:pub struct Broker { cfg, ledger, table, runtime },handle(stream, &Broker). While pending:recv_timeout(1 s), then areadwith a 10 ms read timeout (Ok(0)is gone,WouldBlock/TimedOutis alive, bytes are a protocol error). One more check before running.runner.rs: spec section 7, withRefusingas the production runtime and a recording fake in the tests.RunErrortext is fixed text.serve.rs: the audit lock first, then directories (0700 whether made or found), stale socket removal, bind, 0600; one thread per connection; the expiry thread.- The state-write failure test uses a read-only directory; the audit failure test uses the
AuditSinkseam. - The end-to-end test lives in
loopd, is#[ignore]d withoutBOXMAKER_BROKERD, andmake gatebuilds the workspace and runs it with the variable set. - The straylight script checks
GET /slots?model=ornith-1.5-35b-a3band uses slot 0 only.
The prompts given to areas A to D are the model for E's: they are in this session's transcript; their substance is the numbered conventions below.
Conventions every area follows
- Tests carry "Do not edit." Deterministic, robust under CPU load, no
tempfilecrate (temp directories understd::env::temp_dir()with pid and a counter, ascrates/loopd/tests/support/).cargo fmt --all;cargo clippy --workspace --all-targets --offline -- -D warningsclean. - A reference follows the code rules in
AGENTS.md. - Everything the implementer copies is also under
docs/plans/M3a/files/. Reference source is not. - Task files follow
docs/plans/M2b/07-loopd-channel.md: branchm3a, at most about 3,000 tokens, rules that walk every exit (T14, I11, I12), verified API snippets, expected test counts. - A
checks-<x>.mdper area, honest about what ran green and what only compiled.
Left to do, in order
- Read each area's final report (below) and its
checks-<x>.md. Resolve contract changes they asked for.crates/proto/tests/strict.rsmay have been edited by both A and B. - Merge
m3a-ref-aandm3a-ref-bintom3a-ref; run the whole gate there. - Build area E on top. Merge C and D. Add the
audit verifyarm tobxctl'smain.rs(D owns the file, A specified the arm). Add the end-to-end line to theMakefileinfiles/. - In
m3a-ref:make gateas far as skeletons allow; every test compiles; reference-backed suites pass ten times under load. - Copy
docs/plans/M3a/(task files andfiles/) fromm3a-reftomaster. Writedocs/plans/M3a/README.mdin the M2b README's shape, with the per-task check table and what each check exposed. Fold any spec defects the areas found into the spec anddecisions.md. - Update
CLAUDE.md's "Current state" anddocs/milestones.mdif needed. Delete this file. - Then the owner runs
tools/run-plan.sh docs/plans/M3awith Ornith, on branchm3a.
Already known spec defects found while planning (both fixed in the spec, commit 66fa143): the
one-process end-to-end test would have broken the crate-dependency gate; the locked steps needed a
module of their own (ledger). One finding about the process itself: the approval-handoff
reference cannot run without working config, args, grants, policy and state under it, so
those got a minimal reference too, and the saving from skeletons is confined to loopd, bxctl
and the gate script.
Area reports
(Each area's own record is docs/plans/M3a/checks-<x>.md on its branch.)
Area C, loopd: done, commit 56319ab on m3a-ref-c
- Tasks 16 and 17 written. Task 16's changes are real code and its tests ran green (
tools10,turn6,turn_broker5,config11,pointers7, rest of the suite unchanged).broker_port.rsis a skeleton: onlyBrokerPort::callistodo!(), so 14 of 15 tests inbroker_portand both inbroker_port_badcompile and have never run. - API settled:
ToolPort::call(&self, &ToolRequest, &mut dyn FnMut(&Pending)) -> ToolResponse;Pending { approval: u64, expires: Timestamp };Registry::m3a()(andm2b()kept as the test registry);denial_text;BrokerPort::new(socket, timeout)andwith_log;NoBroker;config::Broker { socket: Option<PathBuf>, timeout_ms }; the envelopeidisrequest.call.0(area E'sbrokerdmust echo it). - Spec defects it found, with wording in
checks-c.md, NOT yet applied to the spec: the section 8 timeout must be a deadline per frame wait, not a per-read socket timeout;tool_denied.nameandapproval_pending.toolcarry the target tool, notcall_tool(task 20 must know); "echostays inFakeToolsonly" is not enough, because recorded conversations find it through the registry; five silent cases resolved and tested (oversized request, far-futureexpires, envelope id,timeout_ms = 0, a pending frame as the final answer). - Needs a run on straylight before hand-over:
make verify-devicebreaks at task 16 becausedevice.rsasks Ornith to useecho. Task 17 ships a changeddevice.rsthat compiles but has never run on the device and depends on how the model behaves.
Area A, audit: done, commits e3065e1, 89f696e, 7da817d on m3a-ref-a
- Tasks 01, 03, 09, 19 written, all with a reference. Passing against it:
records3;chain13 with 30 fixture logs undercrates/proto/tests/fixtures/audit/;brokerdaudit9 andaudit_startup7 (five runs);bxctlverify6. Eight deliberate mutations of the reference each broke a given test. 66 files underfiles/, byte-identical to the tree. The fixture generatorcrates/proto/tests/gen_audit_fixtures.rs(#[ignore]d) stays in the reference tree only. - In that worktree one
strict.rstest fails andproto'swire.rstest does not compile: both wait on area B's wire fixtures and should clear at the merge. - Contract changes other areas must absorb:
DecisionRecord::Allowed {}andAsk {}are empty struct variants, not unit variants: serde ignoresdeny_unknown_fieldson unit variants of an internally tagged enum. JSON unchanged; every construction and match needs the braces (areas B, D, E). Check the other fieldless variants in internally tagged enums inwire.rsfor the same hole.strict.rschanged in the audit test only (expected object count); B edits the envelope list.ChainFailuregainedlast_good,break_prev,break_seq,tail_torn;ChainVerifier::feed(name, content)added and used by bothbxctlandbrokerd.- Writer API as specified, plus
RECOVERED_NOTICEandAuditError::Broken(Box<ChainFailure>). - Tasks 09 and 19 say "Create" the module file and add the
pub modline (the stub files exist only in the reference tree). Every area's task files need the same check. - Task 09 assumes task 04 adds
serde_jsontobrokerd'sCargo.toml. Check task 04 does.
- Spec defects it found, wording in its report and
checks-a.md, NOT yet applied to the spec: the resumed verifier's break exception must also hold inside a failed region; startup step 2 should fall back to verifying the whole log when the previous file's last line does not parse (otherwise an accepted break there deadlocks); the tampering suite fails atbrokerd's startup only when the damaged file is the latest; an unpairedRecoveryis a failure;unfinishedreports the decision'sseqfor approved calls too;--accept-breakwith a torn tail and no failure writes nothing and exits 2; section 9's verify output lines are fixed in task 19, including one for a torn tail. - Left open: task 19 describes the
audit verifyarm in prose; reconcile with D'smain.rs.
Area D, bxctl and the runbook script: done, commits 292780f, 5f27808, 39f3cc7 on m3a-ref-d
- Tasks 18, 20, 21 written. Tests under
crates/bxctl/tests/:escape8,cli12,admin21,chat_print9,chat_approvals20, existingchat12 untouched.check-runbook.shruns for real; its self-test passes and fails under each of five mutations. - At the skeleton state (
5f27808) 38 tests only compiled.cli.rs, thePrinterchanges,stream_turnandmain.rshad to be real code, because existing tests drive the binary. The area then wrote the remaining bodies (about 150 lines,admin.rsandhandle_pending) as a measurement: all 38 passed first time, six more runs under load clean. For this area the reference found nothing the skeleton and a desk-check had missed. The bodies stay on the branch (the straylight check needs a workingbxctl approve) and are not handed over. - API settled:
escape::{escape_json_text, escape_model_text};cli::{USAGE, ChatOptions, Command, UsageError, parse(args, home)}(a new module:main.rswas near the line limit);admin::{AdminError, reason_name, request, list, write_block, cmd_approvals, cmd_approve, cmd_refuse, cmd_grants_check}, eachcmd_*returningResult<bool, AdminError>;chat::{OnPending, Approvals, TurnIo, handle_pending, stream_turn};verify::run(home, out) -> io::Result<bool>. - To act on when integrating:
check-runbook.shfails on any pointer undercrates/**/*.rswhose anchor is not written out literally (format!("…#{anchor}"), a#<anchor>placeholder in a comment), and if it finds no pointer at all. Run it on the merged tree; areas A, B, C, E may trip it. Proposed for spec section 11: "An anchor must be written out in the source; a pointer whose anchor the script cannot read is an error."- Conflict with area A: task 18 creates
verify.rswith a placeholder body and adds all fourpub modlines tolib.rs, so task 19 must MODIFYverify.rsand touch neitherlib.rsnormain.rs. Area A wrote task 19 as "Create". Fix task 19. GrantProblem.problemcan be multi-line becausetomlerrors are. Task 06 should produce one-line problems at the source; check area B did.- Spec section 9's example block shows spaced JSON; section 6 says
serde_jsonoutput, which is compact. The tests use compact. Fix the example. - "
--sayand--jsonprint the event only" was ambiguous. Chosen:--sayshows the block frombrokerdand asks nothing;--jsonprints the event's JSON line and never contactsbrokerd. Put that in the spec. - Escaping now covers tool names and the answer on stdout too. Spec section 9 should say "everything the model wrote".
main.rsmust hold oneBufReaderon stdin for chat lines and approval answers alike; a binary-level test enforces it.- Details the tests now fix that the spec leaves open: time spans and rounding;
expiredoncenow >= expires; a session id over 10 characters shown as 9 plus…;no pending approvals; anaskre-decision printed asruns; an approval id is ASCII digits only ("+41".parse::<u64>()succeeds); end of input at the prompt refuses;brokerdunreachable or an approval expired before the answer is one line and the turn goes on. tests/admin.rsis 497 lines andtests/chat_approvals.rs487: no room to grow.DecisionRecord::Allowed {}/Ask {}(area A's contract change) must be applied to this branch's code and tests at the merge.
- The
Makefileunderfiles/has thecheck-runbook.shline and no end-to-end line yet.
Area B, policy side: done, commit e89e2ce on m3a-ref-b
- Tasks 02, 04 to 08 written, all with a minimal reference. Passing: proto
wire10,turn_wire5,admin_wire10 (new), 17 wire fixtures;brokerd81 tests (config7,args13,grants17,policy7,policy_matching10,policy_redecide7,policy_property4,state9, doctests 7), three runs and one under load. Support files are pulled in with#[path](tests/support/{tmp,build,oracle}.rs), so there is nosupport/mod.rsfor area E to collide with (area A addedtests/support/audit_dir.rs; check how it is included). - Oracle teeth: six mutants of the reference policy were each caught by the oracle alone. The policy reference caught nothing the oracle would not have. Second data point for the decision on references.
- API for area E, in full in its report and
checks-b.md:Configwithbroker_socket(),admin_socket(),audit_dir(),state_dir();args::{ToolName, ToolArgs, ArgsError, parse, valid_path, inside, valid_host, valid_host_pattern, host_matches, url_host}andToolArgs::canonical_json();grants::{LoadedGrant, GrantSet, from_grants, load, render, valid_id, RUNBOOK};policy::{SessionState, Label, Denial, Decision, Ask, Outcome, decide, redecide -> Result<Decision, Denial>}(SessionStateandLabellive inpolicy);state::{StateStore, StateError, RUNBOOK}withreadandraise. - CONFLICT with area A, to resolve first: both found that
DecisionRecordaccepted unknown fields and fixed it differently. A madeAllowed {}andAsk {}empty struct variants (call sites need braces). B kept unit variants and decodes through a privateRawDecisionwith#[serde(try_from = "RawDecision")](public type and JSON unchanged, no call-site change;{"outcome":"allowed","reason":null}is still accepted). Task 02 greps forRawDecisionand stops without it. Pick one. B's keeps the public type as the spec wrote it and touches no other area; A's is stricter aboutreason: null. Whichever wins goes into task 01, and the loser's tests and task text change. Then check the other internally tagged enums (TurnEvent,ToolResponse,AuditEvent,LogRecord) for unit variants with the same hole. strict.rsis edited by tasks 01 and 02. The copy handed over with task 02 must carry A's audit object-count change as well as B's.- Area E's tests must include: one invalid grant file denying a call a valid file would allow
(
grantsonly proves the set does not load), and section 12's sequence properties (taint never down; everyResultfollows aDecision). - Spec defects it found, NOT yet applied to the spec:
- IPv4 literals pass the host grammar (
127.0.0.1,127.1,10.0.0.0x1), so "no IP literals" was false. Rule added and tested; proposed wording: "...and the last label starts with a letter, which excludes every spelling of an IPv4 address." write_filewith overlapping grant paths/sand/s/outand a write to/s/out: a grant path equal to the argument does not count, and the longest path that does count is matched.redecidereturnsResult<Decision, Denial>, notDenyReason(the spec's signature is wrong).- Gaps settled in the tasks: an empty socket path means "under home"; a missing grants directory
is a problem, not an empty set; a state file that says
publicis damaged; an absentcwdis left out of the canonical JSON.
- IPv4 literals pass the host grammar (
- Other: serde's derived structs accept the JSON array form, so
"body":[]decodes asEmpty; true acrossprotosince M1, not fixed, worth a decision.docs/dependencies.md's "Used by" column is stale; task 04 has the implementer fix theserderows. The owner's global gitignore swallows*.toml~, so that fixture is made in a temp dir.
Tool warning for whoever continues
The Write and Edit tools in this environment decode a JSON escape such as backslash-u-002f into the character itself when it appears in the text being written. Build such strings from pieces or patch them in with a script, and check the bytes on disk afterwards.
What the checks have shown so far (for the README and for M3b)
| Area | Check | Exposed |
|---|---|---|
| A, audit | reference | Five spec defects in section 5, two of which would have stopped brokerd starting; the serde unit-variant hole. The reference earned its cost. |
| B, policy | oracle, plus a minimal reference | One spec falsehood (IPv4), one unspecified case, one wrong signature, the same serde hole. The reference added nothing over the oracle. |
C, loopd |
skeleton | A timeout that had to be a deadline, five silent cases, a broken verify-device. 16 tests never run. |
D, bxctl |
skeleton, then bodies as a measurement | Spec ambiguities and an example that contradicted the text. The bodies found nothing the skeleton had not. |
| Planning itself | none | The one-process end-to-end test against the crate gate; the missing ledger module. |