From b1f732a42a4d39c8273b3e035779227c01cee6a2 Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Thu, 24 Sep 2026 03:22:55 -0700 Subject: [PATCH] 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) --- docs/specs/2026-09-24-m4b-approvals.md | 37 ++++++++++++++++---------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/docs/specs/2026-09-24-m4b-approvals.md b/docs/specs/2026-09-24-m4b-approvals.md index ab57a3d..887a46e 100644 --- a/docs/specs/2026-09-24-m4b-approvals.md +++ b/docs/specs/2026-09-24-m4b-approvals.md @@ -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 }` | -| 4 | `brokerd` | `approval_outcome` | true | `{ outcome: ApprovalOutcome }` | +| 3 | `gatewayd` | `approval_reply` | true | `{ approval: u64, approved: bool, by: String, post: String, reason: Option }` | +| 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 @, but denied on the re-check: .` | | `Refused` | `❌ Refused by @: .` | | `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 **`, a blank line and this line. | -`` comes from `GET /users/{id}`, falling back to the id. An edit that fails is logged and -not retried. +`` 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 is not pending here`, `approval - is answered in its own thread`, `approval was answered before it was shown in full`, or -`use !approve or !deny `. A command never reaches `loopd`. `!!` escapes as in +applies: `only an approver can answer approvals`, `use !approve or !deny ` (a +malformed command), `approval is not pending here`, `approval can only be answered with +bxctl approvals` (too long to show), `approval is answered in its own thread`, or `approval + 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