Review M3a: accept with fifteen findings; record the server change

Two medium findings in the audit writer (a startup panic on a record-less
log file, and a log-name filter that disagrees with bxctl's), one in the
missing runbook pointers for startup failures, and twelve low ones. Lessons
I14 and T21, T22; two new AGENTS rules; m3a's T18 renumbered to T20 so
master's T18 and T19 survive the merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 20:48:43 -07:00
co-authored by Claude Opus 5
parent ffbff501d6
commit 22a26aad45
3 changed files with 78 additions and 1 deletions
+68
View File
@@ -313,3 +313,71 @@ failed and the cause was not understood. `docs/M3a/DEBUG-HANDOFF.md` now opens w
After the fixes, `make gate` passes on Talos. On the Mac, fmt, clippy, every test and the gate
scripts pass; `cargo deny` is not installed there, so the gate itself stops at that step. Tasks 20
to 22 are no longer blocked.
### M3a, tasks 01 to 22 — reviewed 2026-09-22 by the design model (Claude)
Accepted, with a follow-up. The decision path matches the spec as revised: grant loading fails
closed on any invalid file, matching follows the revised rules (most restrictive mode, longest
matched path, label combined over every matching grant, a `deny` grant that must hold at every
taint), the ledger keeps the audit writer and the state files under one lock and stops after a
failed append, a decision is on disk before anything runs, results are recorded by hash, the two
sockets refuse each other's kinds, `RunSpec` gives egress to `http_fetch` alone, and a denial
reaches the model as the spec's sentence with the turn going on.
| Check | Result |
|---|---|
| 33 commits on `m3a`, 27 with the trailer | pass (the six without are review and notes commits) |
| All 150 given files identical to `docs/plans/M3a/files/` | pass |
| `make gate` on Talos | `gate: ok`, 524 tests |
| Any source file copied from a reference branch | only `crates/proto/src/chain.rs` (task 03, already recorded above); every other file differs from all six reference branches |
| New library code free of `unwrap`, `expect`, `panic!`, `#[allow]`, `unsafe`, `as` casts | two exceptions (finding 5) |
| Every runbook pointer used has an entry (`scripts/check-runbook.sh`) | pass; four states have no pointer at all (finding 3) |
| 30 audit tampering fixtures, the policy oracle property test, the two-process end-to-end test | present and passing |
| `tools/check-m3a-device.sh` on straylight | not run by the implementer; run in review: passes once the server expectations are corrected (see "The server changed") |
On straylight, with real Ornith: the model reached `read_file` through `find_tool` and `call_tool`,
the approval block appeared in `bxctl approvals` with its arguments escaped, approving it ran the
refusing runner, the model reported the failure in its own words, and `bxctl audit verify` gave
`audit: ok, 3 records` over a `Decision`, an `Approval` and a `Result`.
| # | Severity | Owner | Finding | Fix |
|---|---|---|---|---|
| 1 | medium | implementer (09) | `brokerd` panics at startup when the audit directory holds exactly one log file with no complete record: `audit.rs:346` reads `files[files.len() - 2]`. A zero-length file is what a kill between `create` and the first `write_all` leaves behind. `bxctl audit verify` calls the same directory `audit: ok, 0 records`. Confirmed by running the binary. The reference used `report.head.unwrap_or(resumed_from)`; the given tests cover only the two-file form (`empty-latest`). | Task 23 |
| 2 | medium | implementer (09), and the tests | `brokerd` and `bxctl` disagree on which files are the log: `audit.rs:97` checks one month digit (`b[5..6]` where `b[5..7]` was meant). With `2026-0x-18.jsonl` beside `2026-09-17.jsonl`, `brokerd` chains through both while `bxctl audit verify` prints `ok, 5 records` and silently ignores the other five. Confirmed by running both. Nothing pinned the two filters to each other. | Task 23 |
| 3 | medium | task and spec | Four fail-closed startup states carry no runbook pointer and have no entry to point at: the socket directory cannot be prepared, the socket cannot be bound (seen in review: `cannot listen on …: path must be shorter than SUN_LEN`), the config cannot be read, and the listener thread dies. `scripts/check-runbook.sh` can only check pointers that exist. The spec's own pointer list omits them. | Task 23, with a new runbook entry |
| 4 | low | implementer (09) | `Writer::drop` unlinks `audit/.lock`. The runbook says deleting it neither helps nor is needed, and unlinking opens a narrow two-writer window: a `brokerd` holding the old, unlinked inode and a new one creating a fresh file each believe they are alone. The reference never unlinked it. (On a signal no destructor runs, so the common case leaves the file in place.) | Task 23 |
| 5 | low | implementer | `audit.rs:168` `files.last().unwrap()` and `grants.rs:201` `count() as u64` break AGENTS' rules, though both are locally safe. | Task 23 |
| 6 | low | implementer (15) | `serve.rs:187` uses `std::thread::spawn`, which panics if the OS refuses a thread; the accept thread then unwinds and that listener is silently dead while `brokerd` keeps running. `thread::Builder` returns the error instead. | Task 23 |
| 7 | low | pre-existing | `std::env::args()` panics on a non-UTF-8 argument (`exit 101`, confirmed), in every role's `main.rs` since M1, so a config path that is not UTF-8 cannot be used at all. `args_os()` is the fix. | Task 23 (all four mains) |
| 8 | low | implementer (17) | `loopd`'s post-pending deadline is `expires` plus `timeout_ms` with no cap, and the "expiry too far away" guard cannot fire (an `Instant` 8,000 years out is fine). An absurd `[approvals] ttl_ms`, which makes `brokerd` fall back to `Timestamp::MAX`, therefore parks a turn for ever — the one thing the pending path promises not to do. | Task 24 |
| 9 | low | implementer (18) | `bxctl`'s admin requests set no timeout on connect or read, so a `brokerd` stuck under the ledger lock hangs `bxctl`, including inside a `chat` turn where the spec wants one line and the turn carrying on. | Task 24 |
| 10 | low | implementer (20) | Two print paths escape nothing: `Retrying { error }` (`chat.rs:189`) and `WireError.detail` (`chat.rs:28`, `admin.rs:61`). Both can carry the inference server's raw response body, which is what the escaping rule exists to stop. | Task 24 |
| 11 | low | implementer (13) | The `GONE` result is recorded with the decision's timestamp, not the current one, so `verify` reports a clock warning for a log that is sound. | Task 24 |
| 12 | low | implementer (18) | `bxctl`'s usage says `audit verify` checks "the audit log against the grants"; it verifies the hash chain and never reads a grant. | Task 24 |
| 13 | low | implementer (20) | `AdminError` lacks the `Io` variant task 18 gives, so a failed write is downgraded to `Protocol` and answered by writing another line to the same failed writer. Disclosed in the row's Deviations column. | Task 24 |
| 14 | low | spec | `ToolArgs::HttpFetch` has public `url` and `host`, so "the host is the URL's host" is not enforced by the type. Nothing breaks it today (`args::parse` is the only producer), but M3b fetches `url` while policy matched `host`. | Before M3b |
| 15 | nit | implementer | `bxctl`'s `verify.rs` skips a `.jsonl` file whose name is not a date without a word (related to 2); `MAX_PATH`'s doc mentions URLs; `grants.rs:95` pushes an empty name it never uses; `find_tool` matches core tools, so `find_tool("time")` offers `clock` and `call_tool` then refuses it; the four tool schemas omit `"additionalProperties": false`. | When next touched |
What was good: the argument checks are exactly the spec's tables at every boundary I could
construct, including the IPv4 spellings the spec review added; `RunSpec` and `Decision` are both
sealed with `compile_fail` doctests that fail for the right reason; the ledger's three locked steps
and the "whoever takes the entry answers it" rule are implemented as written, with race tests that
run a hundred rounds; the ten denial sentences are byte-identical to the spec; the escaping covers
every code point the spec names, and `bxctl chat` fetches the approval block from `brokerd` by id
rather than trusting `loopd`'s event. Three of the four `medium` and `low` findings that touch
`brokerd` are in the same file, `audit.rs` (task 09) — the task that the first run never reached
and whose given tests were the most intricate.
### The server changed under M3a — noted 2026-09-22
Ornith on straylight now runs with `parallel = 4` over one unified 262,144-token KV pool
(`~/src/nixos/hw/straylight/default.nix`), not two slots of 131,072 each. Consequences:
- `make verify-device` fails, 2 of 6, because `crates/loopd/tests/device.rs` hard-codes
`n_ctx = 131072` and `slots = 2` in two places; `tools/check-m3a-device.sh` hard-codes the same.
The self-test itself behaved exactly as designed: `context per slot: expected 131072, got 262144`
with the runbook pointer. Both places should read `/props` instead of carrying the numbers.
- `docs/inference-contract.md` says the 262,144 is "split, not shared". With one pool shared by four
slots that is no longer true, and the cache reasoning behind P9 (another client evicting our
slot) needs re-measuring before M3b leans on it.