| index 160f45d..bc31c34 100644 |
| --- a/docs/superpowers/specs/2026-09-07-meet-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-meet-design.md |
| @@ -1,8 +1,9 @@ |
| # 🤖 Meet — design |
| |
| Design, 2026-09-07. Direction approved by Paul in conversation; this is the |
| -written form. **Revision 4**, answering the ten findings of the third |
| -Codex review (`CODEX-FINDINGS-MEET-R3.md`); §14 has all three rounds. Drafted by the Meet |
| +written form. **Revision 5**, answering the four findings of the |
| +fourth Codex review (`CODEX-FINDINGS-MEET-R4.md`); §14 has all four |
| +rounds. Drafted by the Meet |
| session (Fable 5.1) from a brainstorm with Paul on 2026-09-06 and |
| 2026-09-07. Every ruling in §1 attributed to Paul was given in that |
| conversation or on the discussion it cites. |
| @@ -258,12 +259,14 @@ One rule per endpoint, so §4 and §5 do not each restate it: |
| |
| | Endpoint | Authority | Checked | |
| |---|---|---| |
| -| `GET /` , `POST /meetings`, confirm pages and their POSTs, `/m/{sqid}/remove/{peer}` | **Current member standing** | On the request. | |
| -| `GET /m/{sqid}/join`, `GET /m/{sqid}` | Standing (`member` or `guest`) **or** a live participation for this session in this room | On the request. | |
| +| `GET /`, `POST /meetings`, the replace-link and end-meeting confirm pages and their POSTs | **Current member standing** | On the request. | |
| +| `/m/{sqid}/remove/{peer}`, confirm page and POST | An **admitted** participation for this session in this room with **current** `member` kind | On the request, at execution. | |
| +| `GET /m/{sqid}/join`, `GET /m/{sqid}` | Standing (`member` or `guest`) **or** a live participation for this session in this room. §3.3 and §3.4 defer to this row. | On the request. | |
| | `GET /m/{sqid}/stream` with no `peer` | Standing | On the request. Creates an unjoined participation. | |
| | `GET /m/{sqid}/stream?peer=` | Participation bound to this session | On the request. | |
| | `POST /m/{sqid}/signal`, `join` | Participation, **and** standing at that moment, because `join` is the entry | On the request, under the hub lock. | |
| -| `POST /m/{sqid}/signal`, `admit` | Participation with **current** `member` kind, re-derived from standing on this request, not only on the tick; **and** the target's entry authority (`member` standing or an unexpired grant) at this moment, under the same lock as the transition | On the request. | |
| +| `POST /m/{sqid}/signal`, `admit` | An **admitted** participation with **current** `member` kind, re-derived from standing on this request, not only on the tick; **and** the target's entry authority (`member` standing or an unexpired grant) at this moment, under the same lock as the transition | On the request. | |
| +| `POST /m/{sqid}/signal`, `leave` | Participation, any state | On the request. A waiting guest may leave. | |
| | `POST /m/{sqid}/signal`, every other type; `GET /m/{sqid}/ice` | Participation, admitted | On the request. Standing is not consulted; a guest's expired grant does not matter here (ruling 14). | |
| | `/api/v1/*` | Agent token unrevoked **and** operator's current member standing | On the request. | |
| |
| @@ -345,12 +348,19 @@ session and no per-attempt return value, so Meet's `SignedInPath` is |
| unexpired, take the **most recent**. In one transaction: re-check |
| that its link is not revoked, its meeting not ended, and the row not |
| expired, exactly as `GET /j/{token}` would (so a link replaced during |
| - sign-in still 404s even though the meeting has a new live link); |
| + sign-in is refused even though the meeting has a new live link); |
| mint the grant if they are a non-member and hold none; delete every |
| pending row for this fingerprint; redirect to the holding screen. If |
| the re-check fails, delete the rows and fall through to 2 or 3. The |
| pending-join cookie, if present, is cleared and never consulted; the |
| row is the record. |
| + |
| + Two refusals, two outcomes, stated so the tests assert the right |
| + thing: an **outsider** whose only pending row's link was replaced |
| + between mail and click is refused earlier, at `Authorize`, and the |
| + inherited rastrillo handler answers **403**; a **signed-in person** |
| + whose row fails the re-check at `/continue` gets `/` or `/welcome`. |
| + Neither path mints a grant. Meet does not change the inherited 403. |
| 2. Otherwise, if they are a member: redirect to `/`, the meeting list. |
| A member who opened `/signin` directly lands where they expect. |
| 3. Otherwise: redirect to `/welcome`, the not-a-member page: "You're |
| @@ -401,7 +411,7 @@ sign-in form, and the README says so. |
| |
| ### 3.3 The holding screen |
| |
| -`/m/{sqid}/join` requires standing (§2.3) and is the page where a call |
| +`/m/{sqid}/join` is authorized per §2.5 and is the page where a call |
| needs scripts. Rendered server-side it shows the meeting's title, its |
| start time if it has one, the person's own name and standing, the floor |
| sentence from §5.5 when it applies, links to the meeting list and, for |
| @@ -484,11 +494,20 @@ The peer id is a 16-byte random value, shown in rosters so browsers can |
| address each other, and it is not a credential: |
| every `POST /signal` must carry a `peer` that the hub finds **in this |
| room, bound to this session**; anything else is refused with 404 and |
| -nothing is relayed. A session may hold one peer per room; a stream |
| -opened with `?peer=` resumes that participation (§4.5); a stream opened |
| -without `peer` while the session already has a participation in the |
| -room replaces it, closing the old stream with `lost {reason: |
| -"replaced"}`, which is terminal for the old tab (§4.5). |
| +nothing is relayed. A session may hold one peer per room. A stream |
| +opened with `?peer=` resumes that participation (§4.5). A stream opened |
| +without `peer` while the session already has a live participation in |
| +the room (attached or within its resume window) is refused with |
| +**409** and a body naming the existing peer, unless it carries |
| +`?takeover=1`, which is sent only by the "Use this tab instead" button |
| +(§4.5). A takeover **ends** the old participation: a new participation |
| +with a new peer id is created, `gone` goes to admitted peers for the |
| +old id, `lost {reason: "replaced"}` goes to the old stream if one is |
| +attached, and the new one proceeds as a fresh `join`. Nothing automatic |
| +ever displaces a tab. |
| + |
| +Every `hello` carries `boot`, a random id chosen when the process |
| +started, so a browser can tell a restart from any other refusal (§4.5). |
| |
| Every downstream envelope is built by the server: `type`, `from` (the |
| sender's peer id, from the binding, never from the body), the sender's |
| @@ -507,17 +526,19 @@ a multi-host fleet; that is noted, not solved. |
| | `join` | a participation whose person has standing now (§2.5) | — | Member: admit (subject to cap), `roster` to the joiner, `peer` to admitted peers. Guest: mark waiting, `knock` to admitted **members**. Idempotent for a peer already present. | |
| | `admit {peer}` | an admitted participation with current `member` kind (§2.5) | — | Target must be waiting in this room and hold entry authority now (§2.5). Under the hub lock: if the target's grant has expired, `lost {reason: "expired"}` to it and `knock-gone` to members; if admitted count is four, `full` to the admitting member and nothing changes; else mark admitted, `roster` to the guest, `peer` to the other admitted peers, `knock-gone` to members. | |
| | `leave` | any peer | — | Remove the peer. Admitted: `gone` to admitted peers. Waiting: `knock-gone` to members. | |
| -| `offer`, `answer`, `candidate` | an admitted peer | the one admitted peer named in `to` | Relay the payload unread. | |
| +| `offer`, `answer`, `candidate` | an admitted peer | the one admitted peer named in `to` | Refuse with 409 if `gen` is not the pair's current generation (§4.6); otherwise relay the payload unread and keep a copy in the target's replay ring. | |
| +| `reset {to}` | an admitted peer | both peers of the pair | Under the hub lock, increment the pair's generation and send `pair {peer, gen}` to both, into their replay rings. Idempotent in effect: two resets yield one higher generation both sides converge on. | |
| | `chat {text}` | an admitted peer | all admitted peers | Fan out with the sender's name. 2 KB. Not stored. | |
| | `share {on}` | an admitted peer | all admitted peers | Fan out so tiles can be labelled. | |
| -| `hello`, `roster`, `peer`, `gone`, `knock`, `knock-gone`, `full`, `ended`, `lost` | server | as named | `roster` and `peer` go only to admitted peers; a waiting guest receives `hello`, then `roster` on admission, or `lost`, or `ended`, and nothing else. | |
| +| `hello`, `roster`, `peer`, `gone`, `knock`, `knock-gone`, `full`, `ended`, `lost`, `pair`, `gap`, `state` | server | as named | `roster`, `peer`, `pair` and `gap` go only to admitted peers; a waiting guest receives `hello`, `state`, then `roster` on admission, or `lost`, or `ended`, and nothing else. | |
| |
| -`offer`, `answer` and `candidate` also carry a `seq` (§4.6), relayed |
| -unchanged. |
| +`offer`, `answer` and `candidate` also carry `seq` and `gen` (§4.6); |
| +`seq` is relayed unchanged and `gen` is checked. |
| |
| **`roster`** is the authoritative snapshot: every admitted peer with its |
| -id, name and kind, and, when the recipient is a member, the **waiting |
| -queue** too. It is sent on admission, on resume (§4.5), and to every |
| +id, name, kind and the recipient's current **pair generation** with it |
| +(§4.6), and, when the recipient is a member, the **waiting queue** too. |
| +`peer` carries the same fields for one peer. It is sent on admission, on resume (§4.5), and to every |
| admitted peer whenever any participation's kind changes (§2.4). A |
| member who joins a room where a guest is already waiting therefore sees |
| the knock in the roster, not in a `knock` that had no recipient; `knock` |
| @@ -571,8 +592,9 @@ received from it. The hub finds the participation bound to this session |
| and attaches the new stream. What it sends next depends on admission: |
| |
| - **Admitted**: `hello`, then a fresh `roster` (§4.3), then a **replay** |
| - of every targeted message (`offer`, `answer`, `candidate`) held for |
| - this participation in its replay ring with `seq` above `last`. The |
| + of every targeted message (`offer`, `answer`, `candidate`, `pair`) |
| + held for this participation in its replay ring with `seq` above |
| + `last`, or, for `pair`, with a generation above the roster's. The |
| ring holds the last thirty-two targeted messages sent to each |
| participation, for the resume window only, in memory, and is dropped |
| with the participation; that is the one place Meet's process holds |
| @@ -595,24 +617,32 @@ event goes to anyone else; the other browsers' peer connections were |
| never affected because media is P2P. A resume does not consume a cap |
| slot. A participation that misses the window is removed as a leave. |
| |
| -**`lost` reasons.** `buffer` is followed by a resume. `replaced` is |
| -**terminal for the displaced tab**: it closes every peer connection and |
| -shows "This meeting is open in another tab", with one button, "Use this |
| -tab instead", which opens a fresh stream without `peer` and so replaces |
| -the other tab in turn. A displaced tab never resumes on its own, so two |
| -tabs cannot fight; only a person's click moves the participation. |
| -`standing`, `expired`, `removed` and `ended` are terminal and the |
| -browser closes every peer connection and shows why. |
| - |
| -A **process restart** empties the hub. Every browser's stream errors |
| -and its resume is refused with 404 (unknown peer). On that refusal the |
| -browser **closes every peer connection first**, then opens a fresh |
| -stream and posts `join` under its new peer id. Members re-enter at once |
| -and negotiate afresh on the new `peer` events. Guests return to waiting |
| -and must be admitted again, and because their browsers closed their |
| -connections on the refusal, no guest keeps media flowing from before |
| -the restart. That is the honest cost of keeping admission in memory, |
| -stated rather than hidden behind persistence. |
| +**A refused resume** says why, in the status and a JSON body, and the |
| +browser's response is fixed per case: |
| + |
| +| Resume answer | Meaning | Browser does | |
| +|---|---|---| |
| +| `410 {reason: "replaced", by: <peer>}` | This participation was ended by a takeover from another tab on this session. The hub remembers ended-by-takeover peer ids for ten minutes so a tab that never received `lost {reason: "replaced"}` learns it here. | Terminal: close every peer connection, show "This meeting is open in another tab" and the button "Use this tab instead", which opens a fresh stream with `?takeover=1` (§4.2). Never automatic. | |
| +| `410 {reason: "removed" \| "expired" \| "standing" \| "ended"}` | Ended for that reason; remembered ten minutes. | Terminal, show why. | |
| +| `404 {boot: <id>}` with a **different** `boot` from the last `hello` | The process restarted. | Close every peer connection, open a fresh stream without `peer`, `join` again. | |
| +| `404 {boot: <id>}` with the **same** `boot` | The resume window lapsed and nothing else happened. | Close every peer connection, open a fresh stream without `peer`, `join` again. If that fresh stream is refused with 409, another tab owns the room now: show the take-over button, do nothing automatic. | |
| +| `409 {peer: <id>}` on a fresh stream | This session already holds a live participation in the room (§4.2). | Show the take-over button. | |
| + |
| +`lost` reasons on a live stream follow the same split: `buffer` is |
| +followed by a resume; `replaced`, `standing`, `expired`, `removed` and |
| +`ended` are terminal and the browser closes every peer connection and |
| +shows why. A displaced tab therefore never reclaims a room by itself, |
| +whether or not its `replaced` notice was delivered: the 410 on resume |
| +and the 409 on fresh entry both stop it, and only the button moves on. |
| + |
| +A **process restart** empties the hub and changes `boot`. Every |
| +browser's resume is refused with a 404 carrying the new `boot`; the |
| +browser closes every peer connection first, then joins afresh. Members |
| +re-enter at once and negotiate afresh on the new `peer` events. Guests |
| +return to waiting and must be admitted again, and because their |
| +browsers closed their connections on the refusal, no guest keeps media |
| +flowing from before the restart. That is the honest cost of keeping |
| +admission in memory, stated rather than hidden behind persistence. |
| |
| ### 4.6 Delivery: ordered, acknowledged, replayed |
| |
| @@ -629,12 +659,30 @@ unreachable, closes that connection, and re-creates it on the next |
| anything at or below it, so a retried POST that had in fact landed is |
| harmless, and a replay on resume delivers only what was missed. |
| |
| -A **pair reset** is the recovery when replay cannot fill a gap: the |
| -browser that received `gap {from}` closes its connection to that peer |
| -and posts `reset` to it; the peer closes its side too; both then create |
| -a fresh connection and negotiate as for a new `peer`. A reset costs the |
| -pair a second or two of media and happens only after a resume whose |
| -gap exceeded the ring, which is rare by construction. |
| +**Pair generations** are how a pair recovers when replay cannot fill a |
| +gap, and they are held by the hub, not negotiated between browsers. |
| +Every unordered pair of admitted peers has a generation, starting at |
| +one when the pair first appears in a roster or `peer` event, which |
| +carry it. Every `offer`, `answer` and `candidate` carries `gen`; the |
| +hub refuses a stale `gen` with 409 and relays a current one. A browser |
| +that needs a fresh connection with a peer, because it received `gap |
| +{from}` or because its outbound queue to that peer timed out (§4.6 |
| +above), posts `reset {to}`; the hub increments the pair's generation |
| +under its lock and sends `pair {peer, gen}` to both sides, through |
| +their replay rings, and the new generation also appears in any later |
| +`roster`. On `pair`, or on a `roster` whose generation for a peer is |
| +higher than the local one, a browser: closes its connection to that |
| +peer, discards its outbound queue and its inbound candidate buffer for |
| +that peer, resets its `seq` counters for the pair, creates a new |
| +connection tagged with the new generation, and negotiates as for a new |
| +`peer`. A `pair` at or below the local generation is a no-op, so |
| +duplicates and reordering are harmless; two simultaneous resets yield |
| +two increments and both browsers converge on the higher one; an old |
| +offer or answer arriving after a rebuild is refused by the hub (stale |
| +`gen`) or dropped by the receiver (lower `gen` than current). A reset |
| +costs the pair a second or two of media and happens only after a |
| +resume whose gap exceeded the ring or after a delivery timeout, both |
| +rare by construction. |
| |
| Candidates are applied only after a remote description is set; earlier |
| ones are held in a per-peer buffer, which the W3C pattern already |
| @@ -957,7 +1005,8 @@ with the pending-join cookie present and absent at the click, the |
| unknown one, revocation during sign-in, `/continue`'s three outcomes |
| for member and non-member with present, absent and expired rows, a |
| member's signed-out link journey completing in another browser, link A |
| -replaced by B between mail and click (404, no grant), and the outsider |
| +replaced by B between mail and click (outsider: 403 at `Authorize`; |
| +signed-in: `/` or `/welcome`; no grant either way), and the outsider |
| budgets plus the documented exhaustion of the shared allowance by |
| repeated submission of one member address (1); standing as a |
| table test (1); hub tests for peer binding and refusal of a foreign or |
| @@ -971,8 +1020,13 @@ at `join` and at `admit` including four queued guests and concurrent |
| admissions, resume within and after the window with the roster sent to an admitted |
| peer and only `state` to a waiting one, replay from the ring above |
| `last`, `gap` when the ring is exceeded, `replaced` terminal for the |
| -displaced tab, `admit` of a guest whose grant expired since the last |
| -tick refused, |
| +displaced tab including the case where the `lost` was never delivered |
| +(410 on resume, 409 on fresh entry, no automatic takeover), a same-boot |
| +404 followed by a clean rejoin, a different-boot 404 after restart, a |
| +stale-`gen` POST refused with 409, `reset` yielding one `pair` to each |
| +side with the same generation, two simultaneous resets converging, a |
| +`pair` at or below the local generation ignored, `admit` of a guest |
| +whose grant expired since the last tick refused, |
| the 64-event buffer, the rate limit, an admitted guest continuing past |
| grant expiry on every endpoint and being refused fresh entry, a member |
| dropped on the tick (2); the §0.1 database and WAL tests (1) and the |
| @@ -999,7 +1053,10 @@ removed from the allowlist mid-call is dropped on the next tick and the |
| other context sees `gone` (2); a context whose stream is closed while a |
| peer leaves resumes, receives the roster, and has closed the connection |
| to the departed peer (2); an offer whose SSE delivery is cut after the |
| -POST's 2xx is replayed on resume and the pair connects (2); a second tab |
| +POST's 2xx is replayed on resume and the pair connects (2); a gap |
| +beyond the ring produces `gap`, a `reset`, a new generation on both |
| +sides and a reconnected pair, including when one side's `pair` was cut |
| +and it learns the generation from the roster on its own resume (2); a second tab |
| on the same session displaces the first, which shows the take-over |
| button and does not reconnect on its own (2); after a server restart both contexts have |
| closed their old connections before rejoining (2); renewal with the schedule shortened to |
| @@ -1081,7 +1138,19 @@ Dispositions in revision 4: |
| | R3-9 displaced tab fights back | `replaced` is terminal for the displaced tab; only a person's click takes the participation back (§4.5). | |
| | R3-10 stale claims | §7.1 now defers to §3.2; the 429 claim is removed (§4.4). | |
| |
| -Revision 4 awaits Paul's review of the written form and a fourth Codex |
| +**Round 4**, GPT-6 via Codex against revision 4 at 79787b8: |
| +`CODEX-FINDINGS-MEET-R4.md`: eight round-3 findings closed, two |
| +half-closed; four new (two P2, two P3); request changes. Dispositions |
| +in revision 5: |
| + |
| +| Finding | Disposition | |
| +|---|---| |
| +| R4-1 reset not a protocol operation | Pair generations held by the hub; `gen` on every targeted message, refused when stale; `reset` and `pair` are typed messages with recipients; `pair` rides the replay ring and the generation rides every roster; convergence and idempotency stated (§4.3, §4.6). Closes the R3-7 and R2-5 residues. | |
| +| R4-2 lost `replaced` notice | Fresh entry over a live participation is 409 unless the button's `takeover=1`; takeover creates a new peer and ends the old; resume of an ended peer is 410 with the reason for ten minutes; `hello` and 404 carry `boot` so a restart is distinguishable; nothing automatic displaces a tab (§4.2, §4.5). Closes the R3-9, R2-4 and R1-9 residues. | |
| +| R4-3 table disagreements | `leave` row participation-only; admission and removal rows name the admitted-member requirement; §3.3 defers to §2.5 (§2.5, §3.3). | |
| +| R4-4 revoked continuation outcome | Outsider: 403 at `Authorize`, inherited; signed-in: `/` or `/welcome`; no grant either way; §13 asserts those (§3.2, §13). | |
| + |
| +Revision 5 awaits Paul's review of the written form and a fifth Codex |
| round. Neither has happened; this line is the record. |
| |
| |