Specify M4b (approvals over Mattermost); propose P16 on approval replies

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>
This commit is contained in:
2026-09-24 01:48:51 -07:00
co-authored by Claude Opus 5.5
parent 3322a56e69
commit 00a9b576b2
3 changed files with 304 additions and 2 deletions
+299
View File
@@ -0,0 +1,299 @@
# 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}/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` | `!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_answer` | true | `{ approval: u64, approved: bool, by: String, post: String, reason: Option<String> }` |
| 4 | `brokerd` | `approval_outcome` | true | `{ outcome: ApprovalOutcome }` |
```rust
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:
````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.` |
`<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:
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 `brokerd`s, whose approval ids can coincide. A
command counts only in the thread where this `gatewayd` posted 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.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.
- **`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.