| 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. |