# M4b task 18: an approval a non-approver asked for goes to an approver **Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop) **Commit subject:** `gatewayd: approvals asked by non-approvers in direct messages go to an approver` ## Goal M4b spec, section 6, as changed after the review of 2026-09-24. Today an approval always goes into the thread whose turn asked for it. When that turn was asked by someone who may talk to the bot but not approve (in `[allow] users`, not in `[approvals] users`), in a **direct or group message**, no approver can answer it there: it could only be answered with `bxctl`. Now: 1. **Redirected.** Such an approval goes to the first approver's direct messages as a new thread. Its first post is task 17's `asked_post`: who asked (`@username`), where, and their message. Then come the approval's own posts, in that thread, and the asking thread gets one line, `sent_to_approver`. The approver answers in the new thread. 2. **Stays.** An approval asked by an approver, or asked in a channel (`O` or `P`: an approver can reply in the thread there), stays in its thread, as today. 3. **Not followed.** An approval whose session has no turn in flight here already goes to the first approver's direct messages; now its first post is task 17's `unfollowed_post`, and the approval's posts follow in that thread. To know who asked, the serve loop keeps two maps in memory, not in the state file (after a restart the turn is interrupted anyway): `authors`, the author and channel type of the last message queued for each session, and `askers`, for each turn running, its author, channel type and text. `start` fills `askers` from `authors`; `finished` removes both. ## Files - Copy: `crates/gatewayd/tests/serve_redirect.rs`, and a **new version** of the given `crates/gatewayd/tests/serve_approvals.rs` (an unfollowed session's approval now has a first post: 3 posts, not 2) - Modify: `crates/gatewayd/src/serve/approvals.rs`, `crates/gatewayd/src/serve/mod.rs`, `crates/gatewayd/src/serve/handle.rs`, `docs/implementer-log.md` ## The changes, exactly ### `serve/mod.rs` In `struct Gateway`, after the field `open`, add: ```rust /// The author and channel type of the last message queued in each session. authors: HashMap, /// Who asked for each turn running, by session id. askers: HashMap, ``` and in `Gateway::new`, after `open: HashMap::new(),`, add `authors: HashMap::new(),` and `askers: HashMap::new(),`. `HashMap` and `SessionId` are already imported there. ### `serve/approvals.rs` 1. The import `use crate::approvals::text::{self, Ended};` becomes `use crate::approvals::text::{self, Asked, Ended};`. 2. Insert these two types just before the doc comment of `pub(super) struct Open`: ```rust /// Who asked for the turn now running in a session: the author of the last message queued, the /// channel's type, and the turn's text. Kept in memory only, and forgotten when the turn ends. #[derive(Debug, Clone)] pub(super) struct Asker { pub(super) user: String, pub(super) channel_type: String, pub(super) prompt: String, } /// Where an approval goes, and what is posted besides it. struct Place { channel: String, root: String, /// A post before the approval's own: who asked and what, or whose session. first: Option, /// The asking thread, told where the approval went. note: Option<(String, String)>, } ``` 3. Replace the whole function `place`, from its doc comment (`/// Where an approval goes: its session's turn in flight, …`) to its closing `}`, including the numbered comment inside it, by these two functions: ```rust /// Where an approval goes (M4b spec, section 6): its turn's thread; or, when a non-approver /// asked in a direct or group message, the first approver's direct messages with who asked /// and what, and a note in the asking thread; or, for a session not followed here, the first /// approver's direct messages with a line saying so. fn place(&self, info: &PendingApproval) -> Result { let id = info.approval; let session = info.session.as_str(); let Some(turn) = self.state.turn_of(session) else { return Ok(Place { channel: self.approver_dm()?, root: String::new(), first: Some(text::unfollowed_post(id, session)), note: None, }); }; let redirect = self.askers.get(session).filter(|a| { matches!(a.channel_type.as_str(), "D" | "G") && !self.config.approvers().contains(&a.user) }); let Some(asker) = redirect else { return Ok(Place { channel: turn.channel, root: turn.root, first: None, note: None, }); }; let by = match self.client.username(&asker.user) { Ok(name) => format!("@{name}"), Err(_) => asker.user.clone(), }; let asked = Asked { by, channel_type: asker.channel_type.clone(), prompt: asker.prompt.clone(), }; Ok(Place { channel: self.approver_dm()?, root: String::new(), first: Some(text::asked_post(id, &asked)), note: Some((turn.channel, turn.root)), }) } /// The first approver's direct channel with the bot. fn approver_dm(&self) -> Result { let first = self .config .first_approver() .ok_or("no approver is configured")?; self.client .direct_channel(&self.me.id, first) .map_err(|e| format!("no direct channel with the first approver: {e}")) } ``` `place` checks in this order: no turn in flight → not followed; an asker who is not an approver, in a `D` or `G` channel → redirected; anything else → its thread. A user name that cannot be looked up is shown as the user id, as it is elsewhere. 4. In `show`: - Its doc comment (two lines, `/// Post an approval's parts in order, …`) becomes: ```rust /// Post an approval's first post, if any, and its parts in order, record them, and register /// it for answers. An `Err` inside is why it could not be posted; the outer one stops /// `gatewayd` (the state file). ``` - These lines ```rust let (channel, mut root) = match self.place(info) { Ok(place) => place, Err(why) => return Ok(Err(why)), }; let shown = text::render(info); ``` become ```rust let place = match self.place(info) { Ok(place) => place, Err(why) => return Ok(Err(why)), }; let (channel, mut root) = (place.channel, place.root); let shown = text::render(info); // A first post saying who asked, or whose session, then the approval's own posts. let bodies: Vec = place .first .into_iter() .chain(shown.posts.iter().cloned()) .collect(); ``` - Everywhere else in `show`, `shown.posts` becomes `bodies`. There are three: `for body in &shown.posts {` → `for body in &bodies {`; `shown.posts.get(posts.len().wrapping_sub(1))` → `bodies.get(posts.len().wrapping_sub(1))`; `(posts.last(), shown.posts.last())` → `(posts.last(), bodies.last())`. `shown.answerable` stays: it is not a post. - Just before the last line, `Ok(Ok(posts))`, add: ```rust if let Some((note_channel, note_root)) = place.note { self.post(¬e_channel, ¬e_root, &text::sent_to_approver(id)); } ``` The note is posted only after the approval was posted and registered; a failure to post it is only logged (that is what `self.post` does). ### `serve/handle.rs` 1. Add the import `use crate::serve::approvals::Asker;` just before `use crate::serve::{Gateway, Stop};`. 2. In `handle_post`, the arm `Route::Queue(message) => {` gets, as its first statement: ```rust self.authors.insert( message.session.clone(), (post.user_id.clone(), channel_type.to_string()), ); ``` 3. In `start`, just before the line `let (registry, wait) = (Arc::clone(&self.registry), self.notice);`, add: ```rust if let Some((user, channel_type)) = self.authors.get(&batch.session) { let asker = Asker { user: user.clone(), channel_type: channel_type.clone(), prompt: batch.text.clone(), }; self.askers .insert(batch.session.as_str().to_string(), asker); } ``` It must come before that line: the thread spawned below takes `batch` by `move`. 4. Replace `finished`, from its doc comment to its closing `}`, by: ```rust /// Turns that ended: out of flight, who asked forgotten, and the next batch of each session /// started. Never blocks. pub(super) fn finished(&mut self) -> Result<(), Stop> { while let Ok(session) = self.done_rx.try_recv() { self.state.end_turn(session.as_str())?; self.askers.remove(session.as_str()); match self.queues.finish(&session) { Some(batch) => self.start(batch)?, None => { self.authors.remove(&session); } } } Ok(()) } ``` (The old numbered comment inside it goes: it described the loop before this change.) ## Steps - [ ] **1. Copy.** `git switch m4b`, then `cp docs/plans/M4b/files/crates/gatewayd/tests/serve_redirect.rs docs/plans/M4b/files/crates/gatewayd/tests/serve_approvals.rs crates/gatewayd/tests/` - [ ] **2. See it fail.** `cargo test --no-fail-fast -p gatewayd --test serve_redirect --test serve_approvals`. Expected: in `serve_redirect`, 3 fail and 1 passes (`in_a_channel_it_stays_in_the_thread`); in `serve_approvals`, 1 fails (`without_a_turn_in_flight_…`) and 4 pass. - [ ] **3. Make the changes**, one file at a time, in the order above, with `cargo check -p gatewayd` after each. Run `cargo fmt --all`. - [ ] **4. See it pass.** `cargo test -p gatewayd --test serve_redirect --test serve_approvals --test serve_answers --test serve_restart`, three times: 4, 5, 4 and 7 passed. Then `cargo test -p gatewayd`: every suite passes. - [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`, with 4 more tests than before this task (about 854). No file over 500 lines: `serve/approvals.rs` is about 260. - [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit` This is the last task of M4b. Stop after the commit. ## Done when - `cargo test -p gatewayd` passes; `make gate` prints `gate: ok`. ## Stop and report if - A given test still fails after every change above is made exactly. Do not edit the tests. - A test passes only sometimes, or takes more than a few seconds. - The compiler needs a change in a file not listed here.