M3a spec: fold in the defects the plan's checks found in areas A to D
DecisionRecord's empty struct variants, the verifier's report fields and region rule, the startup fallback, the IPv4 host rule, Denial, BrokerPort deadlines and envelope id, the runbook anchor rule, bxctl's --say/--json and escaping. Recorded in docs/decisions.md; the array-form question is left open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
# M3a design: the decision path
|
||||
|
||||
Status: draft for owner review, 2026-09-18; revised the same day after a design review (the
|
||||
"Spec review of M3a" rows in `docs/decisions.md`). M3 is split in two (`docs/decisions.md`). M3a is
|
||||
"Spec review of M3a" rows in `docs/decisions.md`), and again after the plan's checks (the "M3a plan
|
||||
checks" rows there). M3 is split in two (`docs/decisions.md`). M3a is
|
||||
everything that decides whether a tool call may run and records it: grant files and matching,
|
||||
session taint, the audit log, approvals through `bxctl`, the two `brokerd` sockets, and `loopd`'s
|
||||
real tool port. Tools themselves do not run in M3a: the runner is a trait whose only
|
||||
@@ -98,13 +99,16 @@ admin = "/var/lib/boxmaker/run/owner-broker/admin.sock"
|
||||
ttl_ms = 900000 # 15 min
|
||||
```
|
||||
|
||||
A socket path that is absent or empty means the default under `home`, as in `loopd`.
|
||||
|
||||
## 3. Grants
|
||||
|
||||
### Files
|
||||
|
||||
One grant per file, `grants/<id>.toml`. The id is the file stem: 1 to 64 characters of
|
||||
`[a-z0-9-]`. Other files in the directory (not ending in `.toml`) are ignored; a `.toml` file with
|
||||
a bad stem is an invalid grant. The fields are `proto::Grant` as defined in M1.
|
||||
a bad stem is an invalid grant. A grants directory that is missing or cannot be read makes the set
|
||||
invalid; an empty directory is a valid empty set. The fields are `proto::Grant` as defined in M1.
|
||||
|
||||
```toml
|
||||
# grants/notes-read.toml
|
||||
@@ -228,12 +232,16 @@ component. Paths are compared as they are, never normalised, and matching is by
|
||||
`brokerd` does not resolve symlinks. In M3b only the matched grant directory is mounted, at the
|
||||
same path, so a symlink pointing outside it points at nothing inside the container.
|
||||
|
||||
When several of a grant's paths contain the argument, the longest one is the matched path.
|
||||
When several of a grant's paths contain the argument, the longest one is the matched path. For
|
||||
`write_file`, a grant path equal to the argument does not count: with `paths = ["/s", "/s/out"]`,
|
||||
a write to `/s/out` is covered through `/s`, and `/s` is the matched path.
|
||||
|
||||
### Hosts
|
||||
|
||||
A host name: 1 to 253 bytes, lowercase, at least two labels separated by `.`, each label 1 to 63
|
||||
bytes of `[a-z0-9-]` not starting or ending with `-`. A host pattern in a grant is a host name, or
|
||||
bytes of `[a-z0-9-]` not starting or ending with `-`, and the last label starts with a letter,
|
||||
which excludes every spelling of an IPv4 address (`127.0.0.1`, `127.1`, `10.0.0.0x1`). A host
|
||||
pattern in a grant is a host name, or
|
||||
`*.` followed by a host name.
|
||||
|
||||
| Grant host | URL host | Result |
|
||||
@@ -256,16 +264,18 @@ the property test drive it.
|
||||
```rust
|
||||
pub struct SessionState { pub taint: DataClass, pub untrusted: bool }
|
||||
|
||||
pub enum Outcome {
|
||||
Allowed(Decision),
|
||||
Ask(Ask),
|
||||
Denied { reason: DenyReason, grant: Option<String> },
|
||||
pub struct Denial {
|
||||
pub reason: DenyReason,
|
||||
pub grant: Option<String>, // the `deny` grant, for `denied_by_grant` only
|
||||
pub grant_sha256: Option<Hash32>,
|
||||
}
|
||||
|
||||
pub enum Outcome { Allowed(Decision), Ask(Ask), Denied(Denial) }
|
||||
|
||||
pub fn decide(request: ToolRequest, grants: &GrantSet, state: SessionState, now: Timestamp)
|
||||
-> Outcome;
|
||||
pub fn redecide(ask: Ask, grants: &GrantSet, state: SessionState, now: Timestamp)
|
||||
-> Result<Decision, DenyReason>;
|
||||
-> Result<Decision, Denial>;
|
||||
```
|
||||
|
||||
- `Ask` is built like `Decision`: private fields, no `Clone`, no `Deserialize`, a `compile_fail`
|
||||
@@ -274,7 +284,7 @@ pub fn redecide(ask: Ask, grants: &GrantSet, state: SessionState, now: Timestamp
|
||||
into a `Decision`, so "this call was approved" is a fact about the types, not about the order
|
||||
of statements in `broker`.
|
||||
- `redecide` runs the same matching again. `ask` and `auto` both give a `Decision`; anything else
|
||||
gives the reason.
|
||||
gives the `Denial`, whose grant and file hash the `Approval` record needs.
|
||||
- `broker` checks in this order, and the first that applies is the answer: the grant set is
|
||||
invalid (`grants_invalid`); the session's state cannot be read (`state_unreadable`); then
|
||||
`decide`, inside which: the tool is not one of the four (`no_grant`, arguments not parsed); the
|
||||
@@ -295,7 +305,8 @@ pub fn redecide(ask: Ask, grants: &GrantSet, state: SessionState, now: Timestamp
|
||||
Neither ever goes down. The read, the comparison and the write happen under the ledger lock
|
||||
(section 2). A `failed` call changes nothing: its message is `brokerd`'s own text (section 7).
|
||||
- Writes are atomic: write `<id>.json.tmp`, `fsync`, rename over `<id>.json`, `fsync` the directory.
|
||||
- A file that exists but cannot be read or parsed is an error: every call for that session is
|
||||
- A file that exists but cannot be read or parsed, or that says `public` (a session is never below
|
||||
`private`, so `brokerd` did not write it), is an error: every call for that session is
|
||||
denied with `state_unreadable` and `brokerd` prints `see docs/runbook.md#broker-state-damaged`.
|
||||
- The brief's State list names this file (P14, applied 2026-09-18).
|
||||
- `loopd` has no access to `<home>/broker/`. The `class` and `untrusted` values it logs are a copy
|
||||
@@ -342,9 +353,13 @@ pub enum AuditEvent {
|
||||
}
|
||||
|
||||
#[serde(tag = "outcome", rename_all = "snake_case")]
|
||||
pub enum DecisionRecord { Allowed, Ask, Denied { reason: DenyReason } }
|
||||
pub enum DecisionRecord { Allowed {}, Ask {}, Denied { reason: DenyReason } }
|
||||
```
|
||||
|
||||
`Allowed {}` and `Ask {}` are empty struct variants, not unit variants: serde does not apply
|
||||
`deny_unknown_fields` to a unit variant of an internally tagged enum, so with `Allowed` the text
|
||||
`{"outcome":"allowed","x":1}` would decode. The JSON is the same either way.
|
||||
|
||||
`DecisionRecord` keeps the name the brief uses for the plain record of a decision; its M1 shape
|
||||
(with `grant` inside and an `Approved` variant) is replaced, since approval is now its own event.
|
||||
|
||||
@@ -411,20 +426,35 @@ impl ChainVerifier {
|
||||
pub fn resume(next_seq: u64, prev: Hash32) -> Self; // continue from a known point
|
||||
pub fn file(&mut self, name: &str); // starts the next file
|
||||
pub fn line(&mut self, bytes: &[u8], has_newline: bool); // false only for a file's last line
|
||||
pub fn feed(&mut self, name: &str, content: &[u8]); // file, then each of its lines
|
||||
pub fn finish(self) -> ChainReport;
|
||||
}
|
||||
pub struct ChainReport {
|
||||
pub records: u64, pub head: Option<Hash32>, pub next_seq: u64,
|
||||
pub failure: Option<ChainFailure>, // the first one: file, line (1-based), what
|
||||
pub failure: Option<ChainFailure>, // the first one since the last accepted break
|
||||
pub recoveries: Vec<Location>, pub accepted_breaks: Vec<Location>,
|
||||
pub abandoned: Vec<u64>, // seq of Ask decisions with no Approval after them
|
||||
pub unfinished: Vec<u64>, // seq of allowed decisions, and of approvals whose
|
||||
// outcome is allowed, with no Result after them
|
||||
pub unfinished: Vec<u64>, // seq of decisions allowed, at once or by approval,
|
||||
// with no Result after them
|
||||
pub clock_warnings: Vec<Location>, // time went backwards
|
||||
pub torn_tail: Option<Location>, // the last line of the last file needs recovery
|
||||
pub torn_tail: Option<TornTail>, // the last line of the last file needs recovery
|
||||
}
|
||||
pub struct ChainFailure { // file, line (1-based), what; and what an
|
||||
pub file: String, pub line: u64, pub what: String, // AcceptedBreak appended now must carry
|
||||
pub last_good: Hash32, pub break_prev: Hash32, pub break_seq: u64,
|
||||
pub tail_torn: bool, // the last line fed has no newline
|
||||
}
|
||||
pub struct TornTail { // and what its Recovery record must carry
|
||||
pub at: Location, pub has_newline: bool, pub bytes: u64, pub sha256: Hash32,
|
||||
pub recovery_prev: Hash32, pub recovery_seq: u64,
|
||||
}
|
||||
```
|
||||
|
||||
The report carries what the writer must put in a `Recovery` or `AcceptedBreak` record, so that no
|
||||
consumer computes `seq` or `prev` a second time. Both lists name the *decision's* `seq`, for an
|
||||
approved call too: `Approval.decision` and `Result.decision` both refer to it, and it is the
|
||||
approval id the owner saw.
|
||||
|
||||
A line fails if it does not parse as an `AuditRecord`, its `seq` is not the next one, or its
|
||||
`prev` is not the hash of the line before. Files are fed in name order; a file whose first record
|
||||
does not chain from the previous file's last line fails at its line 1. Two cases are not failures:
|
||||
@@ -440,6 +470,9 @@ does not chain from the previous file's last line fails at its line 1. Two cases
|
||||
record exactly before its newline, leaving complete JSON. A recovered line is not a record and
|
||||
nothing may refer to its `seq`, which the `Recovery` record reuses. Reported in `recoveries`.
|
||||
|
||||
A `Recovery` record that describes no line (its hash and length match nothing before it) is a
|
||||
failure at the `Recovery` record.
|
||||
|
||||
The verifier therefore holds each line back until it has seen the next one, and judges it then (or
|
||||
at `finish`).
|
||||
|
||||
@@ -462,15 +495,21 @@ record and are not checked, so one break covers every failure before it.
|
||||
|
||||
A verifier started with `resume` has not seen the earlier files. When it meets an `AcceptedBreak`
|
||||
whose `file` sorts before the first file it was given, it checks only `prev`, accepts it and
|
||||
continues from it; the full verification judges the rest.
|
||||
continues from it; the full verification judges the rest. This holds inside a failed region too:
|
||||
if the latest file has damage before such a break record, the resumed verifier is already in a
|
||||
region when it meets the break, and the break still clears it. Otherwise a correctly accepted
|
||||
break would stop every later start while `--accept-break` said "nothing to accept".
|
||||
|
||||
### Startup
|
||||
|
||||
1. Take the lock. If it is held: print "brokerd is already running"
|
||||
and `see docs/runbook.md#brokerd-already-running`, exit 1.
|
||||
2. Without `--accept-break`: verify the latest file, resumed from the last line of the file before
|
||||
it (if any). That line must parse as a record, or it is the failure. The earlier files are not
|
||||
re-read; `bxctl audit verify` does that. With `--accept-break`: verify every file, exactly as
|
||||
it (if any). If that line does not parse as a record, there is no point to resume from, and the
|
||||
whole log is verified instead. (Treating it as the failure would deadlock: after a break at
|
||||
that very line is accepted, every later start would fail on it again.) Otherwise the earlier
|
||||
files are not re-read; `bxctl audit verify` does that. Damage in an older file is therefore not
|
||||
seen by an ordinary start, by design. With `--accept-break`: verify every file, exactly as
|
||||
`bxctl audit verify` does, because the break to accept must be the first failure of the whole
|
||||
log, and it may lie in an older file that the short check never reads.
|
||||
3. If the report has a failure: without `--accept-break`, write nothing, print the failure's file,
|
||||
@@ -481,7 +520,8 @@ continues from it; the full verification judges the rest.
|
||||
4. Otherwise, if the report has a torn tail: write `\n` after the torn bytes if it is missing,
|
||||
then a `Recovery` record chained from the line before the torn one. Print "audit: recovered a
|
||||
torn final line" and `see docs/runbook.md#audit-recovered`.
|
||||
5. `--accept-break` with no failure is an error: "nothing to accept". Exit 2.
|
||||
5. `--accept-break` with no failure is an error: "nothing to accept". Exit 2. It writes nothing,
|
||||
not even the recovery of a torn tail; the next start without the flag does that.
|
||||
|
||||
An empty latest file (created, then a crash before its first record) is not a failure and needs
|
||||
no recovery: the next record is its line 1 and chains from the file before.
|
||||
@@ -582,7 +622,8 @@ pub struct GrantProblem { pub file: String, pub line: Option<u64>, pub problem:
|
||||
An unknown or already answered `approval` is `error` `no_such_approval`.
|
||||
|
||||
`PendingApproval.arguments` is not the string `loopd` sent. It is the parsed arguments serialised
|
||||
again by `serde_json` from the typed value in `args`, fields in the order of section 3's table.
|
||||
again by `serde_json` from the typed value in `args`, fields in the order of section 3's table,
|
||||
compact, with an absent `cwd` left out (`"cwd": null` in the request means absent).
|
||||
The owner approves what policy matched: in the raw string `"\u002fetc"` and `"/etc"` look
|
||||
different and mean the same, and the re-serialised form shows both as `/etc`. The audit log keeps
|
||||
the raw string.
|
||||
@@ -633,17 +674,27 @@ processes, output size) are M3b's.
|
||||
|
||||
- **`BrokerPort`** implements `ToolPort` over `broker.sock`. `ToolPort::call` gains a callback:
|
||||
`fn call(&self, req: &ToolRequest, on_pending: &mut dyn FnMut(&Pending)) -> ToolResponse`.
|
||||
The read timeout is `[broker] timeout_ms` (default 120,000) until the first frame. After a
|
||||
pending frame it runs until the frame's `expires` plus `timeout_ms`, which leaves a call
|
||||
approved at the last moment the same time to run as any other. M3b must keep its tool time
|
||||
limit under `timeout_ms`. If the socket cannot be reached, closes early or times out, the answer
|
||||
is `Failed { "the tool broker is unavailable" }`, and `loopd` prints
|
||||
`BrokerPort` waits for the first frame until `[broker] timeout_ms` (default 120,000) after the
|
||||
call began, and after a pending frame until the frame's `expires` plus `timeout_ms`, which
|
||||
leaves a call approved at the last moment the same time to run as any other. These are
|
||||
deadlines, not per-read socket timeouts: a peer that trickles bytes must not hold a turn for
|
||||
ever. (Unlike the inference path's liveness rule, this is a total limit.) M3b must keep its tool
|
||||
time limit under `timeout_ms`. If the socket cannot be reached, closes early or times out, the
|
||||
answer is `Failed { "the tool broker is unavailable" }`, and `loopd` prints
|
||||
`see docs/runbook.md#broker-unavailable`. The turn goes on.
|
||||
- The envelope `id` of a tool request is `request.call.0`, and every answer must carry it. Also
|
||||
failures of the port, each with its own fixed text and no runbook pointer unless it says so: a
|
||||
request too large for one frame ("the request is too large for the tool broker"); a pending
|
||||
frame as the final answer ("the tool broker gave no final answer"); an `expires` so far off
|
||||
that the deadline cannot be computed is a broker that is unavailable. `timeout_ms = 0` is not
|
||||
refused at load; every call then fails closed as unavailable.
|
||||
- **`clock` moves into `loopd`**, beside `find_tool` and `call_tool`: the time is not authority and
|
||||
needs no broker. The core tools stay `clock`, `find_tool`, `call_tool`. The registry's
|
||||
discoverable tools become `read_file`, `write_file`, `shell`, `http_fetch`, with the argument
|
||||
schemas from section 3. `echo` stays in `FakeTools` for tests only. This changes the baseline of
|
||||
new sessions only.
|
||||
schemas from section 3. `echo` stays in `FakeTools` and in the test registry `Registry::m2b()`,
|
||||
which recorded conversations and tests find it through; `loopd serve` uses `Registry::m3a()`.
|
||||
The `tools` array is the same for both (`clock`, `find_tool`, `call_tool`), so this changes only
|
||||
what `find_tool` finds.
|
||||
- **Denials become tool results** the model can explain, written by `loopd` (`class` public,
|
||||
`untrusted` false):
|
||||
|
||||
@@ -664,7 +715,9 @@ processes, output size) are M3b's.
|
||||
frame arrives. It is a notice that something is waiting, not a description of it: `loopd` does
|
||||
not know the grant or the taint, and what the owner is shown must not come from `loopd`
|
||||
(section 9). `tool_denied { name, reason }` is sent before the `tool_result` event of a denied
|
||||
call, so the owner sees the reason itself and not only the model's account of it.
|
||||
call, so the owner sees the reason itself and not only the model's account of it. In both
|
||||
events the tool is the one `brokerd` decides on (`request.tool`), not `call_tool`;
|
||||
`tool_call_started` and `tool_result` keep the name the model called.
|
||||
- **Config**: `[broker] socket` and `[broker] timeout_ms`. If `socket` is absent, `loopd serve`
|
||||
uses no port and every tool call except the core ones fails with "no tool broker is
|
||||
configured"; `loopd` prints that once at startup with `see docs/runbook.md#broker-unavailable`.
|
||||
@@ -678,7 +731,7 @@ processes, output size) are M3b's.
|
||||
|
||||
```
|
||||
41 2 min ago expires in 13 min session chat-1758… grant shell-scratch taint private
|
||||
shell {"command": "rm -rf /home/kyle/scratch/build", "cwd": "/home/kyle/scratch"}
|
||||
shell {"command":"rm -rf /home/kyle/scratch/build","cwd":"/home/kyle/scratch"}
|
||||
```
|
||||
|
||||
- `bxctl approve <id>` prints `approved 41: runs` (exit status 0) or
|
||||
@@ -689,8 +742,10 @@ processes, output size) are M3b's.
|
||||
status 1 if there are problems.
|
||||
- `bxctl audit verify [--home <path>]` reads `<home>/audit/` itself (no daemon) and prints
|
||||
`audit: ok, <n> records, head <hex>`, then any recoveries, accepted breaks, approvals "pending or
|
||||
abandoned", calls "running or unfinished" and clock warnings, one per line; or the failure as
|
||||
`<file>:<line>: <what>` and exit status 1. The two double names are because `bxctl` reads the
|
||||
abandoned", calls "running or unfinished", clock warnings and a torn final line, one per line
|
||||
(the exact lines are fixed in task 19; a torn tail is also what a `brokerd` mid-write looks
|
||||
like, so it is not a failure); or the failure as `<file>:<line>: <what>` and the runbook pointer,
|
||||
and exit status 1. The two double names are because `bxctl` reads the
|
||||
files without asking `brokerd`: an approval still waiting and a call still running look the same
|
||||
on disk as ones a crash cut off.
|
||||
- `bxctl chat`: on an `approval_pending` event it sends `approvals` on `admin.sock`, finds the
|
||||
@@ -701,7 +756,12 @@ processes, output size) are M3b's.
|
||||
on stderr and reads one line from stdin; exactly the id sends `approve`, anything else sends
|
||||
`refuse`. The id, not `y`, because lines typed while the turn ran are still waiting in stdin,
|
||||
and `bxctl` cannot discard them without a terminal library: a stray line must never approve.
|
||||
`--say` and `--json` print the event only. `bxctl chat` gains `--admin-socket`.
|
||||
`--say` shows the block and does not ask; the owner answers with `bxctl approve` from another
|
||||
terminal. `--json` prints the event as JSON and nothing else, and never contacts `brokerd`. The
|
||||
chat lines and the approval's answer are read from one buffered reader on stdin. `bxctl chat`
|
||||
gains `--admin-socket`. The exact texts of the block, the spans and the one-line failures
|
||||
(`brokerd` unreachable, an approval expired before the answer, end of input at the question,
|
||||
which refuses) are fixed in tasks 18 and 20.
|
||||
- A `tool_denied` event prints `[denied <name>: <reason>]`, and for `grants_invalid`,
|
||||
`audit_unavailable` and `state_unreadable` the runbook pointer (`grants-invalid`,
|
||||
`audit-unavailable`, `broker-state-damaged`) on the next line.
|
||||
@@ -714,7 +774,8 @@ processes, output size) are M3b's.
|
||||
spelling of the same character, so the printed text is still exactly the arguments and cannot
|
||||
be forged: a real backslash in a value is already `\\` there.
|
||||
- **Model text is printed the same way** from M3a: `bxctl chat` applies the same escaping to
|
||||
reasoning and content, except that newline and tab pass through, and writes `ESC[0m` before an
|
||||
everything the model wrote (reasoning, content, tool names, and the answer on stdout), except
|
||||
that newline and tab pass through, and writes `ESC[0m` before an
|
||||
approval block. Until now it wrote model text to the terminal as it came, and a colour or
|
||||
conceal sequence in it would have carried into the block that follows.
|
||||
- The admin socket defaults to `<BOXMAKER_HOME>/run/owner-broker/admin.sock`.
|
||||
@@ -742,8 +803,12 @@ heading whose text is its anchor (`## grants-invalid`), with five parts: what yo
|
||||
system refuses, how to confirm, how to fix, and how to check the fix.
|
||||
|
||||
Every message for such a state ends with `see docs/runbook.md#<anchor>`. A gate script,
|
||||
`scripts/check-runbook.sh`, collects every `docs/runbook.md#<anchor>` in `crates/` and fails if
|
||||
`docs/runbook.md` lacks the heading `## <anchor>`. It has a self-test like the other gate scripts.
|
||||
`scripts/check-runbook.sh`, collects every `docs/runbook.md#<anchor>` in the `*.rs` files under
|
||||
`crates/` (tests included, `target/` excluded) and fails if `docs/runbook.md` lacks the heading
|
||||
`## <anchor>`. An anchor must be written out in the source: a pointer whose anchor the script
|
||||
cannot read (built with `format!`, or a placeholder in a comment) is an error. It also fails if it
|
||||
finds no pointer at all, since that means the search is broken. It has a self-test like the other
|
||||
gate scripts.
|
||||
|
||||
## 12. Testing
|
||||
|
||||
@@ -769,12 +834,12 @@ implementation, the oracle, or a compiling skeleton) is in section 15.
|
||||
- **Audit.** The writer: chain across a day boundary, `seq` across files, sync on each record, the
|
||||
lock refusing a second writer. The tampering suite, each a fixture directory: a changed byte in a
|
||||
middle line; a deleted line; two lines swapped; a `seq` gap; a file that does not chain from the
|
||||
one before; a middle line cut short. Each must fail at the right file and line in both
|
||||
`ChainVerifier` and `brokerd`'s startup. Not failures: a torn tail (startup writes a `Recovery`,
|
||||
one before; a middle line cut short. Each must fail at the right file and line in `ChainVerifier`, and in
|
||||
`brokerd`'s startup when the damaged file is the latest (an ordinary start reads only that one). Not failures: a torn tail (startup writes a `Recovery`,
|
||||
and the next verify reports it); an accepted break (reported by every later verify). Failures:
|
||||
an `AcceptedBreak` naming the wrong line, or with the wrong `last_good`, `prev` or `seq`; a
|
||||
`Recovery` whose hash or length does not match the line before it; a line no `Recovery`
|
||||
describes followed by a torn `Recovery`.
|
||||
`Recovery` whose hash or length does not match the line before it, or that describes no line; a
|
||||
line no `Recovery` describes followed by a torn `Recovery`.
|
||||
- **Audit edges**, each a fixture or a scripted writer test: a torn tail that is complete JSON
|
||||
lacking only its newline (recovered, and the chain verifies afterwards: this is the case a
|
||||
"does not parse" rule gets wrong); an unparseable last line that has its newline (recovered
|
||||
|
||||
Reference in New Issue
Block a user