# 🤖 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](https://amadan.net/oficina/docs),
[Calendar](https://amadan.net/oficina/calendar),
[Slides](https://amadan.net/oficina/slides) and the suite
[doctrine](https://amadan.net/oficina/suite). 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.
2. Otherwise, if they are a member: redirect to `/`, the meeting list.
   A member who opened `/signin` directly lands where they expect.
3. Otherwise: redirect to `/welcome`, the not-a-member page: "You're
   signed in as <address>. This team's Meet has no meetings for you.
   If someone sent you a meeting link, open it again now." with sign-out
   and nothing else. Scriptless.

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.
