Room broadcasts: implicit subscribe + fan-out outbox #4

Open
opened 2026-05-13 18:44:52 +02:00 by arne · 0 comments
Owner

What to build

The actual §14 "room" behaviour. On every accepted POST whose payload is `posta.text/v1`:

  1. The sender is added to `subscribers` if not already present (implicit subscribe).
  2. A `posta.room/v1` action="broadcast" wrapper is built carrying the sender's original `raw_envelope` and `signature` verbatim.
  3. One outbound row is enqueued per current subscriber, except the original sender. The fan-out outbox loop then signs each outer envelope (with the room's key), POSTs to the recipient, and processes the result.

The outbox mirrors posta-server's existing retry shape:

  • Schedule: 5s, 30s, 5m, 30m, then give up (no `failed-pending-user` for rooms — there's no operator to retry one subscriber by hand).
  • Transient codes (network errors, 5xx, `internal`, `rate-limited`): retry per schedule, then drop this message for this recipient.
  • Permanent codes (404, 410, 401 `unknown-key`, 401 `bad-signature`, 421 `wrong-recipient`): mark this attempt permanently failed; the auto-unsubscribe slice (posta/web#5) is responsible for converting repeated perm-failures into removal.

Non-`posta.text/v1` payloads are persisted but not fanned out. Sender is still added to `subscribers` (any verified POST counts as engagement) — the absence of fan-out for non-text kinds is a renderer policy, not a membership policy.

State lives in:

  • `subscribers` table — gains `joined_at` if not already present from posta/web#1; the `consecutive_perm_failures` column is added in the auto-unsubscribe slice.
  • `outbound` table — `(rowid, recipient_url, payload, status, attempts, next_attempt_at, last_error_code, created_at)`.

Acceptance criteria

  • First-time sender of a `posta.text/v1` is added to `subscribers`
  • A wrapper outbound row is enqueued per subscriber, excluding the original sender
  • The fan-out loop signs and POSTs wrappers; `payload.envelopeBytes` and `payload.signature` are the verbatim inbound `raw_envelope` and `signature` (no re-serialisation)
  • Retries follow the 5s/30s/5m/30m schedule for transient codes; permanent codes terminate immediately for that recipient
  • Non-`posta.text/v1` payloads are archived but no outbound rows are enqueued
  • An end-to-end test demonstrates: two participants POST; each sees the other's wrapped broadcast in their own inbox; the original sender does not get a wrapper of their own message
  • Outbound state survives a posta-web restart (rows persist; on restart, stuck `sending` rows are reset to `pending` per posta/web#1)

Blocked by

  • posta/web#1 (needs the receiver mounted)
  • posta/spec#2 (needs `posta.room/v1` action="broadcast" defined and conformance vectors updated)
## What to build The actual §14 \"room\" behaviour. On every accepted POST whose payload is \`posta.text/v1\`: 1. The sender is added to \`subscribers\` if not already present (implicit subscribe). 2. A \`posta.room/v1\` action=\"broadcast\" wrapper is built carrying the sender's original \`raw_envelope\` and \`signature\` verbatim. 3. One outbound row is enqueued per current subscriber, **except** the original sender. The fan-out outbox loop then signs each outer envelope (with the room's key), POSTs to the recipient, and processes the result. The outbox mirrors posta-server's existing retry shape: - Schedule: 5s, 30s, 5m, 30m, then give up (no \`failed-pending-user\` for rooms — there's no operator to retry one subscriber by hand). - Transient codes (network errors, 5xx, \`internal\`, \`rate-limited\`): retry per schedule, then drop this message for this recipient. - Permanent codes (404, 410, 401 \`unknown-key\`, 401 \`bad-signature\`, 421 \`wrong-recipient\`): mark this attempt permanently failed; the auto-unsubscribe slice (posta/web#5) is responsible for converting repeated perm-failures into removal. Non-\`posta.text/v1\` payloads are persisted but **not** fanned out. Sender is still added to \`subscribers\` (any verified POST counts as engagement) — the absence of fan-out for non-text kinds is a renderer policy, not a membership policy. State lives in: - \`subscribers\` table — gains \`joined_at\` if not already present from posta/web#1; the \`consecutive_perm_failures\` column is added in the auto-unsubscribe slice. - \`outbound\` table — \`(rowid, recipient_url, payload, status, attempts, next_attempt_at, last_error_code, created_at)\`. ## Acceptance criteria - [ ] First-time sender of a \`posta.text/v1\` is added to \`subscribers\` - [ ] A wrapper outbound row is enqueued per subscriber, excluding the original sender - [ ] The fan-out loop signs and POSTs wrappers; \`payload.envelopeBytes\` and \`payload.signature\` are the verbatim inbound \`raw_envelope\` and \`signature\` (no re-serialisation) - [ ] Retries follow the 5s/30s/5m/30m schedule for transient codes; permanent codes terminate immediately for that recipient - [ ] Non-\`posta.text/v1\` payloads are archived but no outbound rows are enqueued - [ ] An end-to-end test demonstrates: two participants POST; each sees the other's wrapped broadcast in their own inbox; the original sender does not get a wrapper of their own message - [ ] Outbound state survives a posta-web restart (rows persist; on restart, stuck \`sending\` rows are reset to \`pending\` per posta/web#1) ## Blocked by - posta/web#1 (needs the receiver mounted) - posta/spec#2 (needs \`posta.room/v1\` action=\"broadcast\" defined and conformance vectors updated)
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
posta/web#4
No description provided.