# M3a task 11: the pending-approval table **Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) **Commit subject:** `Add the table of pending approvals` ## Goal A call an `ask` grant matched waits in `brokerd::approvals::Table` until someone answers it: `bxctl approve`, `bxctl refuse`, the expiry thread, or the waiting thread itself when `loopd` has gone. They can race. One rule settles every race: **whoever takes the entry out of the table answers it**, and everyone else finds it gone. So there is no "look, then remove": `take` is one step under the lock. The table is in memory only. ## Files - Copy: `crates/brokerd/tests/approvals.rs` - Create: `crates/brokerd/src/approvals.rs` - Modify: `crates/brokerd/src/lib.rs` (add `pub mod approvals;`), `docs/implementer-log.md` ## Interfaces ```rust use std::collections::BTreeMap; use std::sync::mpsc::{Receiver, Sender, channel}; use std::sync::{Mutex, MutexGuard}; use proto::{DenyReason, PendingApproval, Timestamp}; use crate::policy::{Ask, Decision}; /// What the waiting thread is told. Boxed: clippy's `large_enum_variant` rejects it unboxed. #[derive(Debug)] pub enum Verdict { Run(Box), Denied(DenyReason) } #[derive(Debug)] pub struct Entry { pub info: PendingApproval, pub ask: Ask, pub reply: Sender } #[derive(Debug, Default)] pub struct Table { entries: Mutex> } impl Table { pub fn new() -> Table; pub fn insert(&self, info: PendingApproval, ask: Ask) -> Receiver; pub fn take(&self, id: u64) -> Option; pub fn take_expired(&self, now: Timestamp) -> Vec; pub fn list(&self) -> Vec; } ``` `PendingApproval` is `proto`'s (task 02); its `approval` field is the id. `Ask` is not `Clone`, which is why `take` returns the entry itself. Verified in the std docs: `std::sync::mpsc::channel::() -> (Sender, Receiver)`; `Sender::send(&self, t) -> Result<(), SendError>`, an error only when the receiver is gone; `Mutex::lock() -> LockResult>`, and a poisoned lock's guard is `poisoned.into_inner()`; `BTreeMap::remove(&mut self, &k) -> Option`; `values()` iterates in key order. ## Rules 1. **One private helper takes the lock**, and every method uses it: `self.entries.lock().unwrap_or_else(|poisoned| poisoned.into_inner())`. Nothing in this module can leave the map half-changed, so a lock poisoned by a panic elsewhere is taken over as it is (the ledger lock of task 12 is different; do not copy this there). 2. `insert`: make a channel, insert `Entry { info, ask, reply: sender }` under `info.approval`, return the receiver. Ids come from the audit log's `seq` and never repeat. 3. `take`: `remove` under the lock and return it. `None` when the id is not there. 4. `take_expired`: under **one** lock, collect the ids of the entries with `now >= expires` (exactly at `expires` is expired), remove each, and return the entries in id order. Taking the lock once per id would let an `approve` slip in between; the tests race the two. 5. `list`: the `info` of every entry, cloned, in id order. No method sends on `reply`: whoever took the entry does that (tasks 13 and 14). ## Steps - [ ] **1. Copy.** `git switch m3a`, then `cp docs/plans/M3a/files/crates/brokerd/tests/approvals.rs crates/brokerd/tests/` - [ ] **2. See the test fail.** `cargo test -p brokerd --test approvals`. Expected: it does not compile. - [ ] **3. Write `approvals.rs`** and add `pub mod approvals;` to `lib.rs`. `cargo fmt --all`. - [ ] **4. See the tests pass.** `cargo test -p brokerd --test approvals`, five times. Expected: `7 passed` every time. Two tests race threads a hundred times each; a failure now and then means a method looks and removes under two separate locks. - [ ] **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 - `approvals` reports 7 passed five runs in a row, and `make gate` prints `gate: ok`. ## Stop and report if - A test needs the table to write anything to disk, or to answer an entry itself.