diff --git a/crates/gatewayd/src/gateway.rs b/crates/gatewayd/src/gateway.rs new file mode 100644 index 0000000..ad75216 --- /dev/null +++ b/crates/gatewayd/src/gateway.rs @@ -0,0 +1,298 @@ +//! `gateway.sock`: the listener, and one thread per `brokerd` connection (M4b spec, sections 4 +//! and 6). A connection thread speaks the protocol only; everything that touches Mattermost or the +//! state file is asked of the serve loop, which alone owns them. + +use std::os::unix::fs::{DirBuilderExt, FileTypeExt, PermissionsExt}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::Path; +use std::sync::Arc; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, TryRecvError}; +use std::time::Duration; + +use proto::{ + ApprovalPosted, ApprovalReply, Envelope, ErrorCode, FrameError, GatewayOutcome, Message, + PROTOCOL_VERSION, PendingApproval, WireError, read_frame, write_frame, +}; + +/// A log line printer, shared by the threads. +pub type Log = Arc; + +/// What a connection thread asks of the serve loop. +#[derive(Debug)] +pub enum ToLoop { + /// Post this approval; an accepted answer comes back on `answer`, and the post ids (or why + /// it could not be posted) on `shown`. + Show { + info: PendingApproval, + answer: Sender, + shown: Sender, String>>, + }, + /// It ended: edit its last post, and forget it. + Ended { approval: u64, ending: Ending }, +} + +/// How an approval ended, as far as this `gatewayd` knows. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Ending { + /// Answered here, by `by`, and `brokerd` said what came of it. + Answered { + by: String, + reason: Option, + outcome: GatewayOutcome, + }, + /// `brokerd` closed first, or said nothing after the answer. + NoLonger, +} + +/// How long to wait: for the first frame, for the loop to post, for `brokerd`'s outcome; and how +/// often to look for an answer meanwhile. +#[derive(Debug, Clone, Copy)] +pub struct Timing { + pub first_frame: Duration, + pub posting: Duration, + pub outcome: Duration, + pub tick: Duration, +} + +impl Default for Timing { + fn default() -> Self { + Timing { + first_frame: Duration::from_secs(10), + posting: Duration::from_secs(120), + outcome: Duration::from_secs(30), + tick: Duration::from_millis(100), + } + } +} + +/// Listen on `socket`: its directory made 0700, a stale socket removed, the socket made 0600. +pub fn listen(socket: &Path) -> Result { + // 1. The directory: `socket.parent()`, which must not be empty, else "cannot prepare : + // a socket needs a directory". + // 2. `std::fs::DirBuilder::new().recursive(true).mode(0o700).create(dir)`, then + // `set_permissions(dir, 0o700)`; an error is "cannot prepare : ". + // 3. `std::fs::symlink_metadata(socket)`: a socket (`file_type().is_socket()`) is removed + // (stale); anything else there is "cannot listen on : something else is there"; + // nothing there is fine. + // 4. `UnixListener::bind(socket)`, then `set_permissions(socket, 0o600)`; an error is "cannot + // listen on : ". + // 5. Ok(listener). Paths are shown with `.display()`. + let dir = match socket.parent() { + Some(dir) => dir, + None => { + return Err(format!( + "cannot prepare {}: a socket needs a directory", + socket.display() + )); + } + }; + if let Err(e) = std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir) + { + return Err(format!("cannot prepare {}: {e}", dir.display())); + } + if let Err(e) = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) { + return Err(format!("cannot prepare {}: {e}", dir.display())); + } + match std::fs::symlink_metadata(socket) { + Ok(meta) if meta.file_type().is_socket() => { + if let Err(e) = std::fs::remove_file(socket) { + return Err(format!("cannot listen on {}: {e}", socket.display())); + } + } + Ok(_) => { + return Err(format!( + "cannot listen on {}: something else is there", + socket.display() + )); + } + Err(_) => {} + } + let listener = match UnixListener::bind(socket) { + Ok(listener) => listener, + Err(e) => return Err(format!("cannot listen on {}: {e}", socket.display())), + }; + if let Err(e) = std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o600)) { + return Err(format!("cannot listen on {}: {e}", socket.display())); + } + Ok(listener) +} + +/// Accept connections on a thread of their own, each served on its own thread. +pub fn serve( + listener: UnixListener, + to_loop: Sender, + timing: Timing, + log: Log, +) -> std::io::Result<()> { + // Spawn (with `std::thread::Builder`, `?`) a thread that loops over `listener.incoming()`, + // skipping errors. For each stream: clone `to_loop` and `log`, and spawn a thread running + // `connection(stream, &to_loop, timing, &log)`; if that spawn fails, log "gatewayd: cannot + // start a thread for an approval: ". Then Ok(()). + std::thread::Builder::new() + .name("gateway-accept".to_string()) + .spawn(move || { + for stream in listener.incoming() { + match stream { + Ok(stream) => { + let to_loop = to_loop.clone(); + let thread_log = log.clone(); + if let Err(e) = std::thread::Builder::new() + .name("gateway-connection".to_string()) + .spawn(move || connection(stream, &to_loop, timing, &thread_log)) + { + log(&format!( + "gatewayd: cannot start a thread for an approval: {e}" + )); + } + } + Err(_) => continue, + } + } + })?; + Ok(()) +} + +fn frame(id: u64, last: bool, msg: Message) -> Envelope { + Envelope { + v: PROTOCOL_VERSION, + id, + r#final: last, + msg, + } +} + +/// Every frame `brokerd` sends, read whole on a thread of its own; shutting the stream down ends it. +fn frames(stream: &UnixStream) -> std::io::Result>> { + // As in `brokerd`'s courier: `let mut reader = stream.try_clone()?;`, a channel, and a thread + // (`std::thread::Builder`, `?`) looping `let frame = read_frame(&mut reader); let last = + // frame.is_err(); if tx.send(frame).is_err() || last { return; }`. Then `Ok(rx)`. + let mut reader = stream.try_clone()?; + let (tx, rx) = mpsc::channel(); + std::thread::Builder::new() + .name("gateway-frame".to_string()) + .spawn(move || { + loop { + let frame = read_frame(&mut reader); + let last = frame.is_err(); + if tx.send(frame).is_err() || last { + return; + } + } + })?; + Ok(rx) +} + +/// One `brokerd` connection, frames 1 to 4 of the spec's section 4. +pub fn connection(mut stream: UnixStream, to_loop: &Sender, timing: Timing, log: &Log) { + // 1. The request, within `first_frame`. + let _ = stream.set_read_timeout(Some(timing.first_frame)); + let info = match read_frame(&mut stream) { + Ok(Envelope { + id: 1, + r#final: true, + msg: Message::ApprovalRequest(req), + .. + }) => req.approval, + _ => return, + }; + let _ = stream.set_read_timeout(None); + let id = info.approval; + // 2. The loop posts it. + let (answer_tx, answer_rx) = mpsc::channel(); + let (shown_tx, shown_rx) = mpsc::channel(); + if to_loop + .send(ToLoop::Show { + info, + answer: answer_tx, + shown: shown_tx, + }) + .is_err() + { + return; + } + let posts = match shown_rx.recv_timeout(timing.posting) { + Ok(Ok(posts)) => posts, + Ok(Err(why)) => { + let e = WireError { + code: ErrorCode::Internal, + detail: why, + }; + let _ = write_frame(&mut stream, &frame(1, true, Message::Error(e))); + return; + } + Err(_) => { + let e = WireError { + code: ErrorCode::Internal, + detail: "gatewayd did not post it in time".to_string(), + }; + let _ = write_frame(&mut stream, &frame(1, true, Message::Error(e))); + return; + } + }; + let rx = match frames(&stream) { + Ok(rx) => rx, + Err(_) => return end(to_loop, id, Ending::NoLonger), + }; + if write_frame( + &mut stream, + &frame(1, false, Message::ApprovalPosted(ApprovalPosted { posts })), + ) + .is_err() + { + return end(to_loop, id, Ending::NoLonger); + } + // 3. An answer from an approver, or brokerd closing first. + let mut answers = Some(answer_rx); + let reply = loop { + if let Some(answers_rx) = &answers { + match answers_rx.recv_timeout(timing.tick) { + Ok(reply) => break reply, + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => answers = None, + } + } else { + std::thread::sleep(timing.tick); + } + match rx.try_recv() { + Err(TryRecvError::Empty) => {} + // brokerd closed (the approval ended elsewhere), or sent what it may not. + _ => { + let _ = stream.shutdown(std::net::Shutdown::Both); + return end(to_loop, id, Ending::NoLonger); + } + } + }; + let (by, reason) = (reply.by.clone(), reply.reason.clone()); + if write_frame(&mut stream, &frame(1, true, Message::ApprovalReply(reply))).is_err() { + return end(to_loop, id, Ending::NoLonger); + } + // 4. What came of it. + let ending = match rx.recv_timeout(timing.outcome) { + Ok(Ok(Envelope { + id: 2, + r#final: true, + msg: Message::ApprovalOutcome(o), + .. + })) => Ending::Answered { + by, + reason, + outcome: o.outcome, + }, + _ => { + (log)(&format!( + "gatewayd: approval {id}: brokerd said nothing after the answer" + )); + Ending::NoLonger + } + }; + let _ = stream.shutdown(std::net::Shutdown::Both); + end(to_loop, id, ending); +} + +fn end(to_loop: &Sender, approval: u64, ending: Ending) { + // `let _ = to_loop.send(ToLoop::Ended { approval, ending });` + let _ = to_loop.send(ToLoop::Ended { approval, ending }); +} diff --git a/crates/gatewayd/src/lib.rs b/crates/gatewayd/src/lib.rs index fb63fda..d2d2571 100644 --- a/crates/gatewayd/src/lib.rs +++ b/crates/gatewayd/src/lib.rs @@ -4,6 +4,7 @@ pub mod approvals; pub mod config; pub mod deliver; +pub mod gateway; pub mod http; pub mod mm; pub mod net; diff --git a/crates/gatewayd/tests/gateway_sock.rs b/crates/gatewayd/tests/gateway_sock.rs new file mode 100644 index 0000000..112d308 --- /dev/null +++ b/crates/gatewayd/tests/gateway_sock.rs @@ -0,0 +1,288 @@ +//! `gateway.sock` from `gatewayd`'s side: the socket's permissions, and one connection's frames, +//! with the test playing both the serve loop and `brokerd` (M4b spec, sections 4 and 6). Do not +//! edit. + +#[path = "support/tmp.rs"] +mod tmp; + +use std::os::unix::fs::PermissionsExt; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::sync::mpsc::{self, Receiver}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use gatewayd::gateway::{Ending, Timing, ToLoop, listen, serve}; +use proto::{ + ApprovalOutcome, ApprovalReply, ApprovalRequest, CallId, DataClass, Envelope, ErrorCode, + GatewayOutcome, Message, PROTOCOL_VERSION, PendingApproval, SessionId, Timestamp, read_frame, + write_frame, +}; +use tmp::TempDir; + +const KYLE: &str = "k0000000000000000000000000"; +const POST: &str = "p0000000000000000000000001"; +const CMD: &str = "c0000000000000000000000001"; +const WAIT: Duration = Duration::from_secs(5); + +fn info(approval: u64) -> PendingApproval { + PendingApproval { + approval, + session: SessionId::new("mm-r0000000000000000000000001").unwrap(), + call: CallId(3), + tool: "shell".to_string(), + arguments: "{}".to_string(), + grant: "g".to_string(), + taint: DataClass::Private, + created: Timestamp::parse("2026-09-24T08:05:00.000Z").unwrap(), + expires: Timestamp::parse("2026-09-24T08:20:00.000Z").unwrap(), + } +} + +fn frame(id: u64, last: bool, msg: Message) -> Envelope { + Envelope { + v: PROTOCOL_VERSION, + id, + r#final: last, + msg, + } +} + +fn timing() -> Timing { + Timing { + first_frame: Duration::from_millis(500), + posting: WAIT, + outcome: Duration::from_millis(500), + tick: Duration::from_millis(20), + } +} + +/// A listening gatewayd side: the socket and what it asks of the loop. +fn start( + dir: &TempDir, +) -> ( + std::path::PathBuf, + Receiver, + Arc>>, +) { + let socket = dir.path().join("run/gateway/gateway.sock"); + let listener = listen(&socket).unwrap(); + let (tx, rx) = mpsc::channel(); + let lines = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&lines); + serve( + listener, + tx, + timing(), + Arc::new(move |l: &str| sink.lock().unwrap().push(l.to_string())), + ) + .unwrap(); + (socket, rx, lines) +} + +/// brokerd: connect and send the request. +fn request(socket: &Path, approval: u64) -> UnixStream { + let mut s = UnixStream::connect(socket).unwrap(); + s.set_read_timeout(Some(WAIT)).unwrap(); + write_frame( + &mut s, + &frame( + 1, + true, + Message::ApprovalRequest(ApprovalRequest { + approval: info(approval), + }), + ), + ) + .unwrap(); + s +} + +fn reply(approval: u64) -> ApprovalReply { + ApprovalReply { + approval, + approved: true, + by: KYLE.to_string(), + post: CMD.to_string(), + reason: None, + } +} + +#[test] +fn the_socket_is_private_and_a_stale_one_is_replaced() { + let dir = TempDir::new("gw-perms"); + let socket = dir.path().join("run/gateway/gateway.sock"); + drop(listen(&socket).unwrap()); + let listener = listen(&socket).expect("a stale socket is replaced"); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + assert_eq!( + (mode(&socket), mode(socket.parent().unwrap())), + (0o600, 0o700) + ); + drop(listener); + let file = dir.path().join("run/gateway/not-a-socket"); + std::fs::write(&file, "x").unwrap(); + let why = listen(&file).unwrap_err(); + assert!( + why.starts_with(&format!("cannot listen on {}", file.display())), + "{why}" + ); + assert!( + dir.path().join("run/gateway/not-a-socket").exists(), + "a file that is not a socket is left alone" + ); +} + +#[test] +fn posted_answered_and_ended() { + let dir = TempDir::new("gw-answer"); + let (socket, to_loop, _) = start(&dir); + let mut brokerd = request(&socket, 42); + let (answer, shown) = match to_loop.recv_timeout(WAIT).unwrap() { + ToLoop::Show { + info: got, + answer, + shown, + } => { + assert_eq!(got, info(42)); + (answer, shown) + } + other => panic!("{other:?}"), + }; + shown.send(Ok(vec![POST.to_string()])).unwrap(); + let posted = read_frame(&mut brokerd).unwrap(); + assert_eq!((posted.id, posted.r#final), (1, false)); + assert!( + matches!(posted.msg, Message::ApprovalPosted(ref p) if p.posts == [POST]), + "{posted:?}" + ); + answer.send(reply(42)).unwrap(); + let answered = read_frame(&mut brokerd).unwrap(); + assert_eq!(answered, frame(1, true, Message::ApprovalReply(reply(42)))); + write_frame( + &mut brokerd, + &frame( + 2, + true, + Message::ApprovalOutcome(ApprovalOutcome { + outcome: GatewayOutcome::Runs {}, + }), + ), + ) + .unwrap(); + match to_loop.recv_timeout(WAIT).unwrap() { + ToLoop::Ended { approval, ending } => { + assert_eq!(approval, 42); + assert_eq!( + ending, + Ending::Answered { + by: KYLE.to_string(), + reason: None, + outcome: GatewayOutcome::Runs {} + } + ); + } + other => panic!("{other:?}"), + } +} + +#[test] +fn brokerd_closing_first_ends_it_as_no_longer_pending() { + let dir = TempDir::new("gw-closed"); + let (socket, to_loop, _) = start(&dir); + let mut brokerd = request(&socket, 7); + let ToLoop::Show { shown, answer, .. } = to_loop.recv_timeout(WAIT).unwrap() else { + panic!() + }; + shown.send(Ok(vec![POST.to_string()])).unwrap(); + read_frame(&mut brokerd).unwrap(); + drop(brokerd); + match to_loop.recv_timeout(WAIT).unwrap() { + ToLoop::Ended { approval, ending } => assert_eq!((approval, ending), (7, Ending::NoLonger)), + other => panic!("{other:?}"), + } + assert!( + answer.send(reply(7)).is_err(), + "nobody listens for an answer any more" + ); +} + +#[test] +fn an_approval_that_cannot_be_posted_is_an_error_to_brokerd() { + let dir = TempDir::new("gw-cannot"); + let (socket, to_loop, _) = start(&dir); + let mut brokerd = request(&socket, 8); + let ToLoop::Show { shown, .. } = to_loop.recv_timeout(WAIT).unwrap() else { + panic!() + }; + shown.send(Err("Mattermost is down".to_string())).unwrap(); + let got = read_frame(&mut brokerd).unwrap(); + assert_eq!((got.id, got.r#final), (1, true)); + match got.msg { + Message::Error(e) => assert_eq!( + (e.code, e.detail.as_str()), + (ErrorCode::Internal, "Mattermost is down") + ), + other => panic!("{other:?}"), + } + assert!( + to_loop.recv_timeout(Duration::from_millis(300)).is_err(), + "nothing more for the loop" + ); +} + +#[test] +fn no_outcome_after_the_answer_is_no_longer_pending_and_logged() { + let dir = TempDir::new("gw-silent"); + let (socket, to_loop, lines) = start(&dir); + let mut brokerd = request(&socket, 9); + let ToLoop::Show { shown, answer, .. } = to_loop.recv_timeout(WAIT).unwrap() else { + panic!() + }; + shown.send(Ok(vec![POST.to_string()])).unwrap(); + read_frame(&mut brokerd).unwrap(); + answer.send(reply(9)).unwrap(); + read_frame(&mut brokerd).unwrap(); + match to_loop.recv_timeout(WAIT).unwrap() { + ToLoop::Ended { approval, ending } => assert_eq!((approval, ending), (9, Ending::NoLonger)), + other => panic!("{other:?}"), + } + assert_eq!( + *lines.lock().unwrap(), + ["gatewayd: approval 9: brokerd said nothing after the answer"] + ); +} + +#[test] +fn anything_but_a_request_first_is_dropped() { + let dir = TempDir::new("gw-bad"); + let (socket, to_loop, _) = start(&dir); + for first in [ + frame(1, true, Message::ApprovalReply(reply(1))), + frame( + 2, + true, + Message::ApprovalRequest(ApprovalRequest { approval: info(1) }), + ), + frame( + 1, + false, + Message::ApprovalRequest(ApprovalRequest { approval: info(1) }), + ), + ] { + let mut s = UnixStream::connect(&socket).unwrap(); + s.set_read_timeout(Some(WAIT)).unwrap(); + write_frame(&mut s, &first).unwrap(); + assert!( + read_frame(&mut s).is_err(), + "closed without an answer: {first:?}" + ); + } + let silent = UnixStream::connect(&socket).unwrap(); + std::thread::sleep(Duration::from_millis(700)); + drop(silent); + assert!( + to_loop.recv_timeout(Duration::from_millis(300)).is_err(), + "nothing reached the loop" + ); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index b3b6c42..6b01446 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/13-gatewayd-gateway-sock | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/src/gateway.rs` to `crates/gatewayd/src/` and `docs/plans/M4b/files/crates/gatewayd/tests/gateway_sock.rs` to `crates/gatewayd/tests/`, then added `pub mod gateway;` to `lib.rs` after `pub mod deliver;`. Filled the four `todo!()`s the skeleton left in `gateway.rs`: `end` (send `ToLoop::Ended { approval, ending }` to the loop, the only thing that touches the loop); `frames` (`try_clone` the stream, spawn a reader thread that hands whole frames over an mpsc channel so the connection waits with a timeout without losing half a frame, `Ok(rx)` — the same as brokerd's courier); `listen` (the parent dir made 0700 via a recursive `DirBuilder` then `set_permissions`, a stale socket removed, anything else left in place as "cannot listen on : something else is there", bind, then the socket made 0600, with the spec's error strings and paths via `.display()`); and `serve` (spawn an accept loop that skips `incoming()` errors and starts a `connection` thread per stream, logging a spawn failure as "gatewayd: cannot start a thread for an approval: ", keeping a separate `log` clone for that message). The skeleton's `ToLoop` was already complete with `Ended`, so it needed no change. All six `gateway_sock` tests pass three times running in ~1 s; `make gate` prints `gate: ok` on the first run. | ? | | M4b/12-gatewayd-state-approvals | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/state_approvals.rs` into `crates/gatewayd/tests/`. In `state.rs`: added the `#[serde(default)] approvals: Vec` field after `in_flight` (optional so an M4a state file loads unchanged) and the `ApprovalPosts { approval, channel, posts }` struct (`#[serde(deny_unknown_fields)`) after `struct StateFile`; in `problem` the `in_flight` loop now has a loop over `approvals` requiring a `valid_id` channel and at least one `valid_id` post, else "approval {}'s posts are not valid"; added `turn_of` (find the `in_flight` entry of a session, cloned), `add_approval` (replace any entry of the same approval, push, save), `remove_approval` (retain by approval, save only when one was removed) and `take_approvals` (`std::mem::take`, save only when there were some, return them) at the end of `impl State`. `cargo fmt --all` then `cargo test -p gatewayd --test state_approvals --test state --test serve_restart` green (4, 4 and 7 passed); `make gate` prints `gate: ok` on the first run. | ? | | 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. | ? |