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

350 lines
21 KiB
Markdown

# M4b design: approvals over Mattermost
Status: approved by the owner, 2026-09-24, with P16 applied to the brief. Changed after the review
of the same day (owner's decision): approvals asked by non-approvers in direct or group messages
are redirected to an approver with who asked and their message (section 6). 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: on the owner's server the bot edited its own post (200, checked
2026-09-24 in its direct channel with itself, then deleted).
- `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`:
```toml
[gateway] # absent: Mattermost approvals are off, as in M3
socket = "" # empty: <home>/run/gateway/gateway.sock
```
`gatewayd.toml`:
```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_reply` | true | `{ approval: u64, approved: bool, by: String, post: String, reason: Option<String> }` |
| 4 | `brokerd` | `approval_outcome` | true | `{ outcome: GatewayOutcome }` |
```rust
pub enum GatewayOutcome { // tag "outcome", snake_case, beside the fields
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
}
```
(`approval_reply`, not `approval_answer`: `proto` already has an `ApprovalAnswer`, the audit
record's enum. The types live in `proto::gateway`; `ApprovalReply::check` holds the id and reason
rules, so `brokerd` and `gatewayd` share one definition.)
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), **unless** the turn was asked by someone
who is not an approver in a direct or group message: no approver could answer it there. (In a
channel an approver can reply in the thread, so it stays.)
- A redirected approval (asked by a non-approver in a direct or group message) goes to the first
approver's direct channel as a new thread whose **first post** says who asked, where, and what
they asked (review of 2026-09-24, owner's requirement: the approver must know who is asking,
their prompt, and what Boxmaker is trying to do):
````text
**Approval 3** was asked by @alex in a direct message with the bot. Their message:
```
Read the file /srv/notes/q3.txt and summarise it.
```
````
The message is the text of the turn that asked (a burst, joined), escaped and fenced like the
arguments; it is context, not what is approved, so it is cut at 2,000 characters with
`(… <n> more characters)` after the fence. The approval's own posts follow in that thread, and
the asking thread gets one line: `waiting for approval <id>: it was sent to an approver`.
- Otherwise (`bxctl chat`, a session no longer in flight, later scheduled jobs), it goes to the
first approver's direct channel as a new thread whose first post says
`**Approval <id>** was asked in session `<session>`, which gatewayd does not follow now (a
restart, or a session from outside Mattermost).`, followed by the approval's posts.
`gatewayd` knows who asked from the posts it queued: for each session it keeps the author of the
last message queued, the channel's type, and, when a turn starts, that turn's text; it forgets them
when the turn ends. They are not in the state file: after a restart the turn is interrupted anyway.
### 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:
````text
**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:
````text
**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.` The state file keeps post ids, not their text, so here the last post's text is **replaced** by `**Approval <id>**`, a blank line and this line. |
`<username>` comes from `GET /users/{id}`, falling back to the id. A refusal's reason is escaped
and shown as inline code, so it cannot mention anyone. An edit that fails is logged and not
retried. A part's post is at most 15,500 characters, so the added line (up to about 600) keeps it
within Mattermost's limit; arguments whose fences would push a part over that are treated as too
long to show.
## 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`, `use !approve <id> or !deny <id> <reason>` (a
malformed command), `approval <id> is not pending here`, `approval <id> can only be answered with
bxctl approvals` (too long to show), `approval <id> is answered in its own thread`, or `approval
<id> was answered before it was shown in full`. 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 `brokerd`s, 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.