Files
boxmaker/docs/plans/M3a/11-brokerd-approvals.md
T
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

4.1 KiB

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

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<Decision>), Denied(DenyReason) }

#[derive(Debug)]
pub struct Entry { pub info: PendingApproval, pub ask: Ask, pub reply: Sender<Verdict> }

#[derive(Debug, Default)]
pub struct Table { entries: Mutex<BTreeMap<u64, Entry>> }

impl Table {
    pub fn new() -> Table;
    pub fn insert(&self, info: PendingApproval, ask: Ask) -> Receiver<Verdict>;
    pub fn take(&self, id: u64) -> Option<Entry>;
    pub fn take_expired(&self, now: Timestamp) -> Vec<Entry>;
    pub fn list(&self) -> Vec<PendingApproval>;
}

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::<T>() -> (Sender<T>, Receiver<T>); Sender::send(&self, t) -> Result<(), SendError<T>>, an error only when the receiver is gone; Mutex::lock() -> LockResult<MutexGuard<T>>, and a poisoned lock's guard is poisoned.into_inner(); BTreeMap::remove(&mut self, &k) -> Option<V>; 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.