M4b spec: names and details settled while building the reference

approval_reply and GatewayOutcome (proto already has an ApprovalAnswer); the bot's edit permission
confirmed on the owner's server; a restart replaces a dead post's text; the reason escaped as
inline code; the part size bound; the reply for an approval too long to show.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 03:22:55 -07:00
co-authored by Claude Opus 5.5
parent a8f7819da3
commit b1f732a42a
+22 -13
View File
@@ -16,7 +16,8 @@ server's version, v11.11.0:
- `PUT /api/v4/posts/{post_id}/patch` with `{"message": …}` replaces a post's text; the other - `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 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). 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`. - `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. - M4a's facts (posts, `posted` events, threads, the 16,383-character limit) still hold.
@@ -104,18 +105,22 @@ On one `gateway.sock` connection, every frame is a `proto` `Envelope` (`read_fra
|---|---|---|---|---| |---|---|---|---|---|
| 1 | `brokerd` | `approval_request` | true | `{ approval: PendingApproval }` | | 1 | `brokerd` | `approval_request` | true | `{ approval: PendingApproval }` |
| 2 | `gatewayd` | `approval_posted` | false | `{ posts: [String] }`, the post ids, first part first | | 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> }` | | 3 | `gatewayd` | `approval_reply` | true | `{ approval: u64, approved: bool, by: String, post: String, reason: Option<String> }` |
| 4 | `brokerd` | `approval_outcome` | true | `{ outcome: ApprovalOutcome }` | | 4 | `brokerd` | `approval_outcome` | true | `{ outcome: GatewayOutcome }` |
```rust ```rust
pub enum ApprovalOutcome { // tag "outcome", snake_case pub enum GatewayOutcome { // tag "outcome", snake_case, beside the fields
Runs, // approved, and the grants as they are now still allow it 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 Denied { reason: DenyReason }, // approved, but denied on the re-check; or the record failed
Refused, // the refusal is recorded Refused {}, // the refusal is recorded
Gone, // no longer pending when the answer came 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 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 `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 section 5. `by` and `post` are Mattermost ids (26 characters of `a-z0-9`), `reason` at most 500
@@ -206,10 +211,13 @@ plus one line:
| `Denied` | `Approved by @<username>, but denied on the re-check: <deny reason>.` | | `Denied` | `Approved by @<username>, but denied on the re-check: <deny reason>.` |
| `Refused` | `❌ Refused by @<username>: <reason, or "no reason given">.` | | `Refused` | `❌ Refused by @<username>: <reason, or "no reason given">.` |
| `Gone`, or `brokerd` closed first | `No longer pending (answered elsewhere, expired, or its turn ended).` | | `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.` | | `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. An edit that fails is logged and `<username>` comes from `GET /users/{id}`, falling back to the id. A refusal's reason is escaped
not retried. 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 ## 7. `gatewayd`: answers, typing, and the old notice
@@ -234,9 +242,10 @@ most 500 characters) now goes to `approvals`, which accepts it only if **all** h
Then the answer goes to the courier's connection as frame 3, and the thread says nothing more 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 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 applies: `only an approver can answer approvals`, `use !approve <id> or !deny <id> <reason>` (a
<id> is answered in its own thread`, `approval <id> was answered before it was shown in full`, or malformed command), `approval <id> is not pending here`, `approval <id> can only be answered with
`use !approve <id> or !deny <id> <reason>`. A command never reaches `loopd`. `!!` escapes as in 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. M4a.
**Typing.** While a session has a pending approval in `approvals`, `user_typing` is not sent for **Typing.** While a session has a pending approval in `approvals`, `user_typing` is not sent for