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>
111 lines
11 KiB
Markdown
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`.
|