The owner's decisions of 2026-09-24: commands only, no reactions; an approvers list that falls back to allow.users; approvals in the asking thread; on a gatewayd restart, old posts are marked dead and brokerd's courier posts pending approvals again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
17 KiB
M4b design: approvals over Mattermost
Status: draft for the owner's review, 2026-09-24. It builds on M4a
(docs/specs/2026-09-23-m4a-gateway.md, section 12 there is the agreed outline) and M3a's approval
path (docs/specs/2026-09-18-m3a-decision-path.md, section 6). Owner's decisions of 2026-09-24,
changing the outline: no reactions, answers are commands only; an [approvals] users list
that falls back to allow.users when empty; approvals are posted in the asking thread, channel
threads included (redacting secrets from tool arguments is later work, with the tools); a
gatewayd restart marks its old approval posts dead and brokerd posts them again.
Facts this rests on, checked on 2026-09-24 against Mattermost's source and API reference at the server's version, v11.11.0:
PUT /api/v4/posts/{post_id}/patchwith{"message": …}replaces a post's text; the other fields are left alone. It needs theedit_postpermission, which a user and a bot have for their own posts by default (to be confirmed on the owner's server at the device check).GET /api/v4/users/{user_id}returns the user, withusername.- M4a's facts (posts,
postedevents, threads, the 16,383-character limit) still hold.
1. What M4b proves
| Claim | Checked by |
|---|---|
An ask call is posted in the asking thread, or for a session not from Mattermost in the first approver's direct messages, with its tool, grant, taint, expiry and full arguments |
Tests against a fake Mattermost; on the owner's server |
!approve <id> from an approver, in that thread, after the post, while pending, runs the call exactly as bxctl approve would, and the audit record names the approver and the post |
Tests; on the owner's server; bxctl audit verify |
!deny <id> [reason] refuses it the same way, with the reason recorded |
Tests; on the owner's server |
| Nobody else can answer: not a user outside the approvers, not a command elsewhere or before the post, not an id that is not pending there | Tests |
An approval answered in bxctl, expired, or abandoned by its requester is marked "no longer pending" in Mattermost |
Tests; on the owner's server |
Arguments are never shortened: long ones come in up to 4 parts with a hash; longer ones are answerable only in bxctl |
Tests |
A gatewayd restart leaves no post that looks answerable and is not; pending approvals are posted again |
Tests; on the owner's server |
gatewayd still opens no TCP port, and never reaches admin.sock |
On the owner's machine (ss); by construction |
Out of scope: reactions (dropped by the owner); redacting secrets from arguments (with the tools, later); approving grants or changing them from Mattermost (never: grants are the owner's files).
2. Components
brokerd ──(one connection per pending approval)──> gateway.sock ── gatewayd ── Mattermost
▲ │
└── admin.sock ── bxctl (unchanged, plus `deny`) └── state.json (+ approvals)
gatewayd listens on gateway.sock, a Unix socket in its own directory
(<home>/run/gateway/, 0700; the socket 0600), made the way brokerd makes its sockets. It is
still the only listening thing gatewayd has, and it is not TCP. brokerd is the client. Nothing
else is meant to connect; a process that could would only be able to make the bot post an
approval whose answer goes back to that process, never to brokerd (section 9).
gatewayd can answer only the approvals brokerd hands it, one per connection. It never learns
of grants, never reaches admin.sock, and cannot create, list or widen anything.
New and changed modules
| Crate | Module | Change |
|---|---|---|
proto |
escape |
Moved from bxctl: escape_json_text, escape_model_text, unchanged. bxctl uses it from here. |
proto |
wire |
The four gateway messages (section 4). |
brokerd |
config |
[gateway] socket (section 3). |
brokerd |
courier |
New: one thread per pending approval, which carries it to gatewayd and its answer back (section 5). |
brokerd |
ledger, admin |
Answer::Approved and Refused gain post; the Approval record's post is filled from it. |
brokerd |
approvals |
Table::contains(id). |
gatewayd |
config |
[approvals] users and socket. |
gatewayd |
approvals |
New: the pending approvals it has posted, the commands that answer them, the text of the posts. Pure. |
gatewayd |
gateway |
New: the listener and one thread per brokerd connection. |
gatewayd |
mm::rest |
patch_post, username. |
gatewayd |
sessions |
!approve and !deny go to approvals instead of the M4a notice. |
gatewayd |
state |
approvals: the posts of each open approval, for the restart. |
gatewayd |
deliver, serve |
Typing paused while a turn waits for approval; the M4a "waiting for approval" line only when brokerd did not send it (section 7). |
bxctl |
cli |
deny as another name for refuse. |
No new dependency. SHA-256 is proto's (emsha).
3. Configuration
brokerd.toml:
[gateway] # absent: Mattermost approvals are off, as in M3
socket = "" # empty: <home>/run/gateway/gateway.sock
gatewayd.toml:
[approvals]
users = [] # Mattermost user ids who may answer; empty: everyone in [allow] users
socket = "" # empty: <home>/run/gateway/gateway.sock
Every id in approvals.users must also be in allow.users (an approver who is not heard could
never answer); anything else is a configuration error. The first approver is
approvals.users[0], or allow.users[0] when the list is empty.
4. The gateway messages
On one gateway.sock connection, every frame is a proto Envelope (read_frame,
write_frame, as on the other sockets):
| # | From | Message (kind) |
final |
Body |
|---|---|---|---|---|
| 1 | brokerd |
approval_request |
true | { approval: PendingApproval } |
| 2 | gatewayd |
approval_posted |
false | { posts: [String] }, the post ids, first part first |
| 3 | gatewayd |
approval_answer |
true | { approval: u64, approved: bool, by: String, post: String, reason: Option<String> } |
| 4 | brokerd |
approval_outcome |
true | { outcome: ApprovalOutcome } |
pub enum ApprovalOutcome { // tag "outcome", snake_case
Runs, // approved, and the grants as they are now still allow it
Denied { reason: DenyReason }, // approved, but denied on the re-check; or the record failed
Refused, // the refusal is recorded
Gone, // no longer pending when the answer came
}
Frames 1 and 4 carry id 1 and 2; frames 2 and 3 answer id 1. Instead of 2, gatewayd may send
error { code: internal, detail } (final) when it cannot post; brokerd then tries again as in
section 5. by and post are Mattermost ids (26 characters of a-z0-9), reason at most 500
characters; brokerd refuses anything else as a bad frame and does not answer the approval.
Either side may close at any time. When brokerd closes before frame 3, the approval ended some
other way. When gatewayd closes before frame 3, it went away.
5. brokerd: the courier
When an ask call enters the pending table and [gateway] is set, brokerd starts a courier
thread for it (with std::thread::Builder; if that fails, it logs one line and the approval stays
answerable in bxctl). The courier:
- Stops as soon as the table no longer holds the approval (
Table::contains). - Connects to the socket and sends frame 1. If it cannot connect, or the answer is an
error, it logs once for this approval,brokerd: approval <id>: gatewayd is not reachable at <path>: <why>; bxctl can still answer itandsee docs/runbook.md#gateway-unreachable, waits 5 s (checking the table every second), and tries again from step 1. When a later attempt posts it, it logsbrokerd: approval <id> posted in Mattermost. - Waits for frame 3, with a read timeout of 1 s, and at every timeout checks the table: if the
approval has gone (answered in
bxctl, expired, requester gone), it closes the connection and stops. Ifgatewaydcloses first, or sends anything but frame 2 then frame 3, the courier goes back to step 1:gatewaydrestarted, or broke, and a fresh post is needed. - On frame 3 for its own id: takes the entry out of the table. If it gets it, it answers it with
admin::answer, exactly asbxctl approveandrefusedo:Answer::Approved { by, post }orAnswer::Refused { by, post, reason }, thebybeing the Mattermost user id. TheApprovalrecord then carriesby,postandreason. It sends frame 4 withRunsorDenied(from the re-decision), orRefused. If the entry was already gone, frame 4 isGoneand nothing is written. Then it closes and stops.
"Whoever takes the entry out of the table answers it" (M3a) holds unchanged: the courier is one more such taker, beside the admin and expiry threads.
6. gatewayd: posting an approval
One thread per accepted gateway.sock connection. It reads frame 1 (bounded by read_frame),
checks the ids in it, and decides where:
- If the session is
mm-<root>andstate.in_flightholds a turn of that session, the approval goes into that turn's thread (its channel and root). This covers direct messages and channels. - Otherwise (
bxctl chat, a session no longer in flight, later scheduled jobs), it goes to the first approver's direct channel (POST /channels/direct), as a new top-level post whose own thread is where it is answered.
The text
The arguments are PendingApproval.arguments (the re-serialised JSON brokerd shows bxctl),
passed through proto::escape::escape_json_text, then fenced with a run of backticks one longer
than the longest run inside them (at least three), so nothing in them renders as Markdown or
mentions anyone. The grant name gets the same escaping, in backticks. Times are UTC.
One part, for arguments up to 15,000 characters after escaping:
**Approval 42** · tool `shell` · grant `ops-shell` · taint private · expires 2026-09-24 14:47 UTC
```
{"command":"ls -la /srv/backups","timeout_ms":10000}
```
Reply in this thread with `!approve 42` or `!deny 42 <reason>`.
Longer arguments are split, at character boundaries, into parts of at most 15,000 characters, each posted in order in the same thread. Parts before the last:
**Approval 42**, part 1 of 3 of the arguments
```
{"command":"…
```
The last part adds, after its fence: Approval 42: 3 parts, 41,207 characters, sha256 9f2c…e1a0 (of the arguments as shown). and the reply line. Only an answer after the last part counts
(section 7), so nobody approves what they have not been shown in full.
More than 4 parts: one post, **Approval 42** · tool … · the arguments are 91,234 characters (sha256 …), too long to show here; answer it with bxctl approvals, and no command answers it.
The outcome
When the approval ends, gatewayd edits the last part (the only answerable one) to its text
plus one line:
| How it ended | Added line |
|---|---|
Runs |
✅ Approved by @<username>: the call runs. |
Denied |
Approved by @<username>, but denied on the re-check: <deny reason>. |
Refused |
❌ Refused by @<username>: <reason, or "no reason given">. |
Gone, or brokerd closed first |
No longer pending (answered elsewhere, expired, or its turn ended). |
gatewayd restarted |
No longer answerable here: gatewayd restarted. If it is still pending it is posted again. |
<username> comes from GET /users/{id}, falling back to the id. An edit that fails is logged and
not retried.
7. gatewayd: answers, typing, and the old notice
Answers. M4a's routing is unchanged up to commands: only allowlisted users are heard, and in
channels only in this Boxmaker's threads. A command !approve <id> or !deny <id> [reason]
(exactly: one space, the id in decimal, then for !deny optionally one space and a reason of at
most 500 characters) now goes to approvals, which accepts it only if all hold:
- its author is an approver;
- approval
<id>is pending in thisgatewayd; - the command is in that approval's thread (its root is the thread's root);
- it was posted after the approval's last part (
create_atlater).
Then the answer goes to the courier's connection as frame 3, and the thread says nothing more
until the edit of section 6. Otherwise gatewayd replies in the thread with the first reason that
applies: only an approver can answer approvals, approval <id> is not pending here, approval <id> is answered in its own thread, approval <id> was answered before it was shown in full, or
use !approve <id> or !deny <id> <reason>. A command never reaches loopd. !! escapes as in
M4a.
Typing. While a session has a pending approval in approvals, user_typing is not sent for
its thread: the turn is waiting for the owner, not working.
The M4a notice. On an approval_pending event, deliver waits up to 10 s for approvals to
hold that id. If it does, it posts nothing (the approval post is there). If not (no [gateway] in
brokerd, or it could not connect), it posts M4a's line, reworded: waiting for approval <id>: brokerd has not sent it here; answer it with bxctl approvals.
8. State and restarts
state.json gains approvals: [{ approval, channel, posts: [ids] }], written when an approval is
posted and removed when it ends. The field is optional, so an M4a state file loads unchanged; like
every other field, its ids must be valid.
At the first connection after a start, every entry left in approvals has its last part edited
with the restart line of section 6, and is removed. The couriers of approvals still pending notice
the dropped connection and post them again (section 5), so the owner sees one live post per
pending approval.
brokerd restarts need nothing new: its pending table is in memory, its couriers die with it, and
their connections close, so gatewayd marks the posts "no longer pending".
9. Security notes
- The approvers are the only people whose commands answer anything; an agent in
allow.userscan talk to Boxmaker but, if the owner lists the approvers, not approve for it. - Arguments are posted in full, in the thread, which in a channel is visible to its members, other agents included. Redacting secrets is deferred to the tools work (owner's decision).
- Several Boxmakers in one channel have their own
brokerds, whose approval ids can coincide. A command counts only in the thread where thisgatewaydposted that id; two Boxmakers with the same id pending in the same thread would both take it. This needs both named in one thread and both asking at once; it is accepted, and noted in the runbook. - A local process of the owner's user can connect to
gateway.sockand have the bot post a fake approval; its answer goes back to that process only. The socket is 0600 in a 0700 directory; M7 keeps it out ofloopd's and the tools' reach.
10. Runbook
New: gateway-unreachable (brokerd cannot post an approval: gatewayd down, [gateway] socket wrong, or Mattermost unreachable; bxctl approvals still works). Changed:
gatewayd-start-failed gains gateway.sock (directory, bind, permissions) and the approvers
check. Every message for these states ends with its pointer.
11. Testing
Offline, as tasks with given tests:
proto::escape: the moved tests pass unchanged, andbxctl's output is byte-identical.- Wire: each gateway message round-trips; unknown fields and bad ids are refused.
brokerdcourier, against a fakegateway.sock: posted and approved (the call runs, the record hasbyandpost); refused with a reason; approved after the grant was removed (Denied); answered inbxctlmeanwhile (the courier closes, frame 4 never sent); expired; the fake closes before answering (the courier posts again); unreachable at first, reachable 5 s later; a bad frame 3 (another id, badby) answers nothing.gatewaydapprovals, pure: every rule of section 7 in both directions; the text of one part, of 3 parts with the hash, and of the refusal to show; fences around backtick runs; escaping of controls, bidi overrides and zero-width characters; every outcome line.gatewaydend to end, against the fake Mattermost and a fakebrokerd: the approval in a direct-message thread, in a channel thread, in the first approver's DMs;!approvefrom an approver runs it; from a non-approver, in another thread, before the last part, for an unknown id, each answered with its reason and nothing sent;brokerdclosing marks the post; a restart marks old posts and the re-sent approval is posted fresh; typing stops while pending; the fallback notice when no approval arrives.bxctl deny: the same output asrefuse.
On the owner's server, by the design model with the owner answering: an approval in a direct
message thread approved with !approve (the call runs; bxctl audit verify shows by and
post), one in a channel thread denied with a reason, one answered in bxctl and marked in
Mattermost, a long one in parts, one answered by a non-approver (refused with the reason), and a
gatewayd restart while one is pending.