Plan M4b: approvals over Mattermost in 15 tasks

Skeletons for new files only; existing files change by exact edits, so no task overwrites an
earlier one's work. Each task's tests were run against a reference built in order from master,
with the gate at each step and mutations checked; each skeleton compiles and fails its tests, and
every todo!() comment was read beside its signature. The reference is kept off this machine.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 03:34:21 -07:00
co-authored by Claude Opus 5.5
parent b1f732a42a
commit 2d415fc1be
64 changed files with 7701 additions and 2 deletions
@@ -0,0 +1,105 @@
//! Approvals in Mattermost (M4b spec, sections 6 and 7): the text of the posts, and the
//! 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;
pub const NOT_APPROVER: &str = "only an approver can answer approvals";
pub const USAGE: &str = "use !approve <id> or !deny <id> <reason>";
/// 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<ApprovalReply>,
}
/// 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<HashMap<u64, Posted>>,
}
impl Registry {
pub fn new() -> Registry {
Registry::default()
}
fn lock(&self) -> MutexGuard<'_, HashMap<u64, Posted>> {
self.posted.lock().unwrap_or_else(|p| p.into_inner())
}
pub fn add(&self, posted: Posted) {
// `self.lock().insert(posted.approval, posted);`
todo!()
}
/// Take it out: it ended, or its connection is done with it.
pub fn remove(&self, id: u64) -> Option<Posted> {
// `self.lock().remove(&id)`
todo!()
}
pub fn holds(&self, id: u64) -> bool {
// `self.lock().contains_key(&id)`
todo!()
}
/// The sessions with an approval waiting here: their turns are waiting, not working.
pub fn waiting_sessions(&self) -> Vec<String> {
// The `session` of every entry: `self.lock().values().map(|p|
// p.session.clone()).collect()`.
todo!()
}
/// 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(<text>)` 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".
todo!()
}
}
@@ -0,0 +1,4 @@
//! Approvals in Mattermost (M4b spec, sections 6 and 7): the text of the posts, and the
//! approvals this `gatewayd` has posted and the commands that answer them.
pub mod text;
@@ -0,0 +1,157 @@
//! The text of an approval's posts, and of the line added when it ends (M4b spec, section 6).
//! Nothing here is shortened: arguments too long to show whole are not shown at all.
use proto::escape::escape_json_text;
use proto::{DenyReason, PendingApproval, Timestamp, sha256};
/// Arguments are split into parts of at most this many characters.
pub const PART_CHARS: usize = 15_000;
/// At most this many parts; more are answerable only in `bxctl`.
pub const MAX_PARTS: usize = 4;
/// The longest a part's post may be, leaving room for the line added when it ends.
pub const MAX_PART_POST: usize = 15_500;
/// An approval's posts, in order. Only an answer after the last counts; when `answerable` is
/// false, no command answers it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Shown {
pub posts: Vec<String>,
pub answerable: bool,
}
/// How an approval ended, for the line added to its last post.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ended {
Runs { by: String },
Denied { by: String, reason: DenyReason },
Refused { by: String, reason: Option<String> },
NoLonger,
Restarted,
}
/// `text` as inline code, with a backtick run longer than any inside it.
pub fn code(text: &str) -> String {
// `let ticks = "`".repeat(longest_run(text) + 1);`, and a pad of one space on both sides when
// `text` starts or ends with a backtick (CommonMark needs it), else none:
// `format!("{ticks}{pad}{text}{pad}{ticks}")`.
todo!()
}
/// `text` as a fenced block, with a fence longer than any backtick run inside it (at least 3).
pub fn fence(text: &str) -> String {
// `let ticks = "`".repeat((longest_run(text) + 1).max(3));` then
// `format!("{ticks}\n{text}\n{ticks}")`.
todo!()
}
fn longest_run(text: &str) -> usize {
// The longest run of consecutive backticks in `text`: walk `text.chars()`, counting up on a
// backtick and back to 0 on anything else, keeping the most seen.
todo!()
}
/// `2026-09-24 14:47 UTC`.
fn minute(at: Timestamp) -> String {
// `let rfc = at.to_rfc3339();` ("2026-09-24T14:47:10.000Z"); then `format!("{} {} UTC",
// rfc.get(..10).unwrap_or_default(), rfc.get(11..16).unwrap_or_default())`.
todo!()
}
/// A serde name (`private`, `no_grant`), or the debug form if there is none.
fn name<T: serde::Serialize + std::fmt::Debug>(value: &T) -> String {
// `match serde_json::to_value(value)`: `Ok(serde_json::Value::String(s))` -> s; anything else
// -> `format!("{value:?}")`.
todo!()
}
fn reply_line(id: u64) -> String {
// `format!("Reply in this thread with `!approve {id}` or `!deny {id} <reason>`.")`.
todo!()
}
/// The posts that show `info`.
pub fn render(info: &PendingApproval) -> Shown {
let id = info.approval;
let args = escape_json_text(&info.arguments);
let header = format!(
"**Approval {id}** · tool {} · grant {} · taint {} · expires {}",
code(&escape_json_text(&info.tool)),
code(&escape_json_text(&info.grant)),
name(&info.taint),
minute(info.expires),
);
let chars: Vec<char> = args.chars().collect();
let parts: Vec<String> = if chars.is_empty() {
vec![String::new()]
} else {
chars
.chunks(PART_CHARS)
.map(|c| c.iter().collect())
.collect()
};
let count = parts.len();
let posts: Vec<String> = if count == 1 {
vec![format!("{header}\n{}\n{}", fence(&args), reply_line(id))]
} else {
let hash = sha256(args.as_bytes())
.map(|h| h.to_hex())
.unwrap_or_default();
parts
.iter()
.enumerate()
.map(|(i, part)| {
let n = i + 1;
let top = if n == 1 {
format!("{header}\nPart 1 of {count} of the arguments:")
} else {
format!("**Approval {id}**, part {n} of {count} of the arguments:")
};
let mut post = format!("{top}\n{}", fence(part));
if n == count {
post.push_str(&format!(
"\nApproval {id}: {count} parts, {} characters, sha256 {hash} (of the arguments as shown).\n{}",
chars.len(),
reply_line(id)
));
}
post
})
.collect()
};
if count > MAX_PARTS || posts.iter().any(|p| p.chars().count() > MAX_PART_POST) {
let hash = sha256(args.as_bytes())
.map(|h| h.to_hex())
.unwrap_or_default();
let post = format!(
"{header}\nThe arguments are {} characters (sha256 {hash}), too long to show here; answer it with `bxctl approvals`.",
chars.len()
);
return Shown {
posts: vec![post],
answerable: false,
};
}
Shown {
posts,
answerable: true,
}
}
/// The line added to the last post when the approval ends. `by` is `@username`, or the id.
pub fn ended_line(ended: &Ended) -> String {
// A `match ended`, one line each, exactly (`by` as given):
// 1. Runs: "✅ Approved by {by}: the call runs."
// 2. Denied: "Approved by {by}, but denied on the re-check: {name(reason)}."
// 3. Refused with Some(reason): "❌ Refused by {by}: {code(&escape_json_text(reason))}."
// 4. Refused with None: "❌ Refused by {by}: no reason given."
// 5. NoLonger: "No longer pending (answered elsewhere, expired, or its turn ended)."
// 6. Restarted: "No longer answerable here: gatewayd restarted. If it is still pending it is
// posted again."
todo!()
}
/// The last post's text with the ending line added.
pub fn with_ending(last: &str, ended: &Ended) -> String {
// `format!("{last}\n\n{}", ended_line(ended))`.
todo!()
}