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>
98 lines
4.7 KiB
Markdown
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.
|