| 1 | # 🤖 Meet — design |
| 2 | |
| 3 | **2026-09-16 rename:** the app is now **Calls**, module |
| 4 | `amadan.net/oficina/calls`, served at `calls.oficina.works`. Every "Meet" |
| 5 | below should be read as "Calls"; the body is left as written because it |
| 6 | records what was designed on 2026-09-07, and ruling 2's choice of |
| 7 | `oficina/meet` stands as the record of that decision. The route grammar is |
| 8 | unchanged — `/m/{sqid}` and `/meetings/{sqid}` keep their paths, and |
| 9 | "meeting" remains the domain noun. `PRODUCT.md`'s Brand Commitments hold |
| 10 | the current name. |
| 11 | |
| 12 | **2026-09-12 sharing update:** Paul's request for Share in the meeting view |
| 13 | supersedes the invitation-token show-once rule below (§3.1 / ruling 16). |
| 14 | Members may retrieve a current invitation via `POST /m/{sqid}/share`. |
| 15 | Tokens remain hash-indexed, with an encrypted copy under the meeting-link |
| 16 | purpose key. Existing hash-only links are preserved; first sharing creates a |
| 17 | recoverable invitation alongside them. Replacement revokes all live links, |
| 18 | and ending prevents further sharing. Token-bearing responses are `no-store`. |
| 19 | Agent credentials remain hash-only and shown once. |
| 20 | |
| 21 | Design, 2026-09-07. Direction approved by Paul in conversation; this is the |
| 22 | written form. **Revision 9**, 2026-09-10: slice 2 built the room, and |
| 23 | §15 records the ten places where what it built departs from revision 8, |
| 24 | each with the reason it was decided on. Revision 8 was approved by Paul |
| 25 | on 2026-09-08 with one direction added as ruling 15; revision 7 carried |
| 26 | the two corrections of the sixth Codex review |
| 27 | (`CODEX-FINDINGS-MEET-R6.md`, verdict: accept with corrections); §14 has |
| 28 | all six rounds. Drafted by the Meet session (Fable 5.1) from a |
| 29 | brainstorm with Paul on 2026-09-06 and 2026-09-07. Every ruling in §1 |
| 30 | attributed to Paul was given in that conversation or on the discussion |
| 31 | it cites. |
| 32 | |
| 33 | Meet is video calling for the Oficina suite: a member makes a meeting, |
| 34 | now or for later, sends one link, and the people who follow it set up |
| 35 | their camera and microphone, wait to be let in if they are not on the |
| 36 | team, and talk. Calls are browser to browser. The instance carries the |
| 37 | pages, the sign-in, the signalling and the in-call chat, and never a |
| 38 | frame of media. One instance per team, on the CARLOS platform, at |
| 39 | `calls.oficina.works` for the first team. |
| 40 | |
| 41 | Built beside [Docs](https://amadan.net/oficina/docs), |
| 42 | [Calendar](https://amadan.net/oficina/calendar), |
| 43 | [Slides](https://amadan.net/oficina/slides) and the suite |
| 44 | [doctrine](https://amadan.net/oficina/suite). Where a decision is already |
| 45 | made over there, this spec consumes it rather than restating it (§0.2). |
| 46 | |
| 47 | --- |
| 48 | |
| 49 | ## 0. The rules this app lives under |
| 50 | |
| 51 | ### 0.1 The one rule |
| 52 | |
| 53 | **Media never touches this app's process, and nothing said in a call is |
| 54 | kept.** The instance holds a sealed title, the links and grants it |
| 55 | minted, and who admitted whom. Chat, presence and peer state live in |
| 56 | memory for the life of a room and are gone when it ends. |
| 57 | |
| 58 | Three processes can be in the path of a call, and the claim is made per |
| 59 | process: |
| 60 | |
| 61 | - **Meet's own process** relays signalling (SDP, ICE candidates) and chat |
| 62 | between browsers. It reads a message's type, sender and target and |
| 63 | never its payload, and it logs none of it (§4.4). It never receives a |
| 64 | media packet: there is no handler that accepts one. |
| 65 | - **The platform TURN relay** (stage 4a, §5) forwards media packets when |
| 66 | a direct path fails. Those packets are DTLS-SRTP, encrypted between |
| 67 | the browsers, so the relay carries bytes it cannot read. The README's |
| 68 | paragraph that introduces the relay says exactly this. |
| 69 | - **The platform SFU** (stage 4b, §5.7) decrypts media to forward it. |
| 70 | When a team opts in, the claim becomes "media never touches this |
| 71 | app's process", and this spec gets a revision. |
| 72 | |
| 73 | Falsifiable, for the database and the logs: publish the SQLite file, its |
| 74 | WAL, and every log line Meet wrote, and the worst anyone learns is the |
| 75 | shape of the team's meetings and who minted what, never what a meeting |
| 76 | was called, what was said, or what any browser sent as signalling. |
| 77 | Enforced by tests that (a) grep the raw SQLite file and the WAL, before |
| 78 | and after a checkpoint, for a known title; (b) capture the logger during |
| 79 | a call that exercises join, admit, chat, offer, candidate, a refused |
| 80 | message and an error path, and grep the capture for the title, the chat |
| 81 | text, any SDP fragment, any candidate address and any link token. A |
| 82 | link token appears once in an HTTP response body and nowhere else. |
| 83 | |
| 84 | **2026-10-03 (§15.26):** the log now also carries connection-quality |
| 85 | reports and, when someone presses Send, problem reports, by Paul's ruling. |
| 86 | Publish every log line and the worst anyone also learns is that some call, |
| 87 | under a random tag, had so many participations on these platforms with this |
| 88 | loss and these notices between these times — never which meeting, who was in |
| 89 | it, or what was said, except whatever a person types into a problem report's |
| 90 | note, and the device names a problem report's diagnostics carry. Nothing about |
| 91 | a call is stored in the database. §15.26 has the exact list. |
| 92 | |
| 93 | What the claim does not cover, said plainly: signalling passes through |
| 94 | Meet's process in plaintext for the instant it is relayed, plus a |
| 95 | replay ring of at most thirty-two targeted messages per participation |
| 96 | held in memory for at most thirty seconds after its stream drops |
| 97 | (§4.5), and a |
| 98 | capability URL is a bearer that the person who holds it may paste |
| 99 | anywhere. Ruling 16 covers the token at rest, not in flight. |
| 100 | |
| 101 | ### 0.2 Consumed suite rulings |
| 102 | |
| 103 | This app cites the suite sharing doctrine (`oficina/suite`, |
| 104 | `docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md`, revision |
| 105 | 8 at 1ac4323) by ruling number and never restates it. |
| 106 | |
| 107 | | Ruling | Used for | |
| 108 | |---|---| |
| 109 | | 1, 5 | One instance per team; the first deploy is the **lone shape** (§7.1). | |
| 110 | | 3 | §2.3: standing (member, guest, neither) is derived at each request and re-derived on every hub tick. | |
| 111 | | 4, 12 | The home now mints assertions; Meet consumes them through `home/connect`'s bridge, resolving to the existing person and re-checking membership on every request, while the allowlist remains the membership authority throughout (§7.1.1). | |
| 112 | | 6 | §5.2, §5.5: Meet never names a STUN or TURN server of its own; ICE servers come from the platform, or there are none. | |
| 113 | | 9 | §8: a member, a guest who signed in, or an agent. | |
| 114 | | 15 | §3: 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. Ruled for Meet on `oficina/suite` discussion 3, Paul, 2026-09-06. | |
| 115 | | 16 | The token at rest (§3.1), as amended by the 2026-09-12 sharing update at the top of this file: invitation tokens are hash-indexed with a sealed copy so a member can share the current one again; agent credentials stay hash-only and shown once (§15.17). Ruling 16 is on the doctrine's `worktree-worktree-token-at-rest` branch (suite discussion 2), not on main on 2026-09-14; Meet adopts it early so it never has the Docs defect that produced it. | |
| 116 | |
| 117 | Doctrine §5.2 is also relied on, for §2. |
| 118 | |
| 119 | Also consumed: `oficina/suite` discussion 4, for the drive surface |
| 120 | (§8); the CARLOS media roadmap |
| 121 | (`carlos/platform`, |
| 122 | `docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md`, |
| 123 | merged at 8291cb2) and the stage 4a relay spec |
| 124 | (`docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md`, revision |
| 125 | 6, contract frozen 2026-09-07, on platform main at dcf0cad; the relay is |
| 126 | live at `relay.eu-west-1.oncarlos.com:3478` since 2026-09-07) for §5; `carlos/platform` discussion 113 |
| 127 | for the signalling transport (§4.1). |
| 128 | |
| 129 | ### 0.3 What this spec does not claim |
| 130 | |
| 131 | - No claim that a call connects across every network. Without a relay, |
| 132 | browsers gather host candidates only, and the call connects when ICE |
| 133 | finds a working direct path between them (§5.5). With the platform |
| 134 | relay, some networks still fail, and the failed tile says a relay was |
| 135 | in use, not why. |
| 136 | - No claim about the home. Meet is built lone and adopts the home's |
| 137 | assertions when `oficina/home` ships them. |
| 138 | - No claim that the four-participant cap is a product limit. It is the |
| 139 | mesh's limit, lifted by stage 4b. |
| 140 | - No attendance record. Meet does not store who was in a call or for |
| 141 | how long. Revision 1 implied one and had no model for it; revision 2 |
| 142 | drops the implication rather than adding the model. |
| 143 | Since §15.26 (2026-10-03) the log shows that a call, under a random tag, |
| 144 | took place between two times with so many clients in it; never which |
| 145 | meeting or who. |
| 146 | |
| 147 | --- |
| 148 | |
| 149 | ## 1. Settled decisions (don't relitigate casually) |
| 150 | |
| 151 | | # | Ruling | Rejected | Who, when | |
| 152 | |---|---|---|---| |
| 153 | | 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 | |
| 154 | | 2 | **The repo is `oficina/meet`.** English name, like docs, sheets, calendar. | `oficina/conferencia` | Paul, 2026-09-06 | |
| 155 | | 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 | |
| 156 | | 4 | **Scheduling is a start time on the meeting.** The link works whenever a signed-in person follows it; the join page shows when the meeting is meant to start. A guest grant expires two hours after the start time, or two hours after creation when there is no start time. Expiry gates entry; an admitted guest continues on a **participation** (§2.4). | A start-to-end window with a clock-driven state machine; recurring meetings | Paul, 2026-09-07 | |
| 157 | | 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 | |
| 158 | | 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 | |
| 159 | | 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 | |
| 160 | | 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 | |
| 161 | | 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 | |
| 162 | | 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 | |
| 163 | | 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 | |
| 164 | | 12 | **The link is the gate for sign-in.** A magic link is sent to a non-member address only when the browser asking has just followed a live meeting link, and the address is recorded as a pending guest before the mail goes out so the emailed link admits it wherever it is opened. Without a live link, the allowlist gates sign-in exactly as Docs does. | Opening sign-in to any address (revision 1), which opened unsolicited mail and a shared rate-limit budget to anyone | This session, 2026-09-07, on Codex findings R1-8 and R2-1 | |
| 165 | | 13 | **Negotiation is collision-safe**, the W3C perfect-negotiation pattern with politeness decided by peer id order, over an ordered, acknowledged signalling queue per peer pair (§4.6). | A fixed offerer, which cannot renegotiate when the other side adds a track; fire-and-forget POSTs | This session, 2026-09-07, on Codex findings R1-10 and R2-5 | |
| 166 | | 14 | **Entry and participation are two authorities.** Standing (§2.3) decides who may enter a room. A participation (§2.4), created at admission and bound to session and peer, decides who may continue in one. A guest whose grant expires mid-call continues on the participation; a member who loses standing does not. | One authority for both, which either drops an admitted guest at the two-hour mark or lets an expired grant re-enter | This session, 2026-09-07, on Codex finding R2-3 | |
| 167 | | 15 | **The wire contracts are client-neutral.** A suite of fully native apps follows the web suite, so the meeting API (§8.2), the signalling protocol (§4) and `/ice` (§5.2) are written and tested as contracts a native client could speak, with nothing in them that assumes HTML or a browser (§8.4). | Contracts that exist only as what the templates happen to post | Paul, 2026-09-08 | |
| 168 | |
| 169 | --- |
| 170 | |
| 171 | ## 2. The meeting model |
| 172 | |
| 173 | ### 2.1 Tables |
| 174 | |
| 175 | Meet's own, gorm on SQLite through rastrillo, migrations numbered as the |
| 176 | scaffold does. The scaffold's `Note` goes in slice 1; rastrillo/auth |
| 177 | brings its own session and pending-link tables through |
| 178 | `migrate.Merge(sessions.Schema, ...)` as Docs and Slides do. |
| 179 | |
| 180 | **`persons`** — `id`, `email` (unique), `name`, `created_at`. Created on |
| 181 | first sign-in from the session subject, per the rastrillo §5 warning |
| 182 | that the subject is an address, not an id. Standing is not a column |
| 183 | (§2.3). |
| 184 | |
| 185 | **`meetings`** — `id`, `sqid` (public, in URLs), `title_sealed`, |
| 186 | `starts_at` (UTC, nullable), `created_by` (actor, §7.2), `created_at`, |
| 187 | `ended_at` (nullable), `ended_by` (actor, nullable). A meeting without |
| 188 | `starts_at` is "now". A meeting with `ended_at` set refuses joins and |
| 189 | its links answer 404 exactly as a revoked one does. |
| 190 | |
| 191 | **`meeting_links`** — `id`, `meeting_id`, `token_hash` (SHA-256 of the |
| 192 | token, ruling 16), `token_sealed` (nullable; added by the 2026-09-12 |
| 193 | sharing amendment, top of file, so a member can retrieve a current |
| 194 | invitation again), `created_by`, `created_at`, `revoked_at`. One |
| 195 | meeting may hold several links so a revoked one can be replaced. The |
| 196 | token is returned once, in the response that minted it, and again on |
| 197 | demand through `POST /m/{sqid}/share` once a sealed copy exists. |
| 198 | |
| 199 | **`pending_joins`** — `id`, `meeting_id`, `link_id` (the |
| 200 | `meeting_links` row the browser followed), `address_fingerprint` (keyed |
| 201 | HMAC of the address under the sealer's purpose key, so the table never |
| 202 | holds a plaintext outsider address, calendar's rule for attendees), |
| 203 | `created_at`, `expires_at` (fifteen minutes). Written at `POST /signin` |
| 204 | when anyone, member or outsider, asks for a magic link while holding a |
| 205 | live meeting link (§3.2); read by the sign-in authorize callback (for |
| 206 | outsiders) and by `/continue` (for everyone); deleted when consumed or |
| 207 | expired. A restart does not lose it, so a magic link clicked after a |
| 208 | deploy still works. |
| 209 | |
| 210 | **`guest_grants`** — `id`, `meeting_id`, `person_id`, `granted_at`, |
| 211 | `expires_at`, `admitted_at` (nullable), `admitted_by` (actor, |
| 212 | nullable). Minted at sign-in continuation when a non-member follows a |
| 213 | live link (§3.2), or from a `pending_joins` row at `/continue`. One row |
| 214 | per person per meeting; following the link twice touches nothing. `admitted_at` and `admitted_by` are an audit of |
| 215 | the first admission and confer nothing: whether a guest is admitted |
| 216 | *now* is hub state (§4.2). |
| 217 | |
| 218 | **`agents`** — slice 5, §8.1. |
| 219 | |
| 220 | Sealing reuses calendar's `internal/calendar/seal`, which is itself the |
| 221 | Sheets sealer's second home: one DEK from platform config (`CALLS_DEK`), |
| 222 | HKDF purpose keys, AES-GCM, and the DEK never written to the database it |
| 223 | opens. Calendar's `seal.go` names the third app as the extraction |
| 224 | trigger. Meet is that third app; the plan carries the extraction task |
| 225 | calendar already defined rather than a new one. |
| 226 | |
| 227 | ### 2.2 Nothing else at rest |
| 228 | |
| 229 | Rooms, peers, admission, chat and presence live in the hub (§4.2) and |
| 230 | die with the process. A process restart empties every room; §4.5 says |
| 231 | what browsers do about it. |
| 232 | |
| 233 | Still nothing at rest in the database. Since 2026-10-03 the app's log is a |
| 234 | store of its own for quality and problem reports, under random tags, kept for |
| 235 | the platform's log retention (seven days); §15.26. |
| 236 | |
| 237 | ### 2.3 Standing: who may enter |
| 238 | |
| 239 | A person's **standing** toward a meeting is one of `member`, `guest` |
| 240 | (holds a grant for this meeting that has not expired), or `none`. It is |
| 241 | computed, never stored: |
| 242 | |
| 243 | - `member`: the address is on the allowlist (lone shape) or the roster |
| 244 | copy at its current epoch (suite shape, when the home exists). Both |
| 245 | are reads of the instance's own state, which is what doctrine ruling |
| 246 | 3 and §5 call the roster; neither is a cached answer from another |
| 247 | service. |
| 248 | - `guest`: a `guest_grants` row for this person and meeting with |
| 249 | `expires_at` in the future. The row is the instance's own authority |
| 250 | under doctrine §5.2. |
| 251 | |
| 252 | Standing is the entry authority. §2.5 says which endpoints check it, |
| 253 | and it is computed on each request and never cached. |
| 254 | |
| 255 | ### 2.4 Participation: who may continue |
| 256 | |
| 257 | A **participation** is the hub's record that a peer is in a room: |
| 258 | `(room, peer id, session id, person id, kind, admitted, joined_at)`, |
| 259 | created when a stream opens and made `admitted` by a member's `join` or |
| 260 | an `admit`. It is the authority for everything a peer does after entry: |
| 261 | `POST /m/{sqid}/signal`, a stream resume (`GET /m/{sqid}/stream?peer=`), |
| 262 | and `GET /m/{sqid}/ice` are authorized by a participation bound to the |
| 263 | requesting session, not by standing. So an admitted guest whose grant |
| 264 | expires at the two-hour mark keeps chatting, renegotiating, resuming its |
| 265 | stream and renewing its relay credential, because none of those is an |
| 266 | entry. |
| 267 | |
| 268 | A participation ends, and its stream is closed with a terminal `lost`, |
| 269 | on: `leave`; a member's removal of it (§3.5); the meeting ending; the |
| 270 | resume window lapsing (§4.5); or, for a **member** only, loss of |
| 271 | standing (below). It is never ended by a guest's grant expiring. |
| 272 | |
| 273 | **Kind** (`member` or `guest`) is re-derived from standing by the hub on |
| 274 | every heartbeat tick (15 s, §4.1) for every participation, and the |
| 275 | transitions are: |
| 276 | |
| 277 | | Was | Now | Effect | |
| 278 | |---|---|---| |
| 279 | | `none` | `member` (added to allowlist) | Next entry succeeds. Nothing in the hub; they were not in it. | |
| 280 | | `member` kind, in a room | standing `none` (removed from allowlist) | Every request 404s. On the next tick: `lost {reason: "standing"}` to the peer, stream closed, participation ended, `gone` to admitted peers. The other browsers close their connection to it on `gone` (§4.5 says how a browser that missed the `gone` still converges). Meet cannot tear down a P2P connection it is not party to, and says so. | |
| 281 | | `member` kind | standing `guest` (removed, live grant) | Participation continues, kind becomes `guest`, `roster` refreshed to admitted peers. May no longer admit or remove anyone. | |
| 282 | | `guest` kind, admitted | standing `member` (added) | Continues, kind becomes `member`, `roster` refreshed. May now admit and remove. | |
| 283 | | `guest` kind, waiting | standing `member` (added) | On the tick, the hub attempts admission under the cap exactly as an `admit` would: admitted, `roster` to them and `peer` to the others; or `full` to them and they stay waiting, now as a member, and may retry with `join`. | |
| 284 | | `guest` kind, admitted | grant expires | **Nothing.** The participation is the authority now (ruling 14). | |
| 285 | | `guest` kind, waiting | grant expires | On the tick, `lost {reason: "expired"}`, stream closed, participation ended, `knock-gone` to members. | |
| 286 | | `guest` kind, admitted | leaves and returns after the resume window | Standing is checked at the new entry; an expired grant means 404 at `/m/{sqid}/join`, which is ruling 4. | |
| 287 | | any | meeting ended | `ended` to every stream, all closed, every participation ended, room dropped. | |
| 288 | |
| 289 | The roster copy's freshness in suite shape is the doctrine's epoch rule |
| 290 | (§5); Meet re-reads its copy on every tick like any other request and |
| 291 | adds no cache of its own. |
| 292 | |
| 293 | --- |
| 294 | |
| 295 | ### 2.5 The authorization contract, per endpoint |
| 296 | |
| 297 | One rule per endpoint, so §4 and §5 do not each restate it: |
| 298 | |
| 299 | | Endpoint | Authority | Checked | |
| 300 | |---|---|---| |
| 301 | | `GET /`, `POST /meetings`, the replace-link and end-meeting confirm pages and their POSTs | **Current member standing** | On the request. | |
| 302 | | `/m/{sqid}/remove/{peer}`, confirm page and POST | An **admitted** participation for this session in this room with **current** `member` kind | On the request, at execution. | |
| 303 | | `/m/{sqid}/takeover`, confirm page and POST | Standing, **and** a live participation for this session in this room that belongs to another stream (§4.2) | On the request, at execution, under the hub lock. | |
| 304 | | `GET /m/{sqid}/join`, `GET /m/{sqid}` | Standing (`member` or `guest`) **or** a live participation for this session in this room. §3.3 and §3.4 defer to this row. | On the request. | |
| 305 | | `GET /m/{sqid}/stream` with no `peer` | Standing | On the request. Creates an unjoined participation. | |
| 306 | | `GET /m/{sqid}/stream?peer=` | Participation bound to this session | On the request. | |
| 307 | | `POST /m/{sqid}/signal`, `join` | Participation, **and** standing at that moment, because `join` is the entry | On the request, under the hub lock. | |
| 308 | | `POST /m/{sqid}/signal`, `admit` | An **admitted** participation with **current** `member` kind, re-derived from standing on this request, not only on the tick; **and** the target's entry authority (`member` standing or an unexpired grant) at this moment, under the same lock as the transition | On the request. | |
| 309 | | `POST /m/{sqid}/signal`, `leave` | Participation, any state | On the request. A waiting guest may leave. | |
| 310 | | `POST /m/{sqid}/signal`, every other type; `GET /m/{sqid}/ice` | Participation, admitted | On the request. Standing is not consulted **for a guest**; a guest's expired grant does not matter here (ruling 14). A participation whose kind is `member` re-derives standing, per the paragraph below (§15.12). | |
| 311 | | `/api/v1/*` | Agent token unrevoked **and** operator's current member standing | On the request. | |
| 312 | |
| 313 | A request by a participation whose kind is `member` re-derives standing |
| 314 | on every request as well as on the tick; a member who has just been |
| 315 | removed is dropped by the next thing they do, not the next tick. The |
| 316 | tick (§2.4) exists for the quiet case. Promotion of a waiting guest on |
| 317 | the tick (§2.4) goes through the same admission check as `admit`, |
| 318 | including the target's entry authority, so a guest whose grant has |
| 319 | expired is never admitted by either path; only an already-admitted |
| 320 | guest survives expiry. |
| 321 | |
| 322 | --- |
| 323 | |
| 324 | ## 3. Joining |
| 325 | |
| 326 | ### 3.1 Making a meeting and its link |
| 327 | |
| 328 | `GET /` lists the team's meetings, upcoming first, ended ones in a |
| 329 | collapsed section. `POST /meetings` takes a title and an optional start |
| 330 | time and creates the meeting and its first link in one transaction. The |
| 331 | response page shows the link once, with a copy button when scripts are |
| 332 | on and a selectable text field when they are off. |
| 333 | |
| 334 | Replacing a link revokes every other link for the meeting, which is |
| 335 | destructive, so it has its own confirm page: `GET |
| 336 | /meetings/{sqid}/links/replace` renders `ui/confirm-form` naming what |
| 337 | will stop working; `POST /meetings/{sqid}/links` does it and shows the |
| 338 | new link once. Ending a meeting is the same shape: `GET |
| 339 | /meetings/{sqid}/end` confirms, `POST /meetings/{sqid}/end` ends. Both |
| 340 | are members-only. |
| 341 | |
| 342 | Links are `https://<host>/j/<token>`, with a token of 32 random bytes, |
| 343 | base64url. The route is mounted outside the session group like |
| 344 | calendar's feed route, because the person following it is by definition |
| 345 | not signed in yet. |
| 346 | |
| 347 | ### 3.2 Following a link |
| 348 | |
| 349 | `GET /j/{token}` looks the hash up. Unknown, revoked, ended or expired |
| 350 | gives one 404 for all four. Live gives one of three outcomes: |
| 351 | |
| 352 | - **A signed-in member** is redirected to the holding screen, |
| 353 | `/m/{sqid}/join`. |
| 354 | - **A signed-in non-member** gets a grant minted now if they have none, |
| 355 | then the holding screen. |
| 356 | - **Nobody signed in**: Meet sets a **pending-join cookie** and |
| 357 | redirects to `/signin`. The cookie holds the link's `token_hash` and |
| 358 | an expiry fifteen minutes out, HMAC-signed under the instance key, |
| 359 | `HttpOnly`, `Secure`, `SameSite=Lax`, path `/`. It carries the hash, |
| 360 | not the token, so a cookie leak is not a link leak. Its only job is |
| 361 | to tell `POST /signin` which link this browser followed. |
| 362 | |
| 363 | **Asking for the magic link** (ruling 12). Docs' `auth.go` gates `POST |
| 364 | /signin` on the allowlist before rastrillo's `Begin`. Meet keeps that |
| 365 | gate with one exception: an address not on the allowlist is let through |
| 366 | when, and only when, the request carries a valid pending-join cookie for |
| 367 | a live link. Whenever the cookie is present and valid, for a member |
| 368 | address or an outsider's, Meet first writes a `pending_joins` row |
| 369 | (the **link** the cookie names, its meeting, the keyed fingerprint of |
| 370 | the address, fifteen minutes) and only then calls `Begin`. Nobody can |
| 371 | cause mail to an arbitrary address without holding a live meeting |
| 372 | link, and holding the link is the team's own choice. |
| 373 | |
| 374 | **Admitting the emailed link.** rastrillo's `Authorize` callback |
| 375 | receives the verified address and nothing else, and Docs wires it to |
| 376 | the allowlist, which would 403 the outsider's click. Meet's `Authorize` |
| 377 | is: the address is on the allowlist, **or** its keyed fingerprint has an |
| 378 | unexpired `pending_joins` row whose link is still live. This works in |
| 379 | whichever browser the emailed link is opened, because the row was |
| 380 | written before the mail went out and lives in the database, not in a |
| 381 | cookie. |
| 382 | |
| 383 | **Continuation.** rastrillo has one configured landing path for a fresh |
| 384 | session and no per-attempt return value, so Meet's `SignedInPath` is |
| 385 | `/continue`. `GET /continue` resolves the signed-in person, then: |
| 386 | |
| 387 | 1. If one or more `pending_joins` rows match their fingerprint and are |
| 388 | unexpired, take the **most recent**. In one transaction: re-check |
| 389 | that its link is not revoked, its meeting not ended, and the row not |
| 390 | expired, exactly as `GET /j/{token}` would (so a link replaced during |
| 391 | sign-in is refused even though the meeting has a new live link); |
| 392 | mint the grant if they are a non-member and hold none; delete every |
| 393 | pending row for this fingerprint; redirect to the holding screen. If |
| 394 | the re-check fails, delete the rows and fall through to 2 or 3. The |
| 395 | pending-join cookie, if present, is cleared and never consulted; the |
| 396 | row is the record. |
| 397 | |
| 398 | Two refusals, two outcomes, stated so the tests assert the right |
| 399 | thing: an **outsider** whose only pending row's link was replaced |
| 400 | between mail and click is refused earlier, at `Authorize`, and the |
| 401 | inherited rastrillo handler answers **403**; a **signed-in person** |
| 402 | whose row fails the re-check at `/continue` gets `/` or `/welcome`. |
| 403 | Neither path mints a grant. Meet does not change the inherited 403. |
| 404 | 2. Otherwise, if they are a member: redirect to `/`, the meeting list. |
| 405 | A member who opened `/signin` directly lands where they expect. |
| 406 | 3. Otherwise: redirect to `/welcome`, the not-a-member page: "You're |
| 407 | signed in as <address>. This team's Meet has no meetings for you. |
| 408 | If someone sent you a meeting link, open it again now." with sign-out |
| 409 | and nothing else. Scriptless. |
| 410 | |
| 411 | A member who followed a link while signed out therefore reaches the |
| 412 | holding screen of that meeting, in whichever browser the emailed link |
| 413 | is opened, by the same row an outsider uses. The rows differ only in |
| 414 | what they authorize: for an outsider the row also admits the address at |
| 415 | `Authorize`; for a member the allowlist does that and the row only |
| 416 | carries the destination. |
| 417 | |
| 418 | **Mail budget, and what it does not buy.** The inherited limiter allows |
| 419 | twenty attempts per client address and five per email address per |
| 420 | fifteen minutes, keyed on `RemoteAddr`, which behind the platform edge |
| 421 | is the edge, so that budget is shared by every visitor. Meet bounds |
| 422 | what it can bound: attempts for a **non-allowlisted** address are |
| 423 | counted before `Begin` is reached, at five per link per fifteen minutes |
| 424 | and ten across all links per fifteen minutes. That stops a leaked link |
| 425 | from being used to mail strangers at volume. |
| 426 | |
| 427 | It does not protect members, and revision 3's claim that it did was |
| 428 | wrong: anyone can submit a **member's** address without any link, |
| 429 | reach `Begin`, and consume the shared client-address allowance twenty |
| 430 | times in fifteen minutes, or a single member's five-per-address |
| 431 | allowance, after which that member cannot sign in for the rest of the |
| 432 | window. The gate classifies an address, not the person typing it, and |
| 433 | nothing in front of `Begin` can tell them apart. This is the residual |
| 434 | denial of service for sign-in on every app that shares this edge and |
| 435 | this limiter, not a Meet-specific one, and Meet records it rather than |
| 436 | claiming a reserve. The fix is a trusted client address from the edge |
| 437 | or a per-app limiter keyed on something better, and a question to that |
| 438 | effect is filed on the platform when slice 1 opens. |
| 439 | |
| 440 | **Responses.** Meet's own refusals (allowlist without a link, link |
| 441 | budget) redirect to `/signin?sent=1`, the same destination as a |
| 442 | successful `Begin`. rastrillo's own outcomes are left as they are: |
| 443 | `?err=address` for a malformed address and `?err=rate` for its limiter. |
| 444 | The residue, stated plainly: while a budget is exhausted, a member |
| 445 | address gets `?err=rate` where an unknown address without a link gets |
| 446 | `?sent=1`, which distinguishes the two; and because a single member |
| 447 | address can be rate-limited on purpose, that distinction is reachable |
| 448 | by an attacker at will, not only under global exhaustion. Membership of |
| 449 | a specific address is therefore not a secret Meet can keep at the |
| 450 | sign-in form, and the README says so. |
| 451 | |
| 452 | ### 3.3 The holding screen |
| 453 | |
| 454 | `/m/{sqid}/join` is authorized per §2.5 and is the page where a call |
| 455 | needs scripts. Rendered server-side it shows the meeting's title, its |
| 456 | start time if it has one, the person's own name and standing, the floor |
| 457 | sentence from §5.5 when it applies, links to the meeting list and, for |
| 458 | members, to the end-meeting confirm page. With scripts on it adds the |
| 459 | camera preview, device pickers for camera, microphone and speaker, mute |
| 460 | toggles, and the join button. Choices persist in the browser's local |
| 461 | storage per device id; nothing of them reaches the server. With scripts |
| 462 | off the join button is replaced by one paragraph saying that a call |
| 463 | needs JavaScript to reach the camera and the other participants, and |
| 464 | that everything else on this page works without it (§9). |
| 465 | |
| 466 | Pressing join opens the stream (§4.1), receives its peer id, and posts |
| 467 | `join`. A member enters. A guest sees "waiting for someone to let you |
| 468 | in" until an admitted member posts `admit` for them; closing the tab |
| 469 | leaves the queue. Every admitted member sees the knock with the guest's |
| 470 | name and one button. |
| 471 | |
| 472 | ### 3.4 The room |
| 473 | |
| 474 | `/m/{sqid}` renders, with scripts on, the grid of participants, own |
| 475 | controls (mute, camera, share screen, leave), the chat panel and the |
| 476 | admission queue. With scripts off it renders the same header as the |
| 477 | holding screen, the same paragraph, and a link to the holding screen; |
| 478 | nothing on it pretends to be a call. Ending a meeting from inside the |
| 479 | room goes through the §3.1 confirm page in a new tab; there is no |
| 480 | in-room destructive control that skips it. Leaving is a `leave` message |
| 481 | and the tab closing; a stream closed for thirty seconds without a |
| 482 | resume (§4.5) is also a leave. |
| 483 | |
| 484 | ### 3.5 Removing a participant |
| 485 | |
| 486 | A member can remove another participant, waiting or admitted. It |
| 487 | disconnects a person, so it has its own confirm page: `GET |
| 488 | /m/{sqid}/remove/{peer}` renders `ui/confirm-form` with the person's |
| 489 | name; `POST /m/{sqid}/remove/{peer}` checks, at execution, that the |
| 490 | actor holds an admitted `member` participation in this room and that |
| 491 | the target participation exists in this room and is not the actor's |
| 492 | own, then ends it: `lost {reason: "removed"}` to the target, its stream |
| 493 | closed, and `gone` to admitted peers or `knock-gone` to members as |
| 494 | appropriate. The in-room control is a link to the confirm page, opened |
| 495 | in a new tab like ending a meeting. Removal is not a signalling |
| 496 | message. |
| 497 | |
| 498 | --- |
| 499 | |
| 500 | ## 4. Signalling |
| 501 | |
| 502 | ### 4.1 Transport |
| 503 | |
| 504 | One SSE stream per peer, `GET /m/{sqid}/stream`, authorized per §2.5, |
| 505 | with the Docs live-sync heartbeat (15 s) and |
| 506 | connection TTL (5 min) so hibernation cannot be held off by a dead |
| 507 | stream. The first event on a new stream is `hello {peer}`, the server's |
| 508 | peer id for this stream. Upstream messages are `POST /m/{sqid}/signal` |
| 509 | with a JSON body carrying `peer` and the message, under CSRF. |
| 510 | |
| 511 | A WebSocket would fit this shape better and is deliberately not used: |
| 512 | Docs live-sync §3 lists the costs, and platform discussion 113 confirmed |
| 513 | that no upgrade crosses a tunnel-backed host and no placement signal |
| 514 | exists. `rastrillo/rastrillo` discussion 17 records the intent to build |
| 515 | one when a second consumer appears. |
| 516 | |
| 517 | ### 4.2 The hub and peer identity |
| 518 | |
| 519 | An in-process room hub in the shape of Sheets' `live/hub.go`, with a |
| 520 | room admission policy the Sheets hub does not need. Per room it holds |
| 521 | the set of peers; per peer: `peer` id, `session` id, `person` id, |
| 522 | standing as last computed, `admitted` (bool), `joined_at`, the |
| 523 | subscriber channel, and `resume_until` when the stream is closed. |
| 524 | |
| 525 | **A peer id is issued by the server** when a stream opens and is bound |
| 526 | to that session and that room, creating a participation (§2.4) that is |
| 527 | not yet joined. A room holds at most **sixteen participations** of any |
| 528 | state (unjoined, waiting, admitted, or held for resume); a stream that |
| 529 | would be the seventeenth is refused with 503 before anything is |
| 530 | allocated. An unjoined participation that has not posted `join` within |
| 531 | thirty seconds is dropped with its stream. |
| 532 | |
| 533 | The peer id is a 16-byte random value, shown in rosters so browsers can |
| 534 | address each other, and it is not a credential: |
| 535 | every `POST /signal` must carry a `peer` that the hub finds **in this |
| 536 | room, bound to this session**; anything else is refused with 404 and |
| 537 | nothing is relayed. A session may hold one peer per room. A stream |
| 538 | opened with `?peer=` resumes that participation (§4.5). A stream opened |
| 539 | without `peer` while the session already has a live participation in |
| 540 | the room (attached or within its resume window) is refused with |
| 541 | **409** and a body naming the existing peer. A `GET` never ends a |
| 542 | participation, and there is no query parameter that makes it do so. |
| 543 | |
| 544 | **Takeover** is the one way a person moves a room from one tab to |
| 545 | another, and it is a confirmed POST like every other destructive |
| 546 | action: `GET /m/{sqid}/takeover` renders `ui/confirm-form` ("This |
| 547 | meeting is open in another tab. Use this one instead?"); `POST |
| 548 | /m/{sqid}/takeover`, under CSRF, checks at execution that this session |
| 549 | holds a live participation in the room, then under the hub lock |
| 550 | **ends** it with the same state-dependent departure `leave` uses: an |
| 551 | admitted peer yields `gone` to admitted peers; a waiting one yields |
| 552 | `knock-gone` to admitted members; an unjoined one yields nothing. The |
| 553 | old stream, if attached, gets `lost {reason: "replaced"}`, and the old |
| 554 | peer id is remembered as replaced for ten minutes (§4.5). The response |
| 555 | redirects to the room, whose fresh stream now opens without conflict |
| 556 | and proceeds as a new `join`. If another tab opens a stream between the |
| 557 | POST and that fresh stream, the fresh one gets 409 and the person sees |
| 558 | the button again; nothing automatic ever displaces a tab, and a |
| 559 | cross-site navigation cannot end a call because the ending is a POST |
| 560 | behind CSRF. |
| 561 | |
| 562 | Every `hello` carries `boot`, a random id chosen when the process |
| 563 | started, so a browser can tell a restart from any other refusal (§4.5). |
| 564 | |
| 565 | Every downstream envelope is built by the server: `type`, `from` (the |
| 566 | sender's peer id, from the binding, never from the body), the sender's |
| 567 | display name where the type carries one, and the opaque `payload`. A |
| 568 | target `to` must name an admitted peer in the same room, or the message |
| 569 | is refused. Cross-room delivery is impossible by construction because |
| 570 | lookups start from the room in the URL. |
| 571 | |
| 572 | The hub is per process, which is fine on one host and would not survive |
| 573 | a multi-host fleet; that is noted, not solved. |
| 574 | |
| 575 | ### 4.3 Messages |
| 576 | |
| 577 | | Type | Who may send | Recipients | Server does | |
| 578 | |---|---|---|---| |
| 579 | | `join` | a participation whose person has standing now (§2.5) | — | Member: admit (subject to cap), `roster` to the joiner, `peer` to admitted peers. Guest: mark waiting, `knock` to admitted **members**. Idempotent for a peer already present. | |
| 580 | | `admit {peer}` | an admitted participation with current `member` kind (§2.5) | — | Target must be waiting in this room and hold entry authority now (§2.5). Under the hub lock: if the target's grant has expired, `lost {reason: "expired"}` to it and `knock-gone` to members; if admitted count is four, `full` to the admitting member and nothing changes; else mark admitted, `roster` to the guest, `peer` to the other admitted peers, `knock-gone` to members. | |
| 581 | | `leave` | any peer | — | Remove the peer. Admitted: `gone` to admitted peers. Waiting: `knock-gone` to members. | |
| 582 | | `offer`, `answer`, `candidate` | an admitted peer | the one admitted peer named in `to` | Refuse with 409 if `gen` is not the pair's current generation (§4.6); otherwise relay the payload unread and keep a copy in the target's replay ring. | |
| 583 | | `reset {to}` | an admitted peer | both peers of the pair | Under the hub lock, increment the pair's generation and send `pair {peer, gen}` to both, into their replay rings. Idempotent in effect: two resets yield one higher generation both sides converge on. | |
| 584 | | `chat {text}` | an admitted peer | all admitted peers | Fan out with the sender's name. 2 KB. Not stored. | |
| 585 | | `share {on}` | an admitted peer | all admitted peers | Fan out so tiles can be labelled. | |
| 586 | | `media {muted, cameraOff, audioBlocked?, noSoundFrom?}` (2026-09-23, §15.19; 2026-09-25, §15.20) | an admitted peer | all other admitted peers | Record on the participation, carry in every later `roster` and `peer`; fan out only a change. | |
| 587 | | `hello`, `roster`, `peer`, `gone`, `knock`, `knock-gone`, `full`, `ended`, `lost`, `pair`, `gap`, `state` | server | as named | `roster`, `peer`, `pair` and `gap` go only to admitted peers; a waiting guest receives `hello`, `state`, then `roster` on admission, or `lost`, or `ended`, and nothing else. | |
| 588 | |
| 589 | `offer`, `answer` and `candidate` also carry `seq` and `gen` (§4.6); |
| 590 | `seq` is relayed unchanged and `gen` is checked. |
| 591 | |
| 592 | **`roster`** is the authoritative snapshot: every admitted peer with its |
| 593 | id, name, kind and the recipient's current **pair generation** with it |
| 594 | (§4.6), and, when the recipient is a member, the **waiting queue** too. |
| 595 | `peer` carries the same fields for one peer. It is sent on admission, on resume (§4.5), and to every |
| 596 | admitted peer whenever any participation's kind changes (§2.4). A |
| 597 | member who joins a room where a guest is already waiting therefore sees |
| 598 | the knock in the roster, not in a `knock` that had no recipient; `knock` |
| 599 | is the incremental form for members already present. |
| 600 | |
| 601 | **`lost`** carries a reason, and §4.5 says what the browser does with |
| 602 | each. |
| 603 | |
| 604 | **The cap** is enforced at every transition into the admitted state, |
| 605 | under the hub lock: a member's `join`, an `admit`, and never on a resume |
| 606 | (§4.5), which is not a transition. Five members joining at once get four |
| 607 | admissions and one `full`; a member who sees `full` can wait and retry. |
| 608 | Queued guests can exceed four while waiting; admission checks the count |
| 609 | at admission time, so four queued guests and one member yield three |
| 610 | admissions and one `full`. |
| 611 | |
| 612 | The server validates type and shape and the sender's authority per |
| 613 | §2.5, and reads nothing inside `offer`, `answer`, `candidate` or |
| 614 | `chat` beyond the byte length. |
| 615 | |
| 616 | ### 4.4 Limits and logging |
| 617 | |
| 618 | - `POST /signal` bodies are refused above 64 KB before decoding. An SDP |
| 619 | offer with several tracks is well under that. |
| 620 | - `chat` text above 2 KB is refused. |
| 621 | - A peer may send thirty signal messages per second with a burst of |
| 622 | sixty; beyond that the hub replies 429 with `Retry-After`, and the |
| 623 | browser's queue (§4.6) honours it and retries; a legitimate |
| 624 | negotiation burst across three targets can touch the limit briefly |
| 625 | and loses nothing. |
| 626 | - A room holds at most sixteen participations in any state (§4.2) and at |
| 627 | most eight waiting; a ninth waiting `join` is refused with `full`. |
| 628 | - A session holds one peer per room (§4.2); a person holds at most four |
| 629 | concurrent streams across all rooms. An unjoined participation lives |
| 630 | thirty seconds; a resume-held one thirty seconds (§4.5). |
| 631 | - A subscriber whose channel holds 64 undelivered events is declared |
| 632 | lost, told so, and closed, as in Sheets. |
| 633 | - The hub logs the type, room sqid, peer id and outcome of a refused |
| 634 | message, and nothing of any accepted one. No payload, chat text, SDP, |
| 635 | candidate, display name, title or link token is ever a log argument; |
| 636 | the §0.1 test captures the logger to prove it. |
| 637 | |
| 638 | ### 4.5 Renewal, resume and restart |
| 639 | |
| 640 | The five-minute TTL and any dropped stream are handled by **resume**, |
| 641 | not by rejoining. When a stream closes, the participation stays in the |
| 642 | hub for a thirty-second `resume_until` with its admission intact. The |
| 643 | browser reopens `GET /m/{sqid}/stream?peer=<id>&last=<json>`, where |
| 644 | `last` maps each sender's peer id to the highest `seq` this browser has |
| 645 | received from it. The hub finds the participation bound to this session |
| 646 | and attaches the new stream. What it sends next depends on admission: |
| 647 | |
| 648 | - **Admitted**: `hello`, then a fresh `roster` (§4.3), then a **replay** |
| 649 | of every targeted message (`offer`, `answer`, `candidate`, `pair`) |
| 650 | held for this participation in its replay ring with `seq` above |
| 651 | `last`, or, for `pair`, with a generation above the roster's. The |
| 652 | ring holds the last thirty-two targeted messages sent to each |
| 653 | participation, for the resume window only, in memory, and is dropped |
| 654 | with the participation; that is the one place Meet's process holds |
| 655 | signalling for longer than the instant of relay, bounded by thirty |
| 656 | seconds and thirty-two messages, and it is stated in §0.1. If a |
| 657 | sender's messages above `last` are no longer all in the ring, the hub |
| 658 | sends `gap {from}` instead of a partial replay, and the browser |
| 659 | performs a **pair reset** with that sender (§4.6). |
| 660 | - **Waiting or unjoined**: `hello`, then `state {waiting: true}`, and |
| 661 | nothing else. A waiting guest learns nothing about the room by |
| 662 | resuming, which is ruling 9's isolation carried through the resume |
| 663 | path. |
| 664 | |
| 665 | The browser **reconciles** against the roster it received: a peer |
| 666 | connection to an id not in the roster is closed, an id in the roster |
| 667 | with no connection gets one. That is how a browser that missed a `gone` |
| 668 | while its buffer overflowed still closes the removed peer's connection: |
| 669 | the roster on resume is the authority, not the events it missed. No |
| 670 | event goes to anyone else; the other browsers' peer connections were |
| 671 | never affected because media is P2P. A resume does not consume a cap |
| 672 | slot. A participation that misses the window is removed as a leave. |
| 673 | |
| 674 | **A refused resume** says why, in the status and a JSON body, and the |
| 675 | browser's response is fixed per case: |
| 676 | |
| 677 | | Resume answer | Meaning | Browser does | |
| 678 | |---|---|---| |
| 679 | | `410 {reason: "replaced"}` | This participation was ended by a takeover from another tab on this session. The hub remembers ended-by-takeover peer ids for ten minutes so a tab that never received `lost {reason: "replaced"}` learns it here. No successor peer is named: the takeover's own stream may not have opened yet, and terminal handling keys on `reason` alone. | Terminal: close every peer connection, show "This meeting is open in another tab" and a link to the takeover confirm page (§4.2). Never automatic. | |
| 680 | | `410 {reason: "removed" \| "expired" \| "standing" \| "ended"}` | Ended for that reason; remembered ten minutes. | Terminal, show why. | |
| 681 | | `404 {boot: <id>}` with a **different** `boot` from the last `hello` | The process restarted. | Close every peer connection, open a fresh stream without `peer`, `join` again. | |
| 682 | | `404 {boot: <id>}` with the **same** `boot` | The resume window lapsed and nothing else happened. | Close every peer connection, open a fresh stream without `peer`, `join` again. If that fresh stream is refused with 409, another tab owns the room now: show the take-over button, do nothing automatic. | |
| 683 | | `409 {peer: <id>}` on a fresh stream | This session already holds a live participation in the room (§4.2). | Show the link to the takeover confirm page. | |
| 684 | |
| 685 | `lost` reasons on a live stream follow the same split: `buffer` is |
| 686 | followed by a resume; `replaced`, `standing`, `expired`, `removed` and |
| 687 | `ended` are terminal and the browser closes every peer connection and |
| 688 | shows why. A displaced tab therefore never reclaims a room by itself, |
| 689 | whether or not its `replaced` notice was delivered: the 410 on resume |
| 690 | and the 409 on fresh entry both stop it, and only the confirmed POST |
| 691 | moves on. |
| 692 | |
| 693 | A **process restart** empties the hub and changes `boot`. Every |
| 694 | browser's resume is refused with a 404 carrying the new `boot`; the |
| 695 | browser closes every peer connection first, then joins afresh. Members |
| 696 | re-enter at once and negotiate afresh on the new `peer` events. Guests |
| 697 | return to waiting and must be admitted again, and because their |
| 698 | browsers closed their connections on the refusal, no guest keeps media |
| 699 | flowing from before the restart. That is the honest cost of keeping |
| 700 | admission in memory, stated rather than hidden behind persistence. |
| 701 | |
| 702 | ### 4.6 Delivery: ordered, acknowledged, replayed |
| 703 | |
| 704 | SSE plus POST is not a session; the browser and the hub make it one. |
| 705 | For each target peer the browser keeps an **outbound queue** and sends |
| 706 | one message at a time, in order, waiting for the POST's 2xx before the |
| 707 | next. Each `offer`, `answer` and `candidate` carries a `seq`, per |
| 708 | (sender, target), starting at one and incrementing **for the life of |
| 709 | the sender's participation, never reset**, across pair generations |
| 710 | included; the server relays `seq` unchanged and keeps a copy in the |
| 711 | target's replay ring (§4.5). Because `seq` is monotonic, the resume |
| 712 | cursor `last` needs no generation: a new generation's first offer has |
| 713 | a higher `seq` than anything the receiver saw before its stream |
| 714 | dropped, so replay delivers it, and `gen` decides only whether a |
| 715 | message is current. A |
| 716 | POST that fails (network, 5xx, 429) is retried with backoff, same |
| 717 | `seq`, up to thirty seconds, then the browser treats the target as |
| 718 | unreachable, closes that connection, and re-creates it on the next |
| 719 | `roster`. The receiver keeps the last `seq` seen per sender and drops |
| 720 | anything at or below it, so a retried POST that had in fact landed is |
| 721 | harmless, and a replay on resume delivers only what was missed. |
| 722 | |
| 723 | **Pair generations** are how a pair recovers when replay cannot fill a |
| 724 | gap, and they are held by the hub, not negotiated between browsers. |
| 725 | Every unordered pair of admitted peers has a generation, starting at |
| 726 | one when the pair first appears in a roster or `peer` event, which |
| 727 | carry it. Every `offer`, `answer` and `candidate` carries `gen`; the |
| 728 | hub refuses a stale `gen` with 409 and relays a current one. A browser |
| 729 | that needs a fresh connection with a peer, because it received `gap |
| 730 | {from}` or because its outbound queue to that peer timed out (§4.6 |
| 731 | above), posts `reset {to}`; the hub increments the pair's generation |
| 732 | under its lock and sends `pair {peer, gen}` to both sides, through |
| 733 | their replay rings, and the new generation also appears in any later |
| 734 | `roster`. On `pair`, or on a `roster` whose generation for a peer is |
| 735 | higher than the local one, a browser: closes its connection to that |
| 736 | peer, discards its outbound queue and its inbound candidate buffer for |
| 737 | that peer, keeps its `seq` counters (they never reset), creates a new |
| 738 | connection tagged with the new generation, and negotiates as for a new |
| 739 | `peer`. A `pair` at or below the local generation is a no-op, so |
| 740 | duplicates and reordering are harmless; two simultaneous resets yield |
| 741 | two increments and both browsers converge on the higher one; an old |
| 742 | offer or answer arriving after a rebuild is refused by the hub (stale |
| 743 | `gen`) or dropped by the receiver (lower `gen` than current). A reset |
| 744 | costs the pair a second or two of media and happens only after a |
| 745 | resume whose gap exceeded the ring or after a delivery timeout, both |
| 746 | rare by construction. |
| 747 | |
| 748 | Candidates are applied only after a remote description is set; earlier |
| 749 | ones are held in a per-peer buffer, which the W3C pattern already |
| 750 | requires. Under perfect negotiation an ignored offer's candidates raise |
| 751 | on `addIceCandidate`, and the browser swallows that error exactly when |
| 752 | `ignoreOffer` is set, as the reference implementation does. A |
| 753 | `candidate` whose `seq` precedes the current offer's `seq` is dropped |
| 754 | as belonging to an old generation. |
| 755 | |
| 756 | --- |
| 757 | |
| 758 | ## 5. Media |
| 759 | |
| 760 | ### 5.1 The mesh and negotiation |
| 761 | |
| 762 | One `RTCPeerConnection` per other admitted peer, created when a `peer` |
| 763 | message arrives or the roster is received. Negotiation follows the W3C |
| 764 | **perfect negotiation** pattern: both sides offer on |
| 765 | `negotiationneeded`; the peer with the lower id is polite and rolls |
| 766 | back on collision, the higher is impolite and ignores the incoming |
| 767 | offer. This is what lets either side add a screen track or restart ICE |
| 768 | without an offer-request message. All candidates trickle as `candidate` |
| 769 | messages. A connection that reaches `failed` shows the tile as "could |
| 770 | not connect" with a retry that restarts ICE; nothing is silently spun. |
| 771 | |
| 772 | ### 5.2 ICE servers from the platform |
| 773 | |
| 774 | At startup the instance reads `CARLOS_RELAY`, standard base64 of a JSON |
| 775 | array of `{region, urls, key, username_suffix}` in **console order** |
| 776 | (the contract makes no placement-first promise; ICE picks a working |
| 777 | pair). Absent or empty means §5.5. A present value that fails to decode |
| 778 | or parse is a startup refusal outside `-env development`, logged as one |
| 779 | line naming the variable and not its value. The decoded value is held |
| 780 | in memory and never logged. |
| 781 | |
| 782 | Per call, `GET /m/{sqid}/ice` (an admitted participation, §2.5) returns |
| 783 | the `RTCIceServer` list. For each element, every URL is passed through |
| 784 | as given; URLs whose scheme is `turn` or `turns` carry `username = |
| 785 | "<unix expiry>:" + username_suffix` and `credential = |
| 786 | base64(HMAC-SHA256(rawkey, username))`, where `rawkey` is the element's |
| 787 | key decoded from base64 to 32 bytes; every other scheme (`stun`, |
| 788 | `stuns`, or one the contract adds later) is passed bare. Expiry is |
| 789 | three hours ahead, inside the four-hour maximum. |
| 790 | |
| 791 | The minting function has one test that reproduces the platform's |
| 792 | vector: key `uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=`, username |
| 793 | `1900000000:abc:meet:0123456789abcdef:k1`, credential |
| 794 | `jRU479Y3GO45nrRHqxBf3BMeBHE6Fip1cwAccNv28MQ=`. |
| 795 | |
| 796 | ### 5.3 Renewal before the deadline |
| 797 | |
| 798 | Credential expiry is a hard deadline on the relay, checked on allocate |
| 799 | and on every refresh: the allocation ends at expiry whether or not the |
| 800 | browser sends anything. The client therefore renews at two and a half |
| 801 | hours, and **renewal is per connection and complete only when the new |
| 802 | path is proven**: |
| 803 | |
| 804 | 1. Fetch `/ice`. On failure, retry every minute; the tile shows a |
| 805 | warning from the first failure. |
| 806 | 2. For each peer connection: `setConfiguration` with the new servers, |
| 807 | `restartIce()`, and let perfect negotiation carry the offer. An ICE |
| 808 | restart changes the local ICE username fragment, so the restart has |
| 809 | a **generation** the browser can name: the new `usernameFragment`. |
| 810 | The connection is **renewed** when, within thirty seconds, (a) the |
| 811 | restart's offer/answer exchange has completed and `signalingState` |
| 812 | is `stable` with that answer applied, and (b) `getStats()` reports a |
| 813 | selected candidate pair in state `succeeded` whose local candidate |
| 814 | carries the new generation's `usernameFragment`. An |
| 815 | `iceConnectionState` change on its own proves nothing, because a |
| 816 | `completed` transport moves to `connected` merely by starting a |
| 817 | restart. Otherwise restart again, up to three times. |
| 818 | 3. The old credential's deadline is kept as the **must-succeed-by** |
| 819 | time for every connection not yet renewed. A connection that is not |
| 820 | renewed by then shows "relay expired" with a retry, which runs step 2 |
| 821 | again with a fresh fetch. The browser cannot see which credential an |
| 822 | allocation is using, so the deadline is the only honest signal. |
| 823 | 4. The next scheduled renewal is two and a half hours after the last |
| 824 | **successful** fetch, per connection. |
| 825 | |
| 826 | On `visibilitychange` to visible, the client compares the clock with |
| 827 | the last successful fetch and runs the sequence at once if it is |
| 828 | overdue, because suspended tabs stop timers. |
| 829 | |
| 830 | ### 5.4 Screen share |
| 831 | |
| 832 | `getDisplayMedia`, added as its own transceiver on every peer |
| 833 | connection, with a `share` message so peers label the tile, and |
| 834 | renegotiation by §5.1 on each. Stopping calls `transceiver.stop()` on |
| 835 | the screen transceiver and renegotiates, so the receiver's track |
| 836 | **ends** rather than merely muting; the receiver removes the share tile |
| 837 | on `ended` and on `share {on: false}`, whichever arrives first, while |
| 838 | the camera track continues. One sharer at a time is not enforced; the |
| 839 | grid shows every shared screen it receives. |
| 840 | |
| 841 | ### 5.5 The floor |
| 842 | |
| 843 | With no `CARLOS_RELAY`, the ICE list is empty and browsers gather host |
| 844 | candidates only: the addresses of their own interfaces. The call |
| 845 | connects when ICE finds a working direct path between them, which may |
| 846 | include a peer-reflexive address learned from the other side's checks, |
| 847 | so one publicly reachable participant can be enough. In practice that |
| 848 | is usually true on a permissive local network, usually false across the |
| 849 | internet, and false on a local network with client isolation. The |
| 850 | pre-join screen says, above the button: "This team's Meet has no relay |
| 851 | yet. Calls usually connect only between people on the same network." |
| 852 | The README's second paragraph, the one that introduces the relay, says |
| 853 | the same; its first paragraph carries the scriptless floor (§9). An |
| 854 | operator enables the platform relay with `carlos relay enable --app |
| 855 | meet`, after which this paragraph no longer applies to that instance. |
| 856 | |
| 857 | With `CARLOS_RELAY` present, a failed connection's tile says "could not |
| 858 | connect, even through the relay" and offers retry. It does not diagnose |
| 859 | the network; a TCP-443-only network is one cause the README names, |
| 860 | because the relay's TLS listener is deferred by the platform, and it is |
| 861 | named as a possibility, not a verdict. |
| 862 | |
| 863 | ### 5.6 What the relay changes |
| 864 | |
| 865 | Stage 4a puts a platform process in the media path for calls that need |
| 866 | it. It forwards DTLS-SRTP packets it cannot decrypt. §0.1's claim about |
| 867 | Meet's own process is unchanged; the README paragraph that introduces |
| 868 | the relay says a platform relay may carry encrypted media, and that a |
| 869 | third-party relay is never used. |
| 870 | |
| 871 | ### 5.7 What the SFU changes |
| 872 | |
| 873 | When the platform SFU exists and a team opts in, media is decrypted by a |
| 874 | platform process to be forwarded. The mesh code is replaced by one |
| 875 | connection to the SFU, the cap is lifted, and §0.1's claim is rewritten |
| 876 | in a revision of this spec, not a footnote. |
| 877 | |
| 878 | --- |
| 879 | |
| 880 | ## 6. Chat |
| 881 | |
| 882 | A `chat` message with text up to 2 KB, delivered to admitted peers only, |
| 883 | rendered in the panel with the sender's name and a local timestamp, |
| 884 | escaped as text. It is not stored, not replayed to a peer who joins or |
| 885 | resumes later, and gone with the tab. Links are not linkified in |
| 886 | version one. This is the suite's first chat, and estilo's rule makes |
| 887 | Meet the first builder: the panel is one self-contained template partial |
| 888 | and one script file so a second app can adopt it. |
| 889 | |
| 890 | --- |
| 891 | |
| 892 | ## 7. Identity and the instance |
| 893 | |
| 894 | ### 7.1 Lone shape |
| 895 | |
| 896 | Sign-in is Docs' `internal/docs/auth.go` adopted with the changes §3.2 |
| 897 | specifies in full: the pending-join exception to the gate in front of |
| 898 | `Begin`, the `pending_joins` row written before `Begin`, an `Authorize` |
| 899 | that admits allowlist-or-pending, `SignedInPath` set to `/continue`, |
| 900 | and the outsider budgets. §3.2 also states which responses remain |
| 901 | distinguishable. `CALLS_ALLOWED_ADDRESSES`, `CALLS_ORIGIN`, |
| 902 | `CALLS_INSTANCE_KEY`, `CALLS_SMTP_*` and `CALLS_DEK` are startup refusals |
| 903 | outside `-env development`. This file already lives in Docs, Calendar |
| 904 | and Slides; Meet's is the fourth copy, and the extraction task Slides |
| 905 | recorded stands. Meet adds none. |
| 906 | |
| 907 | ### 7.1.1 Optional Home sign-in (2026-09-10) |
| 908 | |
| 909 | The operator can enable Home with all four `CALLS_HOME_ISSUER`, |
| 910 | `CALLS_HOME_CONSUMER_ID`, `CALLS_HOME_KEYS_BASE64` and |
| 911 | `CALLS_HOME_DEK_BASE64` settings. Missing all four keeps standalone |
| 912 | sign-in; partial settings refuse startup. The Home key is separate |
| 913 | from the meeting-title key. |
| 914 | |
| 915 | Home links an existing member after fresh independent local sign-in, |
| 916 | a fresh Home proof and explicit confirmation. It never creates a person, |
| 917 | matches a Home email claim or grants membership. Both protected routes |
| 918 | and the public meeting-link handler resolve Home subjects to the existing |
| 919 | person ID, checking current membership. Existing meeting actors remain |
| 920 | `person:<id>`. Person IDs are AUTOINCREMENT and cannot be recycled; |
| 921 | any future account-deletion operation must also delete its Home links in |
| 922 | the same transaction. |
| 923 | |
| 924 | `GET /signin` starts Home unless the browser has a pending guest-join |
| 925 | cookie or is returning a local sign-in result. `GET /signin/local` is |
| 926 | independent local sign-in. Its verification finishes at |
| 927 | `/auth/local/complete`, then `/continue`, preserving the pending-join |
| 928 | rules above. An explicit account-link continuation takes precedence. |
| 929 | Guests still verify locally and cannot access `/account/home`. |
| 930 | |
| 931 | Shared `home/connect` owns `/auth/start`, `POST /auth/assertion`, |
| 932 | `/auth/finish`, `/signed-out` and the GET/POST account-link routes |
| 933 | `/account/home` and `/account/home/confirm`. Only the exact signed |
| 934 | assertion callback bypasses ordinary CSRF protection; its issuer and |
| 935 | proof are checked by the shared protocol. CSP preserves the framework's |
| 936 | other directives and adds only the configured Home issuer to form-action. |
| 937 | All account-link screens use the shared templates and work without scripts. |
| 938 | |
| 939 | ### 7.2 Actors |
| 940 | |
| 941 | Every write records an actor: `person:<id>`, or `agent:<id>` for the |
| 942 | drive surface. Meetings, links, grants, admissions and endings carry it. |
| 943 | A guest's actor is `person:<id>` like a member's; what differs is |
| 944 | standing, decided per request (§2.3). |
| 945 | |
| 946 | ### 7.3 Deploy |
| 947 | |
| 948 | One static binary, `CGO_ENABLED=0`, `linux-arm64`. The scaffold's `make |
| 949 | release` strips and does not stamp; slice 1 changes it to pass |
| 950 | `-X amadan.net/rastrillo/rastrillo.BuildVersion=$(git describe --always --dirty)` |
| 951 | and to refuse a dirty tree, as calendar's does, so `/api/version` |
| 952 | reports the built revision and a deploy can be verified against it. |
| 953 | `carlos deploy --app meet --account oficina` with the build probe on |
| 954 | `/api/version`. `carlos domains attach calls.oficina.works`. Secrets |
| 955 | `CALLS_INSTANCE_KEY`, `CALLS_SMTP_PASS` and `CALLS_DEK` via `carlos |
| 956 | secrets`, the rest via `carlos env`. |
| 957 | |
| 958 | Meet is deployed as a **unit route**, Meet's choice among the shapes the |
| 959 | relay contract accepts (unit, socket-exec, or a TCP exec route with file |
| 960 | delivery); a unit route needs nothing from Meet when an operator runs |
| 961 | `carlos relay enable --app meet`, and the platform restarts the route |
| 962 | when the value is delivered. |
| 963 | |
| 964 | --- |
| 965 | |
| 966 | ## 8. The drive surface |
| 967 | |
| 968 | ### 8.1 Principals |
| 969 | |
| 970 | Adopted from Slides §7.1: a member mints an agent token from Settings, |
| 971 | `agents(id, name, operator_person_id, token_hash, created_at, |
| 972 | revoked_at)`, principal `<name>~<operator email>`, robot badge and |
| 973 | operator name on every write, bearer on the API only, never a cookie on |
| 974 | the API and never a bearer on HTML routes. Cited to suite discussion 4 |
| 975 | for the credential shape at the home; until that is ruled, the token is |
| 976 | instance-minted exactly as Slides does. |
| 977 | |
| 978 | **Reach.** On every API request the token must be unrevoked **and** its |
| 979 | operator must have `member` standing (§2.3) at that moment. An |
| 980 | operator who leaves the team takes their agent's reach with them, with |
| 981 | no revocation step; the request 404s like any other loss of standing. |
| 982 | Suite discussion 4 binds an agent to its operator's reach and no more, |
| 983 | and this is the per-request form of that sentence. |
| 984 | |
| 985 | ### 8.2 Operations |
| 986 | |
| 987 | `GET /api/v1/meetings` (list: `sqid`, title, `starts_at`, `ended_at`, |
| 988 | `link_present`), `POST /api/v1/meetings` (title, optional `starts_at`; |
| 989 | returns the meeting and its link's token, once), `POST |
| 990 | /api/v1/meetings/{sqid}/links` (replace: revokes the rest and returns |
| 991 | the new token, once; the only two responses that ever carry a token |
| 992 | are this and creation), `POST /api/v1/meetings/{sqid}/end`, `GET |
| 993 | /api/v1/meetings/{sqid}` (the meeting and, while it runs, the count of |
| 994 | admitted peers, never their names or the chat). A replacement that |
| 995 | fails leaves the old links untouched; there is no partial state to |
| 996 | retry from. No operation joins a call (ruling 11). |
| 997 | |
| 998 | ### 8.3 MCP |
| 999 | |
| 1000 | A thin adapter over §8.2, deferred until a consumer asks by name. |
| 1001 | |
| 1002 | ### 8.4 Native clients |
| 1003 | |
| 1004 | **2026-09-14 amendment.** The SwiftUI Mac, iPhone and iPad apps this |
| 1005 | section specifies shipped on `main` (`native/`, `callscore/`, |
| 1006 | `protocol/`, tag `v0.1.0` at `9225ec2`), closing the authentication gap |
| 1007 | the bullets below name with a browser-approval hand-off |
| 1008 | (`native/README.md`): a five-minute, one-use authorization bound to a |
| 1009 | proof secret held only by the app; approving in the browser creates an |
| 1010 | ordinary framework session, no cookie copied and no new bearer type |
| 1011 | introduced. The production handoff endpoints were deployed in |
| 1012 | `86ed9f3`. This supersedes the closing paragraph's "version one does |
| 1013 | not ship a native client" below; the rest of this section, written |
| 1014 | before that milestone, otherwise stands as the contract the shipped |
| 1015 | apps speak. |
| 1016 | |
| 1017 | Ruling 15: a native app is the next client after the browser, and |
| 1018 | nothing in Meet's server contracts may assume the browser. Concretely, |
| 1019 | in version one: |
| 1020 | |
| 1021 | - The signalling protocol (§4.3, §4.5, §4.6) is JSON over SSE and POST |
| 1022 | with no HTML in either direction, and the message table is the |
| 1023 | contract; `room.js` is one client of it, not its definition. |
| 1024 | - `/ice` (§5.2) returns a plain `RTCIceServer` list a native WebRTC |
| 1025 | stack consumes unchanged. |
| 1026 | - The meeting API (§8.2) is the same surface a native app would use to |
| 1027 | list, create, link and end meetings. |
| 1028 | - Authentication is the one piece a native client cannot take from |
| 1029 | version one as is: sessions are cookies minted by a magic link, and a |
| 1030 | bearer exists only for agents. A native client's sign-in is the |
| 1031 | home's assertion work (doctrine §5, `oficina/home` §13), not a |
| 1032 | Meet-local token, and Meet adds no interim of its own. |
| 1033 | |
| 1034 | What version one does **not** do: ship a native client, publish an |
| 1035 | OpenAPI document, or version the protocol beyond `gen` and `seq`. The |
| 1036 | fixtures for the message table (§13) are the machine-readable contract |
| 1037 | until an OpenAPI document earns its place. |
| 1038 | |
| 1039 | --- |
| 1040 | |
| 1041 | ## 9. The scriptless floor |
| 1042 | |
| 1043 | The exception is **browser media participation**: reaching a camera |
| 1044 | and another browser needs script. Inside a room that exists, script |
| 1045 | also gates the chat panel (`chat.html`'s whole panel is rendered |
| 1046 | `hidden` until script un-hides it, because a Send button with no |
| 1047 | script behind it would be a lie, §6, §10) and the admission queue's |
| 1048 | Admit controls (`room.js`), since neither can do anything before a |
| 1049 | call does. Every other screen works with scripts off, including the |
| 1050 | meeting list, creating a meeting, seeing and copying a link, every |
| 1051 | confirm page (five), sign-in and continuation, the `/welcome` page, |
| 1052 | the agent token page, and the server-rendered parts of the holding |
| 1053 | screen and the room (§3.3, §3.4): title, start time, standing, floor |
| 1054 | sentence, the paragraph that says what needs script, and navigation to |
| 1055 | everything that does not. |
| 1056 | |
| 1057 | `AGENTS.md`'s line "screens work with JavaScript disabled" gained, in |
| 1058 | slice 2, the one exception in those words; on 2026-09-14, alongside |
| 1059 | this section's own widening, it gained the other two (§15.18), so the |
| 1060 | binding file and the code agree again. `PRODUCT.md` records it in |
| 1061 | "what is deliberately not here" in slice 6. |
| 1062 | |
| 1063 | Hand-written static JavaScript, no bundler, no npm, in three files: |
| 1064 | `devices.js` (the holding screen), `room.js` (signalling, resume and |
| 1065 | the mesh), `chat.js` (the panel). Roughly two thousand lines between |
| 1066 | them, the largest hand-written script in the suite so far, which is why |
| 1067 | they are three files and not one. |
| 1068 | |
| 1069 | --- |
| 1070 | |
| 1071 | ## 10. Estilo and copy |
| 1072 | |
| 1073 | Meet adopts, in this order, whatever estilo holds at the time each |
| 1074 | slice lands: the theme, the sign-in composition once one is extracted, |
| 1075 | error and empty states. Meet builds nothing in estilo in version one |
| 1076 | except what §6 prepares. Every new user-facing string goes through the |
| 1077 | copy review before it lands (`copy-review`, the suite's mechanism), and |
| 1078 | the plan lists the strings per slice so the review is one sitting per |
| 1079 | slice, not one per string. The sentences quoted in §3.2, §3.3 and §5.5 |
| 1080 | are drafts for that review, not approved copy. |
| 1081 | |
| 1082 | --- |
| 1083 | |
| 1084 | ## 11. Deliberately not built yet |
| 1085 | |
| 1086 | Recurring meetings. Calendar integration. Recording. Attendance |
| 1087 | records. A relay of Meet's own. Rooms above four. Persisted chat. |
| 1088 | Reactions. Dial-in. Captions. Multi-host signalling. |
| 1089 | Push-to-talk. Virtual backgrounds. Denying a knock (a guest who is not |
| 1090 | admitted simply waits or leaves). Each is named so the next session |
| 1091 | does not rediscover it. |
| 1092 | |
| 1093 | --- |
| 1094 | |
| 1095 | ## 12. Slices |
| 1096 | |
| 1097 | Each slice is its own branch, discussion and review, per the suite's |
| 1098 | overnight protocol (`oficina/suite` discussion 5), and each carries its |
| 1099 | own gates from §13; nothing is deferred to the deploy slice. |
| 1100 | |
| 1101 | 1. **Meetings, links, sign-in.** `persons`, `meetings`, `meeting_links`, |
| 1102 | `guest_grants`; sealing; the list; create; the link shown once; the |
| 1103 | two confirm pages and their POSTs; the `/j/` route with all three |
| 1104 | outcomes and the one 404; the pending-join cookie, `pending_joins`, |
| 1105 | the `Authorize` callback, `/continue` with its three outcomes and |
| 1106 | the atomic re-check, the allowlist exception and the two outsider |
| 1107 | budgets; `/welcome`; the §2.5 contract as middleware; |
| 1108 | standing (§2.3) as a package with tests; `make release` stamping. |
| 1109 | Scriptless throughout. Joining lands on a holding page that says the |
| 1110 | room is the next slice. |
| 1111 | 2. **Holding screen and room, direct connectivity.** `devices.js`, the |
| 1112 | hub with peer binding, participation, cap, limits, resume with |
| 1113 | admission-branched output, the replay ring, `gap` and pair reset, |
| 1114 | terminal `replaced`, and the tick; the stream and `hello`; every |
| 1115 | message in §4.3 except `chat` and `share`; the removal confirm page; |
| 1116 | `room.js` with perfect negotiation and the §4.6 queue; the |
| 1117 | participation transitions; the `AGENTS.md` exception. `CARLOS_RELAY` absent. The browser gate: two |
| 1118 | isolated browser contexts, two identities, one call on localhost. |
| 1119 | 3. **Relay.** `CARLOS_RELAY` parsing and the startup refusal, minting |
| 1120 | with the vector test, `/ice`, renewal with repeated and failed cases |
| 1121 | under shortened timers. Verified against the real relay when stage |
| 1122 | 4a reaches flagship; until then the gate is the list the browser |
| 1123 | receives and the renewal behaviour under a fake `/ice`. |
| 1124 | 4. **Chat and screen share.** `chat.js`, the `chat` and `share` |
| 1125 | messages with their recipient rules, display-media tracks and |
| 1126 | renegotiation from either side. |
| 1127 | 5. **Drive surface.** `agents`, the five operations, the per-request |
| 1128 | reach check, the badge. |
| 1129 | 6. **Deploy.** Domain, secrets, canary, `PRODUCT.md`, the README with |
| 1130 | the scriptless-floor sentence (§9) in its first paragraph and the |
| 1131 | relay paragraph, carrying §5.5's no-relay sentence, second, and the |
| 1132 | `Consumed suite rulings` commit that suite Appendix A.6 will point |
| 1133 | at. |
| 1134 | |
| 1135 | **Delivered 2026-09-14 (§15.18).** The README, with the floor and |
| 1136 | relay paragraphs first, was written. `PRODUCT.md` was written. |
| 1137 | AGENTS.md's deploy notes were appended. This is the `Consumed suite |
| 1138 | rulings` commit, refreshing §0.2 against doctrine main `1ac4323` |
| 1139 | revision 8. The acknowledgements page is deferred to `oficina/meet` |
| 1140 | discussion 10's branch `feat-open-source-credits`, not duplicated |
| 1141 | here. The Estilo theme pin did not happen, because no tagged release |
| 1142 | exists; the theme is nonetheless already adopted on main at a |
| 1143 | pseudo-version through the Home integration. No production |
| 1144 | promotion happened, because this slice is documentation only; edge |
| 1145 | stays hand-managed by Paul. |
| 1146 | |
| 1147 | --- |
| 1148 | |
| 1149 | ## 13. Testing |
| 1150 | |
| 1151 | **Go**, per slice: store and grant tests including expiry and the |
| 1152 | one-404 rule (1); the full emailed-link round trip for an outsider, |
| 1153 | with the pending-join cookie present and absent at the click, the |
| 1154 | `Authorize` callback admitting the pending address and refusing an |
| 1155 | unknown one, revocation during sign-in, `/continue`'s three outcomes |
| 1156 | for member and non-member with present, absent and expired rows, a |
| 1157 | member's signed-out link journey completing in another browser, link A |
| 1158 | replaced by B between mail and click (outsider: 403 at `Authorize`; |
| 1159 | signed-in: `/` or `/welcome`; no grant either way), and the outsider |
| 1160 | budgets plus the documented exhaustion of the shared allowance by |
| 1161 | repeated submission of one member address (1); standing as a |
| 1162 | table test (1); hub tests for peer binding and refusal of a foreign or |
| 1163 | cross-room `peer`, the sixteen-participation and unjoined-timeout |
| 1164 | limits with streams opened and never joined, targeted relay only to |
| 1165 | admitted peers in the room, a waiting guest receiving nothing but |
| 1166 | `hello` and its own outcome, `roster` carrying the waiting queue to |
| 1167 | members and not to guests, guest-first arrival then a member joining, |
| 1168 | promotion of a waiting guest to member under and at the cap, the cap |
| 1169 | at `join` and at `admit` including four queued guests and concurrent |
| 1170 | admissions, resume within and after the window with the roster sent to an admitted |
| 1171 | peer and only `state` to a waiting one, replay from the ring above |
| 1172 | `last`, `gap` when the ring is exceeded, `replaced` terminal for the |
| 1173 | displaced tab including the case where the `lost` was never delivered |
| 1174 | (410 on resume, 409 on fresh entry, no automatic takeover), a same-boot |
| 1175 | 404 followed by a clean rejoin, a different-boot 404 after restart, a |
| 1176 | takeover of an admitted, a waiting and an unjoined participation |
| 1177 | producing `gone`, `knock-gone` and nothing respectively with an |
| 1178 | admitted guest observing, a cross-origin takeover POST refused by the |
| 1179 | scaffold's origin-checking CSRF middleware while a same-origin |
| 1180 | confirmation succeeds without any token, a resume with a nonzero old |
| 1181 | cursor after a missed `pair` |
| 1182 | receiving the new generation's offer by replay, a stale-`gen` POST |
| 1183 | refused with 409, `reset` yielding one `pair` to each |
| 1184 | side with the same generation, two simultaneous resets converging, a |
| 1185 | `pair` at or below the local generation ignored, `admit` of a guest |
| 1186 | whose grant expired since the last tick refused, |
| 1187 | the 64-event buffer, the rate limit, an admitted guest continuing past |
| 1188 | grant expiry on every endpoint and being refused fresh entry, a member |
| 1189 | dropped on the tick (2); the §0.1 database and WAL tests (1) and the |
| 1190 | captured-log test for join, admit, offer, candidate, a refusal and an |
| 1191 | error (2), extended to accepted chat (4); the minting vector and the |
| 1192 | renewal state machine including a successful fetch followed by a failed |
| 1193 | restart, and a `completed` transport that moves to `connected` on |
| 1194 | restart start without qualifying as renewed (3); the API reach check after an operator's removal (5); |
| 1195 | `make ci` green on every slice. |
| 1196 | |
| 1197 | **Browser.** Calendar's chromedp drive is recorded as not running in |
| 1198 | this environment, so Meet does not inherit it; the driver is Playwright |
| 1199 | through the same `npm install --no-save playwright` step calendar's |
| 1200 | Makefile uses, established in slice 2 before any assertion is written. |
| 1201 | Two **isolated browser contexts** with separate sessions, Chrome's |
| 1202 | `--use-fake-device-for-media-stream` and `--use-fake-ui-for-media-stream`. |
| 1203 | Gates: the holding screen enumerates devices and previews (2); two |
| 1204 | contexts complete a call on localhost with host candidates and each |
| 1205 | receives the other's video track, asserted on `ontrack` and a non-zero |
| 1206 | frame count, not on a label (2); a guest knocks, is admitted, and had |
| 1207 | received no `peer` while waiting (2); a waiting guest receives no |
| 1208 | `chat`, and receives the next one once admitted — the positive control |
| 1209 | that proves the silence was its standing rather than a stream that |
| 1210 | delivers nothing (4, carried by the `chat` scenario, not the guest |
| 1211 | one); a stream renewal with |
| 1212 | the TTL shortened to seconds leaves the call connected (2); a member |
| 1213 | removed from the allowlist mid-call is dropped on the next tick and the |
| 1214 | other context sees `gone` (2); a context whose stream is closed while a |
| 1215 | peer leaves resumes, receives the roster, and has closed the connection |
| 1216 | to the departed peer (2); an offer whose SSE delivery is cut after the |
| 1217 | POST's 2xx is replayed on resume and the pair connects (2); a gap |
| 1218 | beyond the ring produces `gap`, a `reset`, a new generation on both |
| 1219 | sides and a reconnected pair, including when one side's `pair` was cut |
| 1220 | and it learns the generation from the roster on its own resume (2); a second tab |
| 1221 | on the same session is refused with 409, confirms the takeover, and |
| 1222 | the first shows the takeover link and does not reconnect on its own, |
| 1223 | while a cross-site top-level navigation to any Meet URL ends no call |
| 1224 | (2); after a server restart both contexts have |
| 1225 | closed their old connections before rejoining (2); renewal with the schedule shortened to |
| 1226 | seconds produces a second `/ice` fetch and an ICE restart that leaves |
| 1227 | the track flowing, and a failed fetch shows the warning (3); the |
| 1228 | higher-id context shares its screen and the lower receives a second |
| 1229 | video track, then stops, the received track ends, the share tile is |
| 1230 | removed and the camera track keeps flowing (4); chat arrives at the |
| 1231 | admitted context and not at a waiting one (4); the scriptless holding |
| 1232 | and room pages render their paragraph with scripts disabled (2). No |
| 1233 | test touches a relay until one exists. |
| 1234 | |
| 1235 | --- |
| 1236 | |
| 1237 | ## 14. Review record |
| 1238 | |
| 1239 | **Round 1**, GPT-6 via Codex against revision 1 at 3836222: |
| 1240 | `CODEX-FINDINGS-MEET.md`, twenty-one findings, request changes. |
| 1241 | Dispositions in revision 2: |
| 1242 | |
| 1243 | | Finding | Disposition | |
| 1244 | |---|---| |
| 1245 | | R1-1 waiting-room isolation | Recipients per message type (§4.3); a waiting guest gets `hello`, its own outcome and nothing else; `leave` from a waiting guest is `knock-gone` to members only. Test in slice 2. | |
| 1246 | | 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). | |
| 1247 | | 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). | |
| 1248 | | R1-4 unbounded memory | Body, chat, rate, waiting, stream and buffer limits (§4.4). | |
| 1249 | | R1-5 stale standing | Standing defined, computed per request and per tick, transition table with stream closure and the P2P limit stated (§2.3). | |
| 1250 | | R1-6 agent reach | Token unrevoked and operator `member` on every request (§8.1). | |
| 1251 | | 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). | |
| 1252 | | 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). | |
| 1253 | | R1-9 reconnect | Resume with a thirty-second window, admission preserved, no cap consumption, no events; restart behaviour stated (§4.5). | |
| 1254 | | R1-10 offerer ordering | Ruling 13: perfect negotiation; renewal schedule, repeated and failed renewal, suspended timers (§5.1, §5.3). | |
| 1255 | | R1-11 contract claims | Console order; any scheme passed through, credentials on `turn`/`turns`; unit route as Meet's choice (§5.2, §7.3). | |
| 1256 | | R1-12 TURN in the path | Claim made per process; TURN carries encrypted packets; SFU decrypts (§0.1, §5.6, §5.7). | |
| 1257 | | 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). | |
| 1258 | | R1-14 the floor | Host-candidate reachability, usually a permissive LAN, both false directions named; failure text does not diagnose (§5.5, §0.3). | |
| 1259 | | 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). | |
| 1260 | | R1-16 scriptless | Exception narrowed to media participation; both pages' scriptless output specified; `AGENTS.md` changed in slice 2 (§3.3, §3.4, §9). | |
| 1261 | | R1-17 confirm pages | Replace-link and end-meeting confirm pages with `ui/confirm-form` in slice 1 (§3.1). | |
| 1262 | | R1-18 show-once | Replacement returns its token once; both token-bearing responses named (§8.2). | |
| 1263 | | R1-19 browser tests | Playwright, isolated contexts, received-track assertions, renewal and share gates assigned to their slices (§13). | |
| 1264 | | R1-20 version stamp | Stamping added to `make release` in slice 1 (§7.3). | |
| 1265 | | R1-21 provenance | Sealer: Meet is the third app and cites calendar's trigger; auth: fourth copy (§2.1, §7.1). | |
| 1266 | |
| 1267 | **Round 2**, GPT-6 via Codex against revision 2 at 3a1fa42: |
| 1268 | `CODEX-FINDINGS-MEET-R2.md`: thirteen round-1 findings closed, eight |
| 1269 | half-closed, twelve new, request changes. Dispositions in revision 3: |
| 1270 | |
| 1271 | | Finding | Disposition | |
| 1272 | |---|---| |
| 1273 | | R2-1 authorize callback | `pending_guests` (now `pending_joins`) written before `Begin`; `Authorize` admits an allowlisted address or a pending fingerprint; works in any browser (§2.1, §3.2). Closes the R1-7 residue. | |
| 1274 | | R2-2 shared budget | Outsider attempts counted before `Begin`, five per link and ten overall per fifteen minutes, leaving ten of the shared twenty for members; residue and rastrillo's own `?err=` responses stated, not claimed away (§3.2). Closes the R1-8 residue. | |
| 1275 | | R2-3 expiry versus authorization | Ruling 14: standing for entry, participation for continuing; `/signal`, resume and `/ice` authorized by participation; an admitted guest continues past expiry on every endpoint and cannot re-enter (§2.3, §2.4). Closes the R1-5 residue. | |
| 1276 | | R2-4 missed `gone`, restart | Resume sends an authoritative `roster` and the browser reconciles; a restart's 404 makes the browser close every connection before rejoining; `lost` reasons split into resumable and terminal (§4.3, §4.5). Closes the R1-9 residue. | |
| 1277 | | R2-5 delivery | §4.6: one in flight per target, `seq` per pair, retry with backoff, receiver dedupe, candidate buffering, old-generation drop (ruling 13). Closes the R1-10 residue. | |
| 1278 | | R2-6 invisible queue, promotion | `roster` carries the waiting queue to members and is sent on admission, resume and kind change; promotion of a waiting guest attempts admission under the cap on the tick (§4.3, §2.4). | |
| 1279 | | R2-7 member continuation | `/continue` resolves the person first: pending row, else member to `/`, else `/welcome`, which is now specified (§3.2). | |
| 1280 | | R2-8 unjoined streams | Sixteen participations per room checked before allocation; unjoined ones expire in thirty seconds (§4.2, §4.4). Closes the R1-4 residue. | |
| 1281 | | R2-9 renewal completion | Per-connection renewal complete on `connected`/`completed` after restart; old deadline kept as must-succeed-by; retries and expired-path behaviour (§5.3). | |
| 1282 | | R2-10 stop-sharing, slice order | Sender stops the transceiver so the received track ends; gate asserts tile removal and camera continuing; captured-log test assigned to slices 1, 2 and 4 by message type (§5.4, §13). Closes the R1-19 residue. | |
| 1283 | | R2-11 removal confirm | `remove` is no longer a signalling message; confirm page and guarded POST with authorization at execution (§3.5). | |
| 1284 | | R2-12 the floor | Host-only gathering, ICE finds a direct path, peer-reflexive named; bilateral reachability dropped (§5.5, §0.3). Closes the R1-14 residue. | |
| 1285 | |
| 1286 | **Round 3**, GPT-6 via Codex against revision 3 at 1bd7161: |
| 1287 | `CODEX-FINDINGS-MEET-R3.md`: six round-2 findings closed, five |
| 1288 | half-closed, one worse; ten new (three P1); request changes. |
| 1289 | Dispositions in revision 4: |
| 1290 | |
| 1291 | | Finding | Disposition | |
| 1292 | |---|---| |
| 1293 | | R3-1 waiting guest resumes into the roster | Resume output branches on admission: admitted gets `roster` and replay; waiting or unjoined gets `state {waiting}` only (§4.5). Closes the R2-4 regression. | |
| 1294 | | R3-2 revoked link indistinguishable | `pending_joins` binds the originating link; `/continue` re-checks that link, its meeting and the row atomically with grant creation (§2.1, §3.2). | |
| 1295 | | R3-3 admit after expiry, before the tick | `admit` and tick promotion check the target's entry authority under the same lock as the transition (§2.5). | |
| 1296 | | R3-4 endpoint contracts still on standing | §2.5, one authorization row per endpoint; `join` checks standing, everything after admission checks participation only; member kind re-derived on every request (§2.5, §4.1, §4.3, §5.2 point at it). | |
| 1297 | | R3-5 no member reserve | The reserve claim is withdrawn; the residual denial of service through a member's address is stated as the edge-wide limitation it is, with the platform question filed at slice 1 (§3.2). | |
| 1298 | | R3-6 signed-out member loses continuation | `pending_joins` is written for members too and carries the destination; `Authorize` needs it only for outsiders; most recent row wins (§3.2). | |
| 1299 | | R3-7 downstream loss after 2xx | Resume carries `last` per sender; the hub replays from a thirty-two-message ring per participation; `gap` triggers a pair reset (§4.5, §4.6). | |
| 1300 | | R3-8 `connected` proves nothing | Completion requires the restart generation's answer applied and a succeeded selected pair whose local candidate carries the new `usernameFragment` (§5.3). | |
| 1301 | | R3-9 displaced tab fights back | `replaced` is terminal for the displaced tab; only a person's click takes the participation back (§4.5). | |
| 1302 | | R3-10 stale claims | §7.1 now defers to §3.2; the 429 claim is removed (§4.4). | |
| 1303 | |
| 1304 | **Round 4**, GPT-6 via Codex against revision 4 at 79787b8: |
| 1305 | `CODEX-FINDINGS-MEET-R4.md`: eight round-3 findings closed, two |
| 1306 | half-closed; four new (two P2, two P3); request changes. Dispositions |
| 1307 | in revision 5: |
| 1308 | |
| 1309 | | Finding | Disposition | |
| 1310 | |---|---| |
| 1311 | | R4-1 reset not a protocol operation | Pair generations held by the hub; `gen` on every targeted message, refused when stale; `reset` and `pair` are typed messages with recipients; `pair` rides the replay ring and the generation rides every roster; convergence and idempotency stated (§4.3, §4.6). Closes the R3-7 and R2-5 residues. | |
| 1312 | | R4-2 lost `replaced` notice | Fresh entry over a live participation is 409 unless the button's `takeover=1`; takeover creates a new peer and ends the old; resume of an ended peer is 410 with the reason for ten minutes; `hello` and 404 carry `boot` so a restart is distinguishable; nothing automatic displaces a tab (§4.2, §4.5). Closes the R3-9, R2-4 and R1-9 residues. | |
| 1313 | | R4-3 table disagreements | `leave` row participation-only; admission and removal rows name the admitted-member requirement; §3.3 defers to §2.5 (§2.5, §3.3). | |
| 1314 | | R4-4 revoked continuation outcome | Outsider: 403 at `Authorize`, inherited; signed-in: `/` or `/welcome`; no grant either way; §13 asserts those (§3.2, §13). | |
| 1315 | |
| 1316 | **Round 5**, GPT-6 via Codex against revision 5 at bbfe440: |
| 1317 | `CODEX-FINDINGS-MEET-R5.md`: three round-4 findings closed, one |
| 1318 | half-closed; three new (two P1, one P2), all in the takeover and the |
| 1319 | cursor revision 5 introduced; request changes. Dispositions in |
| 1320 | revision 6: |
| 1321 | |
| 1322 | | Finding | Disposition | |
| 1323 | |---|---| |
| 1324 | | R5-1 cursor across generations | `seq` is monotonic for the life of a participation and never resets; `gen` decides currency only, so replay by `seq` alone delivers a new generation's offer (§4.6). Closes the R4-1, R3-7, R2-5 and R1-10 residues. | |
| 1325 | | R5-2 takeover broadcasts a waiting departure | Takeover uses the same state-dependent departure as `leave`: `gone`, `knock-gone`, or nothing (§4.2). | |
| 1326 | | R5-3 takeover is a destructive GET | Takeover is a confirm page plus a CSRF-protected POST; a `GET` never ends a participation and no query parameter can; the 409 and 410 responses link to the confirm page (§2.5, §4.2, §4.5). | |
| 1327 | |
| 1328 | **Round 6**, GPT-6 via Codex against revision 6 at 61475ed: |
| 1329 | `CODEX-FINDINGS-MEET-R6.md`: every prior finding and residue closed; |
| 1330 | two P3 advisories; **verdict: accept with corrections**. Both |
| 1331 | corrections are in revision 7: |
| 1332 | |
| 1333 | | Finding | Disposition | |
| 1334 | |---|---| |
| 1335 | | R6-1 CSRF gate wording | The scaffold's `csrf.Protect` checks origin, not a token; the gate now asserts a cross-origin POST refused and a same-origin confirmation accepted (§13). | |
| 1336 | | R6-2 successor peer in the 410 | `by` removed; terminal handling keys on `reason` (§4.5). | |
| 1337 | |
| 1338 | The independent review is complete at revision 7. **Paul approved the |
| 1339 | written form on 2026-09-08**, adding the native-clients direction that |
| 1340 | became ruling 15 and §8.4 in revision 8. Implementation begins at slice |
| 1341 | 1 from this revision. |
| 1342 | |
| 1343 | |
| 1344 | |
| 1345 | --- |
| 1346 | |
| 1347 | ## 15. Revision 9 — what slice 2 changed, and why |
| 1348 | |
| 1349 | Revision 8 was written before a line of the room was built. Slice 2 (the |
| 1350 | room, the hub, the signalling and the browser client) departed from it |
| 1351 | in ten places. Each one was decided in the slice's own ledger with a |
| 1352 | reason; this section is that reasoning in the published spec, so the |
| 1353 | code and the spec stop disagreeing. Nothing here reopens a §1 ruling. |
| 1354 | Where a departure narrows something revision 8 published — the §2.5 |
| 1355 | room-page row is the one that does — it is marked. |
| 1356 | |
| 1357 | Every item carries the section it amends. Read the section and then this |
| 1358 | entry; the entry wins. |
| 1359 | |
| 1360 | ### 15.1 §4.2 — the hub session key is a keyed hash of the session cookie |
| 1361 | |
| 1362 | Revision 8 said the hub binds a participation to "the session" without |
| 1363 | saying what names one. The implementation needed a value that is stable |
| 1364 | for one browser's sign-in, different for two browsers of one person, and |
| 1365 | not itself a credential. |
| 1366 | |
| 1367 | **Decision.** The key is `hex(sha256(per-boot salt | subject | session |
| 1368 | cookie value))`. Three properties earn the three parts: the per-boot |
| 1369 | salt means the key is meaningless across a restart (the hub is empty |
| 1370 | then anyway); the subject means the key cannot outlive the person it |
| 1371 | belongs to; the cookie value is the only per-browser thing rastrillo |
| 1372 | exposes. The key is neither the session token nor the token hash |
| 1373 | rastrillo stores, so a hub key leaking would not be a credential |
| 1374 | leaking. |
| 1375 | |
| 1376 | **Why not the alternative.** The ledger's first ruling was the subject |
| 1377 | plus the session's `At` instant, and it was wrong: rastrillo stores |
| 1378 | `created_at` as RFC3339, second granularity, so two sign-ins in one |
| 1379 | second would have collided. |
| 1380 | |
| 1381 | **Cost if wrong.** Two tabs of one browser share a key, which is exactly |
| 1382 | the takeover case §4.2 already specifies. Two browsers of one person do |
| 1383 | not, which §4.2 permits (four streams per person). |
| 1384 | |
| 1385 | ### 15.2 §4.5 — the attaching stream GET requires `Accept: text/event-stream` |
| 1386 | |
| 1387 | **Decision.** `GET /m/{sqid}/stream` in its attaching forms answers |
| 1388 | `406 {"error": "accept"}` unless the request's `Accept` names |
| 1389 | `text/event-stream`. `EventSource` always sends it; a cross-site |
| 1390 | top-level navigation never does. |
| 1391 | |
| 1392 | **Why.** Without the gate, a link on another site could make a signed-in |
| 1393 | browser occupy its own session's one participation slot for the full |
| 1394 | stream TTL — no data read, but the person's own next real attempt gets a |
| 1395 | 409 and a takeover page for no reason they can see. |
| 1396 | |
| 1397 | **Why 406 and not the usual 404.** This check runs before any lookup or |
| 1398 | authorization, so its answer is identical for every room id and every |
| 1399 | person and reveals nothing. §2.5's "404, never 403" rule exists to stop |
| 1400 | a refusal being an oracle; there is nothing to be an oracle about here, |
| 1401 | and a mis-headered client is better told the truth than sent round the |
| 1402 | fresh-stream ladder. Ruling 15 and 16 in the slice ledger. |
| 1403 | |
| 1404 | ### 15.3 §4.5 — the resume probe, `?probe=1` |
| 1405 | |
| 1406 | **Decision.** Both stream forms accept `?probe=1`. A probe runs the |
| 1407 | whole refusal ladder of §4.5 and attaches nothing: it answers `204` (with |
| 1408 | `no-store`) when the resume would have been accepted, and otherwise the |
| 1409 | same 404, 409 or 410 with the same body an attaching request would have |
| 1410 | received. Probes are exempt from the `Accept` gate of §15.2 — `fetch` |
| 1411 | does not send that header, and a probe occupies nothing. |
| 1412 | |
| 1413 | **Why.** `EventSource` gives the browser no access to a non-200 status: |
| 1414 | a refused attaching stream is indistinguishable from a network failure. |
| 1415 | Without a probe the client cannot tell "this participation was removed" |
| 1416 | from "the wifi went", and §4.5's whole ladder — read the reason, act on |
| 1417 | it — is unreachable from a browser. The client probes, reads the status, |
| 1418 | then attaches. |
| 1419 | |
| 1420 | **Cost.** One extra GET per resume. |
| 1421 | |
| 1422 | ### 15.4 §4.5 — the 410 is scoped to the tombstone's own room and session |
| 1423 | |
| 1424 | **Decision.** An ended participation's tombstone is answered only through |
| 1425 | the room and the session it was bound to. A resume naming that peer id |
| 1426 | from any other session, or through any other room, gets the ordinary |
| 1427 | 404. |
| 1428 | |
| 1429 | **Why.** Revision 8's table gives the 410 as a property of the peer id |
| 1430 | alone. But peer ids are published in every roster, and *why* a |
| 1431 | participation ended — removed, standing lost, invitation expired — is |
| 1432 | account state about that person. Unscoped, any past room-mate could read |
| 1433 | it off an id they legitimately hold. This is §4.2's binding rule applied |
| 1434 | to the tombstone. |
| 1435 | |
| 1436 | ### 15.5 §4.4, §2.5 — `/signal` refuses a bad room before the limiter |
| 1437 | |
| 1438 | **Decision.** `POST /m/{sqid}/signal` validates the room id (ten |
| 1439 | characters of the id alphabet) and checks that this session holds a live |
| 1440 | participation in that room, both **before** the rate limiter and before |
| 1441 | the body is read. Either refusal is the identical `404 |
| 1442 | {"error":"not-found","boot":…}` every other refusal on the path returns. |
| 1443 | |
| 1444 | **Why.** The limiter is keyed on session and room. A session that rotated |
| 1445 | the room id in the URL would otherwise mint a limiter bucket per request: |
| 1446 | an unbounded map, an O(cap) eviction scan under the limiter's mutex, and |
| 1447 | an attacker-chosen string in the key and in the log line. §2.5's |
| 1448 | authority is reached earlier and more cheaply than the limiter, so it |
| 1449 | goes first. |
| 1450 | |
| 1451 | **Side effect, deliberate.** A session whose participation has already |
| 1452 | ended is refused before the limiter, and a detached-but-resumable session |
| 1453 | is not — it still holds a live participation and may keep posting. |
| 1454 | |
| 1455 | **Watch.** The limiter now sits behind a gate that takes the hub lock. |
| 1456 | Revisit if hub-lock hold time grows. |
| 1457 | |
| 1458 | ### 15.6 §2.5 — the room-page row narrows to a non-member participation |
| 1459 | |
| 1460 | **This narrows a published contract.** Revision 8's §2.5 gives `GET |
| 1461 | /m/{sqid}` as "standing, or a live participation". The implementation |
| 1462 | reads: standing, **or a live participation whose kind is not member**. |
| 1463 | |
| 1464 | **Why.** Ruling 14 makes the participation the authority for continuing, |
| 1465 | and §2.4 says an admitted guest is not ended by its grant expiring — so |
| 1466 | a guest must keep the page. A **member** who lost the team is a different |
| 1467 | case: §2.5 says they are dropped by the next thing they do. If a |
| 1468 | member's own participation could hold the page open, an ex-member would |
| 1469 | keep the room for as long as the hub happened to still hold their |
| 1470 | participation, which is the opposite of what §2.5 promises. The fallback |
| 1471 | is therefore for exactly the kind ruling 14 was written for. |
| 1472 | |
| 1473 | ### 15.7 §4.5 — a resume is refused the moment its window passes |
| 1474 | |
| 1475 | **Decision.** A resume arriving after `resume_until` is refused, even |
| 1476 | though the participation is still in the hub because the fifteen-second |
| 1477 | tick has not run yet. |
| 1478 | |
| 1479 | **Why.** Otherwise the window is "thirty seconds, plus however long |
| 1480 | until the next tick" — up to forty-five — and the number in §4.5 would |
| 1481 | not be the number the code enforces. The tick still does the removal; |
| 1482 | it is not the authority on the window. |
| 1483 | |
| 1484 | ### 15.8 §4.3 — `leave` is not idempotent |
| 1485 | |
| 1486 | **Decision.** A second `leave` for the same peer answers 404. The |
| 1487 | participation is gone; the peer id names nothing. |
| 1488 | |
| 1489 | **Why.** The alternative — answering 200 to a leave for a peer that no |
| 1490 | longer exists — would be the one place on `/signal` where an unknown |
| 1491 | peer id is not a 404, and §4.2's "every lookup starts from the room in |
| 1492 | the URL, and an id from elsewhere names nothing here" would have an |
| 1493 | exception. The client sends `leave` once. |
| 1494 | |
| 1495 | ### 15.9 §4.6 — the stale-generation 409 carries no generation |
| 1496 | |
| 1497 | **Decision.** A targeted message with a stale `gen` is refused `409 |
| 1498 | {"error":"stale"}`, and the body does not name the current generation. |
| 1499 | |
| 1500 | **Why.** `roster.peers[].gen` is the reconciliation source §4.6 already |
| 1501 | specifies, and it arrives on admission, on every kind change and on |
| 1502 | every resume. A generation in the refusal body would be a second source |
| 1503 | of the same fact, able to disagree with the first, for a client that |
| 1504 | must handle the roster anyway. |
| 1505 | |
| 1506 | ### 15.10 §4.5 — four additions to the terminal-reason table |
| 1507 | |
| 1508 | Revision 8's terminal reasons are `replaced`, `standing`, `expired`, |
| 1509 | `removed` and `ended`. The client adds four dead ends that the server |
| 1510 | never names, because a browser can reach them without the hub having an |
| 1511 | opinion: |
| 1512 | |
| 1513 | | Reason | Reached when | What the person is told | |
| 1514 | |---|---|---| |
| 1515 | | `signedout` | a POST to `/signal` answers 401 or 403, or is redirected to sign-in | "You have been signed out." with a link to sign in | |
| 1516 | | `unreachable` | the fresh-stream ladder has failed its ceiling of attempts with no `hello`, over a window as well as a count | "Meet cannot reach the call. The room may be full, or you may have been signed out." | |
| 1517 | | `nowebrtc` | constructing an `RTCPeerConnection` throws | "This browser cannot make calls." | |
| 1518 | | `gone` | a fresh stream is refused 404 for a room this browser may no longer enter | "You cannot join this meeting." | |
| 1519 | |
| 1520 | `gone`'s wording is deliberately the least informative of the four: a |
| 1521 | fresh 404 is §2.5's "a meeting that is not yours does not exist", so the |
| 1522 | sentence must not distinguish an ended meeting from one that was never |
| 1523 | this person's. |
| 1524 | |
| 1525 | Every terminal screen carries a way out — a link to `/m/{sqid}/join` — |
| 1526 | so none of them is a dead end with nothing to click. |
| 1527 | |
| 1528 | ### 15.11 Recorded, not changed |
| 1529 | |
| 1530 | Two things the slice found that are not departures, kept here because |
| 1531 | they were otherwise recorded only in an untracked ledger: |
| 1532 | |
| 1533 | - **A just-added member gets 404 from the removal confirm page until the |
| 1534 | next tick.** `roomAccess` reads fresh standing while `PeerNameFor` |
| 1535 | reads the participation's cached kind, and only the tick refreshes it. |
| 1536 | It self-heals within fifteen seconds, and the Remove control is not |
| 1537 | drawn until the roster says member, so nobody sees the 404 without |
| 1538 | going looking for it. Not worth a database read on the page path. |
| 1539 | - **The hub is per process and dies with it** (§4.2, §4.5, stated but |
| 1540 | not solved). Slice 2 is the first release where that has a |
| 1541 | user-visible cost: **every deploy drops every live call.** Members |
| 1542 | rejoin automatically; guests go back to waiting and must be admitted |
| 1543 | again. No drain exists, and slice 3 shipping did not change that: |
| 1544 | every deploy still drops every live call, so deploying during |
| 1545 | meeting hours means exactly that. |
| 1546 | |
| 1547 | ### 15.12 §2.5 — the last row corrected: standing is not consulted *for a guest* |
| 1548 | |
| 1549 | **This corrects a published contract that contradicted itself.** |
| 1550 | Revision 8's §2.5 gives the last row — `POST /m/{sqid}/signal`, every |
| 1551 | other type, and `GET /m/{sqid}/ice` — as "Participation, admitted. |
| 1552 | Standing is not consulted; a guest's expired grant does not matter here |
| 1553 | (ruling 14)." The paragraph immediately under the same table says "A |
| 1554 | request by a participation whose kind is `member` re-derives standing on |
| 1555 | every request as well as on the tick; a member who has just been removed |
| 1556 | is dropped by the next thing they do, not the next tick." |
| 1557 | |
| 1558 | Both cannot be true. **The paragraph wins**, and the row now reads: |
| 1559 | |
| 1560 | > Participation, admitted. Standing is not consulted **for a guest**; a |
| 1561 | > participation whose kind is `member` re-derives it, per the paragraph |
| 1562 | > below (ruling 14 is about the guest). |
| 1563 | |
| 1564 | **Why.** The row was written for ruling 14 — the admitted guest whose |
| 1565 | grant lapses and keeps everything — and it says so. Reading it as |
| 1566 | covering members too gives an ex-member the room for up to fifteen |
| 1567 | seconds after their removal: their offers relay, their resets execute, |
| 1568 | and their resume returns the roster and the replay. That is exactly what |
| 1569 | the paragraph exists to forbid, and it is the only place in §2.5 where a |
| 1570 | removal is not immediate. |
| 1571 | |
| 1572 | **What this changes in the implementation.** `Relay`, `Reset`, `Resume` |
| 1573 | and `CanResume` — and therefore `GET /m/{sqid}/stream?peer=` and its |
| 1574 | probe — re-derive standing for a member-kind participation. The read |
| 1575 | happens outside the hub lock, like every other standing read; the |
| 1576 | participation is re-found under the lock and the answer applied through |
| 1577 | the one place that decides what a loss of standing costs, so the |
| 1578 | admitted guest's exemption is untouched. A member whose standing has |
| 1579 | gone is ended with `lost {reason: "standing"}` and the request answers |
| 1580 | 404 — byte-identical to the answer an unknown peer gets, because §2.5's |
| 1581 | "a peer that is not yours does not exist" does not make an exception for |
| 1582 | a peer that was yours a moment ago. The probe is the one exception to |
| 1583 | the ending: it answers 404 and writes nothing, because §15.3 defines it |
| 1584 | as changing nothing at all; the next mutating request, or the tick, does |
| 1585 | the ending. |
| 1586 | |
| 1587 | **The cost.** One standing read — two small database reads — per request |
| 1588 | made by a member-kind participation. It is bounded by the signal |
| 1589 | limiter's thirty requests a second per session and room, which was |
| 1590 | already the ceiling on this endpoint, and by the four streams per |
| 1591 | person of §4.4. A guest costs nothing extra: the kind is checked first |
| 1592 | and a guest never reaches the read. |
| 1593 | |
| 1594 | ### 15.13 §5.2 — the `/ice` wire shape, and its authority |
| 1595 | |
| 1596 | **Decision.** `GET /m/{sqid}/ice?peer=<id>` answers `200 |
| 1597 | {"iceServers": [...], "expiresAt": <unix seconds>, "renewAt": <unix |
| 1598 | seconds>}`. `iceServers` is §5.2's own list shape (`{urls, username?, |
| 1599 | credential?}`); a bare `stun`/`stuns` entry carries neither key. With |
| 1600 | no relay configured, the answer is `{"iceServers": [], "expiresAt": 0, |
| 1601 | "renewAt": 0}` — the floor, not a refusal. Every failure is the |
| 1602 | identical `404 {"error":"not-found","boot":…}` `/signal` itself |
| 1603 | answers, byte for byte: a malformed sqid, a malformed peer, an unknown |
| 1604 | room, an unknown or unbound peer, a peer belonging to another session, |
| 1605 | a waiting guest, a member who has lost standing, or a meeting that has |
| 1606 | ended. |
| 1607 | |
| 1608 | **The peer is a query parameter.** `?peer=` carries the id the way the |
| 1609 | stream's own resume already does, not a body. **Ruling.** A native |
| 1610 | client needs no body on a GET, and `/ice` has nothing else a body would |
| 1611 | carry. |
| 1612 | |
| 1613 | **Authority, before the store is ever read.** `/ice` runs |
| 1614 | `LiveForSession` — the same gate `/signal` runs before its own limiter |
| 1615 | — refusing the identical 404 for a session with no live participation |
| 1616 | in this exact room, before either the store or the rate limiter is |
| 1617 | touched. It then spends the shared session+room limiter bucket |
| 1618 | `/signal` already spends: a burst against either endpoint from one |
| 1619 | session drains the one budget it can ever spend, never two. Only past |
| 1620 | both gates does `/ice` read the meeting row — to catch a meeting the |
| 1621 | hub does not yet know has ended — and then call `hub.Authorize`, the |
| 1622 | §2.5 last-row check. |
| 1623 | |
| 1624 | **The development-only header.** `X-Meet-Test-Ice-TTL`, honoured only |
| 1625 | under `-env development`, shortens the credential's life for the gate. |
| 1626 | It is clamped both ways, to between 2 seconds and 3 hours, so a header |
| 1627 | can neither stall the schedule at a zero life nor mint a life past the |
| 1628 | platform's own four-hour ceiling. |
| 1629 | |
| 1630 | **Standing, re-derived exactly as `/signal` does.** §15.12 corrected |
| 1631 | the last row of §2.5's table: a participation whose kind is `member` |
| 1632 | re-derives standing on every request, and one whose standing is gone |
| 1633 | is ended with `lost {reason: "standing"}` and answered the same 404 an |
| 1634 | unknown peer gets. `/ice` is one of those requests, exactly as |
| 1635 | `/signal` is. A guest never consults standing at all (ruling 14) — |
| 1636 | `/ice` does not reopen the door §15.12 closed for a guest; it stands |
| 1637 | behind the same gate `Relay` and `Reset` do. |
| 1638 | |
| 1639 | ### 15.14 §5.3 — the renewal schedule is skew-free, and its two verdicts are not one |
| 1640 | |
| 1641 | **Decision.** The client never schedules a renewal on the server's |
| 1642 | clock alone: it takes the earlier of two readings, both measured from |
| 1643 | the moment of the fetch that produced them. The *ratio* reading is |
| 1644 | `(expiresAt − renewAt) × 5` — the margin the server left itself, |
| 1645 | multiplied out from the contract's own shape (renewal at five sixths |
| 1646 | of the life, so the margin is a sixth of it and the delay is five of |
| 1647 | them) — built from two of the server's own instants subtracted from |
| 1648 | each other, so it survives any skew between the two clocks entirely. |
| 1649 | The *clock* reading is `renewAt − fetchedAt`: this browser's own clock |
| 1650 | against the server's stated instant, exact when the clocks agree and |
| 1651 | wrong only in the direction that costs nothing — a browser running |
| 1652 | ahead renews early, and one running behind gets a longer answer, which |
| 1653 | the ratio reading then caps. The earlier of the two wins, with a floor |
| 1654 | under the delay so a server that has truncated its numbers down to no |
| 1655 | margin at all cannot turn the schedule into a spin. The must-succeed-by |
| 1656 | deadline is read the same way, at six of the margin rather than five. |
| 1657 | |
| 1658 | **Why not `renewAt` read as a point on the server's clock.** The |
| 1659 | server sends whole seconds, and truncation eats the margin at short |
| 1660 | lives: a twelve-second life gives 10 and 12 — margin 2, and the ratio's |
| 1661 | arithmetic is exact — while a ten-second life gives 8 and 10, where the |
| 1662 | same arithmetic would schedule the renewal at the very moment the |
| 1663 | credential dies. Reading `renewAt` as an instant on the server's own |
| 1664 | clock, rather than a duration counted from this browser's own fetch, |
| 1665 | would additionally put a client whose clock is minutes off the |
| 1666 | server's on the wrong schedule outright — early enough to be harmless, |
| 1667 | or late enough to treat a credential as still good after it has |
| 1668 | expired. Neither truncation nor skew is acceptable on its own reading, |
| 1669 | so the client keeps both and takes whichever is shorter. |
| 1670 | |
| 1671 | **The three renewal outcomes.** |
| 1672 | |
| 1673 | - **Renewed.** The exchange has completed, `signalingState` is |
| 1674 | `stable` with the new answer applied, and `getStats()` reports a |
| 1675 | selected pair, `succeeded`, carrying the restart's own |
| 1676 | `usernameFragment` — §5.3 step 2's own proof. Nothing is said to the |
| 1677 | person: the tile carries no sentence at all, and the call continues |
| 1678 | on the new credential. |
| 1679 | - **Expired** — "The relay credential expired for this connection." |
| 1680 | The old credential's own deadline, the must-succeed-by, has passed |
| 1681 | with nothing proven, because the fetch itself kept failing. This is |
| 1682 | the one honest signal the browser has, since it cannot see which |
| 1683 | credential the relay allocation is actually using. |
| 1684 | - **Stuck** — "Could not renew this connection through the relay." A |
| 1685 | new credential is in hand and three ICE restarts have run against |
| 1686 | it, none of them proven within the window. The credential is fine; |
| 1687 | the path is not — saying "expired" here would be a sentence about the |
| 1688 | wrong thing. |
| 1689 | |
| 1690 | ### 15.15 §4.3, §4.4, §5.4, §6, §10 — the chat and share wire, the share tile, and the panel's adoptable contract |
| 1691 | |
| 1692 | **Decision.** `POST /m/{sqid}/signal {"peer","type":"chat","payload": |
| 1693 | {"text":"…"}}` accepts a `text` of 1..2048 bytes of valid UTF-8; |
| 1694 | `{"type":"share","payload":{"on":true|false}}` carries nothing else. |
| 1695 | Either type is refused `400 {"error":"bad-request"}` for any other |
| 1696 | shape — a payload with an extra key, a `text` that is missing, |
| 1697 | non-string or empty, an `on` that is not a bool, a key that is not |
| 1698 | spelled exactly `text` or `on`, the same key twice, or a `to` on either |
| 1699 | type (chat and share are never targeted; §4.3's fan-out has nowhere to |
| 1700 | route one) — and `413 {"error":"too-large"}` once the raw payload — the |
| 1701 | bytes on the wire, before decoding — exceeds 2048 + 32 bytes. The +32 |
| 1702 | margin is the JSON structure the text sits inside (`{"text":"…"}`, the |
| 1703 | field name and its quoting), not a second allowance for the text |
| 1704 | itself. That raw cap, and `utf8.Valid`, are judged against the raw |
| 1705 | bytes before any JSON decoding: a duplicated `"text"` key or a run of |
| 1706 | `\uXXXX` escapes both change the raw byte count without changing what a |
| 1707 | decoded field reports. |
| 1708 | |
| 1709 | **Keys are exact, and counted.** `encoding/json` matches an object's |
| 1710 | keys to struct fields case-insensitively, and `DisallowUnknownFields` |
| 1711 | does not change that: `{"text":"<2049 bytes>","Text":"x"}` fits inside |
| 1712 | the raw cap, decodes to the *last* match — a one-byte text — and, if |
| 1713 | the raw bytes were forwarded, would deliver 2049 bytes to every |
| 1714 | recipient, which reads the lowercase key. So the payload's keys are |
| 1715 | checked byte-exactly, through a token walk that counts members rather |
| 1716 | than a map (which collapses a duplicate and cannot tell |
| 1717 | `{"text":"a","text":"b"}` from `{"text":"b"}`), before any value is |
| 1718 | looked at. |
| 1719 | |
| 1720 | **§4.4's 2 KB is the escaped length of the text, and both sides count |
| 1721 | it.** The limit is on the text as the wire carries it: its JSON-escaped |
| 1722 | UTF-8 form, without the quotes around it. A quote, a backslash and a |
| 1723 | newline each cost two bytes; an angle bracket or an ampersand costs one |
| 1724 | (nothing here is HTML-escaped — see the paragraph below). The panel |
| 1725 | counts the same thing with `JSON.stringify(text)` less its two quotes, |
| 1726 | so a draft the panel accepts is a draft the server accepts: counting |
| 1727 | raw UTF-8 on the client and raw JSON on the server left a gap where a |
| 1728 | 2048-byte text with twenty-three quotes in it passed one and was |
| 1729 | refused `413` by the other, for a rule the client had no way to apply. |
| 1730 | |
| 1731 | **What is forwarded is the validated value, re-encoded.** `Fanout` |
| 1732 | carries a canonical payload built from what these checks judged — not |
| 1733 | the request's own bytes — so an escape is delivered decoded, a key is |
| 1734 | delivered in the one spelling clients read, and the size on every |
| 1735 | recipient's stream is the size that was measured. The SSE encoder |
| 1736 | disables Go's HTML escaping for the same reason: `json.Marshal` turns |
| 1737 | `<`, `>` and `&` into `<`, `>` and `&` *inside* a |
| 1738 | `json.RawMessage` too, which took a validated 2059-byte payload of |
| 1739 | literal `<` to 12,381 bytes on the wire. SSE data is not HTML, and the |
| 1740 | one property a line-oriented wire actually needs — that a payload |
| 1741 | cannot end its own frame and start another — comes from JSON's own |
| 1742 | escaping of newlines inside strings, which is unaffected. |
| 1743 | |
| 1744 | **The downstream envelope.** The hub's `Fanout` rebuilds the message it |
| 1745 | delivers from the sender's own participation — `{type, from, name, |
| 1746 | payload}`, where `from` and `name` are the sender's own peer id and |
| 1747 | display name, never anything the caller's request supplied — carrying |
| 1748 | no `to`, `seq`, `gen`, `reason`, `roster`, `boot` or any other field a |
| 1749 | targeted or server message might carry, however the request tried to |
| 1750 | set them. This is `Relay`'s own authority check (§15.12, ruling 14) |
| 1751 | reused for a fan-out instead of a single target: an unadmitted or |
| 1752 | foreign-session peer is refused before anything is sent, and a member |
| 1753 | whose standing has gone is ended and refused, exactly as `/signal`'s |
| 1754 | targeted types already are. |
| 1755 | |
| 1756 | **Recipients.** Every other admitted peer in the room, and never the |
| 1757 | sender: the sending tab's own panel echoes the line locally, on the |
| 1758 | acceptance the fan-out reports back, so echoing it again over the wire |
| 1759 | would show it twice. A waiting guest receives neither `chat` nor |
| 1760 | `share`, matching every other admitted-only message in §4.3's table; |
| 1761 | its own attempt to send either gets the same 404 an unknown peer id |
| 1762 | gets, never a 403 (§2.5). Nothing of a chat is written to any |
| 1763 | recipient's replay ring, and nothing is stored: §6's "not stored, not |
| 1764 | replayed" is enforced inside `Fanout` itself, which delivers to an |
| 1765 | attached subscriber or not at all — a detached, resumable peer's later |
| 1766 | `Resume` carries no chat it missed while detached, the same silence a |
| 1767 | peer who joined after it was sent already has. |
| 1768 | |
| 1769 | **The panel's mount API, as estilo's adoptable contract.** The chat |
| 1770 | panel is one template partial (`templates/partials/chat.html`) and one |
| 1771 | script (`static/chat.js`), following §10's rule that the panel is |
| 1772 | Meet's first contribution the suite can adopt rather than borrow. The |
| 1773 | contract a host must meet is `window.Chat.mount(root, {name, send}) → |
| 1774 | {append, setEnabled, clear}`: `send(text)` receives trimmed, non-empty |
| 1775 | text of at most 2048 bytes and returns, resolves, or throws to, a |
| 1776 | boolean; the panel appends the sender's own line — |
| 1777 | `append({name, text, self: true})` — only when that answer is the |
| 1778 | literal value `true`, never on any other truthy result, so a `Response` |
| 1779 | object or a non-boolean truthy answer reads as a refusal rather than an |
| 1780 | echo. `append` renders an arriving `{from, name, text, at, self}` with |
| 1781 | `textContent` only; `setEnabled` is the host's own gate, moved on |
| 1782 | admission and on every terminal or leave; `clear` empties the log, |
| 1783 | which is the whole of chat's history once the tab is gone (§6). Nothing |
| 1784 | executable in the two files names a room, a peer id, or Meet by |
| 1785 | anything but a code comment. |
| 1786 | |
| 1787 | **Screen share as its own transceiver.** §5.4's `getDisplayMedia` track |
| 1788 | is carried on one `sendonly` transceiver per connection |
| 1789 | (`addTransceiver(track, {direction: 'sendonly', streams: |
| 1790 | [share.stream]})`), added to every connection already open when |
| 1791 | sharing starts and to every connection built while it continues; the |
| 1792 | camera's own transceivers are untouched. A peer that receives a second |
| 1793 | video track for another peer draws it as a second tile, keyed |
| 1794 | `<peer>#share` and captioned from that peer's roster name and the |
| 1795 | `share` message alone — never a track label, which never crosses the |
| 1796 | wire. The tile is removed on whichever comes first: the received |
| 1797 | track's own `ended` (fired by the sender's `transceiver.stop()`, or by |
| 1798 | the browser's own "stop sharing" control) or a `share {on: false}` |
| 1799 | fan-out; the two are independent because a stopped transceiver does |
| 1800 | not reliably fire `ended` at the far end on every browser. The camera |
| 1801 | tile and its frame count are never touched by a share starting, |
| 1802 | stopping, or being dropped. |
| 1803 | |
| 1804 | ### 15.16 §5.1 — the polite side yields before it offers |
| 1805 | |
| 1806 | **Decision.** Before making an offer, the polite side of a pair |
| 1807 | (§5.1's own tie-break, the lower peer id) waits 300 ms |
| 1808 | (`POLITE_YIELD`) — while it has an allowance left to spend, which is |
| 1809 | the paragraph after this one. A side with none offers straight away and |
| 1810 | takes the ordinary collision path, so the wait is a rule about the |
| 1811 | usual case rather than about every offer. Once entered, the wait itself |
| 1812 | runs to its end; what is read after it is the connection. If a remote offer arrived meanwhile, or a whole remote |
| 1813 | exchange arrived and completed, no offer of this side's own goes out |
| 1814 | and the negotiation belongs to whatever that left behind; otherwise the |
| 1815 | offer goes out as it always did. |
| 1816 | |
| 1817 | **Three things bound it**, all per connection. A **negotiation |
| 1818 | generation**, moved by this side's own offer and by every exchange it |
| 1819 | answers: a yield captures it before sleeping and abandons its offer if |
| 1820 | it has moved, which is the only way to tell "nothing happened" from "an |
| 1821 | exchange came and went while I slept" — both leave the connection |
| 1822 | `stable`. A **single attempt in flight**: `negotiationneeded` can fire |
| 1823 | again while the first attempt sleeps, and two attempts used to produce |
| 1824 | two offers, the second skipping the yield because the first had already |
| 1825 | spent it. And an **allowance of two yields in a row**, spent by giving |
| 1826 | way, zeroed by this side's own offer, and handed back one at a time by |
| 1827 | an exchange this side answered. The hand-back is what keeps the |
| 1828 | allowance from being a per-connection budget that one glare — an ICE |
| 1829 | restart, say — spends for good, leaving a later simultaneous share |
| 1830 | unprotected; the bound is what keeps giving way from becoming a habit, |
| 1831 | so two peers renegotiating in turn cannot leave one permanently a beat |
| 1832 | behind the other. |
| 1833 | |
| 1834 | **And a camera that has already been rebuilt is repaired.** The yield |
| 1835 | prevents the rollback; it cannot undo one. Once a second, per |
| 1836 | connection, `getStats` is read for the outbound video report on this |
| 1837 | side's own camera m-line: two polls in a row with no new |
| 1838 | `framesEncoded`, while the track is `live` and the m-line's |
| 1839 | `currentDirection` sends, is a camera that has stopped encoding — |
| 1840 | nothing else notices, because nothing has failed. The repair is |
| 1841 | `replaceTrack(null)` then `replaceTrack(track)`, which makes the |
| 1842 | browser build a new encoder for the same sender; two more frozen polls |
| 1843 | after that and the pair is reset instead; after that it stops asking, |
| 1844 | because a reset that did not take is not a reason to ask again every |
| 1845 | two seconds. |
| 1846 | |
| 1847 | **And a tile that decodes and does not paint is handed its media |
| 1848 | back.** One link further along, and the one nothing else notices. |
| 1849 | |
| 1850 | *What counts as a stall*, on the same one-second `getStats` walk, and |
| 1851 | all of it together: `framesDecoded` for that tile rising, the tile's |
| 1852 | track live and unmuted, the element not paused, and the element's own |
| 1853 | clock advancing by less than `RENDER_MOVED` (0.25 s) in the poll — |
| 1854 | three polls in a row of that. A second of video is a second of clock |
| 1855 | and a frame is thirty milliseconds, so a tile painting one frame every |
| 1856 | few seconds meets this criterion and **is repaired**: it is a stall |
| 1857 | with a tic, not health. Reading any advance at all as health let such a |
| 1858 | tile reset the budget for ever and never earn its reset. |
| 1859 | |
| 1860 | *The repair* is a fresh `MediaStream` over the same tracks put back on |
| 1861 | the element and played again — fresh, and the role's own, so every |
| 1862 | track adopted afterwards still reaches the element. Two of those, then |
| 1863 | a reset, then it stops asking, until qualifying clock movement gives |
| 1864 | the allowance back. |
| 1865 | |
| 1866 | *What is exempt*, which is a different list and not a softer version of |
| 1867 | the same one. A tile **receiving nothing** is idle: there is nothing to |
| 1868 | paint, and the decode count is read alongside the clock precisely to |
| 1869 | tell that apart from a stall. A track that has **ended or gone muted** |
| 1870 | is the sender's doing and the sender's to fix. An element the autoplay |
| 1871 | policy **paused** is the person's: §5 offers a Play control, and this |
| 1872 | watch — and every rebinding of a tile — leaves it alone while that |
| 1873 | control is showing, because calling `play()` behind it hides a question |
| 1874 | nobody has answered. Each of those three cost a red gate run to learn. |
| 1875 | |
| 1876 | **Three separate facts, recorded separately**, because they were run |
| 1877 | together once and the section contradicted itself. |
| 1878 | |
| 1879 | *The historical observation.* Two peers who each add a screen-share |
| 1880 | transceiver to the same connection in the same moment collide, and the |
| 1881 | polite side's `setRemoteDescription` rolls its own offer back to accept |
| 1882 | the impolite side's. Chromium answers a rollback of an offer that added |
| 1883 | an m-line by tearing down and rebuilding every send **and receive** |
| 1884 | stream on that connection, not only the one the new m-line belongs to. |
| 1885 | In slice 4's Task 4 that was measured as an encoder loss: the camera's |
| 1886 | sender came back on the same SSRC having encoded nothing and never |
| 1887 | encoded another frame, while its m-line went on reporting |
| 1888 | `sendrecv/sendrecv` with a live track and the far side's matching |
| 1889 | receiver went `muted`. It reproduced in three of five runs of |
| 1890 | `mutualshare`, with both screens perfect and both faces stopped. |
| 1891 | Nothing in `room.js` touches the camera; the loss was entirely a side |
| 1892 | effect of the browser's own rollback. That observation stands as |
| 1893 | evidence of what a rollback costs. It is not the diagnosis of anything |
| 1894 | still failing. |
| 1895 | |
| 1896 | *What the yield does about it.* It prevents the rollback rather than |
| 1897 | undoing one: an offer answered from `stable` needs no rollback. It is a |
| 1898 | duration and not a guarantee — a pair whose signalling round trip |
| 1899 | exceeds 300 ms, or whose offer is late for any other reason, a `429` |
| 1900 | retry included, still collides, and on that pair the polite side still |
| 1901 | rolls back. |
| 1902 | |
| 1903 | *What is repaired when it happens anyway.* Two watches, both on the |
| 1904 | one-second `getStats` walk, both bounded, both ending in a reset: the |
| 1905 | encoder watch above (outbound `framesEncoded` static on a sending |
| 1906 | m-line with a live track) and the render watch (frames decoded, clock |
| 1907 | stopped). Neither is a proof that the rollback is harmless; each is a |
| 1908 | bounded recovery from one way it has been seen to hurt. |
| 1909 | |
| 1910 | **What the gate proves, and what it does not.** `laterolls` holds one |
| 1911 | side's own `offer` requests for four times the yield, so the remote |
| 1912 | offer arrives after it and the collision is late by construction, and |
| 1913 | it asserts the rollback separately from the recovery: the scenario |
| 1914 | counts remote offers applied over this side's own outstanding offer, |
| 1915 | and fails if there was no rollback to recover from. The recovery it |
| 1916 | then requires is frames arriving across two successive intervals, for |
| 1917 | both cameras and every screen. On the head this was written against, |
| 1918 | the encoder loss did **not** reproduce — the far side's outbound counts |
| 1919 | kept rising throughout — and what did was the rendering stall: the tile |
| 1920 | bound to exactly the receiver's own live, unmuted track (`meet.media()` |
| 1921 | reports `agrees: true`), the decoder delivering, and the picture |
| 1922 | stopped at `currentTime` 1.452 for as long as anything watched it. |
| 1923 | Rendered frames every 500 ms read `[25,26,26,26,26,26]` — stopped, and |
| 1924 | stopped for as long as anything watched — before the render watch, and |
| 1925 | `[25,25,25,25,25,26]` after it: frozen for the three polls it takes to |
| 1926 | notice, then moving again. A later run of the same scenario stopped |
| 1927 | dead at `[25,25,25,25,25,25]` and came back once the watch escalated |
| 1928 | past the stutter rule. |
| 1929 | |
| 1930 | **What that evidence is worth.** The recovery is now required to *last* |
| 1931 | rather than to happen: every media check in `share`, `mutualshare` and |
| 1932 | `laterolls` observes each expected stream over three windows of a |
| 1933 | second and a half and fails any stream that stops in any one of them, |
| 1934 | which the two-early-returns check it replaced did not — counters |
| 1935 | reading 0, 1, 2, 3 and then frozen for ever satisfied that one. What is |
| 1936 | still not established is *why* a fresh `MediaStream` unblocks the |
| 1937 | element: it is a browser workaround with a measured effect, not a |
| 1938 | diagnosis, and it does not show that the decoder pipeline is rebuilt. |
| 1939 | The identity, decode and render reproduction is kept in the slice |
| 1940 | record for an upstream report; until Chromium is understood, this |
| 1941 | section claims a bounded, timed recovery and no more. |
| 1942 | |
| 1943 | **If a deployment reports a face freezing after two people share at |
| 1944 | once**, the order to check is: `POLITE_YIELD` against the pair's actual |
| 1945 | signalling round trip; then which watch fired, if either — the encoder |
| 1946 | one says the camera stopped being produced, the render one says it |
| 1947 | stopped being painted; then `meet.media()`'s `agrees`, which separates |
| 1948 | a binding fault from a pipeline one; and then whether Chromium still |
| 1949 | rebuilds every stream on the connection when it rolls back an offer |
| 1950 | that added an m-line. |
| 1951 | |
| 1952 | ### 15.17 §8 — the drive surface's API wire, the reach rule, and the badge |
| 1953 | |
| 1954 | **Decision.** `agents(id, name, operator_person_id, token_hash, |
| 1955 | created_at, revoked_at)`. A token is minted once, in plaintext, on the |
| 1956 | one page that shows it (`agent_once.html`): `"mt_" + 32` random bytes, |
| 1957 | base64url without padding; the row stores only its SHA-256 hex. Nothing |
| 1958 | past that page ever sees the plaintext again — a lost token cannot be |
| 1959 | recovered, only revoked and re-minted. An operator holds at most 20 |
| 1960 | live agents at once, enforced in the same transaction as the insert, |
| 1961 | alongside a live-duplicate-name check; a revoked agent's name is free |
| 1962 | to reuse immediately, so this is a plain query rather than a unique |
| 1963 | index. Every write an agent makes records the actor `agent:<id>` |
| 1964 | (`actorForAgent`), exactly the shape `person:<id>` already used |
| 1965 | (§7.2); the identity a reader is shown is the **principal**, |
| 1966 | `<name>~<operator email>` (`principal()`, `agents.go`), never the bare |
| 1967 | id. |
| 1968 | |
| 1969 | **The five operations, exactly.** |
| 1970 | |
| 1971 | | Request | Answer | |
| 1972 | |---|---| |
| 1973 | | `GET /api/v1/meetings` | `200 {"meetings":[<meeting>, …]}`, same order as the HTML list | |
| 1974 | | `POST /api/v1/meetings` `{"title":"…"[,"starts_at":"<RFC3339>"]}` | `201 {"meeting":<meeting>,"link":"<origin>/j/<token>"}` | |
| 1975 | | `GET /api/v1/meetings/{sqid}` | `200 {"meeting":<meeting>,"admitted":N}` — `admitted` present only while `ended_at` is null, and never a `"link"` key | |
| 1976 | | `POST /api/v1/meetings/{sqid}/links` | `200 {"meeting":<meeting>,"link":"<origin>/j/<token>"}` | |
| 1977 | | `POST /api/v1/meetings/{sqid}/end` | `200 {"meeting":<meeting>}` — re-read after the write, so `ended_at` and `link_present:false` are the stored truth, and no `"link"` or `"admitted"` key | |
| 1978 | |
| 1979 | `<meeting>` is exactly six keys — `sqid`, `title`, `starts_at`, |
| 1980 | `ended_at`, `link_present`, `created_by` — a plain JSON object built |
| 1981 | field by field, never a marshalled model. Times are RFC 3339 in UTC (an |
| 1982 | agent has no instance zone to render in). `created_by` is the |
| 1983 | principal for `agent:<id>`, the person's display name for |
| 1984 | `person:<id>`, and the raw actor string if the row behind it no longer |
| 1985 | resolves (`store.creatorLabel`) — a label, never an authorization |
| 1986 | input. |
| 1987 | |
| 1988 | **Refusals, one shape per case.** |
| 1989 | |
| 1990 | | Case | Status | Body | |
| 1991 | |---|---|---| |
| 1992 | | no `Authorization`, a non-bearer scheme, `Bearer` with an empty token, a session cookie with no bearer | 401 | `{"error":"unauthorized"}` | |
| 1993 | | unknown token, revoked token, an operator not currently a member | 404 | `{"error":"not-found"}` | |
| 1994 | | unknown `sqid`, a `sqid` not shaped like `newSqid` produces, a path or method this surface does not have | 404 | `{"error":"not-found"}` | |
| 1995 | | over 60 requests a minute for one agent | 429 | `{"error":"rate"}` + `Retry-After` (whole seconds, never below 1) | |
| 1996 | | `end` or `links` on an ended meeting | 409 | `{"error":"ended"}` | |
| 1997 | | malformed JSON, unknown field, anything other than JSON whitespace after the first value, empty body, title empty or over 200 characters, `starts_at` not RFC 3339 | 400 | `{"error":"bad-request"}` | |
| 1998 | | any body at all on `links` or `end` | 400 | `{"error":"bad-request"}` | |
| 1999 | | reading the body encounters bytes beyond 16,384, on any operation that reads one (`POST /meetings`, `links`, `end`) | 413 | `{"error":"too-large"}` | |
| 2000 | | a driver failure | 500 | `{"error":"server"}` | |
| 2001 | |
| 2002 | **The body rules, in full (review S5-1, corrected by S5-7).** Every |
| 2003 | request on this surface is wrapped in `http.MaxBytesReader` at 16 KB by |
| 2004 | the group's own middleware (`capAPIBody`), before a handler runs — the |
| 2005 | operations that read a body and the ones that do not alike. A cap |
| 2006 | applied inside the decoder only would leave `links` and `end` reading |
| 2007 | nothing and refusing nothing, so a megabyte posted at `/end` performed |
| 2008 | the write. |
| 2009 | |
| 2010 | `POST /meetings` decodes exactly one JSON value and then **requires |
| 2011 | EOF**: a second value, a stray `]` or `}`, a trailing scalar, anything |
| 2012 | but whitespace-then-end is 400. Trailing whitespace *is* accepted — a |
| 2013 | newline on the end of a body is what a file, and `curl --data-binary |
| 2014 | @file`, sends, and whitespace after a value is part of JSON's own |
| 2015 | grammar, not content. `encoding/json`'s `Decoder.More()` is not the |
| 2016 | question to ask — it answers "is another array or object element |
| 2017 | waiting", which is false for a trailing `]` and false for a trailing |
| 2018 | `}`, and it swallows the decoder's stored read error, so a valid |
| 2019 | object followed by 17 KB of whitespace came back as a clean decode |
| 2020 | rather than the 413 the cap promises. A second `Decode` asks the |
| 2021 | honest question and hands back the honest error, and that error is |
| 2022 | what separates 413 from 400: a `*http.MaxBytesError` anywhere in the |
| 2023 | chain is 413, everything else is 400. |
| 2024 | |
| 2025 | `links` and `end` take **no body**. Theirs is read to its end under |
| 2026 | the same cap and refused if it carried anything: a body that runs past |
| 2027 | 16,384 bytes is 413, and any other content — a JSON object, a single |
| 2028 | space, 16,384 bytes of padding — is 400. An absent body and a |
| 2029 | `Content-Length: 0` body are the two shapes that pass. Both answer |
| 2030 | before the write, never after it. |
| 2031 | |
| 2032 | **The cap is 16,384 bytes and the boundary is inclusive (review |
| 2033 | S5-9).** `MaxBytesReader` refuses only when a read encounters a byte |
| 2034 | *beyond* the limit, so a body of exactly 16,384 bytes is within it and |
| 2035 | 16,385 is not. The two surfaces that read a body then answer |
| 2036 | differently on the inside of that boundary, because the cap is not |
| 2037 | what they disagree about: a create padded with JSON whitespace to |
| 2038 | exactly 16,384 bytes is a 201, while a bodyless write carrying the |
| 2039 | same 16,384 bytes is a 400 — it takes no body at all, whatever its |
| 2040 | size. One byte more is 413 on both. `TestAPIBodyCapBoundary` pins all |
| 2041 | four. |
| 2042 | |
| 2043 | **413 is lazy, and the order of refusals says when it can happen |
| 2044 | (review S5-7).** `MaxBytesReader` limits *reading*, not receiving: the |
| 2045 | 413 happens when a handler's consumption of the body runs past the |
| 2046 | cap, so it is reachable only on the three operations that read a body |
| 2047 | at all. Concretely, and deliberately: |
| 2048 | |
| 2049 | 1. **401** — no credential. Judged on the `Authorization` header alone, |
| 2050 | before a byte of body is read, so an oversized request with no |
| 2051 | bearer is 401 rather than 413. |
| 2052 | 2. **404** — a token that does not resolve, or an operator without |
| 2053 | member standing. Also before the body. |
| 2054 | 3. **429** — the agent's own budget, checked in `requireAgent` after |
| 2055 | the token resolves and still before the body. |
| 2056 | 4. **The body rules** — 413 or 400, in the handler, in whatever order |
| 2057 | the bytes arrive: an oversized body that is *also* malformed from |
| 2058 | its first bytes can answer 400 before the reader ever runs past the |
| 2059 | cap, because the decoder fails on what it has already read. Both are |
| 2060 | refusals with no write behind them, so which one an agent gets is |
| 2061 | not a promise this surface makes. |
| 2062 | |
| 2063 | `GET /api/v1/meetings` and `GET /api/v1/meetings/{sqid}` read no body, |
| 2064 | so a GET carrying one is answered 200 on its merits and the body is |
| 2065 | discarded unread with the request. That is Go's own behaviour for a |
| 2066 | handler that ignores `r.Body`, and this surface adds nothing to it: a |
| 2067 | body on a GET has no meaning here, enforcing a size on bytes nobody |
| 2068 | reads would buy nothing, and there is no operation where a GET body |
| 2069 | could reach a write. |
| 2070 | |
| 2071 | **The 409 is decided inside the write, not before it (review S5-2).** |
| 2072 | Both write handlers used to read the meeting, check `ended_at`, and |
| 2073 | then enter the store's transaction — a snapshot, not a guarantee. Two |
| 2074 | ends could each read a running meeting and each answer 200, and a |
| 2075 | replacement could read a running meeting, pause while another request |
| 2076 | ended it, and leave an unrevoked link on an ended meeting with |
| 2077 | `link_present:true`. SQLite's single writer serializes the |
| 2078 | transactions; it does nothing about the reads in front of them. Now |
| 2079 | `store.endMeeting` reports whether its conditional `UPDATE … WHERE id |
| 2080 | = ? AND ended_at IS NULL` actually changed a row — false is the 409 — |
| 2081 | and `store.replaceLink` re-reads `ended_at` inside its own |
| 2082 | transaction, answering `errMeetingEnded`, which the API maps to the |
| 2083 | same 409. Exactly one of two racing ends gets the 200, and an ended |
| 2084 | meeting never keeps a live link. The HTML side shares both store |
| 2085 | paths and keeps the answers it always gave: the confirm page's end |
| 2086 | redirects to the list either way (ending an already-ended meeting is |
| 2087 | what the member asked for), and its replace answers the not-found |
| 2088 | page. |
| 2089 | |
| 2090 | The gate for this is `TestAPIAStaleReadCannotWriteOverAnEnd`, which |
| 2091 | *forces* the interleaving rather than hoping for it (review S5-6): a |
| 2092 | test-only GORM query callback on the harness's own `*gorm.DB` holds one |
| 2093 | request at the point its handler has read the running meeting, a second |
| 2094 | request ends the meeting and commits, and only then is the first let go |
| 2095 | into its write. Firing two requests at once |
| 2096 | (`TestAPIConcurrentEndsAndReplacesSeeTheEndedRow`) is a smoke test |
| 2097 | beside it, not a proof: nothing in it stops the first request |
| 2098 | finishing before the second reads. |
| 2099 | |
| 2100 | **The 401/404 rule, and why it is judged on the header alone.** 401 |
| 2101 | means "you presented no credential" — no header, a non-bearer scheme, |
| 2102 | an empty token — and is true of every caller alike, telling an attacker |
| 2103 | nothing they did not already send. Every token-shaped string, real or |
| 2104 | made up, goes to the store from there: unknown, revoked, and an agent |
| 2105 | whose operator has left the team all answer the identical not-found |
| 2106 | body, so nothing about which of the three it was is ever observable. A |
| 2107 | malformed bearer is judged on the header, never on the token's own |
| 2108 | shape — there is no `mt_` prefix check anywhere on this path, which is |
| 2109 | what keeps a fabricated token indistinguishable by status from a real |
| 2110 | one that was revoked. |
| 2111 | |
| 2112 | **Reach is re-derived on every request, never cached.** |
| 2113 | `requireAgent`'s order is `Authorization` → `agentByToken` (unrevoked) |
| 2114 | → `isMember(operator)` → the per-agent limiter. An operator who leaves |
| 2115 | the team takes their agent's reach with them at the very next call, |
| 2116 | with no revocation step; §13's slice-5 gate |
| 2117 | (`TestAPIReachIsTheOperatorsStandingOnEveryRequest`) proves the round |
| 2118 | trip: allowlisted, listing 200; off the allowlist, the next list is the |
| 2119 | same 404 an unknown token gets; back on, 200 again with no re-minting; |
| 2120 | revoked through Settings, 404 again. |
| 2121 | |
| 2122 | **The per-agent limit is 60 requests a minute, burst 60** — one token a |
| 2123 | second into a bucket of sixty, keyed on `actorForAgent(ag)` and run |
| 2124 | *after* the token resolves to an agent, not on the raw header or the |
| 2125 | client address. Keying on an attacker-chosen string first would let |
| 2126 | 4096 fake tokens (`maxAgentBuckets`) evict every real agent's bucket |
| 2127 | before a single one of them is ever checked to exist. There is |
| 2128 | deliberately no client-address limiter on this surface at all: behind |
| 2129 | the platform's relay socket every request the app sees shares one |
| 2130 | address, so a limiter keyed on it would let one flood lock out every |
| 2131 | real agent along with whatever was flooding. Unauthenticated flood |
| 2132 | control at the edge is a platform question, carried to carlos/platform |
| 2133 | rather than solved here. |
| 2134 | |
| 2135 | **Title length is 200 characters, counted as runes, on both surfaces.** |
| 2136 | The create screen's own copy ("Keep the title under 200 characters.") |
| 2137 | and the API's `bad-request` both measure `utf8.RuneCountInString` |
| 2138 | against the same `maxTitleLen` (`titleTooLong`, `handlers_meetings.go`) |
| 2139 | — not a byte count, which would let the API refuse a title the |
| 2140 | screen's own sentence promises is fine the moment it holds anything |
| 2141 | outside ASCII. A title is trimmed first, as the screen trims. Titles |
| 2142 | are otherwise unfiltered data, never markup: a lone surrogate or an |
| 2143 | embedded NUL is accepted and stored as sent on both surfaces, which is |
| 2144 | informational rather than a defect — nothing downstream renders a |
| 2145 | title as anything but an escaped JSON string or HTML text. |
| 2146 | |
| 2147 | **The API is mounted outside `csrf.Protect`, and the reason is |
| 2148 | structural, not incidental.** `csrf.Protect`'s `SameOrigin` check |
| 2149 | refuses a state-changing request carrying none of `Sec-Fetch-Site`, |
| 2150 | `Origin` or `Referer` — which is every request a non-browser agent |
| 2151 | sends, since those three headers are a browser's own doing. Mounting |
| 2152 | `/api/v1` outside it is safe for exactly one reason: this surface reads |
| 2153 | no cookie and honours nothing but the bearer, so there is no ambient |
| 2154 | credential a cross-origin page could spend through it — the attack CSRF |
| 2155 | defends against does not exist here. `api.go`'s own doc comment on |
| 2156 | `apiRoutes` states this; `TestABearerOnAnHTMLRouteIsIgnored` and |
| 2157 | `TestAPIRequiresABearerAndIgnoresCookies` prove both directions: a |
| 2158 | bearer on an HTML route is inert, and a cookie on the API route is |
| 2159 | 401. |
| 2160 | |
| 2161 | **What this surface does not promise.** `admitted` |
| 2162 | (`GET /api/v1/meetings/{sqid}`) is this process's own count from the |
| 2163 | in-memory hub — on a multi-host fleet it is that host's count, not the |
| 2164 | meeting's, exactly as §4.2 already says of everything else the hub |
| 2165 | answers. `GET /api/v1/meetings` has no paging; a team's meeting list is |
| 2166 | small, but an agent polling a year-old instance reads the whole list |
| 2167 | every time. An unclean path under `/api/v1` (a doubled slash, a |
| 2168 | trailing one) is answered by Go's own path cleaning — a 307 — before |
| 2169 | `requireAgent` or the not-found handler ever sees the request, so that |
| 2170 | redirect is not this surface's own 404 and does not carry its body. |
| 2171 | |
| 2172 | **The badge (§8.1's "robot badge and operator name on every write"), |
| 2173 | Task 4.** `store.meetingViewOf` resolves `created_by` through |
| 2174 | `creatorLabel` into `meetingView.CreatedByPrincipal`, and sets |
| 2175 | `ByAgent` from the `agent:` prefix — the same resolution the API |
| 2176 | already did per-request in `apiBody`, now shared with the HTML list. |
| 2177 | `listMeetings` passes one label cache across the whole list rather than |
| 2178 | one per row, so a run of meetings from the same agent costs one |
| 2179 | `creatorLabel` lookup rather than one per row (the N+1 the API's own |
| 2180 | memoisation was written against, and the list's link-presence count |
| 2181 | still has, unaddressed). `index.html` shows the lucide `bot` icon |
| 2182 | (`internal/calls/icons`) beside a plain-text "Agent" `ui/badge` next to |
| 2183 | an agent-made meeting's title, and "by `<principal>`" beneath it — both |
| 2184 | only when `ByAgent` is true, so a member's own meeting carries neither. |
| 2185 | The principal is rendered as an ordinary Go string through |
| 2186 | `html/template`'s own escaping, never marked `template.HTML` and never |
| 2187 | truncated before the `~` — the badge's icon needs no separate |
| 2188 | `aria-label` either, since rastrillo's own icon rule (`icons.go`: |
| 2189 | every icon "sits beside its own visible text label") is already |
| 2190 | satisfied by the badge's own word, alongside AGENTS.md's own line — |
| 2191 | "State — never colour alone — always a text label too." |
| 2192 | |
| 2193 | ### 15.18 §12 item 6 — what slice 6 delivered, deferred and declined |
| 2194 | |
| 2195 | Slice 6 is documentation only: nothing under `internal/`, `native/`, |
| 2196 | `callscore/` or `protocol/` changed. Against the programme's slice 6 |
| 2197 | line (`docs/superpowers/plans/2026-09-09-meet-slices-2-6-programme.md` |
| 2198 | §5) and this spec's own §12 item 6: |
| 2199 | |
| 2200 | The word "floor" names two different things in this spec: §5.5's "The |
| 2201 | floor" is the no-relay condition, and §9's "The scriptless floor" is |
| 2202 | the JavaScript exception; item 6's bullet above now names each rather |
| 2203 | than leaving the bare word to carry both. |
| 2204 | |
| 2205 | §9's exception was also corrected here, on Codex round 1: browser |
| 2206 | media participation was never the only script-gated part of a room — |
| 2207 | the live admission controls have needed script since slice 2 and the |
| 2208 | chat panel since slice 4 — and §9, the README and `PRODUCT.md` now say |
| 2209 | so rather than "and nothing wider." Codex round 2 found the binding |
| 2210 | file left behind: `AGENTS.md` still named only the media exception, so |
| 2211 | §9's own "the binding file and the code agree" was false again the |
| 2212 | moment §9 widened. `AGENTS.md`'s two scriptless lines were widened to |
| 2213 | match on 2026-09-14, in the same commit. |
| 2214 | |
| 2215 | - **Delivered.** `README.md`, with the scriptless floor sentence and |
| 2216 | the relay paragraph first. `PRODUCT.md`. `AGENTS.md`'s deploy notes. |
| 2217 | §0.2 above, refreshed against doctrine main `1ac4323`, still |
| 2218 | revision 8 on 2026-09-14 — this is the `Consumed suite rulings` |
| 2219 | commit suite Appendix A.6 will point at. The domain was already |
| 2220 | attached outside this slice — `calls.oficina.works` serves the |
| 2221 | flagship instance (§7.3's `carlos domains attach`) — so item 6's |
| 2222 | domain row needed nothing from slice 6. |
| 2223 | - **Deferred, not duplicated.** The acknowledgements page lives on |
| 2224 | `oficina/meet` discussion 10, branch `feat-open-source-credits` |
| 2225 | (`117b6e4`, `bbc126e`): a shared Estilo credits page with a runtime |
| 2226 | dependency inventory. As recorded on that branch's own review, it is |
| 2227 | Fable-approved and was verified live on the overnight canary, not |
| 2228 | yet merged to main — not independently reverified here. Building a |
| 2229 | second one here would be the duplication the programme forbids. |
| 2230 | - **Declined: the Estilo theme pin.** The programme's condition for |
| 2231 | pinning was a tagged release; `git ls-remote --tags` against |
| 2232 | `oficina/estilo` is empty on 2026-09-14, so no tag exists and no pin |
| 2233 | was made. The theme is adopted on main anyway, at a pseudo-version |
| 2234 | (`amadan.net/oficina/estilo.git`), by the Home integration |
| 2235 | (`f9cb47f`, `34fc367`) — the programme's condition is recorded here |
| 2236 | as unmet, and the adoption as done by another route, not as the |
| 2237 | condition having been met after all. |
| 2238 | - **Declined: the production promotion.** A docs-only slice has |
| 2239 | nothing to promote that changes behaviour, and a redeploy would drop |
| 2240 | every live call for no reason (§15.11). As observed on 2026-09-14 |
| 2241 | from `carlos channels` and `oficina/meet` discussions 11 and 14, |
| 2242 | `calls.oficina.works` was being promoted by hand by Paul (`651cff2`, |
| 2243 | 2026-09-12) with other sessions' work queued behind live checks — |
| 2244 | state as of that observation, not asserted as current here. This |
| 2245 | slice does not touch that queue or announce a deploy of its own, |
| 2246 | which is checkable from this diff alone. |
| 2247 | |
| 2248 | ### 15.19 §4.3, §4.5 — media state: who is muted, whose camera is off (2026-09-23) |
| 2249 | |
| 2250 | **Why.** Paul, 2026-09-23: "add mute indicators so that folks can see |
| 2251 | when someone is muted." Until now muting disabled the local audio track |
| 2252 | and sent silence, and nothing on the wire said so; nobody else could |
| 2253 | tell a muted person from a broken microphone. Camera off had the same |
| 2254 | gap, and a remote tile showed a black or frozen frame for it. |
| 2255 | |
| 2256 | **Decision.** A new message type, `media`, that goes both ways and is |
| 2257 | **state, not an event**: |
| 2258 | |
| 2259 | - Upstream, `POST /signal {"peer","type":"media","payload": |
| 2260 | {"muted":bool,"cameraOff":bool}}`. Both keys, spelled exactly, each |
| 2261 | once, both booleans, nothing else, judged on the raw bytes as §15.15 |
| 2262 | judges chat and share (a token walk, not a map). The raw payload is |
| 2263 | capped at 128 bytes before decoding (`413 {"error":"too-large"}`); |
| 2264 | any other shape, a `to`, a `seq` or a `gen` is |
| 2265 | `400 {"error":"bad-request"}`. The sender's authority is `Fanout`'s |
| 2266 | (§15.15): admitted, bound to this room and session, a member's |
| 2267 | standing re-derived first; a waiting or unjoined participation gets |
| 2268 | the same 404 an unknown peer gets. |
| 2269 | - The hub records the value on the participation (`Participation.Media`, |
| 2270 | nil until the first report). **Every roster and every `peer` event |
| 2271 | carries it**, as `media` on the `RosterPeer`, for every admitted peer |
| 2272 | including the recipient itself. That is what makes a late joiner, a |
| 2273 | resume (§4.5) and a restart correct without any new replay rule: |
| 2274 | nothing about `media` goes in the replay ring, because the roster a |
| 2275 | resume is answered with is already the authority, exactly as it is |
| 2276 | for who is present. |
| 2277 | - Downstream, a **change** is fanned out to every other admitted peer as |
| 2278 | `{type:"media", from, name, payload:{"muted","cameraOff"}}`, the |
| 2279 | envelope rebuilt from the sender's participation and the payload |
| 2280 | re-encoded from the two validated booleans. A report equal to what the |
| 2281 | hub already holds is accepted (200) and sends nothing, so clients may |
| 2282 | repeat it freely. |
| 2283 | |
| 2284 | **What a client does.** It keeps its own state locally and reports it |
| 2285 | whenever a roster's entry for itself disagrees or is missing — which is |
| 2286 | admission, a resume after a report was lost, and a fresh join after a |
| 2287 | restart — and at once on each toggle while admitted. Before admission it |
| 2288 | sends nothing. `muted` means the person's microphone is not being sent |
| 2289 | (toggled off, or there is no microphone track at all); `cameraOff` the |
| 2290 | same for the camera. Both are the sender's own claim about what it is |
| 2291 | choosing to send. The server never sees media (§0.1) and cannot check |
| 2292 | them, and nothing depends on them being true: a peer that lies about |
| 2293 | being muted is heard anyway. |
| 2294 | |
| 2295 | **Unknown is not muted.** A `RosterPeer` with no `media` key is a peer |
| 2296 | that has not reported — every client that predates this section, and |
| 2297 | every peer for the moment between admission and its first report. |
| 2298 | Clients show that as neither muted nor camera-off. The zero value is |
| 2299 | not used for it, because `{"muted":false,"cameraOff":false}` is a real |
| 2300 | report and must be told apart from silence. |
| 2301 | |
| 2302 | **Mixed versions.** An old client ignores the `media` key on a roster |
| 2303 | and the `media` event type (the web client's dispatch and the native |
| 2304 | core both drop unknown types), so it never breaks and simply never |
| 2305 | shows or sends indicators; the people talking to it see it as unknown. |
| 2306 | A new client against an old server has its report refused |
| 2307 | `400 {"error":"bad-request"}` as an unknown type; it does not retry a |
| 2308 | 400 and shows nothing. Server and clients should still deploy together |
| 2309 | for the feature to exist, but no order of deployment breaks a call. |
| 2310 | |
| 2311 | **Not logged, not stored.** Like every accepted message, a report logs |
| 2312 | nothing; a refused one logs type, room, peer and outcome only (§4.4). |
| 2313 | The state lives on the in-memory participation and dies with it (§2.2). |
| 2314 | |
| 2315 | ### 15.20 §15.19, §5 — what the receiver can hear (2026-09-25) |
| 2316 | |
| 2317 | **Why.** On 2026-09-25 Tony, in a browser, could not hear Paul, who was |
| 2318 | on the Mac app with a multi-channel audio interface. Paul's device panel |
| 2319 | looked fine because nothing on it measured sound; Tony's side had no way |
| 2320 | to tell Paul anything; and the server keeps no call logs (§0.1), so there |
| 2321 | was nothing to read afterwards. The failure was undiagnosable from either |
| 2322 | end. Two measured facts shaped the answer: |
| 2323 | |
| 2324 | - Chromium (Chrome and Edge) does not apply its autoplay policy to a |
| 2325 | media element whose source is a WebRTC `MediaStream`: with the desktop |
| 2326 | policy forced on and no user gesture at all, the remote tile plays, |
| 2327 | while the same browser refuses a plain audio file. A refusal is still |
| 2328 | possible in Safari, Firefox and a browser set to block sound, so the |
| 2329 | recovery below is kept, but it was not what happened here. |
| 2330 | - LiveKit's AVAudioEngine audio device module connects the microphone in |
| 2331 | mono, and the conversion from a multi-channel interface keeps its first |
| 2332 | input only. A microphone on any other input sends silence. |
| 2333 | |
| 2334 | **Decision.** §15.19's `media` report gains two optional members, each |
| 2335 | carrying only bad news and omitted when there is none: |
| 2336 | |
| 2337 | - `audioBlocked: true` — this browser was refused playback of remote |
| 2338 | sound and is waiting for a click. |
| 2339 | - `noSoundFrom: [peer, …]` — the peers whose audio is arriving here |
| 2340 | (connected, packets flowing) as silence, for about five seconds, while |
| 2341 | they report themselves unmuted. At most fifteen distinct peer ids. |
| 2342 | |
| 2343 | Validation is §15.19's, extended: `muted` and `cameraOff` are required; |
| 2344 | the two new members are optional, byte-exact, at most once each, |
| 2345 | `audioBlocked` a boolean and `noSoundFrom` an array of well-formed peer |
| 2346 | ids with no repeats; no other member; the raw payload capped at 1 KB. |
| 2347 | Ids are checked for shape, not presence — the recipients ignore an id |
| 2348 | they hold no tile for. The hub stores, carries in rosters and fans out |
| 2349 | the report exactly as before, and a change to either member is a change. |
| 2350 | The payload out is re-encoded from the validated values, in the order |
| 2351 | `muted`, `cameraOff`, `audioBlocked`, `noSoundFrom`. |
| 2352 | |
| 2353 | **Compatibility.** Because the members are omitted when there is no bad |
| 2354 | news, the ordinary report is still the two-key object, and a server |
| 2355 | from before this section accepts it; only a report carrying bad news is |
| 2356 | refused there (400), and the web client then falls back to the two-key |
| 2357 | form for the rest of the page. Old clients ignore the extra members. |
| 2358 | Deploy the server before, or with, the clients. |
| 2359 | |
| 2360 | **What clients show.** Every client measures sound where it can, so a |
| 2361 | person can see it rather than infer it: |
| 2362 | |
| 2363 | - A microphone meter on one's own tile, measured on what is sent, and |
| 2364 | "Your microphone isn't picking anything up" when it has been flat for |
| 2365 | about five seconds while unmuted — and only once capture has really |
| 2366 | started and a connection is up and sending it to someone. Alone in a |
| 2367 | call nothing is sent (the apps' WebRTC does not even open the |
| 2368 | microphone), and the line never shows (2026-10-03). |
| 2369 | - A level meter on every other person's tile, measured on what arrives, |
| 2370 | with "No sound from <name>" when their audio has been flat for about |
| 2371 | five seconds while they report themselves unmuted, and the Muted badge |
| 2372 | instead of either when they are muted. |
| 2373 | - On a person's tile, their report about us: "<name>'s browser is |
| 2374 | blocking sound — they need to click to hear you", or "<name> isn't |
| 2375 | getting any sound from you". |
| 2376 | - In a browser whose playback was refused, a page-level "Click anywhere |
| 2377 | to turn on sound"; any click or key anywhere resumes every tile. |
| 2378 | - An audio test, before a call and during one, that meters the |
| 2379 | microphone, plays a test sound, and records a few seconds through the |
| 2380 | real capture path and plays it back. The recording never leaves the |
| 2381 | device and is discarded after playback. |
| 2382 | |
| 2383 | **Not logged, not stored.** As §15.19: the report is the sender's claim, |
| 2384 | logged only on refusal, and held on the in-memory participation. |
| 2385 | |
| 2386 | ### 15.21 §15.19, §5.3, §5.4 — a shared screen that stops, and who says so (2026-09-28) |
| 2387 | |
| 2388 | **Why.** Paul, 2026-09-28, sharing from the Mac app (0.4.0) to Vicky on an |
| 2389 | app: "after a while, my screen froze on her end and she was no longer able |
| 2390 | to see my screen. I had to stop and re-share again." Nothing on either side |
| 2391 | said anything; the sharer could not see it and the watcher could not tell |
| 2392 | him. The server keeps no call logs (§0.1). What the harness measured: |
| 2393 | |
| 2394 | - The apps restarted ICE on every connection on every `/ice` answer, and |
| 2395 | the core fetches one on every roster — every stream resume, every five |
| 2396 | minutes at the stream's TTL — so a relay-configured call restarted ICE |
| 2397 | twelve times an hour instead of once in two and a half. Between two apps |
| 2398 | from before this section, with renewals every ten seconds, the watcher's |
| 2399 | screen stopped for about four seconds at a time while the camera beside |
| 2400 | it kept moving. |
| 2401 | - The Mac's hardware H.264 encoder, as WebRTC sets it up, holds a source |
| 2402 | that is not a screencast (the "Motion and video" mode's, and any share |
| 2403 | that started in it) to the level negotiated, and Chromium answers every |
| 2404 | H.264 offer at level 3.1: 3,600 blocks, 1280×720. A 16:10 display in |
| 2405 | motion mode is 1280×830 at its cap. VideoToolbox refused every frame at |
| 2406 | that size (-12902), for eight or nine seconds at a time until WebRTC's |
| 2407 | quality scaler stepped it down — and for good once the mode was switched |
| 2408 | to Text and detail, which does not let the size fall. Two apps negotiate |
| 2409 | High 5.2 and never meet it. |
| 2410 | - ScreenCaptureKit stops outright — no frames, no idle reports — when every |
| 2411 | surface in its queue is held, and 0.4.0 held one of three for the |
| 2412 | repeat of a still screen. |
| 2413 | |
| 2414 | **Decision.** The sender stops making the problem, the sender watches its |
| 2415 | own screen, and the receiver says when a screen has stopped for it. |
| 2416 | |
| 2417 | - The apps restart ICE only when the list they last gathered on is due for |
| 2418 | renewal (§5.3), or when a relay arrives for a connection built on the |
| 2419 | floor (§5.5); any other fresh list is adopted with `setConfiguration`. |
| 2420 | - A Mac screen whose source is not a screencast is scaled to fit level |
| 2421 | 3.1's blocks; the capture keeps six surfaces, not three. |
| 2422 | - **The sharer's watchdog**, once a second while sharing: a capture that |
| 2423 | reports nothing for three seconds is started again on the same content |
| 2424 | (5, 10, 20… up to 60 seconds apart); a connection whose screen takes |
| 2425 | frames in and encodes or sends none for five seconds, while WebRTC does |
| 2426 | not name the link as what limits it, gets, three seconds apart, a keyframe (the encoding switched off and on), a fresh encoder |
| 2427 | (the screen sent a third smaller, for thirty seconds), and a new |
| 2428 | transceiver (what stopping and sharing again did, renegotiated as §5.4 |
| 2429 | does), then rests thirty seconds. Every step is logged (`os_log` |
| 2430 | subsystem `net.amadan.oficina.calls`, category `screen`) and kept for |
| 2431 | "Copy call diagnostics". |
| 2432 | - **The receiver's report.** §15.19's `media` gains one more optional |
| 2433 | member, `screenStalled: [peer, …]`: the peers whose shared screen has |
| 2434 | decoded nothing new here for four seconds while they say they are |
| 2435 | sharing. Every sender repeats a still screen at least once a second, so |
| 2436 | that is not a still screen. It is bad news only, omitted when there is |
| 2437 | none, held for at most a minute from the last frame (three since §15.22), at most fifteen |
| 2438 | distinct well-formed peer ids, validated exactly as `noSoundFrom` is, and |
| 2439 | re-encoded after it (`muted`, `cameraOff`, `audioBlocked`, `noSoundFrom`, |
| 2440 | `screenStalled`). The raw payload cap is 2 KB, room for both lists full. |
| 2441 | - **A sharer named in anyone's report recovers**: a keyframe for that |
| 2442 | connection, four seconds apart (the report is a few seconds old when it |
| 2443 | lands), three times and then a rest; the fresh encoder and the new |
| 2444 | transceiver only if its own screen has stopped leaving as well — a |
| 2445 | report about a slow link is not the encoder's fault. It says so, quietly, while it lasts: |
| 2446 | "Vicky's view of your screen stalled — recovering". The browser, which |
| 2447 | has no fresh encoder or transceiver to offer short of a new share, sends |
| 2448 | keyframes only. |
| 2449 | |
| 2450 | **Compatibility.** Old clients ignore the member. A server from before this |
| 2451 | section refuses a report carrying it (400); the browser then drops the |
| 2452 | extras for the rest of the page (§15.20), and the apps' core resends the |
| 2453 | same report without `screenStalled` and leaves it out from then on, so a |
| 2454 | mute is never lost to an old server. The sharer's own watchdog works |
| 2455 | whatever the server is. Deploy the server before, or with, the clients. |
| 2456 | |
| 2457 | **Not logged, not stored.** As §15.19. |
| 2458 | |
| 2459 | ### 15.22 §15.21 — when keyframes are not enough (2026-09-30) |
| 2460 | |
| 2461 | **Why.** Paul, 2026-09-30, sharing from the Mac app (0.4.3 or 0.4.4) to |
| 2462 | Samuel: "Still seeing issues with screen sharing after a while." His |
| 2463 | screenshot had §15.21's notice, "Samuel's view of your screen stalled — |
| 2464 | recovering", under "You are sharing your screen". The receiver's report had |
| 2465 | worked; recovery had not. By design, a report alone only ever bought |
| 2466 | keyframes, and a stall that keyframes do not mend — a receiver whose decoder |
| 2467 | has wedged, or one that has stopped taking this stream — had nothing after |
| 2468 | them: the notice sat at "recovering" until the report lapsed a minute later. |
| 2469 | Stop and Share, by hand, always mended it. Two more things came up reading the |
| 2470 | code: a receiver counted only a *larger* decoded count as movement, so a |
| 2471 | screen that had been given a new transceiver (whose count starts again) |
| 2472 | stayed "stalled" there for as long as its new count took to pass the old one; |
| 2473 | and the iPhone's broadcast sends nothing for a still screen, which any |
| 2474 | receiver reported as a stall after four seconds. |
| 2475 | |
| 2476 | **What the harness measured.** A receiver whose screen decoder is wedged |
| 2477 | (the gate's decoder wrapper in the apps, an encoded transform in Chromium) keeps |
| 2478 | receiving packets and frames, decodes none, and drops them: packets +75, |
| 2479 | frames received +32, decoded +0, dropped +29 in the first four seconds, between |
| 2480 | two apps. Keyframes arrive and are dropped with the rest. A new transceiver, |
| 2481 | which gets a new receiver and decoder at the far end, mends it at once, in both |
| 2482 | directions and between two apps. No long share has yet produced a stall that |
| 2483 | keyframes did not mend, so the wedge is a stand-in for the cause, not the cause: |
| 2484 | |
| 2485 | - Samuel was on the web, so the path was the Mac app to Chromium, where |
| 2486 | §15.21 found H.264 held to level 3.1. With the level cap switched off |
| 2487 | (`CALLS_SCREEN_UNCAPPED=1`), a Motion share of a 1920×1246 page (a 16:10 |
| 2488 | Retina display as the capture fits it) reproduces Paul's picture exactly: |
| 2489 | VideoToolbox refuses every frame (-12902, 1,323 times in 150 s), the browser |
| 2490 | receives nothing and sends PLIs, freezes of 7–23 s and one that never ended, |
| 2491 | which keyframes, fresh encoders and a re-add only partly mend. With the cap as |
| 2492 | shipped, the same page starting in Motion and starting in Balanced, modes |
| 2493 | cycled every 30 s and the uplink capped from 600 kbit/s to 3 Mbit/s, eleven |
| 2494 | ICE restarts from the app and twenty-three at the browser in four minutes |
| 2495 | each: no -12902 and no freeze. The real display through ScreenCaptureKit |
| 2496 | (3024×1964, sent at 1920×1246) for seven minutes the same way: no -12902; the |
| 2497 | only gaps, 4 s each, were Text and detail on a 600 kbit/s link, where a |
| 2498 | full-size frame of a still screen takes that long to send, which is the link's |
| 2499 | and which the ladder does not take further (nothing was sent). |
| 2500 | A screencast source (Balanced, Text and detail) is set up by this WebRTC |
| 2501 | build without a fixed level and carries 1920×1246 to Chromium; a source that |
| 2502 | is not one (Motion, or a share started in it) is capped, now from the first |
| 2503 | frame, since the Mac capture's size is known before it delivers one. |
| 2504 | - The browser sharing to the app, modes cycled, renewals every ten seconds: |
| 2505 | four and a half minutes, no freeze. |
| 2506 | - Considered and not done: making Chromium's H.264 level higher (it answers |
| 2507 | 3.1 and room.js does not munge SDP, §5.1), and VP8 or VP9 for screens to |
| 2508 | browsers (software VP8 managed 2 fps at 1080p, §5.4's measurements). The |
| 2509 | measured cap holds; the ladder covers what it does not. |
| 2510 | |
| 2511 | What is left untraced, and what the new statistics are for: a receiver's own |
| 2512 | decoder going wrong (a hardware decoder in the watcher's browser, which a |
| 2513 | headless gate never uses), and the one ICE restart in ten between two apps that |
| 2514 | stops the screen for three or four seconds (§15.21). |
| 2515 | |
| 2516 | **Decision.** The sharer takes a report further, the receiver does its own |
| 2517 | share, and both say what they see. |
| 2518 | |
| 2519 | - **The sharer's ladder**, per reporting peer: keyframes as §15.21. Still |
| 2520 | reported six seconds after the first, **that connection's screen is |
| 2521 | rebuilt** — its transceiver stopped and a new one added once that has been |
| 2522 | negotiated, at the same size and mode; a report never shrinks the screen or |
| 2523 | makes a smaller encoder. Still reported ten seconds after that, |
| 2524 | **everyone's screen is shared again** from the same capture: every screen |
| 2525 | transceiver stopped, a new track on the same source (a clone of the captured |
| 2526 | track in the browser), each connection given it as it settles; no picker. |
| 2527 | Ten seconds after that, it gives up. A rebuild at most once a minute per |
| 2528 | peer, a share again at most once every five minutes; a step that is not |
| 2529 | allowed goes to the next, and none is taken unless at least three frames |
| 2530 | left for that peer since the last step (a screen with nothing new to send, |
| 2531 | or held back by the link, is not a decoder's fault). A report that clears |
| 2532 | ends the ladder for that peer. |
| 2533 | - **The notice moves on in words**: "Samuel's view of your screen stalled — |
| 2534 | recovering", then "Restarting your share for Samuel…", then either nothing |
| 2535 | or "Couldn't restore Samuel's view — try Stop and Share again". Worst news |
| 2536 | first when people are at different steps. |
| 2537 | - **The receiver**: any change in a screen's decoded count is movement. Four |
| 2538 | seconds into a stall it hands the screen's picture to its view again, once |
| 2539 | per stall (a new renderer in the apps, a fresh stream on the element in the |
| 2540 | browser): a picture stopped on this side is this side's to mend. Neither |
| 2541 | WebRTC build has a way for a receiver to ask for a keyframe directly; the |
| 2542 | report is that request, and WebRTC's own PLIs go out as they always did. A |
| 2543 | report is held for up to three minutes from the last frame, not one, so the |
| 2544 | sharer's "Couldn't restore" is not taken away while it is still true. |
| 2545 | - **The iPhone's broadcast repeats its last frame once a second** while the |
| 2546 | screen is still, as the Mac's capture has since §15.21. |
| 2547 | - **What is logged.** Every step, both sides, in the screen log (`os_log` |
| 2548 | category `screen`; the browser's console), and every stall with what the |
| 2549 | receiver's statistics say moved since the screen last did: packets, frames |
| 2550 | received, decoded, dropped, keyframes, PLI and NACK, the decoder, and a |
| 2551 | verdict (nothing arriving; arriving but not assembled; assembled but not |
| 2552 | decoded). "Copy call diagnostics" carries the ladder's counts and stage per |
| 2553 | peer, every arriving screen's statistics, and the last 200 lines of the |
| 2554 | timeline, on the sharer and the receiver alike. The browser gains the same |
| 2555 | button, at the foot of its Devices panel. |
| 2556 | |
| 2557 | **Compatibility.** Nothing on the wire changes. An older receiver reports as |
| 2558 | before and is answered with the new ladder; an older sharer answers a new |
| 2559 | receiver's longer report with keyframes as before. |
| 2560 | |
| 2561 | **Not logged, not stored.** As §15.19; the timeline stays on the device until |
| 2562 | someone copies it. |
| 2563 | |
| 2564 | ### 15.23 §4.5, §5 — "Reconnecting…" only for a problem that lasts (2026-09-30) |
| 2565 | |
| 2566 | **Why.** Paul, 2026-09-30: "During that call I also saw 'Reconnecting' appear |
| 2567 | occasionally over the video, but only for a second … it didn't affect the call |
| 2568 | quality … I didn't notice any interruption in the call." The apps' core enters |
| 2569 | its `reconnecting` phase the moment the signalling stream ends, and the stream |
| 2570 | ends on purpose at its TTL (every five minutes, §4.5) and is resumed half a |
| 2571 | second later; the apps said "Reconnecting…" every time. The media never |
| 2572 | noticed. The browser said nothing at all, even for a real outage. |
| 2573 | |
| 2574 | **Decision.** The notice is said, on Mac, iPhone and web, only when: |
| 2575 | |
| 2576 | - the signalling stream has been down for 4 s or more — the whole call; or |
| 2577 | - a peer connection's ICE state has been `disconnected` or `failed` for 3 s or |
| 2578 | more, or no inbound audio packets have arrived for 2 s or more from a peer |
| 2579 | that is not muted — that peer's tile, "Reconnecting to Samuel…"; or the whole |
| 2580 | call, when it is every peer (two or more). |
| 2581 | |
| 2582 | Once said it stays a second past the recovery, then goes. A routine renewal or |
| 2583 | resume is never said. The timings are named constants (`ReconnectWatch` in |
| 2584 | the apps, `RECONNECT_*` in room.js) with unit tests either side; the browser |
| 2585 | gate's `renewal` scenario and the native gate's `resume` and `renewals` watch |
| 2586 | for the words through several renewals and fail on any. The core's phase is |
| 2587 | unchanged: controls that need an admitted call still follow it. |
| 2588 | |
| 2589 | ### 15.24 §15.19, §15.20 — "… is connecting and can't hear you yet" (2026-10-01) |
| 2590 | |
| 2591 | **Why.** Paul, 2026-10-01, asking for what Zoom shows: "didemeski is |
| 2592 | connecting to audio and cannot hear you yet …". When someone joins, there is |
| 2593 | a gap — a few hundred milliseconds on a good network, seconds on a bad one, |
| 2594 | for ever on a broken one — before their side is receiving and playing your |
| 2595 | audio, and nothing said so: people greeted a newcomer who could not yet hear |
| 2596 | them. Nothing on the wire said whether anyone could hear anyone; §15.20 only |
| 2597 | carried bad news after five seconds of flat sound. |
| 2598 | |
| 2599 | **Decision.** A positive receive signal, and a notice built on it. |
| 2600 | |
| 2601 | - **`hearing`.** §15.19's `media` gains one more optional member: the peers |
| 2602 | whose audio this client is actually receiving and playing. Unlike §15.20's |
| 2603 | and §15.21's lists it is good news, and its presence is itself news: an |
| 2604 | absent list is a client that does not say (one from before this section), |
| 2605 | `[]` is a client that hears nobody yet. So it is sent whenever the server |
| 2606 | takes it, `[]` included, and the hub keeps it with its emptiness — `[]` |
| 2607 | travels as `[]`, and a change between absent and `[]` is a change. |
| 2608 | Otherwise it is held as `noSoundFrom` is: at most fifteen distinct |
| 2609 | well-formed peer ids, checked for shape and not presence, a `null`, a |
| 2610 | non-array, a repeat or a sixteenth id refused 400; re-encoded last |
| 2611 | (`muted`, `cameraOff`, `audioBlocked`, `noSoundFrom`, `screenStalled`, |
| 2612 | `hearing`). The raw payload cap is 3 KB, room for all three lists full |
| 2613 | (about 1,700 bytes) and whitespace. Sent only on a change, like the rest. |
| 2614 | - **The rule — who a client lists.** A peer is listed once the audio path |
| 2615 | from them is established and is playing here: |
| 2616 | - *established*: the connection is up (ICE connected or completed) and |
| 2617 | either their audio is arriving — inbound packets received and decoded |
| 2618 | samples coming out (`totalSamplesReceived` in the browser; frames at the |
| 2619 | receive tap in the apps) on a live receiver — or **they say they are |
| 2620 | muted**, in which case the connection being up and the receiver live |
| 2621 | (or no audio receiver at all, for someone with no microphone) is enough. |
| 2622 | A muted sender may send only silence, or nothing, and must not look |
| 2623 | unheard: the moment they unmute, it plays. Once established it stays so |
| 2624 | until the connection fails or closes; a blip is not losing them. |
| 2625 | - *playing*: in the browser, the tile's element is playing and was not |
| 2626 | refused by an autoplay policy (a refused tile is §15.20's |
| 2627 | `audioBlocked`, and is not listed until a click plays it). The apps play |
| 2628 | whatever they decode; there is no policy to refuse them. |
| 2629 | - **The notice.** From the moment a person appears in the roster — or |
| 2630 | appears again, or is given a new connection (a new pair generation) — |
| 2631 | their tile says "Samuel is connecting and can't hear you yet", with three |
| 2632 | dots after it that brighten in turn (one steady ellipsis under reduced |
| 2633 | motion). It goes the moment their report lists you in `hearing`, and a |
| 2634 | screen reader is told "Samuel can hear you now". In order: |
| 2635 | - **The grace.** It is not said at all if they hear you within 1 s of |
| 2636 | appearing (`CONNECTING_GRACE`, `ConnectingWatch.grace`), so ordinary |
| 2637 | joins say nothing. The browser gate measured 284 ms from appearing to |
| 2638 | being heard on a loopback. |
| 2639 | - **More specific news replaces it**: their `audioBlocked` ("Samuel's |
| 2640 | browser is blocking sound — they need to click to hear you"), or you in |
| 2641 | their `noSoundFrom` while you are unmuted ("Samuel isn't getting any |
| 2642 | sound from you"). Those are their own reports and show even inside the |
| 2643 | grace. |
| 2644 | - **"Reconnecting to Samuel…" waits until after** (§15.23): a connection |
| 2645 | that has never been up is not reconnecting. While someone is in their |
| 2646 | first moments the reconnect watch is told nothing is wrong with them, |
| 2647 | so a slow join of two people is never "Reconnecting…" for the call. |
| 2648 | - **A client that does not say** (no `hearing` in its report — a client |
| 2649 | from before this section, or any client on a server that refused the |
| 2650 | key) is let go once the connection to it is up and either its receiver |
| 2651 | reports show your audio reaching it (`remote-inbound-rtp` for your |
| 2652 | microphone, which exists only once one has come back, with packets |
| 2653 | sent) or it has made a media report without the list. A client that |
| 2654 | does say is believed over this evidence. |
| 2655 | - **The cap.** Whatever happens, it is given up 20 s after they appeared |
| 2656 | (`CONNECTING_CAP`, `ConnectingWatch.cap`), silently; §15.20's "isn't |
| 2657 | getting any sound" and §15.23's "Reconnecting to …" speak for any |
| 2658 | problem after that. |
| 2659 | - **Your own mute does not matter**: it is about their being able to |
| 2660 | hear you once you speak. |
| 2661 | - **Where.** On the person's tile, in the place and the glass of the other |
| 2662 | tile notices, with an ear where they have a warning triangle: every |
| 2663 | layout on the Mac and the iPhone, and the browser's call window. In the |
| 2664 | browser and in the apps' Grid, Speaker and Classic every person in the |
| 2665 | roster has a tile from the roster on, so there is no call-level form; |
| 2666 | the Mac's mini window, which shows one person, puts a small ear on its |
| 2667 | glass for anyone else still connecting (their names in its tooltip and |
| 2668 | for VoiceOver), and its tab shows the same ear. Screen readers hear the |
| 2669 | sentence once when it appears and "… can hear you now" when it goes, |
| 2670 | politely, and never the dots. |
| 2671 | |
| 2672 | **Compatibility, both orders.** Old clients ignore the member (the browser's |
| 2673 | `mediaFrom`, the core's decoder and the apps' `PeerMedia` all drop unknown |
| 2674 | keys). A server from before this section refuses a report carrying it |
| 2675 | (400): the browser then drops the extras for the rest of the page (§15.20) |
| 2676 | — though the browser's page is served by the same server, so only a page |
| 2677 | open across a deploy meets that — and the apps' core resends the same |
| 2678 | report without `hearing` and leaves it out from then on, then without |
| 2679 | `screenStalled` too if that is refused next, so a mute is never lost. The |
| 2680 | people on new clients then fall back to their own evidence about that |
| 2681 | client, as they do for an old one. So the server and the clients may ship |
| 2682 | in either order. |
| 2683 | |
| 2684 | **Not logged, not stored.** As §15.19. The apps log "… can hear us" in the |
| 2685 | connection log (`os_log` category `connection`), the browser on its |
| 2686 | console, each with the first six characters of the peer id only. |
| 2687 | |
| 2688 | ### 15.25 §3.4, §8.4 — no title pill, and Join in the app as the person signed in (2026-10-02) |
| 2689 | |
| 2690 | **The title.** The full-window call on Mac, iPhone and web carried a pill by |
| 2691 | the traffic lights: the title and a few words of state ("Call, 2 Oct 15:40 |
| 2692 | Europe/Dublin / In the meeting"). Paul approved removing it. The state is the |
| 2693 | notices' to say, and each one the pill carried — Connecting, Waiting to be let |
| 2694 | in, The room is full, Reconnecting, the call over — has a notice (the apps' |
| 2695 | `CallNotices`, which now also turn any status other than "In the meeting" into |
| 2696 | one; the web's `statusLine`). A title someone chose is shown as a window title: |
| 2697 | plain, centred in the top strip over the existing dark scrim, fading with the |
| 2698 | controls (Mac: on the traffic lights' line; iPhone: small, inside the safe |
| 2699 | area). A title Calls wrote is not shown. Which is which is a flag set where the |
| 2700 | title is made — `meetings.auto_title`, by Start a call and the apps' Start |
| 2701 | meeting — never read back out of the words; `/m/{sqid}/info` says `named`. The |
| 2702 | window's own title (Window menu, Mission Control, VoiceOver) and the page's |
| 2703 | `<title>` are the meeting's either way. |
| 2704 | |
| 2705 | **The zone.** From `f86d4da` (2026-10-01) an automatic title in the instance |
| 2706 | zone ended with the zone's name, so that a reader elsewhere was not shown a |
| 2707 | bare time in a zone they could not see; every meeting the apps started had it, |
| 2708 | because they sent no zone. The apps now send theirs, the title is "Call, 2 Oct |
| 2709 | 15:40", and the zone is kept beside it as data (`meetings.title_zone`). |
| 2710 | |
| 2711 | **The web menu.** The pill's ☰ menu — the standing line, Meetings, Settings, |
| 2712 | Help, Sign out — is the bar's ⋯ More menu, for guests too, with End meeting |
| 2713 | for everyone last for a member. With no script the header is a plain block |
| 2714 | with the title, the standing, the floor notice and the same navigation. |
| 2715 | |
| 2716 | **Join in the app.** Reported by a Calls user: "App link doesn't work if |
| 2717 | you're signed in". The holding screen's link was |
| 2718 | `oficina-calls://join?url=<origin>/m/<sqid>`, which names a meeting and not a |
| 2719 | person, and the app's session is its own: an app signed in as someone without |
| 2720 | standing joined and failed (or joined as the wrong person), and an app with |
| 2721 | none signed in in the default browser — where a guest, who has no account, |
| 2722 | reached a sign-in page that sends a non-member no mail. Now the link goes |
| 2723 | through `/m/{sqid}/app`, which makes an offer bound to the browser's session |
| 2724 | (native/architecture.md); the app's handoff request names it, and the browser |
| 2725 | the person is in confirms it. Same verifier exchange, one use, five minutes, |
| 2726 | the browser session's own standing, a guest still limited to their meeting. |
| 2727 | `OpenLink` asks `/info` before answering a `/m/` address, so an app session |
| 2728 | without standing is told so (`noAccess`) instead of failing at the join. |
| 2729 | |
| 2730 | |
| 2731 | ### 15.26 §0.1, §0.3, §2.2, §4.4 — quality reports and "Report a problem" (2026-10-03) |
| 2732 | |
| 2733 | **The ruling.** Paul, 2026-10-03, approved both parts below, explicitly |
| 2734 | accepting that the second bends §0.1's "nothing kept" and §2.2's "nothing |
| 2735 | else at rest": the problems people have had in calls (§15.20 to §15.24) have |
| 2736 | each been found from a screenshot and a pasted diagnostics block, after the |
| 2737 | fact, and the calls that went wrong without anyone saying so were never found |
| 2738 | at all. Calls now writes connection-quality facts to its own log. It still |
| 2739 | stores nothing about a call in its database, and the log is the only store. |
| 2740 | |
| 2741 | **A. "Report a problem".** A button in the call's ⋯ More menu and in the |
| 2742 | ⓘ/Devices panel beside Copy call diagnostics, and on the screen a call ends |
| 2743 | on, on Mac, iPhone and the web, guests included. It opens a sheet with an |
| 2744 | optional "What went wrong?" (at most 1,000 characters) and the disclosure |
| 2745 | "Sends this call's connection diagnostics to the Calls server log. No audio or |
| 2746 | video." Send posts `POST /m/{sqid}/problem` with the note and the text Copy |
| 2747 | call diagnostics would copy, with one change: the people in the call are |
| 2748 | written "peer" and six characters of their peer id, not their names. The |
| 2749 | answer is a reference, eight characters of Crockford's alphabet (forty random |
| 2750 | bits), which the sheet shows under "Sent — thank you" so the reference can be |
| 2751 | looked up. The server logs one `calls: problem report` line (WARN: the |
| 2752 | reference, the tags below, the client, the note, and how many parts follow) |
| 2753 | and the diagnostics as `calls: problem report diagnostics` lines of at most |
| 2754 | 6,000 escaped bytes each (`part`/`of`), because the platform keeps 8 KB of a |
| 2755 | log line and truncates the rest. Before logging, it scrubs the text of every |
| 2756 | peer id the call has held, in full and as the six-character prefix, writing |
| 2757 | `peer:<tag>` instead, and of the meeting's sqid, writing `[meeting]`. **Device |
| 2758 | names stay in the diagnostics** (a Scarlett, AirPods): they are what audio |
| 2759 | problems are found by, and the person chose to send them. The note is free |
| 2760 | text and is logged as typed. Not switched off by the kill switch below: it is |
| 2761 | a person asking. |
| 2762 | |
| 2763 | **B. Automatic quality reports.** Every thirty seconds while a client is |
| 2764 | admitted, and once more (`final`) at leave or end, each client posts `POST |
| 2765 | /m/{sqid}/quality`: a fixed schema (`protocol/quality.go`, version 1) of |
| 2766 | numbers and enums, per remote peer (the mesh has one connection per peer, so |
| 2767 | outbound is per peer too): |
| 2768 | |
| 2769 | - *out*, per audio, camera and screen: kbps, packets, and the receiver's own |
| 2770 | account of them (remote-inbound): lost, RTT, jitter; the encoder's |
| 2771 | `qualityLimitationReason` (none, cpu, bandwidth, other); frame size and fps; |
| 2772 | and §15.22's screen recovery steps toward that peer (keyframes, rebuilds, |
| 2773 | shares again, given up). |
| 2774 | - *in*: audio kbps, packets, lost, jitter, samples, concealed, inserted and |
| 2775 | removed samples, and the jitter buffer's mean delay; camera and screen |
| 2776 | kbps, packets, lost, jitter, freezes and their duration where the stack |
| 2777 | reports them, frames decoded and dropped, frame size and fps, and §15.22's |
| 2778 | pictures handed to their view again. |
| 2779 | - *path*: the selected candidate pair's two candidate types (host, srflx, |
| 2780 | prflx, relay) and its transport (udp, tcp, or tls for a relay over TLS), |
| 2781 | and its RTT. Never an address or a port. |
| 2782 | - *events* in the window: "Reconnecting to …" (§15.23) shown, how often and |
| 2783 | for how long; "… can't hear you yet" (§15.24), for how long; whether this |
| 2784 | client listed them in `noSoundFrom`, whether they listed this client, |
| 2785 | whether they reported `audioBlocked`, whether their screen was reported |
| 2786 | stalled here (§15.20, §15.21). For the whole call: the call-wide |
| 2787 | "Reconnecting…" and this client's own blocked sound. |
| 2788 | - *client*: app (web, mac, iphone, ipad), its version (the server's build |
| 2789 | for the web), browser and its version (web), OS and its version — enums |
| 2790 | and digits, never a user agent string or a device model. |
| 2791 | - *devices*: the kind of microphone and speaker, by transport (built-in, |
| 2792 | bluetooth, usb, wired, airplay, virtual, continuity, other, none, |
| 2793 | unknown). Never a device's name: "Paul's AirPods Pro" is a name. |
| 2794 | |
| 2795 | Counts are what happened in the window. Every number is optional, finite, |
| 2796 | non-negative and under a ceiling (`protocol/quality.go`: a window at most |
| 2797 | 600 s, a delay at most 600,000 ms, a count at most 10⁹, a bitrate at most 10⁶ |
| 2798 | kbit/s, a frame at most 16,384 px, 240 fps); every string is one of its |
| 2799 | enums or a version of digits and dots; at most four remote peers (the mesh |
| 2800 | holds three) and a 16 KB body. Anything else is 400. A member the schema does |
| 2801 | not name is tolerated and dropped, so a newer client's addition does not fail |
| 2802 | an older server, and never reaches the log: the line is re-encoded from what |
| 2803 | was validated, numbers rounded to a tenth. The server writes one `calls: |
| 2804 | quality` line per report (INFO), a JSON object whose top level is what |
| 2805 | `carlos logs --field` can filter on — `call`, `peer`, `app`, `version`, |
| 2806 | `browser`, `browserVersion`, `os`, `osVersion`, `final`, `seconds`, |
| 2807 | `remotes`, and four headlines: `relay` (any path through the relay), |
| 2808 | `maxLossPct` (worst inbound loss), `maxRtt`, `freezes`, `reconnecting` — and |
| 2809 | whose `report` member is the rest, with each remote peer named by its peer |
| 2810 | tag. The largest report the schema allows fits in one platform line, which a |
| 2811 | test holds. A web page's or an app's own toggle ("Send anonymous quality |
| 2812 | reports", on by default; localStorage on the web, the apps' Settings) stops |
| 2813 | its reports; Report a problem stays. |
| 2814 | |
| 2815 | **The tags.** The **call tag** is minted by the hub, at random, when a |
| 2816 | participation opens in a room that holds none, and every participation until |
| 2817 | the room is empty again shares it: one call, one tag, however many people come |
| 2818 | and go, and a new tag for the room's next call. The **peer tag** is minted at |
| 2819 | random per participation. Neither is derived from anything — not the sqid, |
| 2820 | the peer id, the person, the session — and neither is ever logged beside any |
| 2821 | of them, so a line cannot be walked back to a meeting or a person through the |
| 2822 | log: §4.4's refusal lines carry the sqid and the peer id, and a report line |
| 2823 | carries neither. A report names a remote peer by the id its client knows; the |
| 2824 | server logs that peer's tag in its place, from the call's own table (at most |
| 2825 | 64 participations a call; past that, `unknown`). The tags live in the hub and |
| 2826 | die with the process. |
| 2827 | |
| 2828 | **Authority.** The hub hands each participation a report token, sixteen |
| 2829 | random bytes, in its `hello` (and the same token in a resumed stream's |
| 2830 | hello). A client sends it in `X-Calls-Report`. It is honoured for the session |
| 2831 | it was given to and the room it was given for, while the participation lives |
| 2832 | and for ten minutes after it ends — any ending: leave, removal, the meeting |
| 2833 | ending, a lapsed resume — so the final report and a Report a problem from the |
| 2834 | screen the call ended on are taken. Anything else is 404, before the body is |
| 2835 | read. The token table is bounded (4,096 grants; past that the grant that ended |
| 2836 | longest ago goes, never a live one) and swept on the tick. Rate limits, keyed |
| 2837 | on the peer tag: a quality report every ten seconds with a burst of six, a |
| 2838 | problem report a minute with a burst of three; 429 with `Retry-After`. Bodies |
| 2839 | are refused past their caps (16 KB, 64 KB) without being read further. |
| 2840 | |
| 2841 | **The kill switch.** `CALLS_QUALITY_REPORTS=off` in the app's environment: |
| 2842 | `/quality` answers 204 before it looks at anything, the boot log says |
| 2843 | "calls: quality reports off", and a client answered 204 sends nothing more for |
| 2844 | that call. Default on. An old client sends nothing; a new client against an |
| 2845 | old server is answered 404 by the router, and its reports are lost, which is |
| 2846 | all that happens. |
| 2847 | |
| 2848 | **Where it goes, and for how long.** The app's stderr, as JSON lines (the |
| 2849 | platform reads fields only from a line that is a JSON object; every other |
| 2850 | line the app writes stays text). CARLOS ships an app's log to its deployment |
| 2851 | bucket every ten seconds and keeps it **seven days, or 50 MiB of compressed |
| 2852 | log per app per box, whichever comes first** (`carlos/platform` |
| 2853 | `internal/host/logship.go`: `logshipMaxAge`, `logshipMaxBytes`, at platform |
| 2854 | main `fe2088e3`, 2026-10-02), pruned an hour at a time. There is no archive |
| 2855 | beyond that. The box's own journal also sees the lines (journald capped at |
| 2856 | 256 MB, host-wide, not readable through `carlos logs`). The platform's `edge` |
| 2857 | stream is reserved and writes nothing, client addresses included; Calls adds |
| 2858 | no address to any line, and cannot speak for logs outside the platform. |
| 2859 | |
| 2860 | **What is collected, and what is not.** |
| 2861 | |
| 2862 | | | Quality report | Problem report | |
| 2863 | |---|---|---| |
| 2864 | | When | every 30 s in a call, and at leave | when someone presses Send | |
| 2865 | | Who | every client, guests included, unless its toggle is off or the server's kill switch is | the person who pressed it | |
| 2866 | | Numbers about the connections (above) | yes | yes, in the diagnostics' own words | |
| 2867 | | Call tag, peer tag | yes | yes | |
| 2868 | | App, version, browser, OS and their versions | yes | yes, and the browser's user agent string in the web's diagnostics | |
| 2869 | | Kind of microphone and speaker | yes | — | |
| 2870 | | Device names | **no** | **yes**, in the diagnostics | |
| 2871 | | What the person typed | — | **yes**, the note | |
| 2872 | | People's names | no | no (peer tags) — unless typed into the note | |
| 2873 | | Emails, the meeting's title or sqid, peer ids | no | no (scrubbed) — unless typed into the note | |
| 2874 | | IP addresses, ports, candidates | no | no | |
| 2875 | | Chat, audio, video, SDP | no | no | |
| 2876 | | The database | nothing | nothing | |
| 2877 | |
| 2878 | **What this does to §0.1 and §0.3.** §0.1's falsifiable claim gains a |
| 2879 | sentence: publish every log line and the worst anyone learns now also |
| 2880 | includes that *some* call, under a random tag, had so many participations, |
| 2881 | on these platforms, with this loss and these notices, between these times; |
| 2882 | not which meeting it was, nor who was in it, nor what they said — except |
| 2883 | what a person types into a problem report's note. §0.3's "no attendance |
| 2884 | record" stands for the database and for any record tied to a person or a |
| 2885 | meeting; the log's timestamps under random tags show that a call happened |
| 2886 | and how many clients took part, which is the bend Paul accepted. |
| 2887 | |
| 2888 | **Reading them.** `docs/operations/quality.md`, and `cmd/quality-summary`, |
| 2889 | which reads `carlos logs` and prints each call: who, by peer tag and |
| 2890 | platform; the worst loss, RTT and freezes for each direction between two |
| 2891 | people, and how much of it went through the relay; and the notices, in order. |
| 2892 | |
| 2893 | **Tests.** `internal/callstest/quality_test.go`: the line's shape and fields; |
| 2894 | one call tag shared, peer tags distinct, a remote peer logged by its tag; |
| 2895 | nothing of the sqid, the title, the addresses, the peer ids or the tokens in |
| 2896 | the log; every schema refusal; unknown members tolerated and not logged; the |
| 2897 | size caps; the largest report fitting one platform line; the token refused |
| 2898 | for another session, room or none; the after-leave window, ended meetings |
| 2899 | included, and its end; a resume keeping the token and an empty room starting |
| 2900 | a new call; both rate limits; the kill switch; a problem report's reference, |
| 2901 | parts and scrubbing. `hub/report_test.go`: the sweep and the bound. The |
| 2902 | browser gate's `quality` scenario and the native gate's check: real clients' |
| 2903 | lines in the log, a shared call tag, no names. |
| 2904 | |