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
+23 -14
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
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`.
- 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 }` |
| 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 }` |
| 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 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
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
@@ -206,10 +211,13 @@ plus one line:
| `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.` |
| `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
not retried.
`<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
@@ -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
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
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