Files
boxmaker/docs/specs/2026-09-24-m4b-approvals.md
T

19 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; and gatewayd strips a leading mention of its own bot from every message before it is handled, so @boxmaker0 !approve 42 is a command and @boxmaker0 summarise the log reaches the model as summarise the log.

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}/patch with {"message": …} replaces a post's text; the other fields are left alone. It needs the edit_post permission, 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, with username.
  • M4a's facts (posts, posted events, 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 A leading mention of this bot is stripped first (section 7); !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:

  1. Stops as soon as the table no longer holds the approval (Table::contains).
  2. 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 it and see 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 logs brokerd: approval <id> posted in Mattermost.
  3. 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. If gatewayd closes first, or sends anything but frame 2 then frame 3, the courier goes back to step 1: gatewayd restarted, or broke, and a fresh post is needed.
  4. 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 as bxctl approve and refuse do: Answer::Approved { by, post } or Answer::Refused { by, post, reason }, the by being the Mattermost user id. The Approval record then carries by, post and reason. It sends frame 4 with Runs or Denied (from the re-decision), or Refused. If the entry was already gone, frame 4 is Gone and 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> and state.in_flight holds 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

The leading mention. Before commands are looked for, and before a message is queued for loopd, gatewayd strips a leading mention of its own bot: @<username> at the very start of the message, matched as M4a names are (case-insensitive, not followed by a name character), then any spaces after it. If nothing would remain, the message is left as it was. This happens after M4a has decided the post is for this Boxmaker (the mention still counts as naming it), and in every kind of channel. So in a thread shared by two Boxmakers, @boxmaker0 !approve 42 is a command for boxmaker0 and, since it names another user, is ignored by the other. A mention anywhere else in the message stays.

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:

  1. its author is an approver;
  2. approval <id> is pending in this gatewayd;
  3. the command is in that approval's thread (its root is the thread's root);
  4. it was posted after the approval's last part (create_at later).

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.users can 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 this gatewayd posted that id. Where two Boxmakers share a thread, the operator addresses the command, @boxmaker0 !approve 42, and only that bot takes it; a bare !approve 42 would be taken by every Boxmaker with approval 42 pending in that thread. The owner's decision: that is the operator's care, not something the design prevents.
  • A local process of the owner's user can connect to gateway.sock and 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 of loopd'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, and bxctl's output is byte-identical.
  • Wire: each gateway message round-trips; unknown fields and bad ids are refused.
  • brokerd courier, against a fake gateway.sock: posted and approved (the call runs, the record has by and post); refused with a reason; approved after the grant was removed (Denied); answered in bxctl meanwhile (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, bad by) answers nothing.
  • The leading mention, pure: stripped in any case of letters and before a command; kept when nothing would remain, when another name comes first, for a longer name (@boxmaker0x), and in the middle of a message; a post naming another bot first is still ignored.
  • gatewayd approvals, 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.
  • gatewayd end to end, against the fake Mattermost and a fake brokerd: the approval in a direct-message thread, in a channel thread, in the first approver's DMs; !approve from 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; brokerd closing 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 as refuse.

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.