docs: M6b implementation plan

Six closed tasks for consult: registry, CONSULT_WAIT, [consult]/[secrets],
ToolName and grants, Decision body, loopback HTTP. Reference is the git bundle
~/boxmaker-refs/m6b-ref.bundle at 5fb05f0. Gate after task 06 is 1611.

Co-Authored-By: Grok 4.6 <noreply@x.ai>
This commit is contained in:
2026-10-03 21:39:31 -07:00
co-authored by Grok 4.6
parent 09580c705a
commit 50e9cc2037
15 changed files with 2887 additions and 0 deletions
@@ -0,0 +1,408 @@
# M6b task 05: Decision `body`; denials still record it
**Branch:** `m6b`. Check it: `git branch --show-current` prints `m6b` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `brokerd: consult decisions record the body`
**Log row:** the Task cell is `M6b/05-brokerd-consult-audit-body`.
**First action.** Run this before any Read of crate source. Do not `ls`
`crates/brokerd/tests/`. Do not Read `main.rs`, `consult_http`, `docs/implementer-log.md`
(except the first 12 lines at the log step). Do not edit `docs/runbook.md` (task 03
already appended `## consult-not-configured`). The signatures below are complete.
```sh
cp docs/plans/M6b/files/crates/brokerd/tests/consult_deny.rs crates/brokerd/tests/
```
## Goal
Every `consult` decision records the JSON body that would be POSTed, including a denial
(spec 4.4). `AuditEvent::Decision` gains optional `body` with
`#[serde(default, skip_serializing_if = "Option::is_none")]`. Other tools leave it unset.
No `[consult]` is `decide_forced` with `InvalidArguments` and `NOT_CONFIGURED` (the
runbook pointer). A grant that does not cover the session's taint is `TaintTooHigh` and
still records the body. Unknown keys are invalid-arguments. Nothing is POSTed on a
denial (`Recording` stays at count 0).
## Every place that changes
Adding `body` to `AuditEvent::Decision` makes every struct literal of that variant fail
to compile until it names the field. List each constructor. Matches that use `..` need
no edit.
| Place | What happens there |
|---|---|
| `crates/loopd` / other crates | **no change** except the constructors below |
| `crates/bxcore/src/audit.rs` | `body: Option<String>` on `Decision` only |
| `crates/bxcore/tests/records.rs` | three `Decision { }` literals: `body: None` |
| `crates/brokerd/tests/ledger.rs` | one `Decision { }` literal: `body: None` |
| `crates/brokerd/tests/support/audit_dir.rs` | `denied()`: `body: None` |
| `crates/brokerd/src/ledger/mod.rs` | `decide_with_body`, `decide_forced`, `decide_inner`; event includes `body` |
| `crates/brokerd/src/consult.rs` | `NOT_CONFIGURED` |
| `crates/brokerd/src/broker.rs` | consult uses `wire_body` then `decide_forced` / `decide_with_body` |
| `crates/brokerd/tests/consult_deny.rs` | new, given |
| everything else | **no change**: not `main.rs`, not HTTP, not `docs/runbook.md` |
`loopd/tests/end_to_end.rs` matches `Decision` with `..` and needs no edit. Do not add
`body` to `AuditEvent::Approval`.
## Files
- Copy, from `docs/plans/M6b/files/` (the same path under it): the new test
`crates/brokerd/tests/consult_deny.rs`.
- Modify: `crates/bxcore/src/audit.rs`, `crates/bxcore/tests/records.rs`,
`crates/brokerd/src/ledger/mod.rs`, `crates/brokerd/src/consult.rs`,
`crates/brokerd/src/broker.rs`, `crates/brokerd/tests/ledger.rs`,
`crates/brokerd/tests/support/audit_dir.rs`, `docs/implementer-log.md`.
- Do not touch: any other file. Do not edit `docs/runbook.md`.
## Facts you need
- **Serde.** `body` is last on `Decision`. `#[serde(default, skip_serializing_if = "Option::is_none")]`.
Existing JSONL fixtures without `body` still decode. A `None` still equals a decoded
record that omitted the field.
- **`decide` stays.** Existing callers keep `decide(...)`. Consult goes through
`decide_with_body` or `decide_forced`. `decide` is `decide_inner(..., None, None)`.
- **`decide_forced` does not match grants.** The outcome is the given `Denial`. The
noconfig test's rig has no consult grant; matching would be `NoGrant`, not
`InvalidArguments`.
- **`NOT_CONFIGURED`** is exactly
`consult is not configured; see docs/runbook.md#consult-not-configured` (no trailing
period). `scripts/check-runbook.sh` reads this pointer; the heading already exists.
- **`Denial`** is `crate::policy::Denial { reason, grant, grant_sha256, detail }`.
- **Wire body on noconfig.** `wire_body(None, arguments)`: empty model, temperature 0.6,
one user message.
- The items the edits use. This table is complete.
| Item | Signature |
|---|---|
| `consult::wire_body` | `wire_body(consult: Option<&Consult>, arguments: &str) -> String` |
| `consult::CONSULT` | `&str`, `"consult"` |
| `Ledger::decide` | `decide(&self, request: ToolRequest, grants: &Grants, now: Timestamp) -> Decided` |
| `policy::Denial` | `{ reason: DenyReason, grant: Option<String>, grant_sha256: Option<Hash32>, detail: Option<String> }` |
| `DenyReason::InvalidArguments` | in `bxcore` |
| `DenyReason::TaintTooHigh` | in `bxcore` |
| `Broker::cfg` | `cfg(&self) -> &Config` |
| `Broker::ledger` | `ledger(&self) -> &Ledger` |
| `Broker::grants` | as used in `handle` already |
## The edits, exactly
Each quoted "before" block occurs exactly once after task 04.
### `crates/bxcore/src/audit.rs`
**E1.** The `Decision` fields ending
```rust
taint: DataClass,
untrusted: bool,
},
Approval {
```
become
```rust
taint: DataClass,
untrusted: bool,
/// The JSON body `consult` would send (or sent). Other tools leave this unset.
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
Approval {
```
Do not add `body` to `Approval`.
### `crates/bxcore/tests/records.rs`
**E2, E3, E4.** Each of the three `AuditEvent::Decision {` literals gains `body: None,`
after `untrusted: …,`. They are:
1. `tool: "read_file"` / `untrusted: false,` then `},` of that event
2. `tool: "shell"` / `untrusted: true,` then `},` of that event
3. `tool: "rm_rf"` / `untrusted: false,` then `},` of that event
Each `untrusted: false,` / `untrusted: true,` that is the last field of a `Decision`
becomes that line plus `body: None,`. Do not add it to `Approval` or `Result`.
### `crates/brokerd/tests/ledger.rs`
**E5.** In `an_allowed_call_is_recorded_in_full`, after `untrusted: false,` of the
`AuditEvent::Decision` add `body: None,`.
### `crates/brokerd/tests/support/audit_dir.rs`
**E6.** In `denied()`, after `untrusted: false,` add `body: None,`.
### `crates/brokerd/src/consult.rs`
**N1.** Immediately before `pub const SECRET_POINTER: &str = "see docs/runbook.md#consult-secret-unreadable";` insert:
```rust
/// Denied when `brokerd.toml` has no `[consult]` table.
pub const NOT_CONFIGURED: &str =
"consult is not configured; see docs/runbook.md#consult-not-configured";
```
### `crates/brokerd/src/ledger/mod.rs`
**D1.** Replace the start of `decide`
```rust
/// Decide and record a call.
pub fn decide(&self, request: ToolRequest, grants: &Grants, now: Timestamp) -> Decided {
let session = request.session.clone();
```
with
```rust
/// Decide and record a call.
pub fn decide(&self, request: ToolRequest, grants: &Grants, now: Timestamp) -> Decided {
self.decide_inner(request, grants, now, None, None)
}
/// Decide and record a call, storing `body` on the decision (consult).
pub fn decide_with_body(
&self,
request: ToolRequest,
grants: &Grants,
now: Timestamp,
body: Option<String>,
) -> Decided {
self.decide_inner(request, grants, now, body, None)
}
/// Record `forced` as the denial, with `body`, without matching grants.
pub fn decide_forced(
&self,
request: ToolRequest,
grants: &Grants,
now: Timestamp,
body: Option<String>,
forced: crate::policy::Denial,
) -> Decided {
self.decide_inner(request, grants, now, body, Some(forced))
}
fn decide_inner(
&self,
request: ToolRequest,
grants: &Grants,
now: Timestamp,
body: Option<String>,
forced: Option<crate::policy::Denial>,
) -> Decided {
let session = request.session.clone();
```
**D2.** The outcome assignment. The lines
```rust
let (outcome, grant, grant_sha256, payload) = match grants {
Err(_) => (
DecisionRecord::Denied {
reason: DenyReason::GrantsInvalid,
},
None,
None,
RecordPayload::Simple(DenyReason::GrantsInvalid),
),
Ok(set) => match state {
Err(()) => (
DecisionRecord::Denied {
reason: DenyReason::StateUnreadable,
},
None,
None,
RecordPayload::Simple(DenyReason::StateUnreadable),
),
Ok(state) => match crate::policy::decide(request, set, state, now) {
crate::policy::Outcome::Allowed(decision) => (
DecisionRecord::Allowed {},
Some(decision.grant().to_string()),
Some(decision.grant_sha256()),
RecordPayload::Allowed(decision),
),
crate::policy::Outcome::Ask(ask) => (
DecisionRecord::Ask {},
Some(ask.grant().to_string()),
Some(ask.grant_sha256()),
RecordPayload::Ask(ask),
),
crate::policy::Outcome::Denied(denial) => (
DecisionRecord::Denied {
reason: denial.reason,
},
denial.grant.clone(),
denial.grant_sha256,
RecordPayload::Denial(denial),
),
},
},
};
```
become
```rust
let (outcome, grant, grant_sha256, payload) = if let Some(denial) = forced {
(
DecisionRecord::Denied {
reason: denial.reason,
},
denial.grant.clone(),
denial.grant_sha256,
RecordPayload::Denial(denial),
)
} else {
match grants {
Err(_) => (
DecisionRecord::Denied {
reason: DenyReason::GrantsInvalid,
},
None,
None,
RecordPayload::Simple(DenyReason::GrantsInvalid),
),
Ok(set) => match state {
Err(()) => (
DecisionRecord::Denied {
reason: DenyReason::StateUnreadable,
},
None,
None,
RecordPayload::Simple(DenyReason::StateUnreadable),
),
Ok(state) => match crate::policy::decide(request, set, state, now) {
crate::policy::Outcome::Allowed(decision) => (
DecisionRecord::Allowed {},
Some(decision.grant().to_string()),
Some(decision.grant_sha256()),
RecordPayload::Allowed(decision),
),
crate::policy::Outcome::Ask(ask) => (
DecisionRecord::Ask {},
Some(ask.grant().to_string()),
Some(ask.grant_sha256()),
RecordPayload::Ask(ask),
),
crate::policy::Outcome::Denied(denial) => (
DecisionRecord::Denied {
reason: denial.reason,
},
denial.grant.clone(),
denial.grant_sha256,
RecordPayload::Denial(denial),
),
},
},
}
};
```
**D3.** The event fields ending
```rust
taint: recorded.taint,
untrusted: recorded.untrusted,
};
```
become
```rust
taint: recorded.taint,
untrusted: recorded.untrusted,
body,
};
```
### `crates/brokerd/src/broker.rs`
**H1.** The lines
```rust
let now = Timestamp::now();
let grants = broker.grants();
let answer = match broker.ledger().decide(request, &grants, now) {
```
become
```rust
let now = Timestamp::now();
let grants = broker.grants();
let decided = if request.tool == crate::consult::CONSULT {
let body = crate::consult::wire_body(broker.cfg().consult.as_ref(), &request.arguments);
if broker.cfg().consult.is_none() {
broker.ledger().decide_forced(
request,
&grants,
now,
Some(body),
crate::policy::Denial {
reason: bxcore::DenyReason::InvalidArguments,
grant: None,
grant_sha256: None,
detail: Some(crate::consult::NOT_CONFIGURED.to_string()),
},
)
} else {
broker
.ledger()
.decide_with_body(request, &grants, now, Some(body))
}
} else {
broker.ledger().decide(request, &grants, now)
};
let answer = match decided {
```
## Steps
- [ ] **1. Copy the given test.**
```sh
cp docs/plans/M6b/files/crates/brokerd/tests/consult_deny.rs crates/brokerd/tests/
```
- [ ] **2. See it fail.** `cargo test -p brokerd --test consult_deny`, timeout 600000 ms:
the target does not compile (`E0560` / no field `body` on `AuditEvent::Decision`, or
unresolved `NOT_CONFIGURED`).
- [ ] **3. The edits.** E1–E6, N1, D1–D3, H1. `cargo check -p bxcore -p brokerd` prints no
error and no warning. `wc -l crates/brokerd/src/ledger/mod.rs` and
`wc -l crates/brokerd/src/broker.rs` each ≤ 500.
- [ ] **4. The tests.** `cargo test -p brokerd --test consult_deny --test ledger --test
audit` and `cargo test -p bxcore --test records`: every test passes, 3 in
`consult_deny`. Existing Decision equality tests still pass.
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last
line `gate: ok`. Test count **1609** (1606 before, plus 3). After `gate: ok`, do not add
up `test result:` lines.
- [ ] **6. Log and commit.** Task cell `M6b/05-brokerd-consult-audit-body`. Read only the
first 12 lines of `docs/implementer-log.md`; insert the row under the header. Stage with
`git add crates/bxcore crates/brokerd docs/implementer-log.md Cargo.lock`, then commit
as `AGENTS.md` says.
## Done when
- `make gate` prints `gate: ok` with 1609 tests.
- `git diff HEAD~1 --stat` lists only the files in the Files section plus the log.
## Stop and report if
- The branch check fails; a "before" block is missing or duplicated (in particular E1
matching `Approval` instead of `Decision`); step 2 compiles; you edit `docs/runbook.md`
or `main.rs`; you add `body` to `Approval`; `cargo check` warns; an existing ledger or
records test fails; a test outside the named files fails in the gate (`git stash -u`,
rerun, `git stash pop`, report both).
## What the given tests pin
No `[consult]` is `InvalidArguments` naming `consult-not-configured` and `docs/runbook.md`;
the audit `body` equals `wire_body(None, question)` and has no `tools`. A secret session
against a private consult grant is `TaintTooHigh` with no detail; the body has
`"model":"test-model"` and one message. An unknown `history` key is `InvalidArguments`.
Nothing reaches the runtime (`count() == 0`).