Files
boxmaker/docs/plans/M4b/18-gatewayd-redirect.md
T
kyleandClaude Opus 5.5 f6dd7f1741 M4b review, and follow-up tasks 16 to 18
The review of tasks 01 to 15 goes in the implementer log, with the
device check against the owner's Mattermost. Three follow-ups:

- 16: the refusal without a runner says why.
- 17: the texts that say who asked.
- 18: approvals from non-approvers in direct or group messages go to
  an approver.

Task 14's given serve_approvals.rs becomes serve_approvals.rs-task14,
since task 18 hands over a new version. Tip T31 is added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 11:35:24 -07:00

11 KiB

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:

    /// The author and channel type of the last message queued in each session.
    authors: HashMap<SessionId, (String, String)>,
    /// Who asked for each turn running, by session id.
    askers: HashMap<String, approvals::Asker>,

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:
/// 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<String>,
    /// The asking thread, told where the approval went.
    note: Option<(String, String)>,
}

  1. 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:
    /// 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<Place, String> {
        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<String, String> {
        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.

  1. In show:
    • Its doc comment (two lines, /// Post an approval's parts in order, …) becomes:
    /// 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
        let (channel, mut root) = match self.place(info) {
            Ok(place) => place,
            Err(why) => return Ok(Err(why)),
        };
        let shown = text::render(info);
 become
        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<String> = 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:
        if let Some((note_channel, note_root)) = place.note {
            self.post(&note_channel, &note_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:
                self.authors.insert(
                    message.session.clone(),
                    (post.user_id.clone(), channel_type.to_string()),
                );
  1. In start, just before the line let (registry, wait) = (Arc::clone(&self.registry), self.notice);, add:
        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:

    /// 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.