Files
boxmaker/docs/plans/M3a/README.md
T
kyleandClaude Opus 5 ed8cf49540 M3a task 01: copy a strict.rs of its own
The strict.rs in files/ is the merged one and walks task 02's new wire
fixtures, so task 01's gate could not pass; the first run stopped on it.
Task 01 now copies area A's version, checked to give 3 and 5 passed at
task 01's end state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 12:07:59 -07:00

111 lines
11 KiB
Markdown

# M3a implementation plan: the decision path
> **For the implementing model:** do not work from this file. The owner gives you one task file at
> a time (`01-…` to `22-…`). This file is the index for the owner and the reviewer.
**Goal:** every tool call `loopd` makes is decided by `brokerd` against owner-written grants and
the session's taint, recorded in a hash-chained audit log before anything happens, and, for `ask`
grants, approved or refused by the owner through `bxctl`. Tools do not run yet: the runner is a
trait whose production implementation refuses (M3b adds Podman).
**Architecture:** `proto` gains the audit records, the chain verifier (shared by `brokerd` and
`bxctl`) and the admin messages. `brokerd` is built bottom up: config, arguments, grants, policy,
session state and the audit writer (tasks 04 to 09), then the runner seam, the pending table, the
ledger (the audit writer and the state files behind one lock), the two socket handlers and
`brokerd serve` (10 to 15). `loopd` gets the real `ToolPort` over `broker.sock` (16, 17). `bxctl`
gets the admin commands, `audit verify` and approvals inside `chat` (18 to 20). A gate script
checks every runbook pointer (21), and one test runs `loopd` against the real `brokerd` binary (22).
**Tech stack:** as M2b. `brokerd` gains `serde`, `serde_json` and `toml`, all already vetted.
**Spec:** `docs/specs/2026-09-18-m3a-decision-path.md`. Brief: `docs/design.md`. Every fail-closed
message ends with a pointer into `docs/runbook.md`.
## Global constraints
- Everything in `AGENTS.md`, including "Lessons from earlier reviews".
- No new dependency beyond the three above. Formats we define reject unknown fields, at every depth
(`proto/tests/strict.rs` walks every object of every fixture).
- `DecisionRecord::Allowed {}` and `Ask {}` are written with braces everywhere (task 01 says why).
- A `Decision` is built only in `policy`, a `RunSpec` only in `runner`; `compile_fail` doctests
prove both.
- Branch `m3a`. One task, one fresh OpenCode session, one commit. Run `cargo fmt --all` before the
gate. Review happens once, after task 22.
## Tasks
The last column is how the given tests were checked before hand-over (decision of 2026-09-18,
tip T17) and what that check exposed. The detail is in `checks-a.md` to `checks-e.md`.
| # | File | Delivers | Tests that define it | Check | What the check exposed |
|---|---|---|---|---|---|
| 01 | `01-proto-audit-types.md` | `AuditRecord`, `AuditEvent`, `DecisionRecord` and friends | `proto/tests/records.rs`, `strict.rs`, `fixtures/records/audit.jsonl` | reference (the types) | `DecisionRecord`'s unit variants accepted unknown fields (serde ignores `deny_unknown_fields` there); found by `strict.rs`. After hand-over: the merged `strict.rs` named task 02's fixtures, so task 01's gate could not pass (fixed with `strict.rs-task01`) |
| 02 | `02-proto-admin-wire.md` | the admin messages, `u64` approval ids, `approval_pending`, `tool_denied` | `wire.rs`, `turn_wire.rs`, `admin_wire.rs`, `strict.rs`, 17 wire fixtures | reference (the types) | the same serde hole, found independently; serde's array form for structs (left open in `decisions.md`) |
| 03 | `03-proto-chain-verifier.md` | `ChainVerifier`, `ChainReport` | `proto/tests/chain.rs`, 30 fixture logs with real hashes | reference, generated fixtures, 8 mutants | four spec gaps: which `seq` the lists report, the resumed verifier inside a failed region, a `Recovery` describing nothing, the report fields a writer needs |
| 04 | `04-brokerd-config.md` | `brokerd.toml` into `Config` | `brokerd/tests/config.rs`, 6 fixtures | minimal reference | socket paths when only `home` is set; stale "Used by" column in `dependencies.md` |
| 05 | `05-brokerd-args.md` | typed tool arguments; path, host and URL checks | `brokerd/tests/args.rs` | minimal reference | **IPv4 literals passed the host grammar**; found by writing the test table |
| 06 | `06-brokerd-grants.md` | `GrantSet` loading, every rule | `brokerd/tests/grants.rs`, grant fixtures | minimal reference | a missing grants directory; a fixture name swallowed by a global gitignore |
| 07 | `07-brokerd-policy.md` | `decide`, `redecide`, `Denial` | `policy.rs`, `policy_matching.rs`, `policy_redecide.rs`, `policy_property.rs` (oracle) | oracle, 6 mutants; plus a minimal reference | the `write_file` overlap case; `redecide`'s signature; the M1 `compile_fail` doctests had lost their teeth. The reference found nothing the oracle did not |
| 08 | `08-brokerd-state.md` | session state files | `brokerd/tests/state.rs` | minimal reference | a file saying `public` is damaged |
| 09 | `09-brokerd-audit-writer.md` | the audit writer, startup, recovery, `--accept-break` | `brokerd/tests/audit.rs`, `audit_startup.rs` | reference, mutants | **two startup defects that would have stopped `brokerd` starting** (the resume deadlock; tampering only visible in the latest file) |
| 10 | `10-brokerd-runner.md` | `Runtime`, `RunSpec`, `run`, `Refusing` | `brokerd/tests/runner.rs`, `support/runtime.rs` | reference | none |
| 11 | `11-brokerd-approvals.md` | the pending table | `brokerd/tests/approvals.rs` | reference | `Verdict::Run` must box the `Decision` (clippy) |
| 12 | `12-brokerd-ledger.md` | the three locked steps, `AuditSink` | `ledger.rs`, `ledger_answer.rs`, `support/{rig,sink}.rs` | reference | unreadable state recorded as `secret`; no `Result` when state fails; the rig broke task order (fixed) |
| 13 | `13-brokerd-broker.md` | one request on `broker.sock` | `broker.rs`, `broker_pending.rs`, `broker_sequence.rs`, `support/client.rs` | reference | every request is recorded, `grants_invalid` included; an unsendable pending frame |
| 14 | `14-brokerd-admin.md` | one request on `admin.sock`; expiry | `brokerd/tests/admin.rs` | reference | a refusal that cannot be recorded must not answer `ok`; the re-decision's mode |
| 15 | `15-brokerd-serve.md` | `brokerd serve` | `brokerd/tests/serve.rs` (drives the binary) | reference | no read timeout on a request frame (accepted for M3a, section 14) |
| 16 | `16-loopd-tools.md` | `ToolPort` with the pending callback, `Registry::m3a()`, denial texts | `tools.rs`, `turn.rs`, `turn_broker.rs` | real code (a skeleton was impossible) | `echo` must stay in the test registry; which tool name the events carry |
| 17 | `17-loopd-broker-port.md` | `BrokerPort`, `[broker]`, three runbook pointers | `broker_port.rs`, `broker_port_bad.rs`, `pointers.rs`, `config.rs`, `device.rs` | skeleton; later a body as a measurement | **the timeout must be a deadline**; five silent cases. The body found nothing more |
| 18 | `18-bxctl-admin.md` | `approvals`, `approve`, `refuse`, `grants check`; escaping | `escape.rs`, `cli.rs`, `admin.rs` | skeleton; later bodies as a measurement | the spec's example contradicted section 6; `"+41"` parses as a `u64`. The bodies found nothing more |
| 19 | `19-bxctl-audit-verify.md` | `bxctl audit verify` | `bxctl/tests/verify.rs` | reference | the output lines, torn tail included; a clash with task 18 over `verify.rs` (fixed) |
| 20 | `20-bxctl-chat-approvals.md` | the approval block and question in `chat` | `chat_print.rs`, `chat_approvals.rs` | skeleton; later a body as a measurement | `--say`/`--json` ambiguous; tool names were printed raw; one stdin reader |
| 21 | `21-runbook-check.md` | `scripts/check-runbook.sh` in the gate | `scripts/test-gate-scripts.sh` | the real script, 5 mutants | a computed anchor cannot be checked; no pointer at all must fail. It then caught such a pointer in task 17's test |
| 22 | `22-end-to-end.md` | the two-process test; the straylight script | `loopd/tests/end_to_end.rs` | reference (the real binary) | the recorded model calls `read_file` directly, not through `call_tool` |
`files/` holds everything the tasks copy in, byte-identical to the reference tree
(`~/src/boxmaker-ref`, branch `m3a-ref`, unpushed). `files/Makefile-task21` is task 21's
`Makefile`; `files/Makefile` is task 22's, with the end-to-end line. Likewise
`files/crates/proto/tests/strict.rs-task01` is task 01's `strict.rs`; `files/.../strict.rs` is task
02's, which also walks task 02's new wire fixtures.
**State of the checks at hand-over.** In the reference tree `make gate` passes in full, the
end-to-end line included, so every given test has passed against some implementation. Area E's
suites (with `broker_port` and `end_to_end`) ran ten times idle and ten under load; area A's
startup suite five times, area B's three times and once under load. The merged tree then ran all
suites five times under sixteen busy loops on eight cores with no failure. For tasks 10 to 15 the tree was cut back to each task's
starting point and the tests compiled at each step. Mutation checks: audit 8 of 8, policy oracle
6 of 6, `check-runbook.sh` 5 of 5, broker 14 of 14. `make verify-device` with task 17's `device.rs`
passed twice on straylight (Ornith, slot 0).
**What the record says for M3b** (the decision on references): the references found real defects
only where the logic was stateful and security-bearing (the audit chain and startup, the broker's
ledger and approvals). For plumbing (`config`, `args`, `grants`, `state`) and for the clients
(`loopd`'s port, `bxctl`), the tests, the exit lists and a skeleton found everything; the bodies
written afterwards as measurements found nothing more. For `policy` the mutation-tested oracle was
enough. Most spec defects were found by writing test tables and exit lists, not by running code.
## For the owner: running a task
`tools/run-plan.sh docs/plans/M3a`, or one fresh OpenCode session per task with
"Read `docs/plans/M3a/01-proto-audit-types.md` and do exactly that task."
- Do not run `make verify-device` between tasks 16 and 17: task 16 removes `echo` from the
registry `loopd serve` uses, and task 17 brings the `device.rs` that expects that.
- After task 22, run `tools/check-m3a-device.sh` once on this machine against straylight. It
checks `/slots` first and uses Ornith slot 0 only; it has not been run yet by anyone.
## For the reviewer: after task 22
1. `git log --oneline master..m3a`: twenty-two commits with the trailer.
2. Copied files unchanged:
`for f in $(cd docs/plans/M3a/files && find . -type f ! -name Makefile-task21 ! -name strict.rs-task01); do cmp "docs/plans/M3a/files/$f" "$f"; done`
3. `git diff master..m3a --stat -- docs/design.md docs/specs docs/plans docs/runbook.md AGENTS.md CLAUDE.md deny.toml`
is empty.
4. `make gate`, `make audit`, `make verify-device`, `tools/check-m3a-device.sh`.
5. Read every `brokerd` source file against its task and the spec, the ledger and the approval
handoff first. Probe from outside: a grant file edited while a call waits for approval, two
`bxctl approve` at once, `brokerd` killed mid-call then restarted, an audit file edited by hand,
`loopd` killed while its call is pending.
6. Run `brokerd`'s suites, `broker_port` and `end_to_end` repeatedly under CPU load.
7. Findings to `docs/implementer-log.md`; lessons to `docs/implementer-lessons.md`.