oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

🤖 Meet — design

2026-09-16 rename: the app is now Calls, module amadan.net/oficina/calls, served at calls.oficina.works. Every "Meet" below should be read as "Calls"; the body is left as written because it records what was designed on 2026-09-07, and ruling 2's choice of oficina/meet stands as the record of that decision. The route grammar is unchanged — /m/{sqid} and /meetings/{sqid} keep their paths, and "meeting" remains the domain noun. PRODUCT.md's Brand Commitments hold the current name.

2026-09-12 sharing update: Paul's request for Share in the meeting view supersedes the invitation-token show-once rule below (§3.1 / ruling 16). Members may retrieve a current invitation via POST /m/{sqid}/share. Tokens remain hash-indexed, with an encrypted copy under the meeting-link purpose key. Existing hash-only links are preserved; first sharing creates a recoverable invitation alongside them. Replacement revokes all live links, and ending prevents further sharing. Token-bearing responses are no-store. Agent credentials remain hash-only and shown once.

Design, 2026-09-07. Direction approved by Paul in conversation; this is the written form. Revision 9, 2026-09-10: slice 2 built the room, and §15 records the ten places where what it built departs from revision 8, each with the reason it was decided on. Revision 8 was approved by Paul on 2026-09-08 with one direction added as ruling 15; revision 7 carried the two corrections of the sixth Codex review (CODEX-FINDINGS-MEET-R6.md, verdict: accept with corrections); §14 has all six rounds. Drafted by the Meet session (Fable 5.1) from a brainstorm with Paul on 2026-09-06 and 2026-09-07. Every ruling in §1 attributed to Paul was given in that conversation or on the discussion it cites.

Meet is video calling for the Oficina suite: a member makes a meeting, now or for later, sends one link, and the people who follow it set up their camera and microphone, wait to be let in if they are not on the team, and talk. Calls are browser to browser. The instance carries the pages, the sign-in, the signalling and the in-call chat, and never a frame of media. One instance per team, on the CARLOS platform, at calls.oficina.works for the first team.

Built beside Docs, Calendar, Slides and the suite doctrine. Where a decision is already made over there, this spec consumes it rather than restating it (§0.2).

---

0. The rules this app lives under

0.1 The one rule

Media never touches this app's process, and nothing said in a call is kept. The instance holds a sealed title, the links and grants it minted, and who admitted whom. Chat, presence and peer state live in memory for the life of a room and are gone when it ends.

Three processes can be in the path of a call, and the claim is made per process:

  • Meet's own process relays signalling (SDP, ICE candidates) and chat between browsers. It reads a message's type, sender and target and never its payload, and it logs none of it (§4.4). It never receives a media packet: there is no handler that accepts one.
  • The platform TURN relay (stage 4a, §5) forwards media packets when a direct path fails. Those packets are DTLS-SRTP, encrypted between the browsers, so the relay carries bytes it cannot read. The README's paragraph that introduces the relay says exactly this.
  • The platform SFU (stage 4b, §5.7) decrypts media to forward it. When a team opts in, the claim becomes "media never touches this app's process", and this spec gets a revision.

Falsifiable, for the database and the logs: publish the SQLite file, its WAL, and every log line Meet wrote, and the worst anyone learns is the shape of the team's meetings and who minted what, never what a meeting was called, what was said, or what any browser sent as signalling. Enforced by tests that (a) grep the raw SQLite file and the WAL, before and after a checkpoint, for a known title; (b) capture the logger during a call that exercises join, admit, chat, offer, candidate, a refused message and an error path, and grep the capture for the title, the chat text, any SDP fragment, any candidate address and any link token. A link token appears once in an HTTP response body and nowhere else.

2026-10-03 (§15.26): the log now also carries connection-quality reports and, when someone presses Send, problem reports, by Paul's ruling. Publish every log line and the worst anyone also learns is that some call, under a random tag, had so many participations on these platforms with this loss and these notices between these times — never which meeting, who was in it, or what was said, except whatever a person types into a problem report's note, and the device names a problem report's diagnostics carry. Nothing about a call is stored in the database. §15.26 has the exact list.

What the claim does not cover, said plainly: signalling passes through Meet's process in plaintext for the instant it is relayed, plus a replay ring of at most thirty-two targeted messages per participation held in memory for at most thirty seconds after its stream drops (§4.5), and a capability URL is a bearer that the person who holds it may paste anywhere. Ruling 16 covers the token at rest, not in flight.

0.2 Consumed suite rulings

This app cites the suite sharing doctrine (oficina/suite, docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md, revision 8 at 1ac4323) by ruling number and never restates it.

| Ruling | Used for | |---|---| | 1, 5 | One instance per team; the first deploy is the lone shape (§7.1). | | 3 | §2.3: standing (member, guest, neither) is derived at each request and re-derived on every hub tick. | | 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). | | 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. | | 9 | §8: a member, a guest who signed in, or an agent. | | 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. | | 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. |

Doctrine §5.2 is also relied on, for §2.

Also consumed: oficina/suite discussion 4, for the drive surface (§8); the CARLOS media roadmap (carlos/platform, docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md, merged at 8291cb2) and the stage 4a relay spec (docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md, revision 6, contract frozen 2026-09-07, on platform main at dcf0cad; the relay is live at relay.eu-west-1.oncarlos.com:3478 since 2026-09-07) for §5; carlos/platform discussion 113 for the signalling transport (§4.1).

0.3 What this spec does not claim

  • No claim that a call connects across every network. Without a relay, browsers gather host candidates only, and the call connects when ICE finds a working direct path between them (§5.5). With the platform relay, some networks still fail, and the failed tile says a relay was in use, not why.
  • No claim about the home. Meet is built lone and adopts the home's assertions when oficina/home ships them.
  • No claim that the four-participant cap is a product limit. It is the mesh's limit, lifted by stage 4b.
  • No attendance record. Meet does not store who was in a call or for how long. Revision 1 implied one and had no model for it; revision 2 drops the implication rather than adding the model. Since §15.26 (2026-10-03) the log shows that a call, under a random tag, took place between two times with so many clients in it; never which meeting or who.

---

1. Settled decisions (don't relitigate casually)

| # | Ruling | Rejected | Who, when | |---|---|---|---| | 1 | Every participant signs in. An outsider following a meeting link takes a guest grant scoped to that meeting. No anonymous join. | An ephemeral participant named only by what they typed, admitted from a waiting room: a fourth actor kind for one app | Paul, 2026-09-06, suite discussion 3 | | 2 | The repo is oficina/meet. English name, like docs, sheets, calendar. | oficina/conferencia | Paul, 2026-09-06 | | 3 | File upstream, then build in-app and extract. Platform pieces (UDP, relay) were filed first because no app can substitute for them; framework pieces are built here and offered to rastrillo on the next consumer. | Asking rastrillo for a WebSocket helper before a consumer exists | Paul, 2026-09-06 | | 4 | Scheduling is a start time on the meeting. The link works whenever a signed-in person follows it; the join page shows when the meeting is meant to start. A guest grant expires two hours after the start time, or two hours after creation when there is no start time. Expiry gates entry; an admitted guest continues on a participation (§2.4). | A start-to-end window with a clock-driven state machine; recurring meetings | Paul, 2026-09-07 | | 5 | Pure P2P, room capped at four admitted peers. Each participant holds one peer connection per other admitted participant. | A relay Meet runs; a third-party TURN by default | Paul, 2026-09-06; cap is this session's, from mesh bandwidth | | 6 | ICE servers come from platform config or nowhere. CARLOS_RELAY present: STUN and TURN from the platform, TURN credentials minted per call. Absent: no ICE servers, host candidates only, the pre-join screen says so. | A per-team STUN or TURN URL field (doctrine §8: the recipient is the app's decision, never the team's); one app-named public STUN provider as an opt-in interim | Paul, 2026-09-07, choosing "no relay" as the interim once stage 4a was ruled to start | | 7 | Signalling is SSE plus POST, one stream per peer, following Docs live-sync §3. | A WebSocket: rastrillo has no helper, Origin would be hand-checked, and no upgrade crosses a tunnel-backed host (platform 113) | This session, 2026-09-07; platform 113 confirmed the constraint | | 8 | Chat is ephemeral, a signalling message rendered client-side, held nowhere at rest, delivered to admitted peers only. | Persisted chat as content with named actors, the suite's first chat product | This session, 2026-09-07 | | 9 | A guest waits to be admitted. A member joins directly; a guest who signed in and holds a grant still waits until any admitted member admits them. A waiting guest receives nothing from the call and sends nothing into it (§4.3). | Auto-admit on grant | This session, 2026-09-07, built regardless of ruling 1's outcome | | 10 | No calendar integration in version one. Meet owns rooms and a start time. Calendar's "recorded, never invited" rule stands; a sealed conference URL on an event is Calendar's later change. | Meet growing a calendar; Calendar sending invitations | This session, 2026-09-07 | | 11 | An agent can make, list, link and end meetings; it cannot join a call. The media plane is browser-only. Its reach is its operator's current reach, checked on every request. | An agent participant; a token that outlives its operator's membership | This session, 2026-09-07, under suite discussion 4 | | 12 | The link is the gate for sign-in. A magic link is sent to a non-member address only when the browser asking has just followed a live meeting link, and the address is recorded as a pending guest before the mail goes out so the emailed link admits it wherever it is opened. Without a live link, the allowlist gates sign-in exactly as Docs does. | Opening sign-in to any address (revision 1), which opened unsolicited mail and a shared rate-limit budget to anyone | This session, 2026-09-07, on Codex findings R1-8 and R2-1 | | 13 | Negotiation is collision-safe, the W3C perfect-negotiation pattern with politeness decided by peer id order, over an ordered, acknowledged signalling queue per peer pair (§4.6). | A fixed offerer, which cannot renegotiate when the other side adds a track; fire-and-forget POSTs | This session, 2026-09-07, on Codex findings R1-10 and R2-5 | | 14 | Entry and participation are two authorities. Standing (§2.3) decides who may enter a room. A participation (§2.4), created at admission and bound to session and peer, decides who may continue in one. A guest whose grant expires mid-call continues on the participation; a member who loses standing does not. | One authority for both, which either drops an admitted guest at the two-hour mark or lets an expired grant re-enter | This session, 2026-09-07, on Codex finding R2-3 | | 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 |

---

2. The meeting model

2.1 Tables

Meet's own, gorm on SQLite through rastrillo, migrations numbered as the scaffold does. The scaffold's Note goes in slice 1; rastrillo/auth brings its own session and pending-link tables through migrate.Merge(sessions.Schema, ...) as Docs and Slides do.

persons — id, email (unique), name, created_at. Created on first sign-in from the session subject, per the rastrillo §5 warning that the subject is an address, not an id. Standing is not a column (§2.3).

meetings — id, sqid (public, in URLs), title_sealed, starts_at (UTC, nullable), created_by (actor, §7.2), created_at, ended_at (nullable), ended_by (actor, nullable). A meeting without starts_at is "now". A meeting with ended_at set refuses joins and its links answer 404 exactly as a revoked one does.

meeting_links — id, meeting_id, token_hash (SHA-256 of the token, ruling 16), token_sealed (nullable; added by the 2026-09-12 sharing amendment, top of file, so a member can retrieve a current invitation again), created_by, created_at, revoked_at. One meeting may hold several links so a revoked one can be replaced. The token is returned once, in the response that minted it, and again on demand through POST /m/{sqid}/share once a sealed copy exists.

pending_joins — id, meeting_id, link_id (the meeting_links row the browser followed), address_fingerprint (keyed HMAC of the address under the sealer's purpose key, so the table never holds a plaintext outsider address, calendar's rule for attendees), created_at, expires_at (fifteen minutes). Written at POST /signin when anyone, member or outsider, asks for a magic link while holding a live meeting link (§3.2); read by the sign-in authorize callback (for outsiders) and by /continue (for everyone); deleted when consumed or expired. A restart does not lose it, so a magic link clicked after a deploy still works.

guest_grants — id, meeting_id, person_id, granted_at, expires_at, admitted_at (nullable), admitted_by (actor, nullable). Minted at sign-in continuation when a non-member follows a live link (§3.2), or from a pending_joins row at /continue. One row per person per meeting; following the link twice touches nothing. admitted_at and admitted_by are an audit of the first admission and confer nothing: whether a guest is admitted now is hub state (§4.2).

agents — slice 5, §8.1.

Sealing reuses calendar's internal/calendar/seal, which is itself the Sheets sealer's second home: one DEK from platform config (CALLS_DEK), HKDF purpose keys, AES-GCM, and the DEK never written to the database it opens. Calendar's seal.go names the third app as the extraction trigger. Meet is that third app; the plan carries the extraction task calendar already defined rather than a new one.

2.2 Nothing else at rest

Rooms, peers, admission, chat and presence live in the hub (§4.2) and die with the process. A process restart empties every room; §4.5 says what browsers do about it.

Still nothing at rest in the database. Since 2026-10-03 the app's log is a store of its own for quality and problem reports, under random tags, kept for the platform's log retention (seven days); §15.26.

2.3 Standing: who may enter

A person's standing toward a meeting is one of member, guest (holds a grant for this meeting that has not expired), or none. It is computed, never stored:

  • member: the address is on the allowlist (lone shape) or the roster copy at its current epoch (suite shape, when the home exists). Both are reads of the instance's own state, which is what doctrine ruling 3 and §5 call the roster; neither is a cached answer from another service.
  • guest: a guest_grants row for this person and meeting with expires_at in the future. The row is the instance's own authority under doctrine §5.2.

Standing is the entry authority. §2.5 says which endpoints check it, and it is computed on each request and never cached.

2.4 Participation: who may continue

A participation is the hub's record that a peer is in a room: (room, peer id, session id, person id, kind, admitted, joined_at), created when a stream opens and made admitted by a member's join or an admit. It is the authority for everything a peer does after entry: POST /m/{sqid}/signal, a stream resume (GET /m/{sqid}/stream?peer=), and GET /m/{sqid}/ice are authorized by a participation bound to the requesting session, not by standing. So an admitted guest whose grant expires at the two-hour mark keeps chatting, renegotiating, resuming its stream and renewing its relay credential, because none of those is an entry.

A participation ends, and its stream is closed with a terminal lost, on: leave; a member's removal of it (§3.5); the meeting ending; the resume window lapsing (§4.5); or, for a member only, loss of standing (below). It is never ended by a guest's grant expiring.

Kind (member or guest) is re-derived from standing by the hub on every heartbeat tick (15 s, §4.1) for every participation, and the transitions are:

| Was | Now | Effect | |---|---|---| | none | member (added to allowlist) | Next entry succeeds. Nothing in the hub; they were not in it. | | member kind, in a room | standing none (removed from allowlist) | Every request 404s. On the next tick: lost {reason: "standing"} to the peer, stream closed, participation ended, gone to admitted peers. The other browsers close their connection to it on gone (§4.5 says how a browser that missed the gone still converges). Meet cannot tear down a P2P connection it is not party to, and says so. | | member kind | standing guest (removed, live grant) | Participation continues, kind becomes guest, roster refreshed to admitted peers. May no longer admit or remove anyone. | | guest kind, admitted | standing member (added) | Continues, kind becomes member, roster refreshed. May now admit and remove. | | guest kind, waiting | standing member (added) | On the tick, the hub attempts admission under the cap exactly as an admit would: admitted, roster to them and peer to the others; or full to them and they stay waiting, now as a member, and may retry with join. | | guest kind, admitted | grant expires | Nothing. The participation is the authority now (ruling 14). | | guest kind, waiting | grant expires | On the tick, lost {reason: "expired"}, stream closed, participation ended, knock-gone to members. | | guest kind, admitted | leaves and returns after the resume window | Standing is checked at the new entry; an expired grant means 404 at /m/{sqid}/join, which is ruling 4. | | any | meeting ended | ended to every stream, all closed, every participation ended, room dropped. |

The roster copy's freshness in suite shape is the doctrine's epoch rule (§5); Meet re-reads its copy on every tick like any other request and adds no cache of its own.

---

2.5 The authorization contract, per endpoint

One rule per endpoint, so §4 and §5 do not each restate it:

| Endpoint | Authority | Checked | |---|---|---| | GET /, POST /meetings, the replace-link and end-meeting confirm pages and their POSTs | Current member standing | On the request. | | /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. | | /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. | | 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. | | GET /m/{sqid}/stream with no peer | Standing | On the request. Creates an unjoined participation. | | GET /m/{sqid}/stream?peer= | Participation bound to this session | On the request. | | POST /m/{sqid}/signal, join | Participation, and standing at that moment, because join is the entry | On the request, under the hub lock. | | 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. | | POST /m/{sqid}/signal, leave | Participation, any state | On the request. A waiting guest may leave. | | 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). | | /api/v1/* | Agent token unrevoked and operator's current member standing | On the request. |

A request by a participation whose kind is member re-derives standing on every request as well as on the tick; a member who has just been removed is dropped by the next thing they do, not the next tick. The tick (§2.4) exists for the quiet case. Promotion of a waiting guest on the tick (§2.4) goes through the same admission check as admit, including the target's entry authority, so a guest whose grant has expired is never admitted by either path; only an already-admitted guest survives expiry.

---

3. Joining

3.1 Making a meeting and its link

GET / lists the team's meetings, upcoming first, ended ones in a collapsed section. POST /meetings takes a title and an optional start time and creates the meeting and its first link in one transaction. The response page shows the link once, with a copy button when scripts are on and a selectable text field when they are off.

Replacing a link revokes every other link for the meeting, which is destructive, so it has its own confirm page: GET /meetings/{sqid}/links/replace renders ui/confirm-form naming what will stop working; POST /meetings/{sqid}/links does it and shows the new link once. Ending a meeting is the same shape: GET /meetings/{sqid}/end confirms, POST /meetings/{sqid}/end ends. Both are members-only.

Links are https://<host>/j/<token>, with a token of 32 random bytes, base64url. The route is mounted outside the session group like calendar's feed route, because the person following it is by definition not signed in yet.

3.2 Following a link

GET /j/{token} looks the hash up. Unknown, revoked, ended or expired gives one 404 for all four. Live gives one of three outcomes:

  • A signed-in member is redirected to the holding screen, /m/{sqid}/join.
  • A signed-in non-member gets a grant minted now if they have none, then the holding screen.
  • Nobody signed in: Meet sets a pending-join cookie and redirects to /signin. The cookie holds the link's token_hash and an expiry fifteen minutes out, HMAC-signed under the instance key, HttpOnly, Secure, SameSite=Lax, path /. It carries the hash, not the token, so a cookie leak is not a link leak. Its only job is to tell POST /signin which link this browser followed.

Asking for the magic link (ruling 12). Docs' auth.go gates POST /signin on the allowlist before rastrillo's Begin. Meet keeps that gate with one exception: an address not on the allowlist is let through when, and only when, the request carries a valid pending-join cookie for a live link. Whenever the cookie is present and valid, for a member address or an outsider's, Meet first writes a pending_joins row (the link the cookie names, its meeting, the keyed fingerprint of the address, fifteen minutes) and only then calls Begin. Nobody can cause mail to an arbitrary address without holding a live meeting link, and holding the link is the team's own choice.

Admitting the emailed link. rastrillo's Authorize callback receives the verified address and nothing else, and Docs wires it to the allowlist, which would 403 the outsider's click. Meet's Authorize is: the address is on the allowlist, or its keyed fingerprint has an unexpired pending_joins row whose link is still live. This works in whichever browser the emailed link is opened, because the row was written before the mail went out and lives in the database, not in a cookie.

Continuation. rastrillo has one configured landing path for a fresh session and no per-attempt return value, so Meet's SignedInPath is /continue. GET /continue resolves the signed-in person, then:

  1. If one or more pending_joins rows match their fingerprint and are unexpired, take the most recent. In one transaction: re-check that its link is not revoked, its meeting not ended, and the row not expired, exactly as GET /j/{token} would (so a link replaced during sign-in is refused even though the meeting has a new live link); mint the grant if they are a non-member and hold none; delete every pending row for this fingerprint; redirect to the holding screen. If the re-check fails, delete the rows and fall through to 2 or 3. The pending-join cookie, if present, is cleared and never consulted; the row is the record.

Two refusals, two outcomes, stated so the tests assert the right thing: an outsider whose only pending row's link was replaced between mail and click is refused earlier, at Authorize, and the inherited rastrillo handler answers 403; a signed-in person whose row fails the re-check at /continue gets / or /welcome. Neither path mints a grant. Meet does not change the inherited 403.

  1. Otherwise, if they are a member: redirect to /, the meeting list. A member who opened /signin directly lands where they expect.
  2. Otherwise: redirect to /welcome, the not-a-member page: "You're signed in as <address>. This team's Meet has no meetings for you. If someone sent you a meeting link, open it again now." with sign-out and nothing else. Scriptless.

A member who followed a link while signed out therefore reaches the holding screen of that meeting, in whichever browser the emailed link is opened, by the same row an outsider uses. The rows differ only in what they authorize: for an outsider the row also admits the address at Authorize; for a member the allowlist does that and the row only carries the destination.

Mail budget, and what it does not buy. The inherited limiter allows twenty attempts per client address and five per email address per fifteen minutes, keyed on RemoteAddr, which behind the platform edge is the edge, so that budget is shared by every visitor. Meet bounds what it can bound: attempts for a non-allowlisted address are counted before Begin is reached, at five per link per fifteen minutes and ten across all links per fifteen minutes. That stops a leaked link from being used to mail strangers at volume.

It does not protect members, and revision 3's claim that it did was wrong: anyone can submit a member's address without any link, reach Begin, and consume the shared client-address allowance twenty times in fifteen minutes, or a single member's five-per-address allowance, after which that member cannot sign in for the rest of the window. The gate classifies an address, not the person typing it, and nothing in front of Begin can tell them apart. This is the residual denial of service for sign-in on every app that shares this edge and this limiter, not a Meet-specific one, and Meet records it rather than claiming a reserve. The fix is a trusted client address from the edge or a per-app limiter keyed on something better, and a question to that effect is filed on the platform when slice 1 opens.

Responses. Meet's own refusals (allowlist without a link, link budget) redirect to /signin?sent=1, the same destination as a successful Begin. rastrillo's own outcomes are left as they are: ?err=address for a malformed address and ?err=rate for its limiter. The residue, stated plainly: while a budget is exhausted, a member address gets ?err=rate where an unknown address without a link gets ?sent=1, which distinguishes the two; and because a single member address can be rate-limited on purpose, that distinction is reachable by an attacker at will, not only under global exhaustion. Membership of a specific address is therefore not a secret Meet can keep at the sign-in form, and the README says so.

3.3 The holding screen

/m/{sqid}/join is authorized per §2.5 and is the page where a call needs scripts. Rendered server-side it shows the meeting's title, its start time if it has one, the person's own name and standing, the floor sentence from §5.5 when it applies, links to the meeting list and, for members, to the end-meeting confirm page. With scripts on it adds the camera preview, device pickers for camera, microphone and speaker, mute toggles, and the join button. Choices persist in the browser's local storage per device id; nothing of them reaches the server. With scripts off the join button is replaced by one paragraph saying that a call needs JavaScript to reach the camera and the other participants, and that everything else on this page works without it (§9).

Pressing join opens the stream (§4.1), receives its peer id, and posts join. A member enters. A guest sees "waiting for someone to let you in" until an admitted member posts admit for them; closing the tab leaves the queue. Every admitted member sees the knock with the guest's name and one button.

3.4 The room

/m/{sqid} renders, with scripts on, the grid of participants, own controls (mute, camera, share screen, leave), the chat panel and the admission queue. With scripts off it renders the same header as the holding screen, the same paragraph, and a link to the holding screen; nothing on it pretends to be a call. Ending a meeting from inside the room goes through the §3.1 confirm page in a new tab; there is no in-room destructive control that skips it. Leaving is a leave message and the tab closing; a stream closed for thirty seconds without a resume (§4.5) is also a leave.

3.5 Removing a participant

A member can remove another participant, waiting or admitted. It disconnects a person, so it has its own confirm page: GET /m/{sqid}/remove/{peer} renders ui/confirm-form with the person's name; POST /m/{sqid}/remove/{peer} checks, at execution, that the actor holds an admitted member participation in this room and that the target participation exists in this room and is not the actor's own, then ends it: lost {reason: "removed"} to the target, its stream closed, and gone to admitted peers or knock-gone to members as appropriate. The in-room control is a link to the confirm page, opened in a new tab like ending a meeting. Removal is not a signalling message.

---

4. Signalling

4.1 Transport

One SSE stream per peer, GET /m/{sqid}/stream, authorized per §2.5, with the Docs live-sync heartbeat (15 s) and connection TTL (5 min) so hibernation cannot be held off by a dead stream. The first event on a new stream is hello {peer}, the server's peer id for this stream. Upstream messages are POST /m/{sqid}/signal with a JSON body carrying peer and the message, under CSRF.

A WebSocket would fit this shape better and is deliberately not used: Docs live-sync §3 lists the costs, and platform discussion 113 confirmed that no upgrade crosses a tunnel-backed host and no placement signal exists. rastrillo/rastrillo discussion 17 records the intent to build one when a second consumer appears.

4.2 The hub and peer identity

An in-process room hub in the shape of Sheets' live/hub.go, with a room admission policy the Sheets hub does not need. Per room it holds the set of peers; per peer: peer id, session id, person id, standing as last computed, admitted (bool), joined_at, the subscriber channel, and resume_until when the stream is closed.

A peer id is issued by the server when a stream opens and is bound to that session and that room, creating a participation (§2.4) that is not yet joined. A room holds at most sixteen participations of any state (unjoined, waiting, admitted, or held for resume); a stream that would be the seventeenth is refused with 503 before anything is allocated. An unjoined participation that has not posted join within thirty seconds is dropped with its stream.

The peer id is a 16-byte random value, shown in rosters so browsers can address each other, and it is not a credential: every POST /signal must carry a peer that the hub finds in this room, bound to this session; anything else is refused with 404 and nothing is relayed. A session may hold one peer per room. A stream opened with ?peer= resumes that participation (§4.5). A stream opened without peer while the session already has a live participation in the room (attached or within its resume window) is refused with 409 and a body naming the existing peer. A GET never ends a participation, and there is no query parameter that makes it do so.

Takeover is the one way a person moves a room from one tab to another, and it is a confirmed POST like every other destructive action: GET /m/{sqid}/takeover renders ui/confirm-form ("This meeting is open in another tab. Use this one instead?"); POST /m/{sqid}/takeover, under CSRF, checks at execution that this session holds a live participation in the room, then under the hub lock ends it with the same state-dependent departure leave uses: an admitted peer yields gone to admitted peers; a waiting one yields knock-gone to admitted members; an unjoined one yields nothing. The old stream, if attached, gets lost {reason: "replaced"}, and the old peer id is remembered as replaced for ten minutes (§4.5). The response redirects to the room, whose fresh stream now opens without conflict and proceeds as a new join. If another tab opens a stream between the POST and that fresh stream, the fresh one gets 409 and the person sees the button again; nothing automatic ever displaces a tab, and a cross-site navigation cannot end a call because the ending is a POST behind CSRF.

Every hello carries boot, a random id chosen when the process started, so a browser can tell a restart from any other refusal (§4.5).

Every downstream envelope is built by the server: type, from (the sender's peer id, from the binding, never from the body), the sender's display name where the type carries one, and the opaque payload. A target to must name an admitted peer in the same room, or the message is refused. Cross-room delivery is impossible by construction because lookups start from the room in the URL.

The hub is per process, which is fine on one host and would not survive a multi-host fleet; that is noted, not solved.

4.3 Messages

| Type | Who may send | Recipients | Server does | |---|---|---|---| | 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. | | 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. | | leave | any peer | — | Remove the peer. Admitted: gone to admitted peers. Waiting: knock-gone to members. | | offer, answer, candidate | an admitted peer | the one admitted peer named in to | 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. | | 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. | | chat {text} | an admitted peer | all admitted peers | Fan out with the sender's name. 2 KB. Not stored. | | share {on} | an admitted peer | all admitted peers | Fan out so tiles can be labelled. | | 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. | | 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. |

offer, answer and candidate also carry seq and gen (§4.6); seq is relayed unchanged and gen is checked.

roster is the authoritative snapshot: every admitted peer with its id, name, kind and the recipient's current pair generation with it (§4.6), and, when the recipient is a member, the waiting queue too. peer carries the same fields for one peer. It is sent on admission, on resume (§4.5), and to every admitted peer whenever any participation's kind changes (§2.4). A member who joins a room where a guest is already waiting therefore sees the knock in the roster, not in a knock that had no recipient; knock is the incremental form for members already present.

lost carries a reason, and §4.5 says what the browser does with each.

The cap is enforced at every transition into the admitted state, under the hub lock: a member's join, an admit, and never on a resume (§4.5), which is not a transition. Five members joining at once get four admissions and one full; a member who sees full can wait and retry. Queued guests can exceed four while waiting; admission checks the count at admission time, so four queued guests and one member yield three admissions and one full.

The server validates type and shape and the sender's authority per §2.5, and reads nothing inside offer, answer, candidate or chat beyond the byte length.

4.4 Limits and logging

  • POST /signal bodies are refused above 64 KB before decoding. An SDP offer with several tracks is well under that.
  • chat text above 2 KB is refused.
  • A peer may send thirty signal messages per second with a burst of sixty; beyond that the hub replies 429 with Retry-After, and the browser's queue (§4.6) honours it and retries; a legitimate negotiation burst across three targets can touch the limit briefly and loses nothing.
  • A room holds at most sixteen participations in any state (§4.2) and at most eight waiting; a ninth waiting join is refused with full.
  • A session holds one peer per room (§4.2); a person holds at most four concurrent streams across all rooms. An unjoined participation lives thirty seconds; a resume-held one thirty seconds (§4.5).
  • A subscriber whose channel holds 64 undelivered events is declared lost, told so, and closed, as in Sheets.
  • The hub logs the type, room sqid, peer id and outcome of a refused message, and nothing of any accepted one. No payload, chat text, SDP, candidate, display name, title or link token is ever a log argument; the §0.1 test captures the logger to prove it.

4.5 Renewal, resume and restart

The five-minute TTL and any dropped stream are handled by resume, not by rejoining. When a stream closes, the participation stays in the hub for a thirty-second resume_until with its admission intact. The browser reopens GET /m/{sqid}/stream?peer=<id>&last=<json>, where last maps each sender's peer id to the highest seq this browser has received from it. The hub finds the participation bound to this session and attaches the new stream. What it sends next depends on admission:

  • Admitted: hello, then a fresh roster (§4.3), then a replay of every targeted message (offer, answer, candidate, pair) held for this participation in its replay ring with seq above last, or, for pair, with a generation above the roster's. The ring holds the last thirty-two targeted messages sent to each participation, for the resume window only, in memory, and is dropped with the participation; that is the one place Meet's process holds signalling for longer than the instant of relay, bounded by thirty seconds and thirty-two messages, and it is stated in §0.1. If a sender's messages above last are no longer all in the ring, the hub sends gap {from} instead of a partial replay, and the browser performs a pair reset with that sender (§4.6).
  • Waiting or unjoined: hello, then state {waiting: true}, and nothing else. A waiting guest learns nothing about the room by resuming, which is ruling 9's isolation carried through the resume path.

The browser reconciles against the roster it received: a peer connection to an id not in the roster is closed, an id in the roster with no connection gets one. That is how a browser that missed a gone while its buffer overflowed still closes the removed peer's connection: the roster on resume is the authority, not the events it missed. No event goes to anyone else; the other browsers' peer connections were never affected because media is P2P. A resume does not consume a cap slot. A participation that misses the window is removed as a leave.

A refused resume says why, in the status and a JSON body, and the browser's response is fixed per case:

| Resume answer | Meaning | Browser does | |---|---|---| | 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. | | 410 {reason: "removed" \| "expired" \| "standing" \| "ended"} | Ended for that reason; remembered ten minutes. | Terminal, show why. | | 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. | | 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. | | 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. |

lost reasons on a live stream follow the same split: buffer is followed by a resume; replaced, standing, expired, removed and ended are terminal and the browser closes every peer connection and shows why. A displaced tab therefore never reclaims a room by itself, whether or not its replaced notice was delivered: the 410 on resume and the 409 on fresh entry both stop it, and only the confirmed POST moves on.

A process restart empties the hub and changes boot. Every browser's resume is refused with a 404 carrying the new boot; the browser closes every peer connection first, then joins afresh. Members re-enter at once and negotiate afresh on the new peer events. Guests return to waiting and must be admitted again, and because their browsers closed their connections on the refusal, no guest keeps media flowing from before the restart. That is the honest cost of keeping admission in memory, stated rather than hidden behind persistence.

4.6 Delivery: ordered, acknowledged, replayed

SSE plus POST is not a session; the browser and the hub make it one. For each target peer the browser keeps an outbound queue and sends one message at a time, in order, waiting for the POST's 2xx before the next. Each offer, answer and candidate carries a seq, per (sender, target), starting at one and incrementing for the life of the sender's participation, never reset, across pair generations included; the server relays seq unchanged and keeps a copy in the target's replay ring (§4.5). Because seq is monotonic, the resume cursor last needs no generation: a new generation's first offer has a higher seq than anything the receiver saw before its stream dropped, so replay delivers it, and gen decides only whether a message is current. A POST that fails (network, 5xx, 429) is retried with backoff, same seq, up to thirty seconds, then the browser treats the target as unreachable, closes that connection, and re-creates it on the next roster. The receiver keeps the last seq seen per sender and drops anything at or below it, so a retried POST that had in fact landed is harmless, and a replay on resume delivers only what was missed.

Pair generations are how a pair recovers when replay cannot fill a gap, and they are held by the hub, not negotiated between browsers. Every unordered pair of admitted peers has a generation, starting at one when the pair first appears in a roster or peer event, which carry it. Every offer, answer and candidate carries gen; the hub refuses a stale gen with 409 and relays a current one. A browser that needs a fresh connection with a peer, because it received gap {from} or because its outbound queue to that peer timed out (§4.6 above), posts reset {to}; the hub increments the pair's generation under its lock and sends pair {peer, gen} to both sides, through their replay rings, and the new generation also appears in any later roster. On pair, or on a roster whose generation for a peer is higher than the local one, a browser: closes its connection to that peer, discards its outbound queue and its inbound candidate buffer for that peer, keeps its seq counters (they never reset), creates a new connection tagged with the new generation, and negotiates as for a new peer. A pair at or below the local generation is a no-op, so duplicates and reordering are harmless; two simultaneous resets yield two increments and both browsers converge on the higher one; an old offer or answer arriving after a rebuild is refused by the hub (stale gen) or dropped by the receiver (lower gen than current). A reset costs the pair a second or two of media and happens only after a resume whose gap exceeded the ring or after a delivery timeout, both rare by construction.

Candidates are applied only after a remote description is set; earlier ones are held in a per-peer buffer, which the W3C pattern already requires. Under perfect negotiation an ignored offer's candidates raise on addIceCandidate, and the browser swallows that error exactly when ignoreOffer is set, as the reference implementation does. A candidate whose seq precedes the current offer's seq is dropped as belonging to an old generation.

---

5. Media

5.1 The mesh and negotiation

One RTCPeerConnection per other admitted peer, created when a peer message arrives or the roster is received. Negotiation follows the W3C perfect negotiation pattern: both sides offer on negotiationneeded; the peer with the lower id is polite and rolls back on collision, the higher is impolite and ignores the incoming offer. This is what lets either side add a screen track or restart ICE without an offer-request message. All candidates trickle as candidate messages. A connection that reaches failed shows the tile as "could not connect" with a retry that restarts ICE; nothing is silently spun.

5.2 ICE servers from the platform

At startup the instance reads CARLOS_RELAY, standard base64 of a JSON array of {region, urls, key, username_suffix} in console order (the contract makes no placement-first promise; ICE picks a working pair). Absent or empty means §5.5. A present value that fails to decode or parse is a startup refusal outside -env development, logged as one line naming the variable and not its value. The decoded value is held in memory and never logged.

Per call, GET /m/{sqid}/ice (an admitted participation, §2.5) returns the RTCIceServer list. For each element, every URL is passed through as given; URLs whose scheme is turn or turns carry username = "<unix expiry>:" + username_suffix and credential = base64(HMAC-SHA256(rawkey, username)), where rawkey is the element's key decoded from base64 to 32 bytes; every other scheme (stun, stuns, or one the contract adds later) is passed bare. Expiry is three hours ahead, inside the four-hour maximum.

The minting function has one test that reproduces the platform's vector: key uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=, username 1900000000:abc:meet:0123456789abcdef:k1, credential jRU479Y3GO45nrRHqxBf3BMeBHE6Fip1cwAccNv28MQ=.

5.3 Renewal before the deadline

Credential expiry is a hard deadline on the relay, checked on allocate and on every refresh: the allocation ends at expiry whether or not the browser sends anything. The client therefore renews at two and a half hours, and renewal is per connection and complete only when the new path is proven:

  1. Fetch /ice. On failure, retry every minute; the tile shows a warning from the first failure.
  2. For each peer connection: setConfiguration with the new servers, restartIce(), and let perfect negotiation carry the offer. An ICE restart changes the local ICE username fragment, so the restart has a generation the browser can name: the new usernameFragment. The connection is renewed when, within thirty seconds, (a) the restart's offer/answer exchange has completed and signalingState is stable with that answer applied, and (b) getStats() reports a selected candidate pair in state succeeded whose local candidate carries the new generation's usernameFragment. An iceConnectionState change on its own proves nothing, because a completed transport moves to connected merely by starting a restart. Otherwise restart again, up to three times.
  3. The old credential's deadline is kept as the must-succeed-by time for every connection not yet renewed. A connection that is not renewed by then shows "relay expired" with a retry, which runs step 2 again with a fresh fetch. The browser cannot see which credential an allocation is using, so the deadline is the only honest signal.
  4. The next scheduled renewal is two and a half hours after the last successful fetch, per connection.

On visibilitychange to visible, the client compares the clock with the last successful fetch and runs the sequence at once if it is overdue, because suspended tabs stop timers.

5.4 Screen share

getDisplayMedia, added as its own transceiver on every peer connection, with a share message so peers label the tile, and renegotiation by §5.1 on each. Stopping calls transceiver.stop() on the screen transceiver and renegotiates, so the receiver's track ends rather than merely muting; the receiver removes the share tile on ended and on share {on: false}, whichever arrives first, while the camera track continues. One sharer at a time is not enforced; the grid shows every shared screen it receives.

5.5 The floor

With no CARLOS_RELAY, the ICE list is empty and browsers gather host candidates only: the addresses of their own interfaces. The call connects when ICE finds a working direct path between them, which may include a peer-reflexive address learned from the other side's checks, so one publicly reachable participant can be enough. In practice that is usually true on a permissive local network, usually false across the internet, and false on a local network with client isolation. The pre-join screen says, above the button: "This team's Meet has no relay yet. Calls usually connect only between people on the same network." The README's second paragraph, the one that introduces the relay, says the same; its first paragraph carries the scriptless floor (§9). An operator enables the platform relay with carlos relay enable --app meet, after which this paragraph no longer applies to that instance.

With CARLOS_RELAY present, a failed connection's tile says "could not connect, even through the relay" and offers retry. It does not diagnose the network; a TCP-443-only network is one cause the README names, because the relay's TLS listener is deferred by the platform, and it is named as a possibility, not a verdict.

5.6 What the relay changes

Stage 4a puts a platform process in the media path for calls that need it. It forwards DTLS-SRTP packets it cannot decrypt. §0.1's claim about Meet's own process is unchanged; the README paragraph that introduces the relay says a platform relay may carry encrypted media, and that a third-party relay is never used.

5.7 What the SFU changes

When the platform SFU exists and a team opts in, media is decrypted by a platform process to be forwarded. The mesh code is replaced by one connection to the SFU, the cap is lifted, and §0.1's claim is rewritten in a revision of this spec, not a footnote.

---

6. Chat

A chat message with text up to 2 KB, delivered to admitted peers only, rendered in the panel with the sender's name and a local timestamp, escaped as text. It is not stored, not replayed to a peer who joins or resumes later, and gone with the tab. Links are not linkified in version one. This is the suite's first chat, and estilo's rule makes Meet the first builder: the panel is one self-contained template partial and one script file so a second app can adopt it.

---

7. Identity and the instance

7.1 Lone shape

Sign-in is Docs' internal/docs/auth.go adopted with the changes §3.2 specifies in full: the pending-join exception to the gate in front of Begin, the pending_joins row written before Begin, an Authorize that admits allowlist-or-pending, SignedInPath set to /continue, and the outsider budgets. §3.2 also states which responses remain distinguishable. CALLS_ALLOWED_ADDRESSES, CALLS_ORIGIN, CALLS_INSTANCE_KEY, CALLS_SMTP_* and CALLS_DEK are startup refusals outside -env development. This file already lives in Docs, Calendar and Slides; Meet's is the fourth copy, and the extraction task Slides recorded stands. Meet adds none.

7.1.1 Optional Home sign-in (2026-09-10)

The operator can enable Home with all four CALLS_HOME_ISSUER, CALLS_HOME_CONSUMER_ID, CALLS_HOME_KEYS_BASE64 and CALLS_HOME_DEK_BASE64 settings. Missing all four keeps standalone sign-in; partial settings refuse startup. The Home key is separate from the meeting-title key.

Home links an existing member after fresh independent local sign-in, a fresh Home proof and explicit confirmation. It never creates a person, matches a Home email claim or grants membership. Both protected routes and the public meeting-link handler resolve Home subjects to the existing person ID, checking current membership. Existing meeting actors remain person:<id>. Person IDs are AUTOINCREMENT and cannot be recycled; any future account-deletion operation must also delete its Home links in the same transaction.

GET /signin starts Home unless the browser has a pending guest-join cookie or is returning a local sign-in result. GET /signin/local is independent local sign-in. Its verification finishes at /auth/local/complete, then /continue, preserving the pending-join rules above. An explicit account-link continuation takes precedence. Guests still verify locally and cannot access /account/home.

Shared home/connect owns /auth/start, POST /auth/assertion, /auth/finish, /signed-out and the GET/POST account-link routes /account/home and /account/home/confirm. Only the exact signed assertion callback bypasses ordinary CSRF protection; its issuer and proof are checked by the shared protocol. CSP preserves the framework's other directives and adds only the configured Home issuer to form-action. All account-link screens use the shared templates and work without scripts.

7.2 Actors

Every write records an actor: person:<id>, or agent:<id> for the drive surface. Meetings, links, grants, admissions and endings carry it. A guest's actor is person:<id> like a member's; what differs is standing, decided per request (§2.3).

7.3 Deploy

One static binary, CGO_ENABLED=0, linux-arm64. The scaffold's make release strips and does not stamp; slice 1 changes it to pass -X amadan.net/rastrillo/rastrillo.BuildVersion=$(git describe --always --dirty) and to refuse a dirty tree, as calendar's does, so /api/version reports the built revision and a deploy can be verified against it. carlos deploy --app meet --account oficina with the build probe on /api/version. carlos domains attach calls.oficina.works. Secrets CALLS_INSTANCE_KEY, CALLS_SMTP_PASS and CALLS_DEK via carlos secrets, the rest via carlos env.

Meet is deployed as a unit route, Meet's choice among the shapes the relay contract accepts (unit, socket-exec, or a TCP exec route with file delivery); a unit route needs nothing from Meet when an operator runs carlos relay enable --app meet, and the platform restarts the route when the value is delivered.

---

8. The drive surface

8.1 Principals

Adopted from Slides §7.1: a member mints an agent token from Settings, agents(id, name, operator_person_id, token_hash, created_at, revoked_at), principal <name>~<operator email>, robot badge and operator name on every write, bearer on the API only, never a cookie on the API and never a bearer on HTML routes. Cited to suite discussion 4 for the credential shape at the home; until that is ruled, the token is instance-minted exactly as Slides does.

Reach. On every API request the token must be unrevoked and its operator must have member standing (§2.3) at that moment. An operator who leaves the team takes their agent's reach with them, with no revocation step; the request 404s like any other loss of standing. Suite discussion 4 binds an agent to its operator's reach and no more, and this is the per-request form of that sentence.

8.2 Operations

GET /api/v1/meetings (list: sqid, title, starts_at, ended_at, link_present), POST /api/v1/meetings (title, optional starts_at; returns the meeting and its link's token, once), POST /api/v1/meetings/{sqid}/links (replace: revokes the rest and returns the new token, once; the only two responses that ever carry a token are this and creation), POST /api/v1/meetings/{sqid}/end, GET /api/v1/meetings/{sqid} (the meeting and, while it runs, the count of admitted peers, never their names or the chat). A replacement that fails leaves the old links untouched; there is no partial state to retry from. No operation joins a call (ruling 11).

8.3 MCP

A thin adapter over §8.2, deferred until a consumer asks by name.

8.4 Native clients

2026-09-14 amendment. The SwiftUI Mac, iPhone and iPad apps this section specifies shipped on main (native/, callscore/, protocol/, tag v0.1.0 at 9225ec2), closing the authentication gap the bullets below name with a browser-approval hand-off (native/README.md): a five-minute, one-use authorization bound to a proof secret held only by the app; approving in the browser creates an ordinary framework session, no cookie copied and no new bearer type introduced. The production handoff endpoints were deployed in 86ed9f3. This supersedes the closing paragraph's "version one does not ship a native client" below; the rest of this section, written before that milestone, otherwise stands as the contract the shipped apps speak.

Ruling 15: a native app is the next client after the browser, and nothing in Meet's server contracts may assume the browser. Concretely, in version one:

  • The signalling protocol (§4.3, §4.5, §4.6) is JSON over SSE and POST with no HTML in either direction, and the message table is the contract; room.js is one client of it, not its definition.
  • /ice (§5.2) returns a plain RTCIceServer list a native WebRTC stack consumes unchanged.
  • The meeting API (§8.2) is the same surface a native app would use to list, create, link and end meetings.
  • Authentication is the one piece a native client cannot take from version one as is: sessions are cookies minted by a magic link, and a bearer exists only for agents. A native client's sign-in is the home's assertion work (doctrine §5, oficina/home §13), not a Meet-local token, and Meet adds no interim of its own.

What version one does not do: ship a native client, publish an OpenAPI document, or version the protocol beyond gen and seq. The fixtures for the message table (§13) are the machine-readable contract until an OpenAPI document earns its place.

---

9. The scriptless floor

The exception is browser media participation: reaching a camera and another browser needs script. Inside a room that exists, script also gates the chat panel (chat.html's whole panel is rendered hidden until script un-hides it, because a Send button with no script behind it would be a lie, §6, §10) and the admission queue's Admit controls (room.js), since neither can do anything before a call does. Every other screen works with scripts off, including the meeting list, creating a meeting, seeing and copying a link, every confirm page (five), sign-in and continuation, the /welcome page, the agent token page, and the server-rendered parts of the holding screen and the room (§3.3, §3.4): title, start time, standing, floor sentence, the paragraph that says what needs script, and navigation to everything that does not.

AGENTS.md's line "screens work with JavaScript disabled" gained, in slice 2, the one exception in those words; on 2026-09-14, alongside this section's own widening, it gained the other two (§15.18), so the binding file and the code agree again. PRODUCT.md records it in "what is deliberately not here" in slice 6.

Hand-written static JavaScript, no bundler, no npm, in three files: devices.js (the holding screen), room.js (signalling, resume and the mesh), chat.js (the panel). Roughly two thousand lines between them, the largest hand-written script in the suite so far, which is why they are three files and not one.

---

10. Estilo and copy

Meet adopts, in this order, whatever estilo holds at the time each slice lands: the theme, the sign-in composition once one is extracted, error and empty states. Meet builds nothing in estilo in version one except what §6 prepares. Every new user-facing string goes through the copy review before it lands (copy-review, the suite's mechanism), and the plan lists the strings per slice so the review is one sitting per slice, not one per string. The sentences quoted in §3.2, §3.3 and §5.5 are drafts for that review, not approved copy.

---

11. Deliberately not built yet

Recurring meetings. Calendar integration. Recording. Attendance records. A relay of Meet's own. Rooms above four. Persisted chat. Reactions. Dial-in. Captions. Multi-host signalling. Push-to-talk. Virtual backgrounds. Denying a knock (a guest who is not admitted simply waits or leaves). Each is named so the next session does not rediscover it.

---

12. Slices

Each slice is its own branch, discussion and review, per the suite's overnight protocol (oficina/suite discussion 5), and each carries its own gates from §13; nothing is deferred to the deploy slice.

  1. Meetings, links, sign-in. persons, meetings, meeting_links, guest_grants; sealing; the list; create; the link shown once; the two confirm pages and their POSTs; the /j/ route with all three outcomes and the one 404; the pending-join cookie, pending_joins, the Authorize callback, /continue with its three outcomes and the atomic re-check, the allowlist exception and the two outsider budgets; /welcome; the §2.5 contract as middleware; standing (§2.3) as a package with tests; make release stamping. Scriptless throughout. Joining lands on a holding page that says the room is the next slice.
  2. Holding screen and room, direct connectivity. devices.js, the hub with peer binding, participation, cap, limits, resume with admission-branched output, the replay ring, gap and pair reset, terminal replaced, and the tick; the stream and hello; every message in §4.3 except chat and share; the removal confirm page; room.js with perfect negotiation and the §4.6 queue; the participation transitions; the AGENTS.md exception. CARLOS_RELAY absent. The browser gate: two isolated browser contexts, two identities, one call on localhost.
  3. Relay. CARLOS_RELAY parsing and the startup refusal, minting with the vector test, /ice, renewal with repeated and failed cases under shortened timers. Verified against the real relay when stage 4a reaches flagship; until then the gate is the list the browser receives and the renewal behaviour under a fake /ice.
  4. Chat and screen share. chat.js, the chat and share messages with their recipient rules, display-media tracks and renegotiation from either side.
  5. Drive surface. agents, the five operations, the per-request reach check, the badge.
  6. Deploy. Domain, secrets, canary, PRODUCT.md, the README with the scriptless-floor sentence (§9) in its first paragraph and the relay paragraph, carrying §5.5's no-relay sentence, second, and the Consumed suite rulings commit that suite Appendix A.6 will point at.

Delivered 2026-09-14 (§15.18). The README, with the floor and relay paragraphs first, was written. PRODUCT.md was written. AGENTS.md's deploy notes were appended. This is the Consumed suite rulings commit, refreshing §0.2 against doctrine main 1ac4323 revision 8. The acknowledgements page is deferred to oficina/meet discussion 10's branch feat-open-source-credits, not duplicated here. The Estilo theme pin did not happen, because no tagged release exists; the theme is nonetheless already adopted on main at a pseudo-version through the Home integration. No production promotion happened, because this slice is documentation only; edge stays hand-managed by Paul.

---

13. Testing

Go, per slice: store and grant tests including expiry and the one-404 rule (1); the full emailed-link round trip for an outsider, with the pending-join cookie present and absent at the click, the Authorize callback admitting the pending address and refusing an unknown one, revocation during sign-in, /continue's three outcomes for member and non-member with present, absent and expired rows, a member's signed-out link journey completing in another browser, link A replaced by B between mail and click (outsider: 403 at Authorize; signed-in: / or /welcome; no grant either way), and the outsider budgets plus the documented exhaustion of the shared allowance by repeated submission of one member address (1); standing as a table test (1); hub tests for peer binding and refusal of a foreign or cross-room peer, the sixteen-participation and unjoined-timeout limits with streams opened and never joined, targeted relay only to admitted peers in the room, a waiting guest receiving nothing but hello and its own outcome, roster carrying the waiting queue to members and not to guests, guest-first arrival then a member joining, promotion of a waiting guest to member under and at the cap, the cap at join and at admit including four queued guests and concurrent admissions, resume within and after the window with the roster sent to an admitted peer and only state to a waiting one, replay from the ring above last, gap when the ring is exceeded, replaced terminal for the displaced tab including the case where the lost was never delivered (410 on resume, 409 on fresh entry, no automatic takeover), a same-boot 404 followed by a clean rejoin, a different-boot 404 after restart, a takeover of an admitted, a waiting and an unjoined participation producing gone, knock-gone and nothing respectively with an admitted guest observing, a cross-origin takeover POST refused by the scaffold's origin-checking CSRF middleware while a same-origin confirmation succeeds without any token, a resume with a nonzero old cursor after a missed pair receiving the new generation's offer by replay, a stale-gen POST refused with 409, reset yielding one pair to each side with the same generation, two simultaneous resets converging, a pair at or below the local generation ignored, admit of a guest whose grant expired since the last tick refused, the 64-event buffer, the rate limit, an admitted guest continuing past grant expiry on every endpoint and being refused fresh entry, a member dropped on the tick (2); the §0.1 database and WAL tests (1) and the captured-log test for join, admit, offer, candidate, a refusal and an error (2), extended to accepted chat (4); the minting vector and the renewal state machine including a successful fetch followed by a failed restart, and a completed transport that moves to connected on restart start without qualifying as renewed (3); the API reach check after an operator's removal (5); make ci green on every slice.

Browser. Calendar's chromedp drive is recorded as not running in this environment, so Meet does not inherit it; the driver is Playwright through the same npm install --no-save playwright step calendar's Makefile uses, established in slice 2 before any assertion is written. Two isolated browser contexts with separate sessions, Chrome's --use-fake-device-for-media-stream and --use-fake-ui-for-media-stream. Gates: the holding screen enumerates devices and previews (2); two contexts complete a call on localhost with host candidates and each receives the other's video track, asserted on ontrack and a non-zero frame count, not on a label (2); a guest knocks, is admitted, and had received no peer while waiting (2); a waiting guest receives no chat, and receives the next one once admitted — the positive control that proves the silence was its standing rather than a stream that delivers nothing (4, carried by the chat scenario, not the guest one); a stream renewal with the TTL shortened to seconds leaves the call connected (2); a member removed from the allowlist mid-call is dropped on the next tick and the other context sees gone (2); a context whose stream is closed while a peer leaves resumes, receives the roster, and has closed the connection to the departed peer (2); an offer whose SSE delivery is cut after the POST's 2xx is replayed on resume and the pair connects (2); a gap beyond the ring produces gap, a reset, a new generation on both sides and a reconnected pair, including when one side's pair was cut and it learns the generation from the roster on its own resume (2); a second tab on the same session is refused with 409, confirms the takeover, and the first shows the takeover link and does not reconnect on its own, while a cross-site top-level navigation to any Meet URL ends no call (2); after a server restart both contexts have closed their old connections before rejoining (2); renewal with the schedule shortened to seconds produces a second /ice fetch and an ICE restart that leaves the track flowing, and a failed fetch shows the warning (3); the higher-id context shares its screen and the lower receives a second video track, then stops, the received track ends, the share tile is removed and the camera track keeps flowing (4); chat arrives at the admitted context and not at a waiting one (4); the scriptless holding and room pages render their paragraph with scripts disabled (2). No test touches a relay until one exists.

---

14. Review record

Round 1, GPT-6 via Codex against revision 1 at 3836222: CODEX-FINDINGS-MEET.md, twenty-one findings, request changes. Dispositions in revision 2:

| Finding | Disposition | |---|---| | R1-1 waiting-room isolation | Recipients per message type (§4.3); a waiting guest gets hello, its own outcome and nothing else; leave from a waiting guest is knock-gone to members only. Test in slice 2. | | R1-2 peer ownership | Server-issued peer id bound to session and room; POST /signal verified against the binding; to resolved inside the room; envelope built server-side (§4.2). | | R1-3 cap bypass | Cap enforced at every transition to admitted under the hub lock; resume is not a transition; queued guests counted at admission (§4.3). | | R1-4 unbounded memory | Body, chat, rate, waiting, stream and buffer limits (§4.4). | | R1-5 stale standing | Standing defined, computed per request and per tick, transition table with stream closure and the P2P limit stated (§2.3). | | R1-6 agent reach | Token unrevoked and operator member on every request (§8.1). | | R1-7 return path | rastrillo's single SignedInPath acknowledged; pending-join cookie plus /continue; no user-supplied destination; other-browser case handled by re-following the link (§3.2). | | R1-8 mail budget | Ruling 12: the link is the gate; allowlist exception only with a live pending-join cookie; per-link mail budget; shared RemoteAddr noted as a platform question (§3.2). | | R1-9 reconnect | Resume with a thirty-second window, admission preserved, no cap consumption, no events; restart behaviour stated (§4.5). | | R1-10 offerer ordering | Ruling 13: perfect negotiation; renewal schedule, repeated and failed renewal, suspended timers (§5.1, §5.3). | | R1-11 contract claims | Console order; any scheme passed through, credentials on turn/turns; unit route as Meet's choice (§5.2, §7.3). | | R1-12 TURN in the path | Claim made per process; TURN carries encrypted packets; SFU decrypts (§0.1, §5.6, §5.7). | | R1-13 the test | WAL before and after checkpoint; captured logger on success and error paths; token-in-flight and signalling-in-flight stated as uncovered (§0.1, §4.4). | | R1-14 the floor | Host-candidate reachability, usually a permissive LAN, both false directions named; failure text does not diagnose (§5.5, §0.3). | | R1-15 persistence | persons is Meet's own, slice 1; attendance dropped from the claim (§0.3, §11); admitted_at is audit, not authority (§2.1). | | R1-16 scriptless | Exception narrowed to media participation; both pages' scriptless output specified; AGENTS.md changed in slice 2 (§3.3, §3.4, §9). | | R1-17 confirm pages | Replace-link and end-meeting confirm pages with ui/confirm-form in slice 1 (§3.1). | | R1-18 show-once | Replacement returns its token once; both token-bearing responses named (§8.2). | | R1-19 browser tests | Playwright, isolated contexts, received-track assertions, renewal and share gates assigned to their slices (§13). | | R1-20 version stamp | Stamping added to make release in slice 1 (§7.3). | | R1-21 provenance | Sealer: Meet is the third app and cites calendar's trigger; auth: fourth copy (§2.1, §7.1). |

Round 2, GPT-6 via Codex against revision 2 at 3a1fa42: CODEX-FINDINGS-MEET-R2.md: thirteen round-1 findings closed, eight half-closed, twelve new, request changes. Dispositions in revision 3:

| Finding | Disposition | |---|---| | R2-1 authorize callback | pending_guests (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. | | R2-2 shared budget | Outsider attempts counted before Begin, five per link and ten overall per fifteen minutes, leaving ten of the shared twenty for members; residue and rastrillo's own ?err= responses stated, not claimed away (§3.2). Closes the R1-8 residue. | | R2-3 expiry versus authorization | Ruling 14: standing for entry, participation for continuing; /signal, resume and /ice authorized by participation; an admitted guest continues past expiry on every endpoint and cannot re-enter (§2.3, §2.4). Closes the R1-5 residue. | | R2-4 missed gone, restart | Resume sends an authoritative roster and the browser reconciles; a restart's 404 makes the browser close every connection before rejoining; lost reasons split into resumable and terminal (§4.3, §4.5). Closes the R1-9 residue. | | R2-5 delivery | §4.6: one in flight per target, seq per pair, retry with backoff, receiver dedupe, candidate buffering, old-generation drop (ruling 13). Closes the R1-10 residue. | | R2-6 invisible queue, promotion | roster carries the waiting queue to members and is sent on admission, resume and kind change; promotion of a waiting guest attempts admission under the cap on the tick (§4.3, §2.4). | | R2-7 member continuation | /continue resolves the person first: pending row, else member to /, else /welcome, which is now specified (§3.2). | | R2-8 unjoined streams | Sixteen participations per room checked before allocation; unjoined ones expire in thirty seconds (§4.2, §4.4). Closes the R1-4 residue. | | R2-9 renewal completion | Per-connection renewal complete on connected/completed after restart; old deadline kept as must-succeed-by; retries and expired-path behaviour (§5.3). | | R2-10 stop-sharing, slice order | Sender stops the transceiver so the received track ends; gate asserts tile removal and camera continuing; captured-log test assigned to slices 1, 2 and 4 by message type (§5.4, §13). Closes the R1-19 residue. | | R2-11 removal confirm | remove is no longer a signalling message; confirm page and guarded POST with authorization at execution (§3.5). | | R2-12 the floor | Host-only gathering, ICE finds a direct path, peer-reflexive named; bilateral reachability dropped (§5.5, §0.3). Closes the R1-14 residue. |

Round 3, GPT-6 via Codex against revision 3 at 1bd7161: CODEX-FINDINGS-MEET-R3.md: six round-2 findings closed, five half-closed, one worse; ten new (three P1); request changes. Dispositions in revision 4:

| Finding | Disposition | |---|---| | 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. | | 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). | | 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). | | 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). | | 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). | | 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). | | 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). | | 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). | | R3-9 displaced tab fights back | replaced is terminal for the displaced tab; only a person's click takes the participation back (§4.5). | | R3-10 stale claims | §7.1 now defers to §3.2; the 429 claim is removed (§4.4). |

Round 4, GPT-6 via Codex against revision 4 at 79787b8: CODEX-FINDINGS-MEET-R4.md: eight round-3 findings closed, two half-closed; four new (two P2, two P3); request changes. Dispositions in revision 5:

| Finding | Disposition | |---|---| | 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. | | 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. | | 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). | | R4-4 revoked continuation outcome | Outsider: 403 at Authorize, inherited; signed-in: / or /welcome; no grant either way; §13 asserts those (§3.2, §13). |

Round 5, GPT-6 via Codex against revision 5 at bbfe440: CODEX-FINDINGS-MEET-R5.md: three round-4 findings closed, one half-closed; three new (two P1, one P2), all in the takeover and the cursor revision 5 introduced; request changes. Dispositions in revision 6:

| Finding | Disposition | |---|---| | 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. | | R5-2 takeover broadcasts a waiting departure | Takeover uses the same state-dependent departure as leave: gone, knock-gone, or nothing (§4.2). | | 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). |

Round 6, GPT-6 via Codex against revision 6 at 61475ed: CODEX-FINDINGS-MEET-R6.md: every prior finding and residue closed; two P3 advisories; verdict: accept with corrections. Both corrections are in revision 7:

| Finding | Disposition | |---|---| | 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). | | R6-2 successor peer in the 410 | by removed; terminal handling keys on reason (§4.5). |

The independent review is complete at revision 7. Paul approved the written form on 2026-09-08, adding the native-clients direction that became ruling 15 and §8.4 in revision 8. Implementation begins at slice 1 from this revision.

---

15. Revision 9 — what slice 2 changed, and why

Revision 8 was written before a line of the room was built. Slice 2 (the room, the hub, the signalling and the browser client) departed from it in ten places. Each one was decided in the slice's own ledger with a reason; this section is that reasoning in the published spec, so the code and the spec stop disagreeing. Nothing here reopens a §1 ruling. Where a departure narrows something revision 8 published — the §2.5 room-page row is the one that does — it is marked.

Every item carries the section it amends. Read the section and then this entry; the entry wins.

15.1 §4.2 — the hub session key is a keyed hash of the session cookie

Revision 8 said the hub binds a participation to "the session" without saying what names one. The implementation needed a value that is stable for one browser's sign-in, different for two browsers of one person, and not itself a credential.

Decision. The key is hex(sha256(per-boot salt | subject | session cookie value)). Three properties earn the three parts: the per-boot salt means the key is meaningless across a restart (the hub is empty then anyway); the subject means the key cannot outlive the person it belongs to; the cookie value is the only per-browser thing rastrillo exposes. The key is neither the session token nor the token hash rastrillo stores, so a hub key leaking would not be a credential leaking.

Why not the alternative. The ledger's first ruling was the subject plus the session's At instant, and it was wrong: rastrillo stores created_at as RFC3339, second granularity, so two sign-ins in one second would have collided.

Cost if wrong. Two tabs of one browser share a key, which is exactly the takeover case §4.2 already specifies. Two browsers of one person do not, which §4.2 permits (four streams per person).

15.2 §4.5 — the attaching stream GET requires Accept: text/event-stream

Decision. GET /m/{sqid}/stream in its attaching forms answers 406 {"error": "accept"} unless the request's Accept names text/event-stream. EventSource always sends it; a cross-site top-level navigation never does.

Why. Without the gate, a link on another site could make a signed-in browser occupy its own session's one participation slot for the full stream TTL — no data read, but the person's own next real attempt gets a 409 and a takeover page for no reason they can see.

Why 406 and not the usual 404. This check runs before any lookup or authorization, so its answer is identical for every room id and every person and reveals nothing. §2.5's "404, never 403" rule exists to stop a refusal being an oracle; there is nothing to be an oracle about here, and a mis-headered client is better told the truth than sent round the fresh-stream ladder. Ruling 15 and 16 in the slice ledger.

15.3 §4.5 — the resume probe, ?probe=1

Decision. Both stream forms accept ?probe=1. A probe runs the whole refusal ladder of §4.5 and attaches nothing: it answers 204 (with no-store) when the resume would have been accepted, and otherwise the same 404, 409 or 410 with the same body an attaching request would have received. Probes are exempt from the Accept gate of §15.2 — fetch does not send that header, and a probe occupies nothing.

Why. EventSource gives the browser no access to a non-200 status: a refused attaching stream is indistinguishable from a network failure. Without a probe the client cannot tell "this participation was removed" from "the wifi went", and §4.5's whole ladder — read the reason, act on it — is unreachable from a browser. The client probes, reads the status, then attaches.

Cost. One extra GET per resume.

15.4 §4.5 — the 410 is scoped to the tombstone's own room and session

Decision. An ended participation's tombstone is answered only through the room and the session it was bound to. A resume naming that peer id from any other session, or through any other room, gets the ordinary 404.

Why. Revision 8's table gives the 410 as a property of the peer id alone. But peer ids are published in every roster, and why a participation ended — removed, standing lost, invitation expired — is account state about that person. Unscoped, any past room-mate could read it off an id they legitimately hold. This is §4.2's binding rule applied to the tombstone.

15.5 §4.4, §2.5 — /signal refuses a bad room before the limiter

Decision. POST /m/{sqid}/signal validates the room id (ten characters of the id alphabet) and checks that this session holds a live participation in that room, both before the rate limiter and before the body is read. Either refusal is the identical 404 {"error":"not-found","boot":…} every other refusal on the path returns.

Why. The limiter is keyed on session and room. A session that rotated the room id in the URL would otherwise mint a limiter bucket per request: an unbounded map, an O(cap) eviction scan under the limiter's mutex, and an attacker-chosen string in the key and in the log line. §2.5's authority is reached earlier and more cheaply than the limiter, so it goes first.

Side effect, deliberate. A session whose participation has already ended is refused before the limiter, and a detached-but-resumable session is not — it still holds a live participation and may keep posting.

Watch. The limiter now sits behind a gate that takes the hub lock. Revisit if hub-lock hold time grows.

15.6 §2.5 — the room-page row narrows to a non-member participation

This narrows a published contract. Revision 8's §2.5 gives GET /m/{sqid} as "standing, or a live participation". The implementation reads: standing, or a live participation whose kind is not member.

Why. Ruling 14 makes the participation the authority for continuing, and §2.4 says an admitted guest is not ended by its grant expiring — so a guest must keep the page. A member who lost the team is a different case: §2.5 says they are dropped by the next thing they do. If a member's own participation could hold the page open, an ex-member would keep the room for as long as the hub happened to still hold their participation, which is the opposite of what §2.5 promises. The fallback is therefore for exactly the kind ruling 14 was written for.

15.7 §4.5 — a resume is refused the moment its window passes

Decision. A resume arriving after resume_until is refused, even though the participation is still in the hub because the fifteen-second tick has not run yet.

Why. Otherwise the window is "thirty seconds, plus however long until the next tick" — up to forty-five — and the number in §4.5 would not be the number the code enforces. The tick still does the removal; it is not the authority on the window.

15.8 §4.3 — leave is not idempotent

Decision. A second leave for the same peer answers 404. The participation is gone; the peer id names nothing.

Why. The alternative — answering 200 to a leave for a peer that no longer exists — would be the one place on /signal where an unknown peer id is not a 404, and §4.2's "every lookup starts from the room in the URL, and an id from elsewhere names nothing here" would have an exception. The client sends leave once.

15.9 §4.6 — the stale-generation 409 carries no generation

Decision. A targeted message with a stale gen is refused 409 {"error":"stale"}, and the body does not name the current generation.

Why. roster.peers[].gen is the reconciliation source §4.6 already specifies, and it arrives on admission, on every kind change and on every resume. A generation in the refusal body would be a second source of the same fact, able to disagree with the first, for a client that must handle the roster anyway.

15.10 §4.5 — four additions to the terminal-reason table

Revision 8's terminal reasons are replaced, standing, expired, removed and ended. The client adds four dead ends that the server never names, because a browser can reach them without the hub having an opinion:

| Reason | Reached when | What the person is told | |---|---|---| | 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 | | 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." | | nowebrtc | constructing an RTCPeerConnection throws | "This browser cannot make calls." | | gone | a fresh stream is refused 404 for a room this browser may no longer enter | "You cannot join this meeting." |

gone's wording is deliberately the least informative of the four: a fresh 404 is §2.5's "a meeting that is not yours does not exist", so the sentence must not distinguish an ended meeting from one that was never this person's.

Every terminal screen carries a way out — a link to /m/{sqid}/join — so none of them is a dead end with nothing to click.

15.11 Recorded, not changed

Two things the slice found that are not departures, kept here because they were otherwise recorded only in an untracked ledger:

  • A just-added member gets 404 from the removal confirm page until the next tick. roomAccess reads fresh standing while PeerNameFor reads the participation's cached kind, and only the tick refreshes it. It self-heals within fifteen seconds, and the Remove control is not drawn until the roster says member, so nobody sees the 404 without going looking for it. Not worth a database read on the page path.
  • The hub is per process and dies with it (§4.2, §4.5, stated but not solved). Slice 2 is the first release where that has a user-visible cost: every deploy drops every live call. Members rejoin automatically; guests go back to waiting and must be admitted again. No drain exists, and slice 3 shipping did not change that: every deploy still drops every live call, so deploying during meeting hours means exactly that.

15.12 §2.5 — the last row corrected: standing is not consulted for a guest

This corrects a published contract that contradicted itself. Revision 8's §2.5 gives the last row — POST /m/{sqid}/signal, every other type, and GET /m/{sqid}/ice — as "Participation, admitted. Standing is not consulted; a guest's expired grant does not matter here (ruling 14)." The paragraph immediately under the same table says "A request by a participation whose kind is member re-derives standing on every request as well as on the tick; a member who has just been removed is dropped by the next thing they do, not the next tick."

Both cannot be true. The paragraph wins, and the row now reads:

> Participation, admitted. Standing is not consulted for a guest; a > participation whose kind is member re-derives it, per the paragraph > below (ruling 14 is about the guest).

Why. The row was written for ruling 14 — the admitted guest whose grant lapses and keeps everything — and it says so. Reading it as covering members too gives an ex-member the room for up to fifteen seconds after their removal: their offers relay, their resets execute, and their resume returns the roster and the replay. That is exactly what the paragraph exists to forbid, and it is the only place in §2.5 where a removal is not immediate.

What this changes in the implementation. Relay, Reset, Resume and CanResume — and therefore GET /m/{sqid}/stream?peer= and its probe — re-derive standing for a member-kind participation. The read happens outside the hub lock, like every other standing read; the participation is re-found under the lock and the answer applied through the one place that decides what a loss of standing costs, so the admitted guest's exemption is untouched. A member whose standing has gone is ended with lost {reason: "standing"} and the request answers 404 — byte-identical to the answer an unknown peer gets, because §2.5's "a peer that is not yours does not exist" does not make an exception for a peer that was yours a moment ago. The probe is the one exception to the ending: it answers 404 and writes nothing, because §15.3 defines it as changing nothing at all; the next mutating request, or the tick, does the ending.

The cost. One standing read — two small database reads — per request made by a member-kind participation. It is bounded by the signal limiter's thirty requests a second per session and room, which was already the ceiling on this endpoint, and by the four streams per person of §4.4. A guest costs nothing extra: the kind is checked first and a guest never reaches the read.

15.13 §5.2 — the /ice wire shape, and its authority

Decision. GET /m/{sqid}/ice?peer=<id> answers 200 {"iceServers": [...], "expiresAt": <unix seconds>, "renewAt": <unix seconds>}. iceServers is §5.2's own list shape ({urls, username?, credential?}); a bare stun/stuns entry carries neither key. With no relay configured, the answer is {"iceServers": [], "expiresAt": 0, "renewAt": 0} — the floor, not a refusal. Every failure is the identical 404 {"error":"not-found","boot":…} /signal itself answers, byte for byte: a malformed sqid, a malformed peer, an unknown room, an unknown or unbound peer, a peer belonging to another session, a waiting guest, a member who has lost standing, or a meeting that has ended.

The peer is a query parameter. ?peer= carries the id the way the stream's own resume already does, not a body. Ruling. A native client needs no body on a GET, and /ice has nothing else a body would carry.

Authority, before the store is ever read. /ice runs LiveForSession — the same gate /signal runs before its own limiter — refusing the identical 404 for a session with no live participation in this exact room, before either the store or the rate limiter is touched. It then spends the shared session+room limiter bucket /signal already spends: a burst against either endpoint from one session drains the one budget it can ever spend, never two. Only past both gates does /ice read the meeting row — to catch a meeting the hub does not yet know has ended — and then call hub.Authorize, the §2.5 last-row check.

The development-only header. X-Meet-Test-Ice-TTL, honoured only under -env development, shortens the credential's life for the gate. It is clamped both ways, to between 2 seconds and 3 hours, so a header can neither stall the schedule at a zero life nor mint a life past the platform's own four-hour ceiling.

Standing, re-derived exactly as /signal does. §15.12 corrected the last row of §2.5's table: a participation whose kind is member re-derives standing on every request, and one whose standing is gone is ended with lost {reason: "standing"} and answered the same 404 an unknown peer gets. /ice is one of those requests, exactly as /signal is. A guest never consults standing at all (ruling 14) — /ice does not reopen the door §15.12 closed for a guest; it stands behind the same gate Relay and Reset do.

15.14 §5.3 — the renewal schedule is skew-free, and its two verdicts are not one

Decision. The client never schedules a renewal on the server's clock alone: it takes the earlier of two readings, both measured from the moment of the fetch that produced them. The ratio reading is (expiresAt − renewAt) × 5 — the margin the server left itself, multiplied out from the contract's own shape (renewal at five sixths of the life, so the margin is a sixth of it and the delay is five of them) — built from two of the server's own instants subtracted from each other, so it survives any skew between the two clocks entirely. The clock reading is renewAt − fetchedAt: this browser's own clock against the server's stated instant, exact when the clocks agree and wrong only in the direction that costs nothing — a browser running ahead renews early, and one running behind gets a longer answer, which the ratio reading then caps. The earlier of the two wins, with a floor under the delay so a server that has truncated its numbers down to no margin at all cannot turn the schedule into a spin. The must-succeed-by deadline is read the same way, at six of the margin rather than five.

Why not renewAt read as a point on the server's clock. The server sends whole seconds, and truncation eats the margin at short lives: a twelve-second life gives 10 and 12 — margin 2, and the ratio's arithmetic is exact — while a ten-second life gives 8 and 10, where the same arithmetic would schedule the renewal at the very moment the credential dies. Reading renewAt as an instant on the server's own clock, rather than a duration counted from this browser's own fetch, would additionally put a client whose clock is minutes off the server's on the wrong schedule outright — early enough to be harmless, or late enough to treat a credential as still good after it has expired. Neither truncation nor skew is acceptable on its own reading, so the client keeps both and takes whichever is shorter.

The three renewal outcomes.

  • Renewed. The exchange has completed, signalingState is stable with the new answer applied, and getStats() reports a selected pair, succeeded, carrying the restart's own usernameFragment — §5.3 step 2's own proof. Nothing is said to the person: the tile carries no sentence at all, and the call continues on the new credential.
  • Expired — "The relay credential expired for this connection." The old credential's own deadline, the must-succeed-by, has passed with nothing proven, because the fetch itself kept failing. This is the one honest signal the browser has, since it cannot see which credential the relay allocation is actually using.
  • Stuck — "Could not renew this connection through the relay." A new credential is in hand and three ICE restarts have run against it, none of them proven within the window. The credential is fine; the path is not — saying "expired" here would be a sentence about the wrong thing.

15.15 §4.3, §4.4, §5.4, §6, §10 — the chat and share wire, the share tile, and the panel's adoptable contract

Decision. POST /m/{sqid}/signal {"peer","type":"chat","payload": {"text":"…"}} accepts a text of 1..2048 bytes of valid UTF-8; {"type":"share","payload":{"on":true|false}} carries nothing else. Either type is refused 400 {"error":"bad-request"} for any other shape — a payload with an extra key, a text that is missing, non-string or empty, an on that is not a bool, a key that is not spelled exactly text or on, the same key twice, or a to on either type (chat and share are never targeted; §4.3's fan-out has nowhere to route one) — and 413 {"error":"too-large"} once the raw payload — the bytes on the wire, before decoding — exceeds 2048 + 32 bytes. The +32 margin is the JSON structure the text sits inside ({"text":"…"}, the field name and its quoting), not a second allowance for the text itself. That raw cap, and utf8.Valid, are judged against the raw bytes before any JSON decoding: a duplicated "text" key or a run of \uXXXX escapes both change the raw byte count without changing what a decoded field reports.

Keys are exact, and counted. encoding/json matches an object's keys to struct fields case-insensitively, and DisallowUnknownFields does not change that: {"text":"<2049 bytes>","Text":"x"} fits inside the raw cap, decodes to the last match — a one-byte text — and, if the raw bytes were forwarded, would deliver 2049 bytes to every recipient, which reads the lowercase key. So the payload's keys are checked byte-exactly, through a token walk that counts members rather than a map (which collapses a duplicate and cannot tell {"text":"a","text":"b"} from {"text":"b"}), before any value is looked at.

§4.4's 2 KB is the escaped length of the text, and both sides count it. The limit is on the text as the wire carries it: its JSON-escaped UTF-8 form, without the quotes around it. A quote, a backslash and a newline each cost two bytes; an angle bracket or an ampersand costs one (nothing here is HTML-escaped — see the paragraph below). The panel counts the same thing with JSON.stringify(text) less its two quotes, so a draft the panel accepts is a draft the server accepts: counting raw UTF-8 on the client and raw JSON on the server left a gap where a 2048-byte text with twenty-three quotes in it passed one and was refused 413 by the other, for a rule the client had no way to apply.

What is forwarded is the validated value, re-encoded. Fanout carries a canonical payload built from what these checks judged — not the request's own bytes — so an escape is delivered decoded, a key is delivered in the one spelling clients read, and the size on every recipient's stream is the size that was measured. The SSE encoder disables Go's HTML escaping for the same reason: json.Marshal turns <, > and & into <, > and & inside a json.RawMessage too, which took a validated 2059-byte payload of literal < to 12,381 bytes on the wire. SSE data is not HTML, and the one property a line-oriented wire actually needs — that a payload cannot end its own frame and start another — comes from JSON's own escaping of newlines inside strings, which is unaffected.

The downstream envelope. The hub's Fanout rebuilds the message it delivers from the sender's own participation — {type, from, name, payload}, where from and name are the sender's own peer id and display name, never anything the caller's request supplied — carrying no to, seq, gen, reason, roster, boot or any other field a targeted or server message might carry, however the request tried to set them. This is Relay's own authority check (§15.12, ruling 14) reused for a fan-out instead of a single target: an unadmitted or foreign-session peer is refused before anything is sent, and a member whose standing has gone is ended and refused, exactly as /signal's targeted types already are.

Recipients. Every other admitted peer in the room, and never the sender: the sending tab's own panel echoes the line locally, on the acceptance the fan-out reports back, so echoing it again over the wire would show it twice. A waiting guest receives neither chat nor share, matching every other admitted-only message in §4.3's table; its own attempt to send either gets the same 404 an unknown peer id gets, never a 403 (§2.5). Nothing of a chat is written to any recipient's replay ring, and nothing is stored: §6's "not stored, not replayed" is enforced inside Fanout itself, which delivers to an attached subscriber or not at all — a detached, resumable peer's later Resume carries no chat it missed while detached, the same silence a peer who joined after it was sent already has.

The panel's mount API, as estilo's adoptable contract. The chat panel is one template partial (templates/partials/chat.html) and one script (static/chat.js), following §10's rule that the panel is Meet's first contribution the suite can adopt rather than borrow. The contract a host must meet is window.Chat.mount(root, {name, send}) → {append, setEnabled, clear}: send(text) receives trimmed, non-empty text of at most 2048 bytes and returns, resolves, or throws to, a boolean; the panel appends the sender's own line — append({name, text, self: true}) — only when that answer is the literal value true, never on any other truthy result, so a Response object or a non-boolean truthy answer reads as a refusal rather than an echo. append renders an arriving {from, name, text, at, self} with textContent only; setEnabled is the host's own gate, moved on admission and on every terminal or leave; clear empties the log, which is the whole of chat's history once the tab is gone (§6). Nothing executable in the two files names a room, a peer id, or Meet by anything but a code comment.

Screen share as its own transceiver. §5.4's getDisplayMedia track is carried on one sendonly transceiver per connection (addTransceiver(track, {direction: 'sendonly', streams: [share.stream]})), added to every connection already open when sharing starts and to every connection built while it continues; the camera's own transceivers are untouched. A peer that receives a second video track for another peer draws it as a second tile, keyed <peer>#share and captioned from that peer's roster name and the share message alone — never a track label, which never crosses the wire. The tile is removed on whichever comes first: the received track's own ended (fired by the sender's transceiver.stop(), or by the browser's own "stop sharing" control) or a share {on: false} fan-out; the two are independent because a stopped transceiver does not reliably fire ended at the far end on every browser. The camera tile and its frame count are never touched by a share starting, stopping, or being dropped.

15.16 §5.1 — the polite side yields before it offers

Decision. Before making an offer, the polite side of a pair (§5.1's own tie-break, the lower peer id) waits 300 ms (POLITE_YIELD) — while it has an allowance left to spend, which is the paragraph after this one. A side with none offers straight away and takes the ordinary collision path, so the wait is a rule about the usual case rather than about every offer. Once entered, the wait itself runs to its end; what is read after it is the connection. If a remote offer arrived meanwhile, or a whole remote exchange arrived and completed, no offer of this side's own goes out and the negotiation belongs to whatever that left behind; otherwise the offer goes out as it always did.

Three things bound it, all per connection. A negotiation generation, moved by this side's own offer and by every exchange it answers: a yield captures it before sleeping and abandons its offer if it has moved, which is the only way to tell "nothing happened" from "an exchange came and went while I slept" — both leave the connection stable. A single attempt in flight: negotiationneeded can fire again while the first attempt sleeps, and two attempts used to produce two offers, the second skipping the yield because the first had already spent it. And an allowance of two yields in a row, spent by giving way, zeroed by this side's own offer, and handed back one at a time by an exchange this side answered. The hand-back is what keeps the allowance from being a per-connection budget that one glare — an ICE restart, say — spends for good, leaving a later simultaneous share unprotected; the bound is what keeps giving way from becoming a habit, so two peers renegotiating in turn cannot leave one permanently a beat behind the other.

And a camera that has already been rebuilt is repaired. The yield prevents the rollback; it cannot undo one. Once a second, per connection, getStats is read for the outbound video report on this side's own camera m-line: two polls in a row with no new framesEncoded, while the track is live and the m-line's currentDirection sends, is a camera that has stopped encoding — nothing else notices, because nothing has failed. The repair is replaceTrack(null) then replaceTrack(track), which makes the browser build a new encoder for the same sender; two more frozen polls after that and the pair is reset instead; after that it stops asking, because a reset that did not take is not a reason to ask again every two seconds.

And a tile that decodes and does not paint is handed its media back. One link further along, and the one nothing else notices.

What counts as a stall, on the same one-second getStats walk, and all of it together: framesDecoded for that tile rising, the tile's track live and unmuted, the element not paused, and the element's own clock advancing by less than RENDER_MOVED (0.25 s) in the poll — three polls in a row of that. A second of video is a second of clock and a frame is thirty milliseconds, so a tile painting one frame every few seconds meets this criterion and is repaired: it is a stall with a tic, not health. Reading any advance at all as health let such a tile reset the budget for ever and never earn its reset.

The repair is a fresh MediaStream over the same tracks put back on the element and played again — fresh, and the role's own, so every track adopted afterwards still reaches the element. Two of those, then a reset, then it stops asking, until qualifying clock movement gives the allowance back.

What is exempt, which is a different list and not a softer version of the same one. A tile receiving nothing is idle: there is nothing to paint, and the decode count is read alongside the clock precisely to tell that apart from a stall. A track that has ended or gone muted is the sender's doing and the sender's to fix. An element the autoplay policy paused is the person's: §5 offers a Play control, and this watch — and every rebinding of a tile — leaves it alone while that control is showing, because calling play() behind it hides a question nobody has answered. Each of those three cost a red gate run to learn.

Three separate facts, recorded separately, because they were run together once and the section contradicted itself.

The historical observation. Two peers who each add a screen-share transceiver to the same connection in the same moment collide, and the polite side's setRemoteDescription rolls its own offer back to accept the impolite side's. Chromium answers a rollback of an offer that added an m-line by tearing down and rebuilding every send and receive stream on that connection, not only the one the new m-line belongs to. In slice 4's Task 4 that was measured as an encoder loss: the camera's sender came back on the same SSRC having encoded nothing and never encoded another frame, while its m-line went on reporting sendrecv/sendrecv with a live track and the far side's matching receiver went muted. It reproduced in three of five runs of mutualshare, with both screens perfect and both faces stopped. Nothing in room.js touches the camera; the loss was entirely a side effect of the browser's own rollback. That observation stands as evidence of what a rollback costs. It is not the diagnosis of anything still failing.

What the yield does about it. It prevents the rollback rather than undoing one: an offer answered from stable needs no rollback. It is a duration and not a guarantee — a pair whose signalling round trip exceeds 300 ms, or whose offer is late for any other reason, a 429 retry included, still collides, and on that pair the polite side still rolls back.

What is repaired when it happens anyway. Two watches, both on the one-second getStats walk, both bounded, both ending in a reset: the encoder watch above (outbound framesEncoded static on a sending m-line with a live track) and the render watch (frames decoded, clock stopped). Neither is a proof that the rollback is harmless; each is a bounded recovery from one way it has been seen to hurt.

What the gate proves, and what it does not. laterolls holds one side's own offer requests for four times the yield, so the remote offer arrives after it and the collision is late by construction, and it asserts the rollback separately from the recovery: the scenario counts remote offers applied over this side's own outstanding offer, and fails if there was no rollback to recover from. The recovery it then requires is frames arriving across two successive intervals, for both cameras and every screen. On the head this was written against, the encoder loss did not reproduce — the far side's outbound counts kept rising throughout — and what did was the rendering stall: the tile bound to exactly the receiver's own live, unmuted track (meet.media() reports agrees: true), the decoder delivering, and the picture stopped at currentTime 1.452 for as long as anything watched it. Rendered frames every 500 ms read [25,26,26,26,26,26] — stopped, and stopped for as long as anything watched — before the render watch, and [25,25,25,25,25,26] after it: frozen for the three polls it takes to notice, then moving again. A later run of the same scenario stopped dead at [25,25,25,25,25,25] and came back once the watch escalated past the stutter rule.

What that evidence is worth. The recovery is now required to last rather than to happen: every media check in share, mutualshare and laterolls observes each expected stream over three windows of a second and a half and fails any stream that stops in any one of them, which the two-early-returns check it replaced did not — counters reading 0, 1, 2, 3 and then frozen for ever satisfied that one. What is still not established is why a fresh MediaStream unblocks the element: it is a browser workaround with a measured effect, not a diagnosis, and it does not show that the decoder pipeline is rebuilt. The identity, decode and render reproduction is kept in the slice record for an upstream report; until Chromium is understood, this section claims a bounded, timed recovery and no more.

If a deployment reports a face freezing after two people share at once, the order to check is: POLITE_YIELD against the pair's actual signalling round trip; then which watch fired, if either — the encoder one says the camera stopped being produced, the render one says it stopped being painted; then meet.media()'s agrees, which separates a binding fault from a pipeline one; and then whether Chromium still rebuilds every stream on the connection when it rolls back an offer that added an m-line.

15.17 §8 — the drive surface's API wire, the reach rule, and the badge

Decision. agents(id, name, operator_person_id, token_hash, created_at, revoked_at). A token is minted once, in plaintext, on the one page that shows it (agent_once.html): "mt_" + 32 random bytes, base64url without padding; the row stores only its SHA-256 hex. Nothing past that page ever sees the plaintext again — a lost token cannot be recovered, only revoked and re-minted. An operator holds at most 20 live agents at once, enforced in the same transaction as the insert, alongside a live-duplicate-name check; a revoked agent's name is free to reuse immediately, so this is a plain query rather than a unique index. Every write an agent makes records the actor agent:<id> (actorForAgent), exactly the shape person:<id> already used (§7.2); the identity a reader is shown is the principal, <name>~<operator email> (principal(), agents.go), never the bare id.

The five operations, exactly.

| Request | Answer | |---|---| | GET /api/v1/meetings | 200 {"meetings":[<meeting>, …]}, same order as the HTML list | | POST /api/v1/meetings {"title":"…"[,"starts_at":"<RFC3339>"]} | 201 {"meeting":<meeting>,"link":"<origin>/j/<token>"} | | GET /api/v1/meetings/{sqid} | 200 {"meeting":<meeting>,"admitted":N} — admitted present only while ended_at is null, and never a "link" key | | POST /api/v1/meetings/{sqid}/links | 200 {"meeting":<meeting>,"link":"<origin>/j/<token>"} | | 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 |

<meeting> is exactly six keys — sqid, title, starts_at, ended_at, link_present, created_by — a plain JSON object built field by field, never a marshalled model. Times are RFC 3339 in UTC (an agent has no instance zone to render in). created_by is the principal for agent:<id>, the person's display name for person:<id>, and the raw actor string if the row behind it no longer resolves (store.creatorLabel) — a label, never an authorization input.

Refusals, one shape per case.

| Case | Status | Body | |---|---|---| | no Authorization, a non-bearer scheme, Bearer with an empty token, a session cookie with no bearer | 401 | {"error":"unauthorized"} | | unknown token, revoked token, an operator not currently a member | 404 | {"error":"not-found"} | | unknown sqid, a sqid not shaped like newSqid produces, a path or method this surface does not have | 404 | {"error":"not-found"} | | over 60 requests a minute for one agent | 429 | {"error":"rate"} + Retry-After (whole seconds, never below 1) | | end or links on an ended meeting | 409 | {"error":"ended"} | | 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"} | | any body at all on links or end | 400 | {"error":"bad-request"} | | reading the body encounters bytes beyond 16,384, on any operation that reads one (POST /meetings, links, end) | 413 | {"error":"too-large"} | | a driver failure | 500 | {"error":"server"} |

The body rules, in full (review S5-1, corrected by S5-7). Every request on this surface is wrapped in http.MaxBytesReader at 16 KB by the group's own middleware (capAPIBody), before a handler runs — the operations that read a body and the ones that do not alike. A cap applied inside the decoder only would leave links and end reading nothing and refusing nothing, so a megabyte posted at /end performed the write.

POST /meetings decodes exactly one JSON value and then requires EOF: a second value, a stray ] or }, a trailing scalar, anything but whitespace-then-end is 400. Trailing whitespace is accepted — a newline on the end of a body is what a file, and curl --data-binary @file, sends, and whitespace after a value is part of JSON's own grammar, not content. encoding/json's Decoder.More() is not the question to ask — it answers "is another array or object element waiting", which is false for a trailing ] and false for a trailing }, and it swallows the decoder's stored read error, so a valid object followed by 17 KB of whitespace came back as a clean decode rather than the 413 the cap promises. A second Decode asks the honest question and hands back the honest error, and that error is what separates 413 from 400: a *http.MaxBytesError anywhere in the chain is 413, everything else is 400.

links and end take no body. Theirs is read to its end under the same cap and refused if it carried anything: a body that runs past 16,384 bytes is 413, and any other content — a JSON object, a single space, 16,384 bytes of padding — is 400. An absent body and a Content-Length: 0 body are the two shapes that pass. Both answer before the write, never after it.

The cap is 16,384 bytes and the boundary is inclusive (review S5-9). MaxBytesReader refuses only when a read encounters a byte beyond the limit, so a body of exactly 16,384 bytes is within it and 16,385 is not. The two surfaces that read a body then answer differently on the inside of that boundary, because the cap is not what they disagree about: a create padded with JSON whitespace to exactly 16,384 bytes is a 201, while a bodyless write carrying the same 16,384 bytes is a 400 — it takes no body at all, whatever its size. One byte more is 413 on both. TestAPIBodyCapBoundary pins all four.

413 is lazy, and the order of refusals says when it can happen (review S5-7). MaxBytesReader limits reading, not receiving: the 413 happens when a handler's consumption of the body runs past the cap, so it is reachable only on the three operations that read a body at all. Concretely, and deliberately:

  1. 401 — no credential. Judged on the Authorization header alone, before a byte of body is read, so an oversized request with no bearer is 401 rather than 413.
  2. 404 — a token that does not resolve, or an operator without member standing. Also before the body.
  3. 429 — the agent's own budget, checked in requireAgent after the token resolves and still before the body.
  4. The body rules — 413 or 400, in the handler, in whatever order the bytes arrive: an oversized body that is also malformed from its first bytes can answer 400 before the reader ever runs past the cap, because the decoder fails on what it has already read. Both are refusals with no write behind them, so which one an agent gets is not a promise this surface makes.

GET /api/v1/meetings and GET /api/v1/meetings/{sqid} read no body, so a GET carrying one is answered 200 on its merits and the body is discarded unread with the request. That is Go's own behaviour for a handler that ignores r.Body, and this surface adds nothing to it: a body on a GET has no meaning here, enforcing a size on bytes nobody reads would buy nothing, and there is no operation where a GET body could reach a write.

The 409 is decided inside the write, not before it (review S5-2). Both write handlers used to read the meeting, check ended_at, and then enter the store's transaction — a snapshot, not a guarantee. Two ends could each read a running meeting and each answer 200, and a replacement could read a running meeting, pause while another request ended it, and leave an unrevoked link on an ended meeting with link_present:true. SQLite's single writer serializes the transactions; it does nothing about the reads in front of them. Now store.endMeeting reports whether its conditional UPDATE … WHERE id = ? AND ended_at IS NULL actually changed a row — false is the 409 — and store.replaceLink re-reads ended_at inside its own transaction, answering errMeetingEnded, which the API maps to the same 409. Exactly one of two racing ends gets the 200, and an ended meeting never keeps a live link. The HTML side shares both store paths and keeps the answers it always gave: the confirm page's end redirects to the list either way (ending an already-ended meeting is what the member asked for), and its replace answers the not-found page.

The gate for this is TestAPIAStaleReadCannotWriteOverAnEnd, which forces the interleaving rather than hoping for it (review S5-6): a test-only GORM query callback on the harness's own *gorm.DB holds one request at the point its handler has read the running meeting, a second request ends the meeting and commits, and only then is the first let go into its write. Firing two requests at once (TestAPIConcurrentEndsAndReplacesSeeTheEndedRow) is a smoke test beside it, not a proof: nothing in it stops the first request finishing before the second reads.

The 401/404 rule, and why it is judged on the header alone. 401 means "you presented no credential" — no header, a non-bearer scheme, an empty token — and is true of every caller alike, telling an attacker nothing they did not already send. Every token-shaped string, real or made up, goes to the store from there: unknown, revoked, and an agent whose operator has left the team all answer the identical not-found body, so nothing about which of the three it was is ever observable. A malformed bearer is judged on the header, never on the token's own shape — there is no mt_ prefix check anywhere on this path, which is what keeps a fabricated token indistinguishable by status from a real one that was revoked.

Reach is re-derived on every request, never cached. requireAgent's order is Authorization → agentByToken (unrevoked) → isMember(operator) → the per-agent limiter. An operator who leaves the team takes their agent's reach with them at the very next call, with no revocation step; §13's slice-5 gate (TestAPIReachIsTheOperatorsStandingOnEveryRequest) proves the round trip: allowlisted, listing 200; off the allowlist, the next list is the same 404 an unknown token gets; back on, 200 again with no re-minting; revoked through Settings, 404 again.

The per-agent limit is 60 requests a minute, burst 60 — one token a second into a bucket of sixty, keyed on actorForAgent(ag) and run after the token resolves to an agent, not on the raw header or the client address. Keying on an attacker-chosen string first would let 4096 fake tokens (maxAgentBuckets) evict every real agent's bucket before a single one of them is ever checked to exist. There is deliberately no client-address limiter on this surface at all: behind the platform's relay socket every request the app sees shares one address, so a limiter keyed on it would let one flood lock out every real agent along with whatever was flooding. Unauthenticated flood control at the edge is a platform question, carried to carlos/platform rather than solved here.

Title length is 200 characters, counted as runes, on both surfaces. The create screen's own copy ("Keep the title under 200 characters.") and the API's bad-request both measure utf8.RuneCountInString against the same maxTitleLen (titleTooLong, handlers_meetings.go) — not a byte count, which would let the API refuse a title the screen's own sentence promises is fine the moment it holds anything outside ASCII. A title is trimmed first, as the screen trims. Titles are otherwise unfiltered data, never markup: a lone surrogate or an embedded NUL is accepted and stored as sent on both surfaces, which is informational rather than a defect — nothing downstream renders a title as anything but an escaped JSON string or HTML text.

The API is mounted outside csrf.Protect, and the reason is structural, not incidental. csrf.Protect's SameOrigin check refuses a state-changing request carrying none of Sec-Fetch-Site, Origin or Referer — which is every request a non-browser agent sends, since those three headers are a browser's own doing. Mounting /api/v1 outside it is safe for exactly one reason: this surface reads no cookie and honours nothing but the bearer, so there is no ambient credential a cross-origin page could spend through it — the attack CSRF defends against does not exist here. api.go's own doc comment on apiRoutes states this; TestABearerOnAnHTMLRouteIsIgnored and TestAPIRequiresABearerAndIgnoresCookies prove both directions: a bearer on an HTML route is inert, and a cookie on the API route is 401.

What this surface does not promise. admitted (GET /api/v1/meetings/{sqid}) is this process's own count from the in-memory hub — on a multi-host fleet it is that host's count, not the meeting's, exactly as §4.2 already says of everything else the hub answers. GET /api/v1/meetings has no paging; a team's meeting list is small, but an agent polling a year-old instance reads the whole list every time. An unclean path under /api/v1 (a doubled slash, a trailing one) is answered by Go's own path cleaning — a 307 — before requireAgent or the not-found handler ever sees the request, so that redirect is not this surface's own 404 and does not carry its body.

The badge (§8.1's "robot badge and operator name on every write"), Task 4. store.meetingViewOf resolves created_by through creatorLabel into meetingView.CreatedByPrincipal, and sets ByAgent from the agent: prefix — the same resolution the API already did per-request in apiBody, now shared with the HTML list. listMeetings passes one label cache across the whole list rather than one per row, so a run of meetings from the same agent costs one creatorLabel lookup rather than one per row (the N+1 the API's own memoisation was written against, and the list's link-presence count still has, unaddressed). index.html shows the lucide bot icon (internal/calls/icons) beside a plain-text "Agent" ui/badge next to an agent-made meeting's title, and "by <principal>" beneath it — both only when ByAgent is true, so a member's own meeting carries neither. The principal is rendered as an ordinary Go string through html/template's own escaping, never marked template.HTML and never truncated before the ~ — the badge's icon needs no separate aria-label either, since rastrillo's own icon rule (icons.go: every icon "sits beside its own visible text label") is already satisfied by the badge's own word, alongside AGENTS.md's own line — "State — never colour alone — always a text label too."

15.18 §12 item 6 — what slice 6 delivered, deferred and declined

Slice 6 is documentation only: nothing under internal/, native/, callscore/ or protocol/ changed. Against the programme's slice 6 line (docs/superpowers/plans/2026-09-09-meet-slices-2-6-programme.md §5) and this spec's own §12 item 6:

The word "floor" names two different things in this spec: §5.5's "The floor" is the no-relay condition, and §9's "The scriptless floor" is the JavaScript exception; item 6's bullet above now names each rather than leaving the bare word to carry both.

§9's exception was also corrected here, on Codex round 1: browser media participation was never the only script-gated part of a room — the live admission controls have needed script since slice 2 and the chat panel since slice 4 — and §9, the README and PRODUCT.md now say so rather than "and nothing wider." Codex round 2 found the binding file left behind: AGENTS.md still named only the media exception, so §9's own "the binding file and the code agree" was false again the moment §9 widened. AGENTS.md's two scriptless lines were widened to match on 2026-09-14, in the same commit.

  • Delivered. README.md, with the scriptless floor sentence and the relay paragraph first. PRODUCT.md. AGENTS.md's deploy notes. §0.2 above, refreshed against doctrine main 1ac4323, still revision 8 on 2026-09-14 — this is the Consumed suite rulings commit suite Appendix A.6 will point at. The domain was already attached outside this slice — calls.oficina.works serves the flagship instance (§7.3's carlos domains attach) — so item 6's domain row needed nothing from slice 6.
  • Deferred, not duplicated. The acknowledgements page lives on oficina/meet discussion 10, branch feat-open-source-credits (117b6e4, bbc126e): a shared Estilo credits page with a runtime dependency inventory. As recorded on that branch's own review, it is Fable-approved and was verified live on the overnight canary, not yet merged to main — not independently reverified here. Building a second one here would be the duplication the programme forbids.
  • Declined: the Estilo theme pin. The programme's condition for pinning was a tagged release; git ls-remote --tags against oficina/estilo is empty on 2026-09-14, so no tag exists and no pin was made. The theme is adopted on main anyway, at a pseudo-version (amadan.net/oficina/estilo.git), by the Home integration (f9cb47f, 34fc367) — the programme's condition is recorded here as unmet, and the adoption as done by another route, not as the condition having been met after all.
  • Declined: the production promotion. A docs-only slice has nothing to promote that changes behaviour, and a redeploy would drop every live call for no reason (§15.11). As observed on 2026-09-14 from carlos channels and oficina/meet discussions 11 and 14, calls.oficina.works was being promoted by hand by Paul (651cff2, 2026-09-12) with other sessions' work queued behind live checks — state as of that observation, not asserted as current here. This slice does not touch that queue or announce a deploy of its own, which is checkable from this diff alone.

15.19 §4.3, §4.5 — media state: who is muted, whose camera is off (2026-09-23)

Why. Paul, 2026-09-23: "add mute indicators so that folks can see when someone is muted." Until now muting disabled the local audio track and sent silence, and nothing on the wire said so; nobody else could tell a muted person from a broken microphone. Camera off had the same gap, and a remote tile showed a black or frozen frame for it.

Decision. A new message type, media, that goes both ways and is state, not an event:

  • Upstream, POST /signal {"peer","type":"media","payload": {"muted":bool,"cameraOff":bool}}. Both keys, spelled exactly, each once, both booleans, nothing else, judged on the raw bytes as §15.15 judges chat and share (a token walk, not a map). The raw payload is capped at 128 bytes before decoding (413 {"error":"too-large"}); any other shape, a to, a seq or a gen is 400 {"error":"bad-request"}. The sender's authority is Fanout's (§15.15): admitted, bound to this room and session, a member's standing re-derived first; a waiting or unjoined participation gets the same 404 an unknown peer gets.
  • The hub records the value on the participation (Participation.Media, nil until the first report). Every roster and every peer event carries it, as media on the RosterPeer, for every admitted peer including the recipient itself. That is what makes a late joiner, a resume (§4.5) and a restart correct without any new replay rule: nothing about media goes in the replay ring, because the roster a resume is answered with is already the authority, exactly as it is for who is present.
  • Downstream, a change is fanned out to every other admitted peer as {type:"media", from, name, payload:{"muted","cameraOff"}}, the envelope rebuilt from the sender's participation and the payload re-encoded from the two validated booleans. A report equal to what the hub already holds is accepted (200) and sends nothing, so clients may repeat it freely.

What a client does. It keeps its own state locally and reports it whenever a roster's entry for itself disagrees or is missing — which is admission, a resume after a report was lost, and a fresh join after a restart — and at once on each toggle while admitted. Before admission it sends nothing. muted means the person's microphone is not being sent (toggled off, or there is no microphone track at all); cameraOff the same for the camera. Both are the sender's own claim about what it is choosing to send. The server never sees media (§0.1) and cannot check them, and nothing depends on them being true: a peer that lies about being muted is heard anyway.

Unknown is not muted. A RosterPeer with no media key is a peer that has not reported — every client that predates this section, and every peer for the moment between admission and its first report. Clients show that as neither muted nor camera-off. The zero value is not used for it, because {"muted":false,"cameraOff":false} is a real report and must be told apart from silence.

Mixed versions. An old client ignores the media key on a roster and the media event type (the web client's dispatch and the native core both drop unknown types), so it never breaks and simply never shows or sends indicators; the people talking to it see it as unknown. A new client against an old server has its report refused 400 {"error":"bad-request"} as an unknown type; it does not retry a 400 and shows nothing. Server and clients should still deploy together for the feature to exist, but no order of deployment breaks a call.

Not logged, not stored. Like every accepted message, a report logs nothing; a refused one logs type, room, peer and outcome only (§4.4). The state lives on the in-memory participation and dies with it (§2.2).

15.20 §15.19, §5 — what the receiver can hear (2026-09-25)

Why. On 2026-09-25 Tony, in a browser, could not hear Paul, who was on the Mac app with a multi-channel audio interface. Paul's device panel looked fine because nothing on it measured sound; Tony's side had no way to tell Paul anything; and the server keeps no call logs (§0.1), so there was nothing to read afterwards. The failure was undiagnosable from either end. Two measured facts shaped the answer:

  • Chromium (Chrome and Edge) does not apply its autoplay policy to a media element whose source is a WebRTC MediaStream: with the desktop policy forced on and no user gesture at all, the remote tile plays, while the same browser refuses a plain audio file. A refusal is still possible in Safari, Firefox and a browser set to block sound, so the recovery below is kept, but it was not what happened here.
  • LiveKit's AVAudioEngine audio device module connects the microphone in mono, and the conversion from a multi-channel interface keeps its first input only. A microphone on any other input sends silence.

Decision. §15.19's media report gains two optional members, each carrying only bad news and omitted when there is none:

  • audioBlocked: true — this browser was refused playback of remote sound and is waiting for a click.
  • noSoundFrom: [peer, …] — the peers whose audio is arriving here (connected, packets flowing) as silence, for about five seconds, while they report themselves unmuted. At most fifteen distinct peer ids.

Validation is §15.19's, extended: muted and cameraOff are required; the two new members are optional, byte-exact, at most once each, audioBlocked a boolean and noSoundFrom an array of well-formed peer ids with no repeats; no other member; the raw payload capped at 1 KB. Ids are checked for shape, not presence — the recipients ignore an id they hold no tile for. The hub stores, carries in rosters and fans out the report exactly as before, and a change to either member is a change. The payload out is re-encoded from the validated values, in the order muted, cameraOff, audioBlocked, noSoundFrom.

Compatibility. Because the members are omitted when there is no bad news, the ordinary report is still the two-key object, and a server from before this section accepts it; only a report carrying bad news is refused there (400), and the web client then falls back to the two-key form for the rest of the page. Old clients ignore the extra members. Deploy the server before, or with, the clients.

What clients show. Every client measures sound where it can, so a person can see it rather than infer it:

  • A microphone meter on one's own tile, measured on what is sent, and "Your microphone isn't picking anything up" when it has been flat for about five seconds while unmuted — and only once capture has really started and a connection is up and sending it to someone. Alone in a call nothing is sent (the apps' WebRTC does not even open the microphone), and the line never shows (2026-10-03).
  • A level meter on every other person's tile, measured on what arrives, with "No sound from <name>" when their audio has been flat for about five seconds while they report themselves unmuted, and the Muted badge instead of either when they are muted.
  • On a person's tile, their report about us: "<name>'s browser is blocking sound — they need to click to hear you", or "<name> isn't getting any sound from you".
  • In a browser whose playback was refused, a page-level "Click anywhere to turn on sound"; any click or key anywhere resumes every tile.
  • An audio test, before a call and during one, that meters the microphone, plays a test sound, and records a few seconds through the real capture path and plays it back. The recording never leaves the device and is discarded after playback.

Not logged, not stored. As §15.19: the report is the sender's claim, logged only on refusal, and held on the in-memory participation.

15.21 §15.19, §5.3, §5.4 — a shared screen that stops, and who says so (2026-09-28)

Why. Paul, 2026-09-28, sharing from the Mac app (0.4.0) to Vicky on an app: "after a while, my screen froze on her end and she was no longer able to see my screen. I had to stop and re-share again." Nothing on either side said anything; the sharer could not see it and the watcher could not tell him. The server keeps no call logs (§0.1). What the harness measured:

  • The apps restarted ICE on every connection on every /ice answer, and the core fetches one on every roster — every stream resume, every five minutes at the stream's TTL — so a relay-configured call restarted ICE twelve times an hour instead of once in two and a half. Between two apps from before this section, with renewals every ten seconds, the watcher's screen stopped for about four seconds at a time while the camera beside it kept moving.
  • The Mac's hardware H.264 encoder, as WebRTC sets it up, holds a source that is not a screencast (the "Motion and video" mode's, and any share that started in it) to the level negotiated, and Chromium answers every H.264 offer at level 3.1: 3,600 blocks, 1280×720. A 16:10 display in motion mode is 1280×830 at its cap. VideoToolbox refused every frame at that size (-12902), for eight or nine seconds at a time until WebRTC's quality scaler stepped it down — and for good once the mode was switched to Text and detail, which does not let the size fall. Two apps negotiate High 5.2 and never meet it.
  • ScreenCaptureKit stops outright — no frames, no idle reports — when every surface in its queue is held, and 0.4.0 held one of three for the repeat of a still screen.

Decision. The sender stops making the problem, the sender watches its own screen, and the receiver says when a screen has stopped for it.

  • The apps restart ICE only when the list they last gathered on is due for renewal (§5.3), or when a relay arrives for a connection built on the floor (§5.5); any other fresh list is adopted with setConfiguration.
  • A Mac screen whose source is not a screencast is scaled to fit level 3.1's blocks; the capture keeps six surfaces, not three.
  • The sharer's watchdog, once a second while sharing: a capture that reports nothing for three seconds is started again on the same content (5, 10, 20… up to 60 seconds apart); a connection whose screen takes frames in and encodes or sends none for five seconds, while WebRTC does not name the link as what limits it, gets, three seconds apart, a keyframe (the encoding switched off and on), a fresh encoder (the screen sent a third smaller, for thirty seconds), and a new transceiver (what stopping and sharing again did, renegotiated as §5.4 does), then rests thirty seconds. Every step is logged (os_log subsystem net.amadan.oficina.calls, category screen) and kept for "Copy call diagnostics".
  • The receiver's report. §15.19's media gains one more optional member, screenStalled: [peer, …]: the peers whose shared screen has decoded nothing new here for four seconds while they say they are sharing. Every sender repeats a still screen at least once a second, so that is not a still screen. It is bad news only, omitted when there is none, held for at most a minute from the last frame (three since §15.22), at most fifteen distinct well-formed peer ids, validated exactly as noSoundFrom is, and re-encoded after it (muted, cameraOff, audioBlocked, noSoundFrom, screenStalled). The raw payload cap is 2 KB, room for both lists full.
  • A sharer named in anyone's report recovers: a keyframe for that connection, four seconds apart (the report is a few seconds old when it lands), three times and then a rest; the fresh encoder and the new transceiver only if its own screen has stopped leaving as well — a report about a slow link is not the encoder's fault. It says so, quietly, while it lasts: "Vicky's view of your screen stalled — recovering". The browser, which has no fresh encoder or transceiver to offer short of a new share, sends keyframes only.

Compatibility. Old clients ignore the member. A server from before this section refuses a report carrying it (400); the browser then drops the extras for the rest of the page (§15.20), and the apps' core resends the same report without screenStalled and leaves it out from then on, so a mute is never lost to an old server. The sharer's own watchdog works whatever the server is. Deploy the server before, or with, the clients.

Not logged, not stored. As §15.19.

15.22 §15.21 — when keyframes are not enough (2026-09-30)

Why. Paul, 2026-09-30, sharing from the Mac app (0.4.3 or 0.4.4) to Samuel: "Still seeing issues with screen sharing after a while." His screenshot had §15.21's notice, "Samuel's view of your screen stalled — recovering", under "You are sharing your screen". The receiver's report had worked; recovery had not. By design, a report alone only ever bought keyframes, and a stall that keyframes do not mend — a receiver whose decoder has wedged, or one that has stopped taking this stream — had nothing after them: the notice sat at "recovering" until the report lapsed a minute later. Stop and Share, by hand, always mended it. Two more things came up reading the code: a receiver counted only a larger decoded count as movement, so a screen that had been given a new transceiver (whose count starts again) stayed "stalled" there for as long as its new count took to pass the old one; and the iPhone's broadcast sends nothing for a still screen, which any receiver reported as a stall after four seconds.

What the harness measured. A receiver whose screen decoder is wedged (the gate's decoder wrapper in the apps, an encoded transform in Chromium) keeps receiving packets and frames, decodes none, and drops them: packets +75, frames received +32, decoded +0, dropped +29 in the first four seconds, between two apps. Keyframes arrive and are dropped with the rest. A new transceiver, which gets a new receiver and decoder at the far end, mends it at once, in both directions and between two apps. No long share has yet produced a stall that keyframes did not mend, so the wedge is a stand-in for the cause, not the cause:

  • Samuel was on the web, so the path was the Mac app to Chromium, where §15.21 found H.264 held to level 3.1. With the level cap switched off (CALLS_SCREEN_UNCAPPED=1), a Motion share of a 1920×1246 page (a 16:10 Retina display as the capture fits it) reproduces Paul's picture exactly: VideoToolbox refuses every frame (-12902, 1,323 times in 150 s), the browser receives nothing and sends PLIs, freezes of 7–23 s and one that never ended, which keyframes, fresh encoders and a re-add only partly mend. With the cap as shipped, the same page starting in Motion and starting in Balanced, modes cycled every 30 s and the uplink capped from 600 kbit/s to 3 Mbit/s, eleven ICE restarts from the app and twenty-three at the browser in four minutes each: no -12902 and no freeze. The real display through ScreenCaptureKit (3024×1964, sent at 1920×1246) for seven minutes the same way: no -12902; the only gaps, 4 s each, were Text and detail on a 600 kbit/s link, where a full-size frame of a still screen takes that long to send, which is the link's and which the ladder does not take further (nothing was sent). A screencast source (Balanced, Text and detail) is set up by this WebRTC build without a fixed level and carries 1920×1246 to Chromium; a source that is not one (Motion, or a share started in it) is capped, now from the first frame, since the Mac capture's size is known before it delivers one.
  • The browser sharing to the app, modes cycled, renewals every ten seconds: four and a half minutes, no freeze.
  • Considered and not done: making Chromium's H.264 level higher (it answers 3.1 and room.js does not munge SDP, §5.1), and VP8 or VP9 for screens to browsers (software VP8 managed 2 fps at 1080p, §5.4's measurements). The measured cap holds; the ladder covers what it does not.

What is left untraced, and what the new statistics are for: a receiver's own decoder going wrong (a hardware decoder in the watcher's browser, which a headless gate never uses), and the one ICE restart in ten between two apps that stops the screen for three or four seconds (§15.21).

Decision. The sharer takes a report further, the receiver does its own share, and both say what they see.

  • The sharer's ladder, per reporting peer: keyframes as §15.21. Still reported six seconds after the first, that connection's screen is rebuilt — its transceiver stopped and a new one added once that has been negotiated, at the same size and mode; a report never shrinks the screen or makes a smaller encoder. Still reported ten seconds after that, everyone's screen is shared again from the same capture: every screen transceiver stopped, a new track on the same source (a clone of the captured track in the browser), each connection given it as it settles; no picker. Ten seconds after that, it gives up. A rebuild at most once a minute per peer, a share again at most once every five minutes; a step that is not allowed goes to the next, and none is taken unless at least three frames left for that peer since the last step (a screen with nothing new to send, or held back by the link, is not a decoder's fault). A report that clears ends the ladder for that peer.
  • The notice moves on in words: "Samuel's view of your screen stalled — recovering", then "Restarting your share for Samuel…", then either nothing or "Couldn't restore Samuel's view — try Stop and Share again". Worst news first when people are at different steps.
  • The receiver: any change in a screen's decoded count is movement. Four seconds into a stall it hands the screen's picture to its view again, once per stall (a new renderer in the apps, a fresh stream on the element in the browser): a picture stopped on this side is this side's to mend. Neither WebRTC build has a way for a receiver to ask for a keyframe directly; the report is that request, and WebRTC's own PLIs go out as they always did. A report is held for up to three minutes from the last frame, not one, so the sharer's "Couldn't restore" is not taken away while it is still true.
  • The iPhone's broadcast repeats its last frame once a second while the screen is still, as the Mac's capture has since §15.21.
  • What is logged. Every step, both sides, in the screen log (os_log category screen; the browser's console), and every stall with what the receiver's statistics say moved since the screen last did: packets, frames received, decoded, dropped, keyframes, PLI and NACK, the decoder, and a verdict (nothing arriving; arriving but not assembled; assembled but not decoded). "Copy call diagnostics" carries the ladder's counts and stage per peer, every arriving screen's statistics, and the last 200 lines of the timeline, on the sharer and the receiver alike. The browser gains the same button, at the foot of its Devices panel.

Compatibility. Nothing on the wire changes. An older receiver reports as before and is answered with the new ladder; an older sharer answers a new receiver's longer report with keyframes as before.

Not logged, not stored. As §15.19; the timeline stays on the device until someone copies it.

15.23 §4.5, §5 — "Reconnecting…" only for a problem that lasts (2026-09-30)

Why. Paul, 2026-09-30: "During that call I also saw 'Reconnecting' appear occasionally over the video, but only for a second … it didn't affect the call quality … I didn't notice any interruption in the call." The apps' core enters its reconnecting phase the moment the signalling stream ends, and the stream ends on purpose at its TTL (every five minutes, §4.5) and is resumed half a second later; the apps said "Reconnecting…" every time. The media never noticed. The browser said nothing at all, even for a real outage.

Decision. The notice is said, on Mac, iPhone and web, only when:

  • the signalling stream has been down for 4 s or more — the whole call; or
  • a peer connection's ICE state has been disconnected or failed for 3 s or more, or no inbound audio packets have arrived for 2 s or more from a peer that is not muted — that peer's tile, "Reconnecting to Samuel…"; or the whole call, when it is every peer (two or more).

Once said it stays a second past the recovery, then goes. A routine renewal or resume is never said. The timings are named constants (ReconnectWatch in the apps, RECONNECT_* in room.js) with unit tests either side; the browser gate's renewal scenario and the native gate's resume and renewals watch for the words through several renewals and fail on any. The core's phase is unchanged: controls that need an admitted call still follow it.

15.24 §15.19, §15.20 — "… is connecting and can't hear you yet" (2026-10-01)

Why. Paul, 2026-10-01, asking for what Zoom shows: "didemeski is connecting to audio and cannot hear you yet …". When someone joins, there is a gap — a few hundred milliseconds on a good network, seconds on a bad one, for ever on a broken one — before their side is receiving and playing your audio, and nothing said so: people greeted a newcomer who could not yet hear them. Nothing on the wire said whether anyone could hear anyone; §15.20 only carried bad news after five seconds of flat sound.

Decision. A positive receive signal, and a notice built on it.

  • hearing. §15.19's media gains one more optional member: the peers whose audio this client is actually receiving and playing. Unlike §15.20's and §15.21's lists it is good news, and its presence is itself news: an absent list is a client that does not say (one from before this section), [] is a client that hears nobody yet. So it is sent whenever the server takes it, [] included, and the hub keeps it with its emptiness — [] travels as [], and a change between absent and [] is a change. Otherwise it is held as noSoundFrom is: at most fifteen distinct well-formed peer ids, checked for shape and not presence, a null, a non-array, a repeat or a sixteenth id refused 400; re-encoded last (muted, cameraOff, audioBlocked, noSoundFrom, screenStalled, hearing). The raw payload cap is 3 KB, room for all three lists full (about 1,700 bytes) and whitespace. Sent only on a change, like the rest.
  • The rule — who a client lists. A peer is listed once the audio path from them is established and is playing here: - established: the connection is up (ICE connected or completed) and either their audio is arriving — inbound packets received and decoded samples coming out (totalSamplesReceived in the browser; frames at the receive tap in the apps) on a live receiver — or they say they are muted, in which case the connection being up and the receiver live (or no audio receiver at all, for someone with no microphone) is enough. A muted sender may send only silence, or nothing, and must not look unheard: the moment they unmute, it plays. Once established it stays so until the connection fails or closes; a blip is not losing them. - playing: in the browser, the tile's element is playing and was not refused by an autoplay policy (a refused tile is §15.20's audioBlocked, and is not listed until a click plays it). The apps play whatever they decode; there is no policy to refuse them.
  • The notice. From the moment a person appears in the roster — or appears again, or is given a new connection (a new pair generation) — their tile says "Samuel is connecting and can't hear you yet", with three dots after it that brighten in turn (one steady ellipsis under reduced motion). It goes the moment their report lists you in hearing, and a screen reader is told "Samuel can hear you now". In order: - The grace. It is not said at all if they hear you within 1 s of appearing (CONNECTING_GRACE, ConnectingWatch.grace), so ordinary joins say nothing. The browser gate measured 284 ms from appearing to being heard on a loopback. - More specific news replaces it: their audioBlocked ("Samuel's browser is blocking sound — they need to click to hear you"), or you in their noSoundFrom while you are unmuted ("Samuel isn't getting any sound from you"). Those are their own reports and show even inside the grace. - "Reconnecting to Samuel…" waits until after (§15.23): a connection that has never been up is not reconnecting. While someone is in their first moments the reconnect watch is told nothing is wrong with them, so a slow join of two people is never "Reconnecting…" for the call. - A client that does not say (no hearing in its report — a client from before this section, or any client on a server that refused the key) is let go once the connection to it is up and either its receiver reports show your audio reaching it (remote-inbound-rtp for your microphone, which exists only once one has come back, with packets sent) or it has made a media report without the list. A client that does say is believed over this evidence. - The cap. Whatever happens, it is given up 20 s after they appeared (CONNECTING_CAP, ConnectingWatch.cap), silently; §15.20's "isn't getting any sound" and §15.23's "Reconnecting to …" speak for any problem after that. - Your own mute does not matter: it is about their being able to hear you once you speak.
  • Where. On the person's tile, in the place and the glass of the other tile notices, with an ear where they have a warning triangle: every layout on the Mac and the iPhone, and the browser's call window. In the browser and in the apps' Grid, Speaker and Classic every person in the roster has a tile from the roster on, so there is no call-level form; the Mac's mini window, which shows one person, puts a small ear on its glass for anyone else still connecting (their names in its tooltip and for VoiceOver), and its tab shows the same ear. Screen readers hear the sentence once when it appears and "… can hear you now" when it goes, politely, and never the dots.

Compatibility, both orders. Old clients ignore the member (the browser's mediaFrom, the core's decoder and the apps' PeerMedia all drop unknown keys). A server from before this section refuses a report carrying it (400): the browser then drops the extras for the rest of the page (§15.20) — though the browser's page is served by the same server, so only a page open across a deploy meets that — and the apps' core resends the same report without hearing and leaves it out from then on, then without screenStalled too if that is refused next, so a mute is never lost. The people on new clients then fall back to their own evidence about that client, as they do for an old one. So the server and the clients may ship in either order.

Not logged, not stored. As §15.19. The apps log "… can hear us" in the connection log (os_log category connection), the browser on its console, each with the first six characters of the peer id only.

15.25 §3.4, §8.4 — no title pill, and Join in the app as the person signed in (2026-10-02)

The title. The full-window call on Mac, iPhone and web carried a pill by the traffic lights: the title and a few words of state ("Call, 2 Oct 15:40 Europe/Dublin / In the meeting"). Paul approved removing it. The state is the notices' to say, and each one the pill carried — Connecting, Waiting to be let in, The room is full, Reconnecting, the call over — has a notice (the apps' CallNotices, which now also turn any status other than "In the meeting" into one; the web's statusLine). A title someone chose is shown as a window title: plain, centred in the top strip over the existing dark scrim, fading with the controls (Mac: on the traffic lights' line; iPhone: small, inside the safe area). A title Calls wrote is not shown. Which is which is a flag set where the title is made — meetings.auto_title, by Start a call and the apps' Start meeting — never read back out of the words; /m/{sqid}/info says named. The window's own title (Window menu, Mission Control, VoiceOver) and the page's <title> are the meeting's either way.

The zone. From f86d4da (2026-10-01) an automatic title in the instance zone ended with the zone's name, so that a reader elsewhere was not shown a bare time in a zone they could not see; every meeting the apps started had it, because they sent no zone. The apps now send theirs, the title is "Call, 2 Oct 15:40", and the zone is kept beside it as data (meetings.title_zone).

The web menu. The pill's ☰ menu — the standing line, Meetings, Settings, Help, Sign out — is the bar's ⋯ More menu, for guests too, with End meeting for everyone last for a member. With no script the header is a plain block with the title, the standing, the floor notice and the same navigation.

Join in the app. Reported by a Calls user: "App link doesn't work if you're signed in". The holding screen's link was oficina-calls://join?url=<origin>/m/<sqid>, which names a meeting and not a person, and the app's session is its own: an app signed in as someone without standing joined and failed (or joined as the wrong person), and an app with none signed in in the default browser — where a guest, who has no account, reached a sign-in page that sends a non-member no mail. Now the link goes through /m/{sqid}/app, which makes an offer bound to the browser's session (native/architecture.md); the app's handoff request names it, and the browser the person is in confirms it. Same verifier exchange, one use, five minutes, the browser session's own standing, a guest still limited to their meeting. OpenLink asks /info before answering a /m/ address, so an app session without standing is told so (noAccess) instead of failing at the join.

15.26 §0.1, §0.3, §2.2, §4.4 — quality reports and "Report a problem" (2026-10-03)

The ruling. Paul, 2026-10-03, approved both parts below, explicitly accepting that the second bends §0.1's "nothing kept" and §2.2's "nothing else at rest": the problems people have had in calls (§15.20 to §15.24) have each been found from a screenshot and a pasted diagnostics block, after the fact, and the calls that went wrong without anyone saying so were never found at all. Calls now writes connection-quality facts to its own log. It still stores nothing about a call in its database, and the log is the only store.

A. "Report a problem". A button in the call's ⋯ More menu and in the ⓘ/Devices panel beside Copy call diagnostics, and on the screen a call ends on, on Mac, iPhone and the web, guests included. It opens a sheet with an optional "What went wrong?" (at most 1,000 characters) and the disclosure "Sends this call's connection diagnostics to the Calls server log. No audio or video." Send posts POST /m/{sqid}/problem with the note and the text Copy call diagnostics would copy, with one change: the people in the call are written "peer" and six characters of their peer id, not their names. The answer is a reference, eight characters of Crockford's alphabet (forty random bits), which the sheet shows under "Sent — thank you" so the reference can be looked up. The server logs one calls: problem report line (WARN: the reference, the tags below, the client, the note, and how many parts follow) and the diagnostics as calls: problem report diagnostics lines of at most 6,000 escaped bytes each (part/of), because the platform keeps 8 KB of a log line and truncates the rest. Before logging, it scrubs the text of every peer id the call has held, in full and as the six-character prefix, writing peer:<tag> instead, and of the meeting's sqid, writing [meeting]. Device names stay in the diagnostics (a Scarlett, AirPods): they are what audio problems are found by, and the person chose to send them. The note is free text and is logged as typed. Not switched off by the kill switch below: it is a person asking.

B. Automatic quality reports. Every thirty seconds while a client is admitted, and once more (final) at leave or end, each client posts POST /m/{sqid}/quality: a fixed schema (protocol/quality.go, version 1) of numbers and enums, per remote peer (the mesh has one connection per peer, so outbound is per peer too):

  • out, per audio, camera and screen: kbps, packets, and the receiver's own account of them (remote-inbound): lost, RTT, jitter; the encoder's qualityLimitationReason (none, cpu, bandwidth, other); frame size and fps; and §15.22's screen recovery steps toward that peer (keyframes, rebuilds, shares again, given up).
  • in: audio kbps, packets, lost, jitter, samples, concealed, inserted and removed samples, and the jitter buffer's mean delay; camera and screen kbps, packets, lost, jitter, freezes and their duration where the stack reports them, frames decoded and dropped, frame size and fps, and §15.22's pictures handed to their view again.
  • path: the selected candidate pair's two candidate types (host, srflx, prflx, relay) and its transport (udp, tcp, or tls for a relay over TLS), and its RTT. Never an address or a port.
  • events in the window: "Reconnecting to …" (§15.23) shown, how often and for how long; "… can't hear you yet" (§15.24), for how long; whether this client listed them in noSoundFrom, whether they listed this client, whether they reported audioBlocked, whether their screen was reported stalled here (§15.20, §15.21). For the whole call: the call-wide "Reconnecting…" and this client's own blocked sound.
  • client: app (web, mac, iphone, ipad), its version (the server's build for the web), browser and its version (web), OS and its version — enums and digits, never a user agent string or a device model.
  • devices: the kind of microphone and speaker, by transport (built-in, bluetooth, usb, wired, airplay, virtual, continuity, other, none, unknown). Never a device's name: "Paul's AirPods Pro" is a name.

Counts are what happened in the window. Every number is optional, finite, non-negative and under a ceiling (protocol/quality.go: a window at most 600 s, a delay at most 600,000 ms, a count at most 10⁹, a bitrate at most 10⁶ kbit/s, a frame at most 16,384 px, 240 fps); every string is one of its enums or a version of digits and dots; at most four remote peers (the mesh holds three) and a 16 KB body. Anything else is 400. A member the schema does not name is tolerated and dropped, so a newer client's addition does not fail an older server, and never reaches the log: the line is re-encoded from what was validated, numbers rounded to a tenth. The server writes one calls: quality line per report (INFO), a JSON object whose top level is what carlos logs --field can filter on — call, peer, app, version, browser, browserVersion, os, osVersion, final, seconds, remotes, and four headlines: relay (any path through the relay), maxLossPct (worst inbound loss), maxRtt, freezes, reconnecting — and whose report member is the rest, with each remote peer named by its peer tag. The largest report the schema allows fits in one platform line, which a test holds. A web page's or an app's own toggle ("Send anonymous quality reports", on by default; localStorage on the web, the apps' Settings) stops its reports; Report a problem stays.

The tags. The call tag is minted by the hub, at random, when a participation opens in a room that holds none, and every participation until the room is empty again shares it: one call, one tag, however many people come and go, and a new tag for the room's next call. The peer tag is minted at random per participation. Neither is derived from anything — not the sqid, the peer id, the person, the session — and neither is ever logged beside any of them, so a line cannot be walked back to a meeting or a person through the log: §4.4's refusal lines carry the sqid and the peer id, and a report line carries neither. A report names a remote peer by the id its client knows; the server logs that peer's tag in its place, from the call's own table (at most 64 participations a call; past that, unknown). The tags live in the hub and die with the process.

Authority. The hub hands each participation a report token, sixteen random bytes, in its hello (and the same token in a resumed stream's hello). A client sends it in X-Calls-Report. It is honoured for the session it was given to and the room it was given for, while the participation lives and for ten minutes after it ends — any ending: leave, removal, the meeting ending, a lapsed resume — so the final report and a Report a problem from the screen the call ended on are taken. Anything else is 404, before the body is read. The token table is bounded (4,096 grants; past that the grant that ended longest ago goes, never a live one) and swept on the tick. Rate limits, keyed on the peer tag: a quality report every ten seconds with a burst of six, a problem report a minute with a burst of three; 429 with Retry-After. Bodies are refused past their caps (16 KB, 64 KB) without being read further.

The kill switch. CALLS_QUALITY_REPORTS=off in the app's environment: /quality answers 204 before it looks at anything, the boot log says "calls: quality reports off", and a client answered 204 sends nothing more for that call. Default on. An old client sends nothing; a new client against an old server is answered 404 by the router, and its reports are lost, which is all that happens.

Where it goes, and for how long. The app's stderr, as JSON lines (the platform reads fields only from a line that is a JSON object; every other line the app writes stays text). CARLOS ships an app's log to its deployment bucket every ten seconds and keeps it seven days, or 50 MiB of compressed log per app per box, whichever comes first (carlos/platform internal/host/logship.go: logshipMaxAge, logshipMaxBytes, at platform main fe2088e3, 2026-10-02), pruned an hour at a time. There is no archive beyond that. The box's own journal also sees the lines (journald capped at 256 MB, host-wide, not readable through carlos logs). The platform's edge stream is reserved and writes nothing, client addresses included; Calls adds no address to any line, and cannot speak for logs outside the platform.

What is collected, and what is not.

| | Quality report | Problem report | |---|---|---| | When | every 30 s in a call, and at leave | when someone presses Send | | Who | every client, guests included, unless its toggle is off or the server's kill switch is | the person who pressed it | | Numbers about the connections (above) | yes | yes, in the diagnostics' own words | | Call tag, peer tag | yes | yes | | App, version, browser, OS and their versions | yes | yes, and the browser's user agent string in the web's diagnostics | | Kind of microphone and speaker | yes | — | | Device names | no | yes, in the diagnostics | | What the person typed | — | yes, the note | | People's names | no | no (peer tags) — unless typed into the note | | Emails, the meeting's title or sqid, peer ids | no | no (scrubbed) — unless typed into the note | | IP addresses, ports, candidates | no | no | | Chat, audio, video, SDP | no | no | | The database | nothing | nothing |

What this does to §0.1 and §0.3. §0.1's falsifiable claim gains a sentence: publish every log line and the worst anyone learns now also includes that some call, under a random tag, had so many participations, on these platforms, with this loss and these notices, between these times; not which meeting it was, nor who was in it, nor what they said — except what a person types into a problem report's note. §0.3's "no attendance record" stands for the database and for any record tied to a person or a meeting; the log's timestamps under random tags show that a call happened and how many clients took part, which is the bend Paul accepted.

Reading them. docs/operations/quality.md, and cmd/quality-summary, which reads carlos logs and prints each call: who, by peer tag and platform; the worst loss, RTT and freezes for each direction between two people, and how much of it went through the relay; and the notices, in order.

Tests. internal/callstest/quality_test.go: the line's shape and fields; one call tag shared, peer tags distinct, a remote peer logged by its tag; nothing of the sqid, the title, the addresses, the peer ids or the tokens in the log; every schema refusal; unknown members tolerated and not logged; the size caps; the largest report fitting one platform line; the token refused for another session, room or none; the after-leave window, ended meetings included, and its end; a resume keeping the token and an empty room starting a new call; both rate limits; the kill switch; a problem report's reference, parts and scrubbing. hub/report_test.go: the sweep and the bound. The browser gate's quality scenario and the native gate's check: real clients' lines in the log, a shared call tag, no names.