oficina / meet Public

spec: Meet revision 2, answering codex round 1

Standing computed per request and per tick with a transition table;
server-issued peer ids bound to session and room; recipients per
message type so a waiting guest sees nothing; cap at every admission;
limits and a no-payload logging rule; resume instead of rejoin; perfect
negotiation; pending-join cookie and /continue in place of a return
path; the link is the gate for sign-in (ruling 12); per-link mail
budget; agent reach checked per request; console-order ICE list;
TURN-in-path claim made per process; floor restated as host-candidate
reachability; attendance dropped; confirm pages; Playwright gates per
slice; version stamping in slice 1. Dispositions in §14.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 3a1fa428536b647c719cc46435a2769116ae00cb parent fc73931
2 files changed, +548 −267
  • docs/superpowers/specs/2026-09-07-meet-design.md +547 −266
  • docs/superpowers/specs/CODEX-REVIEW-BRIEF-MEET.md +1 −1
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md
index 1257c16..2d00179 100644
--- a/docs/superpowers/specs/2026-09-07-meet-design.md
+++ b/docs/superpowers/specs/2026-09-07-meet-design.md
@@ -1,10 +1,11 @@
# 🤖 Meet — design
Design, 2026-09-07. Direction approved by Paul in conversation; this is the
-written form. **Revision 1.** 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.
+written form. **Revision 2**, answering the twenty-one findings of the
+first Codex review (`CODEX-FINDINGS-MEET.md`, §14). 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.
Meet is video calling for the Oficina suite: a member makes a meeting,
now or for later, sends one link, and the people who follow it set up
@@ -26,22 +27,41 @@ made over there, this spec consumes it rather than restating it (§0.2).
### 0.1 The one rule
-**Media never touches the server.** The instance holds a sealed title,
-who was in the room and when, and nothing that was said or shown in a
-call. Chat, presence and peer state live in memory for the life of a
-room and are gone when it ends.
-
-Falsifiable: publish the database, the WAL and the logs, and the worst
-anyone learns is the shape of the team's meetings and who was in them,
-never what a meeting was called, what was said, or what was on anyone's
-screen. Enforced by a test that greps the raw SQLite file after a
-checkpoint for a known title, and by the absence of any handler that
-accepts a media byte.
-
-The claim narrows when the platform SFU (stage 4b) arrives: media will
-then pass through a platform process the team already trusts with its
-pages. The README says so in the paragraph that introduces the relay,
-and this spec's §5.6 records the change of claim in advance.
+**Media never touches this app's process, and nothing said in a call is
+kept.** The instance holds a sealed title, the links and grants it
+minted, and who admitted whom. Chat, presence and peer state live in
+memory for the life of a room and are gone when it ends.
+
+Three processes can be in the path of a call, and the claim is made per
+process:
+
+- **Meet's own process** relays signalling (SDP, ICE candidates) and chat
+ between browsers. It reads a message's type, sender and target and
+ never its payload, and it logs none of it (§4.4). It never receives a
+ media packet: there is no handler that accepts one.
+- **The platform TURN relay** (stage 4a, §5) forwards media packets when
+ a direct path fails. Those packets are DTLS-SRTP, encrypted between
+ the browsers, so the relay carries bytes it cannot read. The README's
+ paragraph that introduces the relay says exactly this.
+- **The platform SFU** (stage 4b, §5.7) decrypts media to forward it.
+ When a team opts in, the claim becomes "media never touches this
+ app's process", and this spec gets a revision.
+
+Falsifiable, for the database and the logs: publish the SQLite file, its
+WAL, and every log line Meet wrote, and the worst anyone learns is the
+shape of the team's meetings and who minted what, never what a meeting
+was called, what was said, or what any browser sent as signalling.
+Enforced by tests that (a) grep the raw SQLite file and the WAL, before
+and after a checkpoint, for a known title; (b) capture the logger during
+a call that exercises join, admit, chat, offer, candidate, a refused
+message and an error path, and grep the capture for the title, the chat
+text, any SDP fragment, any candidate address and any link token. A
+link token appears once in an HTTP response body and nowhere else.
+
+What the claim does not cover, said plainly: signalling passes through
+Meet's process in plaintext for the instant it is relayed, and a
+capability URL is a bearer that the person who holds it may paste
+anywhere. Ruling 16 covers the token at rest, not in flight.
### 0.2 Consumed suite rulings
@@ -52,15 +72,21 @@ This app cites the suite sharing doctrine (`oficina/suite`,
| Ruling | Used for |
|---|---|
| 1, 5 | One instance per team; the first deploy is the **lone shape** (§7.1). |
+| 3 | Authorization is asked per request and never cached. Standing (member, guest, neither) is derived at each request and re-derived on every hub tick (§2.3). |
| 4, 12 | Identity and roster belong to the home. Until the home mints assertions, the lone instance keeps its own allowlist exactly as Docs and Slides do. |
| 6 | No product-wide egress. Meet never names a STUN or TURN server of its own; ICE servers come from the platform (§5.2) or there are none (§5.5). |
| 9 | Every write names its real actor: a member, a guest who signed in, or an agent (§8). |
| 15 | Three rungs plus a read-only link. A meeting link is a capability to obtain a **guest grant at sign-in**, the `granted` rung, and never a way into a room without a name (§3). Ruled for Meet on `oficina/suite` discussion 3, Paul, 2026-09-06. |
| 16 | A link token is stored hashed and shown once (§3.1). Ruling 16 is on the doctrine's `worktree-worktree-token-at-rest` branch (suite discussion 2), not yet on main; Meet adopts it now so it never has the Docs defect that produced it. |
+Doctrine §5.2 is also relied on: a guest grant held by the instance is
+authoritative for that grant, which is why §2 stores it and does not
+ask anyone.
+
Also consumed: `oficina/suite` discussion 4 (agents as their own
-principal, on behalf of a person) for the drive surface (§8); the CARLOS
-media roadmap (`carlos/platform`,
+principal, on behalf of a person, bound to the operator's reach and no
+more) for the drive surface (§8); the CARLOS media roadmap
+(`carlos/platform`,
`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
@@ -70,13 +96,16 @@ 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,
- calls connect on the same network only (§5.5). With the platform relay,
- a network that permits only TCP 443 still fails, and the pre-join
- screen says so.
+ 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.
- 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
mesh's limit, lifted by stage 4b.
+- No attendance record. Meet does not store who was in a call or for
+ how long. Revision 1 implied one and had no model for it; revision 2
+ drops the implication rather than adding the model.
---
@@ -86,22 +115,33 @@ 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 second 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. | A start-to-end window with a clock-driven state machine; recurring meetings | Paul, 2026-09-07 |
-| 5 | **Pure P2P, room capped at four.** Each participant holds one peer connection per other 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, calls connect on the same network 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 room, 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 |
-| 8 | **Chat is ephemeral**, a signalling message rendered client-side, held nowhere at rest. | Persisted chat as content with named actors, the suite's first chat product | This session, 2026-09-07 |
-| 9 | **A guest waits to be admitted.** A member joins directly; a guest who signed in and holds a grant still waits until any member in the room admits them. | Auto-admit on grant | This session, 2026-09-07, built regardless of ruling 1's outcome |
+| 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 |
+| 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 |
+| 8 | **Chat is ephemeral**, a signalling message rendered client-side, held nowhere at rest, delivered to admitted peers only. | Persisted chat as content with named actors, the suite's first chat product | This session, 2026-09-07 |
+| 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. | An agent participant | This session, 2026-09-07, under suite discussion 4 |
+| 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 |
---
## 2. The meeting model
-Three tables of Meet's own beside the scaffold's persons table, and `agents` in slice 5 (§8.1), gorm on SQLite through rastrillo, migrations numbered as
-the scaffold does.
+### 2.1 Tables
+
+Meet's own, gorm on SQLite through rastrillo, migrations numbered as the
+scaffold does. The scaffold's `Note` goes in slice 1; rastrillo/auth
+brings its own session and pending-link tables through
+`migrate.Merge(sessions.Schema, ...)` as Docs and Slides do.
+
+**`persons`** — `id`, `email` (unique), `name`, `created_at`. Created on
+first sign-in from the session subject, per the rastrillo §5 warning
+that the subject is an address, not an id. Standing is not a column
+(§2.3).
**`meetings`** — `id`, `sqid` (public, in URLs), `title_sealed`,
`starts_at` (UTC, nullable), `created_by` (actor, §7.2), `created_at`,
@@ -112,28 +152,63 @@ its links answer 404 exactly as a revoked one does.
**`meeting_links`** — `id`, `meeting_id`, `token_hash` (SHA-256 of the
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
-shown to its maker once, on the page that created it, and never again.
+returned once, in the response that minted it, and never again.
**`guest_grants`** — `id`, `meeting_id`, `person_id`, `granted_at`,
-`expires_at`, `admitted_at` (nullable), `admitted_by` (actor, nullable).
-Minted at sign-in when a non-member follows a live link (§3.2). One row
-per person per meeting; following the link twice touches nothing.
-
-**`persons`** — the scaffold's users table: `id`, `email`, `name`,
-`created_at`. `member` is a boolean derived at request time from the
-allowlist in lone shape, and from the roster copy once the home exists.
-It is not stored, so a change to the allowlist takes effect on the next
-request.
-
-Sealing reuses the shape of calendar's `internal/calendar/seal`: one
-DEK from platform config (`MEET_DEK`), HKDF purpose keys, AES-GCM, and
-the DEK never written to the database it opens. Meet seals one column.
-Lifting the package into estilo or rastrillo is the extraction trigger
-the calendar spec already names; this is the second consumer, so the
-plan carries a task to propose it.
-
-Nothing else is at rest. Rooms, peers, admission state, chat and
-presence live in the hub (§4.2).
+`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
+the first admission and confer nothing: whether a guest is admitted
+*now* is hub state (§4.2).
+
+**`agents`** — slice 5, §8.1.
+
+Sealing reuses calendar's `internal/calendar/seal`, which is itself the
+Sheets sealer's second home: one DEK from platform config (`MEET_DEK`),
+HKDF purpose keys, AES-GCM, and the DEK never written to the database it
+opens. Calendar's `seal.go` names the third app as the extraction
+trigger. Meet is that third app; the plan carries the extraction task
+calendar already defined rather than a new one.
+
+### 2.2 Nothing else at rest
+
+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
+
+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
+computed, never stored:
+
+- `member`: the address is on the allowlist (lone shape) or the roster
+ copy at its current epoch (suite shape, when the home exists). Both
+ are reads of the instance's own state, which is what doctrine ruling
+ 3 and §5 call the roster; neither is a cached answer from another
+ service.
+- `guest`: a `guest_grants` row for this person and meeting with
+ `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:
+
+| 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. |
+
+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
+adds no cache of its own.
---
@@ -145,8 +220,15 @@ presence live in the hub (§4.2).
collapsed section. `POST /meetings` takes a title and an optional start
time and creates the meeting and its first link in one transaction. The
response page shows the link once, with a copy button when scripts are
-on and a selectable text field when they are off. `POST /meetings/{sqid}/links`
-mints a replacement and revokes the rest.
+on and a selectable text field when they are off.
+
+Replacing a link revokes every other link for the meeting, which is
+destructive, so it has its own confirm page: `GET
+/meetings/{sqid}/links/replace` renders `ui/confirm-form` naming what
+will stop working; `POST /meetings/{sqid}/links` does it and shows the
+new link once. Ending a meeting is the same shape: `GET
+/meetings/{sqid}/end` confirms, `POST /meetings/{sqid}/end` ends. Both
+are members-only.
Links are `https://<host>/j/<token>`, with a token of 32 random bytes,
base64url. The route is mounted outside the session group like
@@ -156,54 +238,85 @@ not signed in yet.
### 3.2 Following a link
`GET /j/{token}` looks the hash up. Unknown, revoked, ended or expired
-gives one 404 for all four. Live gives one of three redirects:
-
-- **A signed-in member** goes to the holding screen, `/m/{sqid}/join`.
-- **A signed-in non-member** with no grant gets one minted now, then
- goes to the holding screen. With a grant, straight there.
-- **Nobody signed in** goes to `/signin?return=/j/<token>`. The token
- rides in the return path and nowhere else, so sign-in completes and
- the same route runs again with a session. Sign-in refuses a return
- path that is not a `/j/` path on this host.
-
-A non-member may sign in at all because the allowlist gates
-**membership**, not sign-in, on this app alone: the magic link is sent
-to any syntactically valid address, and what the address gets is a
-grant or nothing. This is the one place Meet departs from Docs'
-`auth.go`, and it is stated here because the departure is the whole
-guest feature. An address that signs in without following a live link
-lands on a page that says it is not a member of this team and has no
-meetings, and can do nothing else.
-
-The expiry of a grant is set at mint time from ruling 4. The grant is
-checked, not cached, on every request under `/m/{sqid}` (doctrine
-ruling 3).
+gives one 404 for all four. Live gives one of three outcomes:
+
+- **A signed-in member** is redirected to the holding screen,
+ `/m/{sqid}/join`.
+- **A signed-in non-member** gets a grant minted now if they have none,
+ then the holding screen.
+- **Nobody signed in**: Meet sets a **pending-join cookie** and
+ 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.
+
+**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.
### 3.3 The holding screen
-`/m/{sqid}/join` is the only page in Meet that requires scripts, and it
-says so in place when they are off (§9). It shows the meeting's title,
-its start time if it has one, the person's own name, a camera preview,
-device pickers for camera, microphone and speaker, and mute toggles.
-Choices persist in the browser's local storage per device id, nothing
-of them reaches the server. If `CARLOS_RELAY` is absent the screen
-carries the floor sentence from §5.5 above the join button.
-
-Pressing join opens the room stream (§4) and posts `join`. A member
-enters. A guest sees "waiting for someone to let you in" until a member
-in the room posts `admit` for them, and leaves the queue if they close
-the tab. Every member in the room sees the knock with the guest's name
-and one button.
+`/m/{sqid}/join` requires standing (§2.3) and is the page where a call
+needs scripts. Rendered server-side it shows the meeting's title, its
+start time if it has one, the person's own name and standing, the floor
+sentence from §5.5 when it applies, links to the meeting list and, for
+members, to the end-meeting confirm page. With scripts on it adds the
+camera preview, device pickers for camera, microphone and speaker, mute
+toggles, and the join button. Choices persist in the browser's local
+storage per device id; nothing of them reaches the server. With scripts
+off the join button is replaced by one paragraph saying that a call
+needs JavaScript to reach the camera and the other participants, and
+that everything else on this page works without it (§9).
+
+Pressing join opens the stream (§4.1), receives its peer id, and posts
+`join`. A member enters. A guest sees "waiting for someone to let you
+in" until an admitted member posts `admit` for them; closing the tab
+leaves the queue. Every admitted member sees the knock with the guest's
+name and one button.
### 3.4 The room
-`/m/{sqid}` renders the grid of participants, own controls (mute,
-camera, share screen, leave), the chat panel and the admission queue.
-The creator or any member can end the meeting from the room or from
-the list, which sets `ended_at`, closes every stream with an `ended`
-message and revokes every link. Leaving is a `leave` message and the
-tab closing; the server also treats a stream that has been closed for
-thirty seconds as a leave.
+`/m/{sqid}` renders, with scripts on, the grid of participants, own
+controls (mute, camera, share screen, leave), the chat panel and the
+admission queue. With scripts off it renders the same header as the
+holding screen, the same paragraph, and a link to the holding screen;
+nothing on it pretends to be a call. Ending a meeting from inside the
+room goes through the §3.1 confirm page in a new tab; there is no
+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.
---
@@ -211,12 +324,12 @@ thirty seconds as a leave.
### 4.1 Transport
-One SSE stream per room and participant, `GET /m/{sqid}/stream`, under
-the session, with the Docs live-sync heartbeat (15 s) and connection TTL
-(5 min) so hibernation cannot be held off by a dead stream. Upstream
-messages are `POST /m/{sqid}/signal` with a JSON body, under CSRF. A
-client that loses its stream reconnects and posts `join` again; peers
-that see a second `join` from the same peer id renegotiate.
+One SSE stream per peer, `GET /m/{sqid}/stream`, under the session and
+the standing check, with the Docs live-sync heartbeat (15 s) and
+connection TTL (5 min) so hibernation cannot be held off by a dead
+stream. The first event on a new stream is `hello {peer}`, the server's
+peer id for this stream. Upstream messages are `POST /m/{sqid}/signal`
+with a JSON body carrying `peer` and the message, under CSRF.
A WebSocket would fit this shape better and is deliberately not used:
Docs live-sync §3 lists the costs, and platform discussion 113 confirmed
@@ -224,117 +337,203 @@ that no upgrade crosses a tunnel-backed host and no placement signal
exists. `rastrillo/rastrillo` discussion 17 records the intent to build
one when a second consumer appears.
-### 4.2 The hub
-
-An in-process room hub in the shape of Sheets' `live/hub.go`: opaque
-payloads, a bounded per-subscriber buffer, a slow subscriber declared
-lost and told so. It holds, per room: the set of peers (peer id, actor,
-display name, member or guest, admitted or waiting, joined at), and
-nothing else. Peer ids are random per tab. The hub is per process,
-which is fine on one host and would not survive a multi-host fleet;
-that is noted, not solved.
+### 4.2 The hub and peer identity
+
+An in-process room hub in the shape of Sheets' `live/hub.go`, with a
+room admission policy the Sheets hub does not need. Per room it holds
+the set of peers; per peer: `peer` id, `session` id, `person` id,
+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
+rosters so browsers can address each other, and it is not a credential:
+every `POST /signal` must carry a `peer` that the hub finds **in this
+room, bound to this session**; anything else is refused with 404 and
+nothing is relayed. A session may hold one peer per room; a second
+stream from the same session for the same room resumes the existing
+peer if it is within `resume_until` (§4.5) and otherwise replaces it,
+closing the old stream with `lost {reason: "replaced"}`.
+
+Every downstream envelope is built by the server: `type`, `from` (the
+sender's peer id, from the binding, never from the body), the sender's
+display name where the type carries one, and the opaque `payload`. A
+target `to` must name an admitted peer in the same room, or the message
+is refused. Cross-room delivery is impossible by construction because
+lookups start from the room in the URL.
+
+The hub is per process, which is fine on one host and would not survive
+a multi-host fleet; that is noted, not solved.
### 4.3 Messages
-Every message carries `type`, `from` (set by the server, never trusted
-from the client) and, where targeted, `to`. Types:
-
-| Type | Direction | Who may send | Server does |
+| Type | Who may send | Recipients | Server does |
|---|---|---|---|
-| `join` | up | anyone with access | Adds the peer. Member: admitted, broadcast `peer` to the room, send the roster to the joiner. Guest: waiting, broadcast `knock` to members only. Refuses with `full` at four admitted peers. |
-| `admit` | up | a member in the room | Marks the guest admitted, broadcasts `peer`, sends the guest the roster. |
-| `leave` | up | the peer | Removes the peer, broadcasts `gone`. |
-| `offer`, `answer`, `candidate` | up, targeted | an admitted peer | Relays to `to` unchanged. Never broadcast. |
-| `chat` | up | an admitted peer | Broadcasts with `from` and the sender's display name. Not stored. 2 KB limit. |
-| `share` | up | an admitted peer | Broadcasts `{on: bool}` so peers can label the tile. |
-| `roster`, `peer`, `gone`, `knock`, `full`, `ended`, `lost` | down | server | As named. |
-
-The server validates type and shape and the sender's standing, and
-reads nothing inside `offer`, `answer` or `candidate`. Which peer makes
-the offer is decided by peer id order, so two peers never both offer.
+| `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. |
+
+**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
+admissions and one `full`; a member who sees `full` can wait and retry.
+Queued guests can exceed four while waiting; admission checks the count
+at admission time, so four queued guests and one member yield three
+admissions and one `full`.
+
+The server validates type and shape and the sender's standing and
+admission, and reads nothing inside `offer`, `answer`, `candidate` or
+`chat` beyond the byte length.
+
+### 4.4 Limits and logging
+
+- `POST /signal` bodies are refused above 64 KB before decoding. An SDP
+ 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`.
+- A session holds one peer per room (§4.2); a person holds at most four
+ concurrent streams across all rooms.
+- 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
+ message, and nothing of any accepted one. No payload, chat text, SDP,
+ candidate, display name, title or link token is ever a log argument;
+ the §0.1 test captures the logger to prove it.
+
+### 4.5 Renewal, resume and restart
+
+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.
---
## 5. Media
-### 5.1 The mesh
+### 5.1 The mesh and negotiation
One `RTCPeerConnection` per other admitted peer, created when a `peer`
-message arrives or the roster is received. The lower peer id offers.
-Tracks: microphone and camera from the holding screen's choices, and a
-display-media track when sharing. All ICE candidates are trickled as
-`candidate` messages. A connection that reaches `failed` shows the
-tile as "could not connect" with a retry that restarts ICE; nothing is
-silently spun.
+message arrives or the roster is received. Negotiation follows the W3C
+**perfect negotiation** pattern: both sides offer on
+`negotiationneeded`; the peer with the lower id is polite and rolls
+back on collision, the higher is impolite and ignores the incoming
+offer. This is what lets either side add a screen track or restart ICE
+without an offer-request message. All candidates trickle as `candidate`
+messages. A connection that reaches `failed` shows the tile as "could
+not connect" with a retry that restarts ICE; nothing is silently spun.
### 5.2 ICE servers from the platform
At startup the instance reads `CARLOS_RELAY`, standard base64 of a JSON
-array, nearest region first, each element `{region, urls, key,
-username_suffix}`. Absent or empty means §5.5. The decoded value is
-held in memory and never logged.
-
-Per call, `GET /m/{sqid}/ice` (under the session and the room's access
-check) returns the `RTCIceServer` list: every `stun:` URL bare, every
-`turn:` URL with `username = "<unix expiry>:" + username_suffix` and
-`credential = base64(HMAC-SHA256(rawkey, username))`, where `rawkey` is
-the element's key decoded from base64 to 32 bytes. Expiry is three
-hours ahead, inside the four-hour maximum. The list is built from
-whatever URLs are present; no shape is assumed, and no `turns:` URL is
-expected in version one.
+array of `{region, urls, key, username_suffix}` in **console order**
+(the contract makes no placement-first promise; ICE picks a working
+pair). Absent or empty means §5.5. A present value that fails to decode
+or parse is a startup refusal outside `-env development`, logged as one
+line naming the variable and not its value. The decoded value is held
+in memory and never logged.
+
+Per call, `GET /m/{sqid}/ice` (under the session and standing) returns
+the `RTCIceServer` list. For each element, every URL is passed through
+as given; URLs whose scheme is `turn` or `turns` carry `username =
+"<unix expiry>:" + username_suffix` and `credential =
+base64(HMAC-SHA256(rawkey, username))`, where `rawkey` is the element's
+key decoded from base64 to 32 bytes; every other scheme (`stun`,
+`stuns`, or one the contract adds later) is passed bare. Expiry is
+three hours ahead, inside the four-hour maximum.
The minting function has one test that reproduces the platform's
vector: key `uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=`, username
`1900000000:abc:meet:0123456789abcdef:k1`, credential
`jRU479Y3GO45nrRHqxBf3BMeBHE6Fip1cwAccNv28MQ=`.
-### 5.3 Re-mint before the deadline
+### 5.3 Renewal before the deadline
-Credential expiry is a hard deadline on the relay: the allocation ends
-whether or not the browser sends anything. The client fetches a fresh
-list at two and a half hours, calls `setConfiguration` on every peer
-connection and restarts ICE. Under three hours nothing happens. This
-is a small client task, and it is the reason §5.2 mints at three
-hours rather than four.
+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.
### 5.4 Screen share
`getDisplayMedia`, added as a track to every peer connection, with a
-`share` message so peers label the tile, and renegotiation on each.
-Stopping removes the track and renegotiates. One sharer at a time is
-not enforced; the grid shows every shared screen it receives.
+`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.
### 5.5 The floor
With no `CARLOS_RELAY`, the ICE list is empty and browsers gather host
-candidates only. Calls connect between participants on the same
-network and nowhere else. The pre-join screen says, above the button:
-"This team's Meet has no relay yet, so calls connect only between
-people on the same network." The README's first paragraph says the
-same. Nothing pretends.
-
-With `CARLOS_RELAY` and a network that permits only TCP 443, the call
-still fails, because the relay's TLS listener is deferred by the
-platform. The failed tile's text names that case.
-
-### 5.6 What changes at stage 4b
-
-When the platform SFU exists and a team opts in, media passes through
-a platform process. §0.1's claim becomes "media never touches this
-app's server". The mesh code is replaced by one connection to the SFU,
-the cap is lifted, and this spec gets a revision, not a footnote.
+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.
+
+With `CARLOS_RELAY` present, a failed connection's tile says "could not
+connect, even through the relay" and offers retry. It does not diagnose
+the network; a TCP-443-only network is one cause the README names,
+because the relay's TLS listener is deferred by the platform, and it is
+named as a possibility, not a verdict.
+
+### 5.6 What the relay changes
+
+Stage 4a puts a platform process in the media path for calls that need
+it. It forwards DTLS-SRTP packets it cannot decrypt. §0.1's claim about
+Meet's own process is unchanged; the README paragraph that introduces
+the relay says a platform relay may carry encrypted media, and that a
+third-party relay is never used.
+
+### 5.7 What the SFU changes
+
+When the platform SFU exists and a team opts in, media is decrypted by a
+platform process to be forwarded. The mesh code is replaced by one
+connection to the SFU, the cap is lifted, and §0.1's claim is rewritten
+in a revision of this spec, not a footnote.
---
## 6. Chat
-A `chat` message with text up to 2 KB, rendered in the panel with the
-sender's name and a local timestamp, escaped as text. It is not stored,
-not sent to a peer who joins later, and gone with the tab. Links are
-not linkified in version one. This is the suite's first chat, and
-estilo's rule makes Meet the first builder: the panel is written as one
-self-contained template partial and one script file so a second app can
-adopt it.
+A `chat` message with text up to 2 KB, delivered to admitted peers only,
+rendered in the panel with the sender's name and a local timestamp,
+escaped as text. It is not stored, not replayed to a peer who joins or
+resumes later, and gone with the tab. Links are not linkified in
+version one. This is the suite's first chat, and estilo's rule makes
+Meet the first builder: the panel is one self-contained template partial
+and one script file so a second app can adopt it.
---
@@ -342,32 +541,39 @@ adopt it.
### 7.1 Lone shape
-Sign-in is Docs' `internal/docs/auth.go` adopted with the one change in
-§3.2: `MEET_ALLOWED_ADDRESSES` decides membership, not whether a magic
-link is sent. `MEET_ORIGIN`, `MEET_INSTANCE_KEY`, `MEET_SMTP_*` and
-`MEET_DEK` are startup refusals outside `-env development`. The same
-303 for every address. Slides already recorded that its copy of this
-file is the extraction trigger; Meet's is the third, and the plan
-carries the same task rather than a new one.
+Sign-in is Docs' `internal/docs/auth.go` adopted with the two changes in
+§3.2: the pending-join exception to the allowlist gate, and
+`SignedInPath` set to `/continue`. `MEET_ALLOWED_ADDRESSES`,
+`MEET_ORIGIN`, `MEET_INSTANCE_KEY`, `MEET_SMTP_*` and `MEET_DEK` are
+startup refusals outside `-env development`. The same 303 for every
+address. This file already lives in Docs, Calendar and Slides; Meet's is
+the fourth copy, and the extraction task Slides recorded stands. Meet
+adds none.
### 7.2 Actors
Every write records an actor: `person:<id>`, or `agent:<id>` for the
drive surface. Meetings, links, grants, admissions and endings carry it.
A guest's actor is `person:<id>` like a member's; what differs is
-standing, decided per request (§2).
+standing, decided per request (§2.3).
### 7.3 Deploy
-One static binary, `CGO_ENABLED=0`, `linux-arm64`, `make release` with
-the version stamped, `carlos deploy --app meet --account oficina` with
-the build probe on `/api/version`. `carlos domains attach
-meet.oficina.works`. Secrets `MEET_INSTANCE_KEY`, `MEET_SMTP_PASS` and
-`MEET_DEK` via `carlos secrets`, the rest via `carlos env`. The route is
-a unit route, which is what `carlos relay enable --app meet` needs. The
-relay itself is switched on by an operator when stage 4a reaches the
-account's region; Meet needs no change for it beyond reading the value
-at the restart the platform performs.
+One static binary, `CGO_ENABLED=0`, `linux-arm64`. The scaffold's `make
+release` strips and does not stamp; slice 1 changes it to pass
+`-X amadan.net/rastrillo/rastrillo.BuildVersion=$(git describe --always --dirty)`
+and to refuse a dirty tree, as calendar's does, so `/api/version`
+reports the built revision and a deploy can be verified against it.
+`carlos deploy --app meet --account oficina` with the build probe on
+`/api/version`. `carlos domains attach meet.oficina.works`. Secrets
+`MEET_INSTANCE_KEY`, `MEET_SMTP_PASS` and `MEET_DEK` via `carlos
+secrets`, the rest via `carlos env`.
+
+Meet is deployed as a **unit route**, Meet's choice among the shapes the
+relay contract accepts (unit, socket-exec, or a TCP exec route with file
+delivery); a unit route needs nothing from Meet when an operator runs
+`carlos relay enable --app meet`, and the platform restarts the route
+when the value is delivered.
---
@@ -375,23 +581,33 @@ at the restart the platform performs.
### 8.1 Principals
-Adopted from Slides §7.1 unchanged: a member mints an agent token from
-Settings, `agents(id, name, operator, token_hash, created_at,
+Adopted from Slides §7.1: a member mints an agent token from Settings,
+`agents(id, name, operator_person_id, token_hash, created_at,
revoked_at)`, principal `<name>~<operator email>`, robot badge and
-operator name on every write, bearer on the API only, never a cookie
-on the API and never a bearer on HTML routes. Cited to suite discussion
-4 for the credential shape at the home; until that is ruled, the token
-is instance-minted exactly as Slides does.
+operator name on every write, bearer on the API only, never a cookie on
+the API and never a bearer on HTML routes. Cited to suite discussion 4
+for the credential shape at the home; until that is ruled, the token is
+instance-minted exactly as Slides does.
+
+**Reach.** On every API request the token must be unrevoked **and** its
+operator must have `member` standing (§2.3) at that moment. An
+operator who leaves the team takes their agent's reach with them, with
+no revocation step; the request 404s like any other loss of standing.
+Suite discussion 4 binds an agent to its operator's reach and no more,
+and this is the per-request form of that sentence.
### 8.2 Operations
-`GET /api/v1/meetings` (list, with `sqid`, title, `starts_at`,
-`ended_at`, `link_present`), `POST /api/v1/meetings` (title, optional
-`starts_at`; returns the meeting and its link, the only time the API
-returns a token), `POST /api/v1/meetings/{sqid}/links` (replace the
-link), `POST /api/v1/meetings/{sqid}/end`, `GET /api/v1/meetings/{sqid}`
-(the meeting and, while it runs, the count and names of admitted
-peers, never the chat). No operation joins a call (ruling 11).
+`GET /api/v1/meetings` (list: `sqid`, title, `starts_at`, `ended_at`,
+`link_present`), `POST /api/v1/meetings` (title, optional `starts_at`;
+returns the meeting and its link's token, once), `POST
+/api/v1/meetings/{sqid}/links` (replace: revokes the rest and returns
+the new token, once; the only two responses that ever carry a token
+are this and creation), `POST /api/v1/meetings/{sqid}/end`, `GET
+/api/v1/meetings/{sqid}` (the meeting and, while it runs, the count of
+admitted peers, never their names or the chat). A replacement that
+fails leaves the old links untouched; there is no partial state to
+retry from. No operation joins a call (ruling 11).
### 8.3 MCP
@@ -401,21 +617,25 @@ A thin adapter over §8.2, deferred until a consumer asks by name.
## 9. The scriptless floor
-Everything except the holding screen and the room works with scripts
-off: the list, creating a meeting, seeing and copying a link, replacing
-it, ending a meeting, signing in, the agent token page. The holding
-screen with scripts off renders the title, the start time, the floor
-sentence if it applies, and one paragraph saying a call needs
-JavaScript and why, with nothing hidden behind a broken button. This
-is the suite's first app whose core surface needs scripts, and
-PRODUCT.md says so in the "what is deliberately not here" list rather
-than letting the scaffold's claim stand.
+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
+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.
+
+`AGENTS.md`'s line "screens work with JavaScript disabled" gains, in
+slice 2, the one exception in those words, so the binding file and the
+code agree from the commit that introduces the exception. `PRODUCT.md`
+records it in "what is deliberately not here" in slice 6.
Hand-written static JavaScript, no bundler, no npm, in three files:
-`devices.js` (the holding screen), `room.js` (signalling and the mesh),
-`chat.js` (the panel). Roughly two thousand lines between them, the
-largest hand-written script in the suite so far, which is why they are
-three files and not one.
+`devices.js` (the holding screen), `room.js` (signalling, resume and
+the mesh), `chat.js` (the panel). Roughly two thousand lines between
+them, the largest hand-written script in the suite so far, which is why
+they are three files and not one.
---
@@ -427,66 +647,127 @@ error and empty states. Meet builds nothing in estilo in version one
except what §6 prepares. Every new user-facing string goes through the
copy review before it lands (`copy-review`, the suite's mechanism), and
the plan lists the strings per slice so the review is one sitting per
-slice, not one per string.
+slice, not one per string. The sentences quoted in §3.2, §3.3 and §5.5
+are drafts for that review, not approved copy.
---
## 11. Deliberately not built yet
-Recurring meetings. Calendar integration. Recording. A relay of Meet's
-own. Rooms above four. Persisted chat. Reactions. Dial-in. Captions.
-Home integration. Multi-host signalling. Push-to-talk. Virtual
-backgrounds. Each is named so the next session does not rediscover it.
+Recurring meetings. Calendar integration. Recording. Attendance
+records. A relay of Meet's own. Rooms above four. Persisted chat.
+Reactions. Dial-in. Captions. Home integration. Multi-host signalling.
+Push-to-talk. Virtual backgrounds. Denying a knock (a guest who is not
+admitted simply waits or leaves). Each is named so the next session
+does not rediscover it.
---
## 12. Slices
-1. **Meetings and links.** The four tables, sealing, the list, create,
- the link shown once, replace, end. Scriptless throughout. Sign-in
- with the §3.2 change and the `/j/` route with all three redirects and
- the one 404. Grants minted and checked. No room yet: joining lands on
- a page that says the room is the next slice.
-2. **Holding screen and room, same network.** `devices.js`, the hub,
- the stream, the signalling messages except `chat` and `share`, `room.js` with the mesh,
- admission, leave, end. `CARLOS_RELAY` absent. Two headless browsers
- complete a call on localhost.
-3. **Relay.** `CARLOS_RELAY` parsing, minting with the vector test,
- `/ice`, the re-mint. Testable without a relay by asserting the list
- the browser receives; tested against the real relay when stage 4a
- reaches flagship.
-4. **Chat and screen share.** `chat.js`, the `chat` and `share`
- messages, display-media tracks and renegotiation.
-5. **Drive surface.** Agent tokens, the five operations, the badge.
-6. **Deploy.** Release, domain, secrets, canary, `PRODUCT.md`,
- `AGENTS.md`, the README with the floor sentence in its first
- paragraph, and the `Consumed suite rulings` commit that suite
- Appendix A.6 will point at.
-
Each slice is its own branch, discussion and review, per the suite's
-overnight protocol (`oficina/suite` discussion 5).
+overnight protocol (`oficina/suite` discussion 5), and each carries its
+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;
+ 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
+ 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
+ under shortened timers. Verified against the real relay when stage
+ 4a reaches flagship; until then the gate is the list the browser
+ receives and the renewal behaviour under a fake `/ice`.
+4. **Chat and screen share.** `chat.js`, the `chat` and `share`
+ messages with their recipient rules, display-media tracks and
+ renegotiation from either side.
+5. **Drive surface.** `agents`, the five operations, the per-request
+ reach check, the badge.
+6. **Deploy.** Domain, secrets, canary, `PRODUCT.md`, the README with
+ the floor sentence in its first paragraph and the relay paragraph,
+ and the `Consumed suite rulings` commit that suite Appendix A.6 will
+ point at.
---
## 13. Testing
-Go: store and grant tests including expiry and the one-404 rule; hub
-tests for cap, admission, targeted relay, slow subscriber; handler
-tests for every redirect in §3.2 and every refusal in §4.3; the sealing
-grep test from §0.1; the minting vector from §5.2; `make ci` green.
-
-Browser, with chromedp as calendar does, and Chrome's
-`--use-fake-device-for-media-stream` and `--use-fake-ui-for-media-stream`:
-the holding screen enumerates devices and previews; two tabs in one
-headless Chrome complete a call on localhost with host candidates and
-each sees the other's track; a guest knocks and is admitted; chat
-arrives; a share labels a tile; the scriptless holding page renders its
-paragraph. No test touches a relay until one exists.
+**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.
+
+**Browser.** Calendar's chromedp drive is recorded as not running in
+this environment, so Meet does not inherit it; the driver is Playwright
+through the same `npm install --no-save playwright` step calendar's
+Makefile uses, established in slice 2 before any assertion is written.
+Two **isolated browser contexts** with separate sessions, Chrome's
+`--use-fake-device-for-media-stream` and `--use-fake-ui-for-media-stream`.
+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
+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
+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
+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.
---
## 14. Review record
-Revision 1: awaiting Paul's review of the written form and an
-independent review by a model at least as capable as the author, per
-the suite protocol. Neither has happened; this line is the record.
+**Round 1**, GPT-6 via Codex against revision 1 at 3836222:
+`CODEX-FINDINGS-MEET.md`, twenty-one findings, request changes.
+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-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). |
+| R1-5 stale standing | Standing defined, computed per request and per tick, transition table with stream closure and the P2P limit stated (§2.3). |
+| R1-6 agent reach | Token unrevoked and operator `member` on every request (§8.1). |
+| R1-7 return path | rastrillo's single `SignedInPath` acknowledged; pending-join cookie plus `/continue`; no user-supplied destination; other-browser case handled by re-following the link (§3.2). |
+| R1-8 mail budget | Ruling 12: the link is the gate; allowlist exception only with a live pending-join cookie; per-link mail budget; shared `RemoteAddr` noted as a platform question (§3.2). |
+| R1-9 reconnect | Resume with a thirty-second window, admission preserved, no cap consumption, no events; restart behaviour stated (§4.5). |
+| R1-10 offerer ordering | Ruling 13: perfect negotiation; renewal schedule, repeated and failed renewal, suspended timers (§5.1, §5.3). |
+| R1-11 contract claims | Console order; any scheme passed through, credentials on `turn`/`turns`; unit route as Meet's choice (§5.2, §7.3). |
+| R1-12 TURN in the path | Claim made per process; TURN carries encrypted packets; SFU decrypts (§0.1, §5.6, §5.7). |
+| R1-13 the test | WAL before and after checkpoint; captured logger on success and error paths; token-in-flight and signalling-in-flight stated as uncovered (§0.1, §4.4). |
+| R1-14 the floor | Host-candidate reachability, usually a permissive LAN, both false directions named; failure text does not diagnose (§5.5, §0.3). |
+| R1-15 persistence | `persons` is Meet's own, slice 1; attendance dropped from the claim (§0.3, §11); `admitted_at` is audit, not authority (§2.1). |
+| R1-16 scriptless | Exception narrowed to media participation; both pages' scriptless output specified; `AGENTS.md` changed in slice 2 (§3.3, §3.4, §9). |
+| R1-17 confirm pages | Replace-link and end-meeting confirm pages with `ui/confirm-form` in slice 1 (§3.1). |
+| R1-18 show-once | Replacement returns its token once; both token-bearing responses named (§8.2). |
+| R1-19 browser tests | Playwright, isolated contexts, received-track assertions, renewal and share gates assigned to their slices (§13). |
+| 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. Neither has happened; this line is the record.
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-MEET.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-MEET.md
index eac65b8..b7dd7f8 100644
--- a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-MEET.md
+++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-MEET.md
@@ -1,4 +1,4 @@
-# Review brief: Meet design, revision 1
+# Review brief: Meet design (round 2 reviews revision 2 against CODEX-FINDINGS-MEET.md)
You are GPT-6 reviewing an app design spec independently of its author.
Your output is a findings report, not an edit. Do not modify any file.