oficina / meet Public

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>
Paul Campbell pushed by paul@keymail.dev 1bd71614ac654e4fe5103d33516b6191fca42e27 parent 9395dbe
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.
+