spec: Meet revision 3, answering codex round 2
pending_guests written before the mail goes out so rastrillo's Authorize admits the emailed link in any browser; /continue with three outcomes and /welcome; outsider budgets bounded in front of the shared limiter with the residue stated; ruling 14 separating standing (entry) from participation (continuing) so an admitted guest survives grant expiry; roster on resume with browser reconciliation; restart closes connections before rejoin; ordered acknowledged signalling queue with seq (4.6); roster carries the waiting queue to members; promotion under the cap; sixteen-participation bound before allocation; per-connection renewal completion; transceiver.stop on share end; removal as a confirm page; floor restated with peer-reflexive; relay live and cited on platform main. Dispositions in 14.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+314
−121
docs/superpowers/specs/2026-09-07-meet-design.md+314 −121
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md| index 2d00179..8a093d4 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 2**, answering the twenty-one findings of the |
| -first Codex review (`CODEX-FINDINGS-MEET.md`, §14). Drafted by the Meet |
| +written form. **Revision 3**, answering the twelve findings of the |
| +second Codex review (`CODEX-FINDINGS-MEET-R2.md`) on top of revision 2's |
| +answers to the first (`CODEX-FINDINGS-MEET.md`); §14 has both tables. 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. |
| @@ -90,15 +91,17 @@ more) for the drive surface (§8); the CARLOS media roadmap |
| `docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md`, |
| merged at 8291cb2) and the stage 4a relay spec |
| (`docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md`, revision |
| -6, contract frozen 2026-09-07) for §5; `carlos/platform` discussion 113 |
| +6, contract frozen 2026-09-07, on platform main at dcf0cad; the relay is |
| +live at `relay.eu-west-1.oncarlos.com:3478` since 2026-09-07) for §5; `carlos/platform` discussion 113 |
| for the signalling transport (§4.1). |
| ### 0.3 What this spec does not claim |
| - No claim that a call connects across every network. Without a relay, |
| - a call connects only when each browser can reach the other's host |
| - candidates directly (§5.5). With the platform relay, some networks |
| - still fail, and the failed tile says a relay was in use, not why. |
| + browsers gather host candidates only, and the call connects when ICE |
| + finds a working direct path between them (§5.5). With the platform |
| + relay, some networks still fail, and the failed tile says a relay was |
| + in use, not why. |
| - No claim about the home. Meet is built lone and adopts the home's |
| assertions when `oficina/home` ships them. |
| - No claim that the four-participant cap is a product limit. It is the |
| @@ -116,7 +119,7 @@ for the signalling transport (§4.1). |
| | 1 | **Every participant signs in.** An outsider following a meeting link takes a guest grant scoped to that meeting. No anonymous join. | An ephemeral participant named only by what they typed, admitted from a waiting room: a fourth actor kind for one app | Paul, 2026-09-06, suite discussion 3 | |
| | 2 | **The repo is `oficina/meet`.** English name, like docs, sheets, calendar. | `oficina/conferencia` | Paul, 2026-09-06 | |
| | 3 | **File upstream, then build in-app and extract.** Platform pieces (UDP, relay) were filed first because no app can substitute for them; framework pieces are built here and offered to rastrillo on the next consumer. | Asking rastrillo for a WebSocket helper before a consumer exists | Paul, 2026-09-06 | |
| -| 4 | **Scheduling is a start time on the meeting.** The link works whenever a signed-in person follows it; the join page shows when the meeting is meant to start. A guest grant expires two hours after the start time, or two hours after creation when there is no start time. Expiry gates entry, not presence (§2.3). | A start-to-end window with a clock-driven state machine; recurring meetings | Paul, 2026-09-07 | |
| +| 4 | **Scheduling is a start time on the meeting.** The link works whenever a signed-in person follows it; the join page shows when the meeting is meant to start. A guest grant expires two hours after the start time, or two hours after creation when there is no start time. Expiry gates entry; an admitted guest continues on a **participation** (§2.4). | A start-to-end window with a clock-driven state machine; recurring meetings | Paul, 2026-09-07 | |
| | 5 | **Pure P2P, room capped at four admitted peers.** Each participant holds one peer connection per other admitted participant. | A relay Meet runs; a third-party TURN by default | Paul, 2026-09-06; cap is this session's, from mesh bandwidth | |
| | 6 | **ICE servers come from platform config or nowhere.** `CARLOS_RELAY` present: STUN and TURN from the platform, TURN credentials minted per call. Absent: no ICE servers, host candidates only, the pre-join screen says so. | A per-team STUN or TURN URL field (doctrine §8: the recipient is the app's decision, never the team's); one app-named public STUN provider as an opt-in interim | Paul, 2026-09-07, choosing "no relay" as the interim once stage 4a was ruled to start | |
| | 7 | **Signalling is SSE plus POST**, one stream per peer, following Docs live-sync §3. | A WebSocket: rastrillo has no helper, `Origin` would be hand-checked, and no upgrade crosses a tunnel-backed host (platform 113) | This session, 2026-09-07; platform 113 confirmed the constraint | |
| @@ -124,8 +127,9 @@ for the signalling transport (§4.1). |
| | 9 | **A guest waits to be admitted.** A member joins directly; a guest who signed in and holds a grant still waits until any admitted member admits them. A waiting guest receives nothing from the call and sends nothing into it (§4.3). | Auto-admit on grant | This session, 2026-09-07, built regardless of ruling 1's outcome | |
| | 10 | **No calendar integration in version one.** Meet owns rooms and a start time. Calendar's "recorded, never invited" rule stands; a sealed conference URL on an event is Calendar's later change. | Meet growing a calendar; Calendar sending invitations | This session, 2026-09-07 | |
| | 11 | **An agent can make, list, link and end meetings; it cannot join a call.** The media plane is browser-only. Its reach is its operator's current reach, checked on every request. | An agent participant; a token that outlives its operator's membership | This session, 2026-09-07, under suite discussion 4 | |
| -| 12 | **The link is the gate for sign-in.** A magic link is sent to a non-member address only when the browser asking has just followed a live meeting link. Without one, the allowlist gates sign-in exactly as Docs does. | Opening sign-in to any address (revision 1), which opened unsolicited mail and a shared rate-limit budget to anyone | This session, 2026-09-07, on Codex finding R1-8 | |
| -| 13 | **Negotiation is collision-safe**, the W3C perfect-negotiation pattern with politeness decided by peer id order. | A fixed offerer, which cannot renegotiate when the other side adds a track | This session, 2026-09-07, on Codex finding R1-10 | |
| +| 12 | **The link is the gate for sign-in.** A magic link is sent to a non-member address only when the browser asking has just followed a live meeting link, and the address is recorded as a pending guest before the mail goes out so the emailed link admits it wherever it is opened. Without a live link, the allowlist gates sign-in exactly as Docs does. | Opening sign-in to any address (revision 1), which opened unsolicited mail and a shared rate-limit budget to anyone | This session, 2026-09-07, on Codex findings R1-8 and R2-1 | |
| +| 13 | **Negotiation is collision-safe**, the W3C perfect-negotiation pattern with politeness decided by peer id order, over an ordered, acknowledged signalling queue per peer pair (§4.6). | A fixed offerer, which cannot renegotiate when the other side adds a track; fire-and-forget POSTs | This session, 2026-09-07, on Codex findings R1-10 and R2-5 | |
| +| 14 | **Entry and participation are two authorities.** Standing (§2.3) decides who may enter a room. A participation (§2.4), created at admission and bound to session and peer, decides who may continue in one. A guest whose grant expires mid-call continues on the participation; a member who loses standing does not. | One authority for both, which either drops an admitted guest at the two-hour mark or lets an expired grant re-enter | This session, 2026-09-07, on Codex finding R2-3 | |
| --- |
| @@ -154,11 +158,20 @@ token, ruling 16), `created_by`, `created_at`, `revoked_at`. One meeting |
| may hold several links so a revoked one can be replaced. The token is |
| returned once, in the response that minted it, and never again. |
| +**`pending_guests`** — `id`, `meeting_id`, `address_fingerprint` (keyed |
| +HMAC of the address under the sealer's purpose key, so the table never |
| +holds a plaintext outsider address, calendar's rule for attendees), |
| +`created_at`, `expires_at` (fifteen minutes). Written at `POST /signin` |
| +when an outsider asks for a magic link through a live meeting link |
| +(§3.2), read by the sign-in authorize callback and by `/continue`, and |
| +deleted when a grant is minted from it or when it expires. A restart |
| +does not lose it, so a magic link clicked after a deploy still works. |
| + |
| **`guest_grants`** — `id`, `meeting_id`, `person_id`, `granted_at`, |
| `expires_at`, `admitted_at` (nullable), `admitted_by` (actor, |
| nullable). Minted at sign-in continuation when a non-member follows a |
| -live link (§3.2). One row per person per meeting; following the link |
| -twice touches nothing. `admitted_at` and `admitted_by` are an audit of |
| +live link (§3.2), or from a `pending_guests` row at `/continue`. One row |
| +per person per meeting; following the link twice touches nothing. `admitted_at` and `admitted_by` are an audit of |
| the first admission and confer nothing: whether a guest is admitted |
| *now* is hub state (§4.2). |
| @@ -177,7 +190,7 @@ Rooms, peers, admission, chat and presence live in the hub (§4.2) and |
| die with the process. A process restart empties every room; §4.5 says |
| what browsers do about it. |
| -### 2.3 Standing, and when it changes |
| +### 2.3 Standing: who may enter |
| A person's **standing** toward a meeting is one of `member`, `guest` |
| (holds a grant for this meeting that has not expired), or `none`. It is |
| @@ -192,19 +205,43 @@ computed, never stored: |
| `expires_at` in the future. The row is the instance's own authority |
| under doctrine §5.2. |
| -Standing is computed on every HTTP request under `/m/{sqid}` and |
| -`/api/`, and re-computed by the hub for every peer on every heartbeat |
| -tick (15 s, §4.1). Transitions: |
| +Standing is what `/m/{sqid}/join`, a fresh `GET /m/{sqid}/stream` with |
| +no `peer`, and every `/api/` request check. It is computed on each |
| +request and never cached. |
| + |
| +### 2.4 Participation: who may continue |
| + |
| +A **participation** is the hub's record that a peer is in a room: |
| +`(room, peer id, session id, person id, kind, admitted, joined_at)`, |
| +created when a stream opens and made `admitted` by a member's `join` or |
| +an `admit`. It is the authority for everything a peer does after entry: |
| +`POST /m/{sqid}/signal`, a stream resume (`GET /m/{sqid}/stream?peer=`), |
| +and `GET /m/{sqid}/ice` are authorized by a participation bound to the |
| +requesting session, not by standing. So an admitted guest whose grant |
| +expires at the two-hour mark keeps chatting, renegotiating, resuming its |
| +stream and renewing its relay credential, because none of those is an |
| +entry. |
| + |
| +A participation ends, and its stream is closed with a terminal `lost`, |
| +on: `leave`; a member's removal of it (§3.5); the meeting ending; the |
| +resume window lapsing (§4.5); or, for a **member** only, loss of |
| +standing (below). It is never ended by a guest's grant expiring. |
| + |
| +**Kind** (`member` or `guest`) is re-derived from standing by the hub on |
| +every heartbeat tick (15 s, §4.1) for every participation, and the |
| +transitions are: |
| | Was | Now | Effect | |
| |---|---|---| |
| -| `none` | `member` (added to allowlist) | Next request succeeds. Nothing to do in the hub; they were not in it. | |
| -| `member` | `none` (removed) | Every request 404s. On the next tick the hub sends the peer `lost {reason: "standing"}`, closes its stream, and sends `gone` to the room. Their existing peer connections are torn down by the other browsers on `gone`; Meet cannot tear down a P2P connection it is not party to, and says so. | |
| -| `member` | `guest` (removed, but holds a live grant) | Continues in the room as a guest, already admitted; the roster entry changes kind on the next tick. May no longer admit anyone. | |
| -| `guest` | `member` (added) | Continues; roster entry changes kind. May now admit. | |
| -| `guest`, admitted | grant expires | **Stays.** Expiry gates entry (ruling 4), not presence. An admitted guest leaves when they leave, when a member removes them (`remove`, §4.3), or when the meeting ends. | |
| -| `guest`, waiting | grant expires | On the next tick, `lost {reason: "expired"}`, stream closed, `knock-gone` to members. | |
| -| any | meeting ended | `ended` to every stream, all closed, room dropped. | |
| +| `none` | `member` (added to allowlist) | Next entry succeeds. Nothing in the hub; they were not in it. | |
| +| `member` kind, in a room | standing `none` (removed from allowlist) | Every request 404s. On the next tick: `lost {reason: "standing"}` to the peer, stream closed, participation ended, `gone` to admitted peers. The other browsers close their connection to it on `gone` (§4.5 says how a browser that missed the `gone` still converges). Meet cannot tear down a P2P connection it is not party to, and says so. | |
| +| `member` kind | standing `guest` (removed, live grant) | Participation continues, kind becomes `guest`, `roster` refreshed to admitted peers. May no longer admit or remove anyone. | |
| +| `guest` kind, admitted | standing `member` (added) | Continues, kind becomes `member`, `roster` refreshed. May now admit and remove. | |
| +| `guest` kind, waiting | standing `member` (added) | On the tick, the hub attempts admission under the cap exactly as an `admit` would: admitted, `roster` to them and `peer` to the others; or `full` to them and they stay waiting, now as a member, and may retry with `join`. | |
| +| `guest` kind, admitted | grant expires | **Nothing.** The participation is the authority now (ruling 14). | |
| +| `guest` kind, waiting | grant expires | On the tick, `lost {reason: "expired"}`, stream closed, participation ended, `knock-gone` to members. | |
| +| `guest` kind, admitted | leaves and returns after the resume window | Standing is checked at the new entry; an expired grant means 404 at `/m/{sqid}/join`, which is ruling 4. | |
| +| any | meeting ended | `ended` to every stream, all closed, every participation ended, room dropped. | |
| The roster copy's freshness in suite shape is the doctrine's epoch rule |
| (§5); Meet re-reads its copy on every tick like any other request and |
| @@ -248,43 +285,74 @@ gives one 404 for all four. Live gives one of three outcomes: |
| redirects to `/signin`. The cookie holds the link's `token_hash` and |
| an expiry fifteen minutes out, HMAC-signed under the instance key, |
| `HttpOnly`, `Secure`, `SameSite=Lax`, path `/`. It carries the hash, |
| - not the token, so a cookie leak is not a link leak. |
| - |
| -rastrillo/auth has one configured landing path for a fresh session and |
| -no per-attempt return value, so Meet's `SignedInPath` is `/continue`. |
| -`GET /continue` reads the pending-join cookie, verifies its signature |
| -and expiry, clears it, looks the link up again as though the person had |
| -just followed it (so a link revoked during sign-in still 404s), mints |
| -the grant if they are a non-member, and redirects to the holding |
| -screen. No cookie, or a stale one, lands on the not-a-member page below. |
| -There is no `?return=` anywhere; nothing user-supplied names the |
| -destination. |
| - |
| -The magic link is often opened in a different browser or on a phone, |
| -where the cookie is absent. That case is normal and handled by the page |
| -they land on: "You're signed in. Open the meeting link again in this |
| -browser to join." The token is still in their email or chat, and |
| -following it now takes the signed-in path. The cookie is a convenience |
| -for the same-browser case, not the mechanism. |
| - |
| -**Who may sign in at all** (ruling 12). Docs' `auth.go` gates `POST |
| -/signin` on the allowlist. Meet keeps that gate with one exception: an |
| -address not on the allowlist is admitted to sign-in when, and only |
| -when, the request carries a valid pending-join cookie for a live link. |
| -So the surface revision 1 opened is closed again: nobody can cause mail |
| -to an arbitrary address without first holding a live meeting link, and |
| -holding the link is the team's own choice. |
| + not the token, so a cookie leak is not a link leak. Its only job is |
| + to tell `POST /signin` which link this browser followed. |
| + |
| +**Asking for the magic link** (ruling 12). Docs' `auth.go` gates `POST |
| +/signin` on the allowlist before rastrillo's `Begin`. Meet keeps that |
| +gate with one exception: an address not on the allowlist is let through |
| +when, and only when, the request carries a valid pending-join cookie for |
| +a live link. In that case Meet first writes a `pending_guests` row |
| +(meeting, keyed fingerprint of the address, fifteen minutes) and only |
| +then calls `Begin`. Nobody can cause mail to an arbitrary address |
| +without holding a live meeting link, and holding the link is the team's |
| +own choice. |
| + |
| +**Admitting the emailed link.** rastrillo's `Authorize` callback |
| +receives the verified address and nothing else, and Docs wires it to |
| +the allowlist, which would 403 the outsider's click. Meet's `Authorize` |
| +is: the address is on the allowlist, **or** its keyed fingerprint has an |
| +unexpired `pending_guests` row. This works in whichever browser the |
| +emailed link is opened, because the row was written before the mail |
| +went out and lives in the database, not in a cookie. |
| + |
| +**Continuation.** rastrillo has one configured landing path for a fresh |
| +session and no per-attempt return value, so Meet's `SignedInPath` is |
| +`/continue`. `GET /continue` resolves the signed-in person, then: |
| + |
| +1. If a `pending_guests` row matches their fingerprint and is unexpired: |
| + look the meeting up as though they had just followed a live link (so |
| + a link revoked during sign-in still 404s), mint the grant if they are |
| + a non-member, delete the row, redirect to the holding screen. The |
| + pending-join cookie, if present, is cleared; it is not consulted, |
| + because the row is the record. |
| +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 |
| + signed in as <address>. This team's Meet has no meetings for you. |
| + If someone sent you a meeting link, open it again now." with sign-out |
| + and nothing else. Scriptless. |
| + |
| +The other-browser case (magic link opened on a phone) is therefore not |
| +a fallback: step 1 finds the row regardless of cookies. Re-following the |
| +link also works, because a signed-in non-member takes the second bullet |
| +at the top of this section. |
| **Mail budget.** The inherited limiter allows twenty attempts per client |
| -address and five per email address per fifteen minutes, and behind the |
| -platform edge `RemoteAddr` is the edge, so that budget is shared by |
| -everyone. Meet adds a **per-link budget**: twenty sign-in mails per link |
| -per hour, counted in memory by `token_hash`. A link that is being abused |
| -runs out, and its meeting's members, who sign in without a link, are |
| -unaffected. Every refusal, whether allowlist, budget or invalid address, |
| -is the same 303 to the same page as success, as in Docs. Whether the |
| -edge can hand the instance a trusted client address is a platform |
| -question, noted here and not solved. |
| +address and five per email address per fifteen minutes, keyed on |
| +`RemoteAddr`, which behind the platform edge is the edge, so that |
| +budget is shared by every visitor. Meet cannot separate guest and |
| +member traffic inside that limiter, so it separates it in front: Meet's |
| +gate counts outsider attempts, refused or not, **before** `Begin` is |
| +reached, at five per link per fifteen minutes and ten across all links |
| +per fifteen minutes. Outsider traffic can therefore consume at most ten |
| +of the shared twenty, leaving ten attempts per window for members. A |
| +team whose members need more than ten sign-ins in fifteen minutes while |
| +a link is under attack will see rate errors; that is the stated |
| +residue, and a trusted client address from the edge is the platform |
| +change that would remove it, filed as a question on the platform when |
| +slice 1 opens, not solved here. |
| + |
| +**Responses.** Meet's own refusals (allowlist without a link, link |
| +budget) redirect to `/signin?sent=1`, the same destination as a |
| +successful `Begin`. rastrillo's own outcomes are left as they are: |
| +`?err=address` for a malformed address and `?err=rate` for its limiter, |
| +both of which describe the requester's own input or behaviour. The |
| +residue, stated plainly: while the shared budget is exhausted, a member |
| +sees `?err=rate` where a non-member without a link would have seen |
| +`?sent=1`, which distinguishes them. That window exists only under the |
| +attack the budget above bounds, and it is recorded here rather than |
| +claimed away. |
| ### 3.3 The holding screen |
| @@ -318,6 +386,20 @@ in-room destructive control that skips it. Leaving is a `leave` message |
| and the tab closing; a stream closed for thirty seconds without a |
| resume (§4.5) is also a leave. |
| +### 3.5 Removing a participant |
| + |
| +A member can remove another participant, waiting or admitted. It |
| +disconnects a person, so it has its own confirm page: `GET |
| +/m/{sqid}/remove/{peer}` renders `ui/confirm-form` with the person's |
| +name; `POST /m/{sqid}/remove/{peer}` checks, at execution, that the |
| +actor holds an admitted `member` participation in this room and that |
| +the target participation exists in this room and is not the actor's |
| +own, then ends it: `lost {reason: "removed"}` to the target, its stream |
| +closed, and `gone` to admitted peers or `knock-gone` to members as |
| +appropriate. The in-room control is a link to the confirm page, opened |
| +in a new tab like ending a meeting. Removal is not a signalling |
| +message. |
| + |
| --- |
| ## 4. Signalling |
| @@ -346,7 +428,12 @@ standing as last computed, `admitted` (bool), `joined_at`, the |
| subscriber channel, and `resume_until` when the stream is closed. |
| **A peer id is issued by the server** when a stream opens and is bound |
| -to that session and that room. It is a 16-byte random value, shown in |
| +to that session and that room, creating a participation (§2.4) that is |
| +not yet joined. A room holds at most **sixteen participations** of any |
| +state (unjoined, waiting, admitted, or held for resume); a stream that |
| +would be the seventeenth is refused with 503 before anything is |
| +allocated. An unjoined participation that has not posted `join` within |
| +thirty seconds is dropped with its stream. It 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 |
| @@ -371,13 +458,28 @@ a multi-host fleet; that is noted, not solved. |
| |---|---|---|---| |
| | `join` | any peer with standing | — | 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 member | — | Target must be waiting in this room. Under the hub lock: 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. | |
| -| `remove {peer}` | an admitted member | — | Target admitted or waiting in this room, not self. `lost {reason: "removed"}` to the target, stream closed; `gone` to admitted peers or `knock-gone` to members as appropriate. | |
| | `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. | |
| | `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. | |
| +`offer`, `answer` and `candidate` also carry a `seq` (§4.6), relayed |
| +unchanged. |
| + |
| +**`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 |
| +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` |
| +is the incremental form for members already present. |
| + |
| +**`lost`** carries a reason, and the reason decides the browser's |
| +response: `buffer` and `replaced` are transport outcomes and the |
| +browser resumes; `standing`, `expired`, `removed` and `ended` are |
| +terminal and the browser closes every peer connection and shows why. |
| + |
| **The cap** is enforced at every transition into the admitted state, |
| under the hub lock: a member's `join`, an `admit`, and never on a resume |
| (§4.5), which is not a transition. Five members joining at once get four |
| @@ -396,12 +498,14 @@ admission, and reads nothing inside `offer`, `answer`, `candidate` or |
| offer with several tracks is well under that. |
| - `chat` text above 2 KB is refused. |
| - A peer may send thirty signal messages per second with a burst of |
| - sixty; beyond that the hub drops the message and replies 429. ICE |
| - trickling stays under this. |
| -- A room holds at most eight waiting guests; a ninth `join` is refused |
| - with `full`. |
| + sixty; beyond that the hub replies 429 with `Retry-After`, and the |
| + browser's queue (§4.6) retries rather than drops. The client sends |
| + one message at a time per target, so 429 is reached only by abuse. |
| +- A room holds at most sixteen participations in any state (§4.2) and at |
| + most eight waiting; a ninth waiting `join` is refused with `full`. |
| - A session holds one peer per room (§4.2); a person holds at most four |
| - concurrent streams across all rooms. |
| + concurrent streams across all rooms. An unjoined participation lives |
| + thirty seconds; a resume-held one thirty seconds (§4.5). |
| - A subscriber whose channel holds 64 undelivered events is declared |
| lost, told so, and closed, as in Sheets. |
| - The hub logs the type, room sqid, peer id and outcome of a refused |
| @@ -415,19 +519,49 @@ The five-minute TTL and any dropped stream are handled by **resume**, |
| not by rejoining. When a stream closes, the peer stays in the hub for a |
| thirty-second `resume_until` with its admission intact. The browser |
| reopens `GET /m/{sqid}/stream?peer=<id>`; the hub finds the peer bound |
| -to this session, attaches the new stream, replays nothing, and emits no |
| -event to anyone: the other browsers' peer connections were never |
| -affected because media is P2P. A resume does not consume a cap slot. A |
| -peer that misses the window is removed as a leave. `lost {reason: |
| -"buffer"}` is followed by the same resume. |
| - |
| -A **process restart** empties the hub. Every browser's stream errors, |
| -its resume is refused (unknown peer), and it falls back to `join` on a |
| -fresh stream. Members re-enter at once; peer connections that survived |
| -in the browsers are re-established by a fresh negotiation on the new |
| -`peer` events. Guests return to waiting and must be admitted again, |
| -which is the honest cost of keeping admission in memory, stated rather |
| -than hidden behind persistence. |
| +to this session, attaches the new stream, and sends it a fresh `roster` |
| +(§4.3) as the first event after `hello`. The browser **reconciles** |
| +against that roster: a peer connection to an id not in the roster is |
| +closed, an id in the roster with no connection gets one. That is how a |
| +browser that missed a `gone` while its buffer overflowed still closes |
| +the removed peer's connection: the roster it receives on resume is the |
| +authority, not the events it missed. No 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 peer that misses the |
| +window is removed as a leave. `lost {reason: "buffer"}` and `{reason: |
| +"replaced"}` are followed by the same resume. |
| + |
| +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. |
| + |
| +### 4.6 Delivery: ordered, acknowledged, per pair |
| + |
| +SSE plus POST is not a session; the browser makes it one. For each |
| +target peer the browser keeps an **outbound queue** and sends one |
| +message at a time, in order, waiting for the POST's 2xx before the |
| +next. Each `offer`, `answer` and `candidate` carries a `seq`, per |
| +(sender, target), starting at one and incrementing; the server relays |
| +`seq` unchanged. A POST that fails (network, 5xx, 429) is retried with |
| +backoff, same `seq`, up to thirty seconds, then the browser treats the |
| +target as unreachable, closes that connection, and re-creates it on the |
| +next `roster`. The receiver keeps the last `seq` seen per sender and |
| +drops anything at or below it, so a retried POST that had in fact |
| +landed is harmless. |
| + |
| +Candidates are applied only after a remote description is set; earlier |
| +ones are held in a per-peer buffer, which the W3C pattern already |
| +requires. Under perfect negotiation an ignored offer's candidates raise |
| +on `addIceCandidate`, and the browser swallows that error exactly when |
| +`ignoreOffer` is set, as the reference implementation does. A |
| +`candidate` whose `seq` precedes the current offer's `seq` is dropped |
| +as belonging to an old generation. |
| --- |
| @@ -474,33 +608,53 @@ vector: key `uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=`, username |
| Credential expiry is a hard deadline on the relay, checked on allocate |
| and on every refresh: the allocation ends at expiry whether or not the |
| browser sends anything. The client therefore renews at two and a half |
| -hours: fetch `/ice`, `setConfiguration` on every peer connection, |
| -`restartIce()`, and let perfect negotiation carry the new offer. Renewal |
| -repeats every two and a half hours for as long as the call runs. A |
| -renewal whose fetch fails is retried every minute until the old |
| -credential's deadline; the tile shows a warning from the first failure |
| -and "relay expired" if the deadline passes. On `visibilitychange` to |
| -visible, the client compares the clock with the last mint and renews at |
| -once if it is overdue, because suspended tabs stop timers. |
| +hours, and **renewal is per connection and complete only when the new |
| +path works**: |
| + |
| +1. Fetch `/ice`. On failure, retry every minute; the tile shows a |
| + warning from the first failure. |
| +2. For each peer connection: `setConfiguration` with the new servers, |
| + `restartIce()`, and let perfect negotiation carry the offer. The |
| + connection is **renewed** when `iceConnectionState` reaches |
| + `connected` or `completed` after the restart, within thirty seconds. |
| + Otherwise restart again, up to three times. |
| +3. The old credential's deadline is kept as the **must-succeed-by** |
| + time for every connection not yet renewed. A connection that is not |
| + renewed by then shows "relay expired" with a retry, which runs step 2 |
| + again with a fresh fetch. The browser cannot see which credential an |
| + allocation is using, so the deadline is the only honest signal. |
| +4. The next scheduled renewal is two and a half hours after the last |
| + **successful** fetch, per connection. |
| + |
| +On `visibilitychange` to visible, the client compares the clock with |
| +the last successful fetch and runs the sequence at once if it is |
| +overdue, because suspended tabs stop timers. |
| ### 5.4 Screen share |
| -`getDisplayMedia`, added as a track to every peer connection, with a |
| -`share` message so peers label the tile, and renegotiation by §5.1 on |
| -each. Stopping removes the track and renegotiates. One sharer at a time |
| -is not enforced; the grid shows every shared screen it receives. |
| +`getDisplayMedia`, added as its own transceiver on every peer |
| +connection, with a `share` message so peers label the tile, and |
| +renegotiation by §5.1 on each. Stopping calls `transceiver.stop()` on |
| +the screen transceiver and renegotiates, so the receiver's track |
| +**ends** rather than merely muting; the receiver removes the share tile |
| +on `ended` and on `share {on: false}`, whichever arrives first, while |
| +the camera track continues. One sharer at a time is not enforced; the |
| +grid shows every shared screen it receives. |
| ### 5.5 The floor |
| With no `CARLOS_RELAY`, the ICE list is empty and browsers gather host |
| -candidates only: the addresses of their own interfaces. A call then |
| -connects only when each browser can reach the other's host candidates |
| -directly, which is usually true on a permissive local network and |
| -usually false across the internet, and can be false on a local network |
| -with client isolation or true across the internet for two publicly |
| -addressed machines. The pre-join screen says, above the button: "This |
| -team's Meet has no relay yet. Calls usually connect only between people |
| -on the same network." The README's first paragraph says the same. |
| +candidates only: the addresses of their own interfaces. The call |
| +connects when ICE finds a working direct path between them, which may |
| +include a peer-reflexive address learned from the other side's checks, |
| +so one publicly reachable participant can be enough. In practice that |
| +is usually true on a permissive local network, usually false across the |
| +internet, and false on a local network with client isolation. The |
| +pre-join screen says, above the button: "This team's Meet has no relay |
| +yet. Calls usually connect only between people on the same network." |
| +The README's first paragraph says the same. An operator enables the |
| +platform relay with `carlos relay enable --app meet`, after which this |
| +paragraph no longer applies to that instance. |
| With `CARLOS_RELAY` present, a failed connection's tile says "could not |
| connect, even through the relay" and offers retry. It does not diagnose |
| @@ -620,8 +774,8 @@ A thin adapter over §8.2, deferred until a consumer asks by name. |
| The exception is **browser media participation** and nothing wider: |
| reaching a camera and another browser needs script. Every other screen |
| works with scripts off, including the meeting list, creating a meeting, |
| -seeing and copying a link, both confirm pages, sign-in and continuation, |
| -the not-a-member page, the agent token page, and the server-rendered |
| +seeing and copying a link, all three confirm pages, sign-in and |
| +continuation, the `/welcome` page, the agent token page, and the server-rendered |
| parts of the holding screen and the room (§3.3, §3.4): title, start |
| time, standing, floor sentence, the paragraph that says what needs |
| script, and navigation to everything that does not. |
| @@ -672,16 +826,18 @@ own gates from §13; nothing is deferred to the deploy slice. |
| 1. **Meetings, links, sign-in.** `persons`, `meetings`, `meeting_links`, |
| `guest_grants`; sealing; the list; create; the link shown once; the |
| two confirm pages and their POSTs; the `/j/` route with all three |
| - outcomes and the one 404; the pending-join cookie, `/continue`, the |
| - allowlist exception and the per-link budget; the not-a-member page; |
| + outcomes and the one 404; the pending-join cookie, `pending_guests`, |
| + the `Authorize` callback, `/continue` with its three outcomes, the |
| + allowlist exception and the two outsider budgets; `/welcome`; |
| standing (§2.3) as a package with tests; `make release` stamping. |
| Scriptless throughout. Joining lands on a holding page that says the |
| room is the next slice. |
| 2. **Holding screen and room, direct connectivity.** `devices.js`, the |
| - hub with peer binding, cap, limits, resume and the tick; the stream |
| - and `hello`; every message in §4.3 except `chat` and `share`; |
| - `room.js` with perfect negotiation; the standing transitions; the |
| - `AGENTS.md` exception. `CARLOS_RELAY` absent. The browser gate: two |
| + hub with peer binding, participation, cap, limits, resume with |
| + roster reconciliation and the tick; the stream and `hello`; every |
| + message in §4.3 except `chat` and `share`; the removal confirm page; |
| + `room.js` with perfect negotiation and the §4.6 queue; the |
| + participation transitions; the `AGENTS.md` exception. `CARLOS_RELAY` absent. The browser gate: two |
| isolated browser contexts, two identities, one call on localhost. |
| 3. **Relay.** `CARLOS_RELAY` parsing and the startup refusal, minting |
| with the vector test, `/ice`, renewal with repeated and failed cases |
| @@ -703,17 +859,30 @@ own gates from §13; nothing is deferred to the deploy slice. |
| ## 13. Testing |
| **Go**, per slice: store and grant tests including expiry and the |
| -one-404 rule (1); standing transitions as a table test (1, 2); the |
| -pending-join cookie's signature, expiry, revocation-during-sign-in and |
| -absent-cookie paths, and the per-link budget (1); hub tests for peer |
| -binding and refusal of a foreign or cross-room `peer`, targeted relay |
| -only to admitted peers in the room, a waiting guest receiving nothing |
| -but `hello` and its own outcome, the cap at `join` and at `admit` |
| -including four queued guests and concurrent admissions, resume within |
| -and after the window, the eight-waiting limit, the 64-event buffer, the |
| -rate limit (2); the §0.1 database, WAL and captured-log tests (1, 2); |
| -the minting vector and the renewal schedule (3); the API reach check |
| -after an operator's removal (5); `make ci` green on every slice. |
| +one-404 rule (1); the full emailed-link round trip for an outsider, |
| +with the pending-join cookie present and absent at the click, the |
| +`Authorize` callback admitting the pending address and refusing an |
| +unknown one, revocation during sign-in, `/continue`'s three outcomes |
| +for member and non-member with present, absent and expired rows, and |
| +the two outsider budgets including outsider exhaustion followed by a |
| +member sign-in through the same client address (1); standing as a |
| +table test (1); hub tests for peer binding and refusal of a foreign or |
| +cross-room `peer`, the sixteen-participation and unjoined-timeout |
| +limits with streams opened and never joined, targeted relay only to |
| +admitted peers in the room, a waiting guest receiving nothing but |
| +`hello` and its own outcome, `roster` carrying the waiting queue to |
| +members and not to guests, guest-first arrival then a member joining, |
| +promotion of a waiting guest to member under and at the cap, the cap |
| +at `join` and at `admit` including four queued guests and concurrent |
| +admissions, resume within and after the window with the roster sent, |
| +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 |
| +captured-log test for join, admit, offer, candidate, a refusal and an |
| +error (2), extended to accepted chat (4); the minting vector and the |
| +renewal state machine including a successful fetch followed by a failed |
| +restart (3); the API reach check after an operator's removal (5); |
| +`make ci` green on every slice. |
| **Browser.** Calendar's chromedp drive is recorded as not running in |
| this environment, so Meet does not inherit it; the driver is Playwright |
| @@ -725,14 +894,18 @@ Gates: the holding screen enumerates devices and previews (2); two |
| contexts complete a call on localhost with host candidates and each |
| receives the other's video track, asserted on `ontrack` and a non-zero |
| frame count, not on a label (2); a guest knocks, is admitted, and had |
| -received no `peer` or `chat` while waiting (2); a stream renewal with |
| +received no `peer` while waiting (2), and no `chat` while waiting (4); a stream renewal with |
| the TTL shortened to seconds leaves the call connected (2); a member |
| removed from the allowlist mid-call is dropped on the next tick and the |
| -other context sees `gone` (2); renewal with the schedule shortened to |
| +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); after a server restart both contexts have |
| +closed their old connections before rejoining (2); renewal with the schedule shortened to |
| seconds produces a second `/ice` fetch and an ICE restart that leaves |
| the track flowing, and a failed fetch shows the warning (3); the |
| higher-id context shares its screen and the lower receives a second |
| -video track, then stops and the track ends (4); chat arrives at the |
| +video track, then stops, the received track ends, the share tile is |
| +removed and the camera track keeps flowing (4); chat arrives at the |
| admitted context and not at a waiting one (4); the scriptless holding |
| and room pages render their paragraph with scripts disabled (2). No |
| test touches a relay until one exists. |
| @@ -747,7 +920,7 @@ Dispositions in revision 2: |
| | Finding | Disposition | |
| |---|---| |
| -| R1-1 waiting-room isolation | Recipients per message type (§4.3); a waiting guest gets `hello`, its own outcome and nothing else; `leave` from a waiting guest is `knock-gone` to members only. Tested. | |
| +| R1-1 waiting-room isolation | Recipients per message type (§4.3); a waiting guest gets `hello`, its own outcome and nothing else; `leave` from a waiting guest is `knock-gone` to members only. Test in slice 2. | |
| | R1-2 peer ownership | Server-issued peer id bound to session and room; `POST /signal` verified against the binding; `to` resolved inside the room; envelope built server-side (§4.2). | |
| | R1-3 cap bypass | Cap enforced at every transition to admitted under the hub lock; resume is not a transition; queued guests counted at admission (§4.3). | |
| | R1-4 unbounded memory | Body, chat, rate, waiting, stream and buffer limits (§4.4). | |
| @@ -769,5 +942,25 @@ Dispositions in revision 2: |
| | R1-20 version stamp | Stamping added to `make release` in slice 1 (§7.3). | |
| | R1-21 provenance | Sealer: Meet is the third app and cites calendar's trigger; auth: fourth copy (§2.1, §7.1). | |
| -Revision 2 awaits Paul's review of the written form and a second Codex |
| +**Round 2**, GPT-6 via Codex against revision 2 at 3a1fa42: |
| +`CODEX-FINDINGS-MEET-R2.md`: thirteen round-1 findings closed, eight |
| +half-closed, twelve new, request changes. Dispositions in revision 3: |
| + |
| +| Finding | Disposition | |
| +|---|---| |
| +| R2-1 authorize callback | `pending_guests` written before `Begin`; `Authorize` admits an allowlisted address or a pending fingerprint; works in any browser (§2.1, §3.2). Closes the R1-7 residue. | |
| +| R2-2 shared budget | Outsider attempts counted before `Begin`, five per link and ten overall per fifteen minutes, leaving ten of the shared twenty for members; residue and rastrillo's own `?err=` responses stated, not claimed away (§3.2). Closes the R1-8 residue. | |
| +| R2-3 expiry versus authorization | Ruling 14: standing for entry, participation for continuing; `/signal`, resume and `/ice` authorized by participation; an admitted guest continues past expiry on every endpoint and cannot re-enter (§2.3, §2.4). Closes the R1-5 residue. | |
| +| R2-4 missed `gone`, restart | Resume sends an authoritative `roster` and the browser reconciles; a restart's 404 makes the browser close every connection before rejoining; `lost` reasons split into resumable and terminal (§4.3, §4.5). Closes the R1-9 residue. | |
| +| R2-5 delivery | §4.6: one in flight per target, `seq` per pair, retry with backoff, receiver dedupe, candidate buffering, old-generation drop (ruling 13). Closes the R1-10 residue. | |
| +| R2-6 invisible queue, promotion | `roster` carries the waiting queue to members and is sent on admission, resume and kind change; promotion of a waiting guest attempts admission under the cap on the tick (§4.3, §2.4). | |
| +| R2-7 member continuation | `/continue` resolves the person first: pending row, else member to `/`, else `/welcome`, which is now specified (§3.2). | |
| +| R2-8 unjoined streams | Sixteen participations per room checked before allocation; unjoined ones expire in thirty seconds (§4.2, §4.4). Closes the R1-4 residue. | |
| +| R2-9 renewal completion | Per-connection renewal complete on `connected`/`completed` after restart; old deadline kept as must-succeed-by; retries and expired-path behaviour (§5.3). | |
| +| R2-10 stop-sharing, slice order | Sender stops the transceiver so the received track ends; gate asserts tile removal and camera continuing; captured-log test assigned to slices 1, 2 and 4 by message type (§5.4, §13). Closes the R1-19 residue. | |
| +| R2-11 removal confirm | `remove` is no longer a signalling message; confirm page and guarded POST with authorization at execution (§3.5). | |
| +| R2-12 the floor | Host-only gathering, ICE finds a direct path, peer-reflexive named; bilateral reachability dropped (§5.5, §0.3). Closes the R1-14 residue. | |
| + |
| +Revision 3 awaits Paul's review of the written form and a third Codex |
| round. Neither has happened; this line is the record. |
| + |