Files
kyleandClaude Opus 5 e3f37da232 Hand over the M3a plan: 22 tasks, their files, and the check record
Task files, the files they copy in (byte-identical to the reference on
m3a-ref), each area's check record, and a README with the per-task
table of what each check exposed. The handoff note is done with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 23:45:43 -07:00

98 lines
4.7 KiB
Markdown

# M3a task 14: one owner request on `admin.sock`, and expiry
**Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `Handle approvals, refusals and grant checks on admin.sock`
## Goal
`brokerd::admin::handle` serves one `admin.sock` connection from `bxctl`: `approvals`, `approve`,
`refuse` or `check_grants`, one final answer. `expire_due` answers approvals that have run out.
Both use one function, `answer`, because they do the same thing: **whoever takes an entry out of
the table answers it**: the ledger writes the `Approval` record, then the verdict goes to the
waiting thread (task 13), and only then is `bxctl` answered.
## Files
- Copy: `crates/brokerd/tests/admin.rs`
- Create: `crates/brokerd/src/admin.rs`
- Modify: `crates/brokerd/src/lib.rs` (add `pub mod admin;`), `docs/implementer-log.md`
## Interfaces
```rust
use crate::approvals::Entry;
use crate::broker::{Broker, forbid, read_request, send};
use crate::ledger::{Answer, Answered};
pub const BY: &str = "bxctl"; // the Approval record's `by` for answers through admin.sock
pub fn answer(broker: &Broker, entry: Entry, answer: Answer, now: Timestamp) -> DecisionRecord;
pub fn expire_due(broker: &Broker, now: Timestamp) -> usize;
pub fn handle(stream: UnixStream, broker: &Broker);
```
## `answer`
1. `let grants = broker.grants();` (it reads the grant files now: an approval decides again
with the grants as they are).
2. Destructure `Entry { info, ask, reply }`, then `let Answered { verdict, outcome } =
broker.ledger().answer(ask, info.approval, answer, &grants, now);`
3. `reply.send(verdict)`. If it is `Err` (the waiting thread has gone), `broker.log` the line
`brokerd: approval {id} was answered after its requester had gone`. The record is written
either way; there is nothing else to do.
4. Return `outcome`.
`expire_due`: `broker.table().take_expired(now)`, call `answer(.., Answer::Expired, now)` on each
entry, return how many there were.
## `handle`: every exit
1. `read_request` is `None` → return.
2. `now = Timestamp::now()`. Then, by message kind:
| Message | Does | Answer (`final: true`, the request's `id`) |
|---|---|---|
| `Approvals(Empty {})` | `table().list()` | `ApprovalList { items }` |
| `Approve { approval }` | `table().take(approval)`: `None` | error `NoSuchApproval`, detail `approval {id} is not pending` |
| | `Some(e)` → `answer(.., Answer::Approved { by: Some(BY) }, now)` | `ApproveResult { outcome }` |
| `Refuse { approval, reason }` | `take`: `None` | error `NoSuchApproval` as above |
| | `Some(e)` → `answer(.., Answer::Refused { by: Some(BY), reason }, now)`, outcome is `Denied { reason: ApprovalRefused }` | `Ok(Empty {})` |
| | the same, any other outcome (the record could not be written) | error `Internal`, detail below |
| `CheckGrants(Empty {})` | `grants::load(&broker.cfg().paths.grants)` | `GrantsReport { problems }`: the `Err`'s list, or empty |
| anything else | `forbid(broker, &mut stream, id, &msg, "admin.sock")` | (sent by `forbid`) |
The `Internal` detail is one literal: `"the refusal could not be recorded; the call is denied;
see docs/runbook.md#audit-unavailable"`. The waiting call is denied whatever happens: the ledger
sends `Denied(AuditUnavailable)` when it cannot record.
`CheckGrants` does not go through `broker.grants()`: `bxctl grants check` shows the problems
itself and must not use up the "print once" of task 13.
3. Send the answer. A failed send is ignored.
Notice what is **not** here: nothing looks at an entry and then removes it in a second step;
`take` is the only way in. The test `approve_and_refuse_at_once_give_exactly_one_answer` races the
two a hundred times and checks for exactly one `Approval` record each time.
## Steps
- [ ] **1. Copy.** `git switch m3a`, then
`cp docs/plans/M3a/files/crates/brokerd/tests/admin.rs crates/brokerd/tests/`
- [ ] **2. See the test fail.** `cargo test -p brokerd --test admin`. Expected: no compile.
- [ ] **3. Write `admin.rs`**, add `pub mod admin;`. Run `cargo fmt --all`.
- [ ] **4. See the tests pass.** `cargo test -p brokerd --test admin`, five times. Expected:
`12 passed` every time.
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`.
- [ ] **6. Log and commit.** `git add crates/brokerd docs/implementer-log.md && git commit`
## Done when
- `admin` reports 12 passed five runs in a row, and `make gate` prints `gate: ok`.
## Stop and report if
- A test expects `approve` to run the call on the admin thread. It never does: the waiting thread
of task 13 runs it.
- `bxctl`'s tests (`cargo test -p bxctl`) stop passing. They talk to a fake, but the kinds and
answers above are the ones they expect; report the difference.