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>
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:
- 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. - Stays. An approval asked by an approver, or asked in a channel (
OorP: an approver can reply in the thread there), stays in its thread, as today. - 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 givencrates/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
- The import
use crate::approvals::text::{self, Ended};becomesuse crate::approvals::text::{self, Asked, Ended};. - 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)>,
}
- 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.
- In
show:- Its doc comment (two lines,
/// Post an approval's parts in order, …) becomes:
- Its doc comment (two lines,
/// 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.postsbecomesbodies. 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.answerablestays: 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(¬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
- Add the import
use crate::serve::approvals::Asker;just beforeuse crate::serve::{Gateway, Stop};. - In
handle_post, the armRoute::Queue(message) => {gets, as its first statement:
self.authors.insert(
message.session.clone(),
(post.user_id.clone(), channel_type.to_string()),
);
- In
start, just before the linelet (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, thencp 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: inserve_redirect, 3 fail and 1 passes (in_a_channel_it_stays_in_the_thread); inserve_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 gatewaydafter each. Runcargo 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. Thencargo 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.rsis 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 gatewaydpasses;make gateprintsgate: 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.