From c5df86d13daf911ac4374a076aa4bd4b4e413663 Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Thu, 24 Sep 2026 04:48:06 -0700 Subject: [PATCH] gatewayd: approvals, the registry and the judging of commands Implemented-By: OpenCode session (model recorded in docs/implementer-log.md) --- crates/gatewayd/src/approvals/mod.rs | 133 ++++++++++++++++++ crates/gatewayd/tests/approvals_judge.rs | 166 +++++++++++++++++++++++ docs/implementer-log.md | 1 + 3 files changed, 300 insertions(+) create mode 100644 crates/gatewayd/tests/approvals_judge.rs diff --git a/crates/gatewayd/src/approvals/mod.rs b/crates/gatewayd/src/approvals/mod.rs index 6a8cd58..b204a7e 100644 --- a/crates/gatewayd/src/approvals/mod.rs +++ b/crates/gatewayd/src/approvals/mod.rs @@ -2,3 +2,136 @@ //! approvals this `gatewayd` has posted and the commands that answer them. pub mod text; + +use std::collections::HashMap; +use std::sync::mpsc::Sender; +use std::sync::{Mutex, MutexGuard}; + +use proto::ApprovalReply; + +use crate::sessions::ApprovalCommand::{self, Approve, Deny, Malformed}; + +pub const NOT_APPROVER: &str = "only an approver can answer approvals"; +pub const USAGE: &str = "use !approve or !deny "; + +/// One approval posted here and not yet answered. +#[derive(Debug)] +pub struct Posted { + pub approval: u64, + /// The session whose turn waits for it. + pub session: String, + /// The thread it is answered in. + pub channel: String, + pub root: String, + /// The last post's `create_at`: only a command after it counts. + pub shown_at: i64, + /// False when the arguments were too long to show: only `bxctl` answers it. + pub answerable: bool, + /// Where an accepted answer goes: the thread of its `gateway.sock` connection. + pub answer: Sender, +} + +/// What a command came to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Judged { + /// The answer went to `brokerd`'s connection; the approval post will show the outcome. + Sent, + /// Refused: this line goes in the thread. + Reply(String), +} + +/// The approvals posted here, by id. +#[derive(Debug, Default)] +pub struct Registry { + posted: Mutex>, +} + +impl Registry { + pub fn new() -> Registry { + Registry::default() + } + + fn lock(&self) -> MutexGuard<'_, HashMap> { + self.posted.lock().unwrap_or_else(|p| p.into_inner()) + } + + pub fn add(&self, posted: Posted) { + self.lock().insert(posted.approval, posted); + } + + /// Take it out: it ended, or its connection is done with it. + pub fn remove(&self, id: u64) -> Option { + self.lock().remove(&id) + } + + pub fn holds(&self, id: u64) -> bool { + self.lock().contains_key(&id) + } + + /// The sessions with an approval waiting here: their turns are waiting, not working. + pub fn waiting_sessions(&self) -> Vec { + self.lock().values().map(|p| p.session.clone()).collect() + } + + /// Judge a command from `user`, posted as `post` at `at` in the thread `root`, in the order of + /// the spec's section 7. An accepted answer is taken out and sent; nothing else changes. + pub fn judge( + &self, + command: &ApprovalCommand, + user: &str, + root: &str, + post: &str, + at: i64, + approvers: &[String], + ) -> Judged { + // In this order; each refusal is `Judged::Reply()` and changes nothing: + // 1. `user` not in `approvers`: NOT_APPROVER. + // 2. The command: Approve(id) is (id, approved true, reason None); Deny(id, reason) is (id, + // false, reason.clone()); Malformed: USAGE. + // 3. `let mut posted = self.lock();` Not in `posted`: "approval {id} is not pending here". + // 4. `!entry.answerable`: "approval {id} can only be answered with bxctl approvals". + // 5. `entry.root != root`: "approval {id} is answered in its own thread". + // 6. `at <= entry.shown_at`: "approval {id} was answered before it was shown in full". + // 7. Take it out (`posted.remove(&id)`) and send `ApprovalReply { approval: id, approved, + // by: user, post, reason }` on its `answer`: Ok -> `Judged::Sent`; Err (the connection + // is gone) -> "approval {id} is not pending here". + if !approvers.iter().any(|a| a.as_str() == user) { + return Judged::Reply(NOT_APPROVER.to_string()); + } + let (id, approved, reason) = match command { + Approve(id) => (*id, true, None), + Deny(id, reason) => (*id, false, reason.clone()), + Malformed => return Judged::Reply(USAGE.to_string()), + }; + let mut posted = self.lock(); + let entry = match posted.get(&id) { + Some(entry) => entry, + None => return Judged::Reply(format!("approval {id} is not pending here")), + }; + let answer = entry.answer.clone(); + if !entry.answerable { + return Judged::Reply(format!( + "approval {id} can only be answered with bxctl approvals" + )); + } + if entry.root != root { + return Judged::Reply(format!("approval {id} is answered in its own thread")); + } + if at <= entry.shown_at { + return Judged::Reply(format!( + "approval {id} was answered before it was shown in full" + )); + } + posted.remove(&id); + match answer.send(ApprovalReply { + approval: id, + approved, + by: user.to_string(), + post: post.to_string(), + reason, + }) { + Ok(()) => Judged::Sent, + Err(_) => Judged::Reply(format!("approval {id} is not pending here")), + } + } +} diff --git a/crates/gatewayd/tests/approvals_judge.rs b/crates/gatewayd/tests/approvals_judge.rs new file mode 100644 index 0000000..0d225c8 --- /dev/null +++ b/crates/gatewayd/tests/approvals_judge.rs @@ -0,0 +1,166 @@ +//! Which commands answer an approval, in the order of the M4b spec, section 7; everything else is +//! answered with its reason and changes nothing. Do not edit. + +use std::sync::mpsc::{self, Receiver}; + +use gatewayd::approvals::{Judged, NOT_APPROVER, Posted, Registry, USAGE}; +use gatewayd::sessions::ApprovalCommand::{self, Approve, Deny, Malformed}; +use proto::ApprovalReply; + +const KYLE: &str = "k0000000000000000000000000"; +const HERMES: &str = "h0000000000000000000000000"; +const DM: &str = "d0000000000000000000000000"; +const ROOT: &str = "r0000000000000000000000000"; +const ELSEWHERE: &str = "e0000000000000000000000000"; +const CMD: &str = "c0000000000000000000000001"; + +fn approvers() -> Vec { + vec![KYLE.to_string()] +} + +/// A registry with approval 42 posted in ROOT, shown at 100. +fn posted(answerable: bool) -> (Registry, Receiver) { + let registry = Registry::new(); + let (tx, rx) = mpsc::channel(); + registry.add(Posted { + approval: 42, + session: format!("mm-{ROOT}"), + channel: DM.to_string(), + root: ROOT.to_string(), + shown_at: 100, + answerable, + answer: tx, + }); + (registry, rx) +} + +fn judge(r: &Registry, command: &ApprovalCommand, user: &str, root: &str, at: i64) -> Judged { + r.judge(command, user, root, CMD, at, &approvers()) +} + +#[test] +fn an_approver_in_the_thread_after_the_post_answers_it() { + let (r, rx) = posted(true); + assert_eq!(judge(&r, &Approve(42), KYLE, ROOT, 101), Judged::Sent); + assert_eq!( + rx.try_recv().unwrap(), + ApprovalReply { + approval: 42, + approved: true, + by: KYLE.to_string(), + post: CMD.to_string(), + reason: None + } + ); + assert!(!r.holds(42), "answered once"); + assert_eq!( + judge(&r, &Approve(42), KYLE, ROOT, 102), + Judged::Reply("approval 42 is not pending here".to_string()) + ); + assert!(rx.try_recv().is_err(), "a second answer goes nowhere"); +} + +#[test] +fn a_refusal_carries_its_reason() { + let (r, rx) = posted(true); + assert_eq!( + judge( + &r, + &Deny(42, Some("not on a Friday".to_string())), + KYLE, + ROOT, + 101 + ), + Judged::Sent + ); + let got = rx.try_recv().unwrap(); + assert_eq!( + (got.approved, got.reason.as_deref()), + (false, Some("not on a Friday")) + ); + let (r, rx) = posted(true); + assert_eq!(judge(&r, &Deny(42, None), KYLE, ROOT, 101), Judged::Sent); + assert_eq!(rx.try_recv().unwrap().reason, None); +} + +#[test] +fn everything_else_is_refused_with_its_reason_in_order() { + let cases: &[(&ApprovalCommand, &str, &str, i64, &str)] = &[ + (&Approve(42), HERMES, ROOT, 101, NOT_APPROVER), + (&Malformed, HERMES, ROOT, 101, NOT_APPROVER), + (&Malformed, KYLE, ROOT, 101, USAGE), + ( + &Approve(7), + KYLE, + ROOT, + 101, + "approval 7 is not pending here", + ), + ( + &Approve(42), + KYLE, + ELSEWHERE, + 101, + "approval 42 is answered in its own thread", + ), + ( + &Approve(42), + KYLE, + ROOT, + 100, + "approval 42 was answered before it was shown in full", + ), + ( + &Approve(42), + KYLE, + ROOT, + 50, + "approval 42 was answered before it was shown in full", + ), + ( + &Approve(42), + KYLE, + ELSEWHERE, + 50, + "approval 42 is answered in its own thread", + ), + ]; + for (command, user, root, at, want) in cases { + let (r, rx) = posted(true); + assert_eq!( + judge(&r, command, user, root, *at), + Judged::Reply(want.to_string()), + "{command:?} {user} {root} {at}" + ); + assert!(r.holds(42), "still pending after {command:?}"); + assert!(rx.try_recv().is_err(), "nothing sent for {command:?}"); + } +} + +#[test] +fn too_long_to_show_is_answered_only_in_bxctl() { + let (r, rx) = posted(false); + assert_eq!( + judge(&r, &Approve(42), KYLE, ROOT, 101), + Judged::Reply("approval 42 can only be answered with bxctl approvals".to_string()) + ); + assert!(r.holds(42) && rx.try_recv().is_err()); +} + +#[test] +fn an_answer_whose_connection_is_gone_is_not_pending() { + let (r, rx) = posted(true); + drop(rx); + assert_eq!( + judge(&r, &Approve(42), KYLE, ROOT, 101), + Judged::Reply("approval 42 is not pending here".to_string()) + ); +} + +#[test] +fn the_waiting_sessions_and_removal() { + let (r, _rx) = posted(true); + assert_eq!(r.waiting_sessions(), [format!("mm-{ROOT}")]); + assert_eq!(r.remove(42).map(|p| p.approval), Some(42)); + assert!(r.waiting_sessions().is_empty() && !r.holds(42) && r.remove(42).is_none()); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 1bc67cb..035b7a3 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M4b/11-gatewayd-approvals-registry | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/src/approvals/mod.rs` (the structs `Posted`/`Judged`/`Registry`, the `pub mod text;` still there, and the five `todo!()`s) to `crates/gatewayd/src/approvals/`, and `docs/plans/M4b/files/crates/gatewayd/tests/approvals_judge.rs` to `crates/gatewayd/tests/`. Filled `add` (insert by `approval`), `remove` (remove by id), `holds` (contains_key), `waiting_sessions` (the `session` of every entry), and `judge` (the six spec-section-7 checks in order: `user` in `approvers` else `NOT_APPROVER`, the command to `(id, approved, reason)` — `Approve` true/None, `Deny` false/`reason.clone()`, `Malformed` `USAGE`, then `get(&id)` present else "not pending here", `answerable` else "bxctl approvals", `root` matches else "its own thread", `at <= shown_at` else "answered before shown in full", then `remove` and `send` the `ApprovalReply`, a gone connection → "not pending here"). Brought `Approve`/`Deny`/`Malformed` into scope with the `ApprovalCommand` type, and cloned the `answer` `Sender` out of the entry before the early-return refusals so the held lock's `remove` does not conflict. All six `approvals_judge` and six `approvals_text` tests pass; `make gate` prints `gate: ok` on the first run. | ? | | M4b/10-gatewayd-approval-text | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/src/approvals/mod.rs-task10` to `crates/gatewayd/src/approvals/mod.rs` (the module comment and `pub mod text;`), the `text.rs` skeleton, and `docs/plans/M4b/files/crates/gatewayd/tests/approvals_text.rs` to `crates/gatewayd/tests/`, then added `pub mod approvals;` to `lib.rs` first, before `pub mod config;`. Filled the eight functions the skeleton left as `todo!()`: `code` (a backtick run one longer than `longest_run`, with a padding space on both sides when the text starts or ends with a backtick so CommonMark keeps it literal), `fence` (same, at least three backticks, wrapped `fence\n`), `longest_run` (a char scan keeping the longest run of consecutive backticks), `minute` (the rfc3339 date and `HH:MM` plus ` UTC`), `name` (the serde string form when there is one, else `{value:?}`), `reply_line` (the `!approve`/`!deny` line), `ended_line` (the six-arm match on `Ended`, the `Denied` reason and any `Refused` reason run through `name`/`code`), and `with_ending` (the last post, a blank line, then `ended_line`). `render` was already written in the skeleton. All six `approvals_text` tests pass; `make gate` prints `gate: ok` on the first run. | ? | | M4b/09-gatewayd-mm-edit | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/mm_edit.rs` into `crates/gatewayd/tests/`. In `rest.rs`: added the `User { username: String }` struct and `valid_username` (1..=64 bytes of a-z, 0-9, `.`, `-` and `_`) after `Channel`, then `patch_post` (a `post` that is not `valid_id` is `Json` and nothing is sent; otherwise PUT `/api/v4/posts/{post}/patch` with `{"message": message}` through `call`, the answer a `Post` `check`ed and dropped) and `username` (a `user` that is not `valid_id` is `Json`; GET `/api/v4/users/{user}`, a username that is not `valid_username` is `Json`, else the name) at the end of `impl Client`. `cargo fmt --all` then `cargo test -p gatewayd --test mm_edit --test mm_rest` green (4 and 10 passed); `make gate` prints `gate: ok` on the first run. | ? | | M4b/08-gatewayd-mention-commands | 2026-09-24 | done | 1 | pass | none | Copied `sessions_mention.rs` into `crates/gatewayd/tests/`, the new `sessions.rs` (commands now route to `Approval`, the mention stripped) and `serve.rs` (from `serve.rs-task08`, the turn's text is `start`). In `sessions.rs`: added `Route::Approval { thread, command, user, post, at }` after `Queue(Message)`; added the given `ApprovalCommand` enum, `MAX_REASON = proto::MAX_REASON`, and `approval_command` (returns `None` for text that is not a `!` command, else `Approve`/`Deny`/`Malformed`); wrote `strip_mention` (the five steps: strip a leading `@`, take the longest name run, trim a trailing dot, match `me` case-insensitively, return the message when the remainder is empty); in `route` bound `message` from `strip_mention(&post.message, &self.me_name)`, replaced the `!` command block with `approval_command` → Approval then any other `!` → Reply UNKNOWN_COMMAND, and changed the final Queue's `text: message.clone()` to `message.to_string()` since `message` is now `&str`; updated the step comment. In `handle.rs` `handle_post` added the `Route::Approval` arm posting M4B_COMMAND (approvals module wired in task 15) and `M4B_COMMAND` to the `use`. All three suites pass (serve 6, sessions 8, sessions_mention 4); `make gate` prints `gate: ok` on the first run. | ? |