oficina / meet Public

spec: Meet design, revision 1

Video calling for the suite: sign-in plus guest grant (suite discussion
3), start-time scheduling, P2P mesh capped at four, ICE servers from
CARLOS_RELAY or none (relay spec revision 6), SSE plus POST signalling
(platform 113), ephemeral chat, screen share, a drive surface under
suite discussion 4. Six slices. Awaiting Paul's review and an
independent review.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 75fed57331503aa92fe092024e7f6e5bc7f400eb parent 6a9ff3a
1 file changed, +492 −0
  • docs/superpowers/specs/2026-09-07-meet-design.md +492 −0
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md
new file mode 100644
index 0000000..1257c16
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-07-meet-design.md
@@ -0,0 +1,492 @@
+# 🤖 Meet — design
+
+Design, 2026-09-07. Direction approved by Paul in conversation; this is the
+written form. **Revision 1.** Drafted by the Meet session (Fable 5.1) from
+a brainstorm with Paul on 2026-09-06 and 2026-09-07. Every ruling in §1
+attributed to Paul was given in that conversation or on the discussion it
+cites.
+
+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
+`meet.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 the server.** The instance holds a sealed title,
+who was in the room and when, and nothing that was said or shown in a
+call. Chat, presence and peer state live in memory for the life of a
+room and are gone when it ends.
+
+Falsifiable: publish the database, the WAL and the logs, and the worst
+anyone learns is the shape of the team's meetings and who was in them,
+never what a meeting was called, what was said, or what was on anyone's
+screen. Enforced by a test that greps the raw SQLite file after a
+checkpoint for a known title, and by the absence of any handler that
+accepts a media byte.
+
+The claim narrows when the platform SFU (stage 4b) arrives: media will
+then pass through a platform process the team already trusts with its
+pages. The README says so in the paragraph that introduces the relay,
+and this spec's §5.6 records the change of claim in advance.
+
+### 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). |
+| 4, 12 | Identity and roster belong to the home. Until the home mints assertions, the lone instance keeps its own allowlist exactly as Docs and Slides do. |
+| 6 | No product-wide egress. Meet never names a STUN or TURN server of its own; ICE servers come from the platform (§5.2) or there are none (§5.5). |
+| 9 | Every write names its real actor: a member, a guest who signed in, or an agent (§8). |
+| 15 | Three rungs plus a read-only link. A meeting link is a capability to obtain a **guest grant at sign-in**, the `granted` rung, and never a way into a room without a name (§3). Ruled for Meet on `oficina/suite` discussion 3, Paul, 2026-09-06. |
+| 16 | A link token is stored hashed and shown once (§3.1). Ruling 16 is on the doctrine's `worktree-worktree-token-at-rest` branch (suite discussion 2), not yet on main; Meet adopts it now so it never has the Docs defect that produced it. |
+
+Also consumed: `oficina/suite` discussion 4 (agents as their own
+principal, on behalf of a person) for the drive surface (§8); the CARLOS
+media roadmap (`carlos/platform`,
+`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) 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,
+ calls connect on the same network only (§5.5). With the platform relay,
+ a network that permits only TCP 443 still fails, and the pre-join
+ screen says so.
+- 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.
+
+---
+
+## 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 second consumer. | Asking rastrillo for a WebSocket helper before a consumer exists | Paul, 2026-09-06 |
+| 4 | **Scheduling is a start time on the meeting.** The link works whenever a signed-in person follows it; the join page shows when the meeting is meant to start. A guest grant expires two hours after the start time, or two hours after creation when there is no start time. | A start-to-end window with a clock-driven state machine; recurring meetings | Paul, 2026-09-07 |
+| 5 | **Pure P2P, room capped at four.** Each participant holds one peer connection per other participant. | A relay Meet runs; a third-party TURN by default | Paul, 2026-09-06; cap is this session's, from mesh bandwidth |
+| 6 | **ICE servers come from platform config or nowhere.** `CARLOS_RELAY` present: STUN and TURN from the platform, TURN credentials minted per call. Absent: no ICE servers, calls connect on the same network only, the pre-join screen says so. | A per-team STUN or TURN URL field (doctrine §8: the recipient is the app's decision, never the team's); one app-named public STUN provider as an opt-in interim | Paul, 2026-09-07, choosing "no relay" as the interim once stage 4a was ruled to start |
+| 7 | **Signalling is SSE plus POST**, one stream per room, following Docs live-sync §3. | A WebSocket: rastrillo has no helper, `Origin` would be hand-checked, and no upgrade crosses a tunnel-backed host (platform 113) | This session, 2026-09-07; platform 113 confirmed the constraint |
+| 8 | **Chat is ephemeral**, a signalling message rendered client-side, held nowhere at rest. | Persisted chat as content with named actors, the suite's first chat product | This session, 2026-09-07 |
+| 9 | **A guest waits to be admitted.** A member joins directly; a guest who signed in and holds a grant still waits until any member in the room admits them. | Auto-admit on grant | This session, 2026-09-07, built regardless of ruling 1's outcome |
+| 10 | **No calendar integration in version one.** Meet owns rooms and a start time. Calendar's "recorded, never invited" rule stands; a sealed conference URL on an event is Calendar's later change. | Meet growing a calendar; Calendar sending invitations | This session, 2026-09-07 |
+| 11 | **An agent can make, list, link and end meetings; it cannot join a call.** The media plane is browser-only. | An agent participant | This session, 2026-09-07, under suite discussion 4 |
+
+---
+
+## 2. The meeting model
+
+Three tables of Meet's own beside the scaffold's persons table, and `agents` in slice 5 (§8.1), gorm on SQLite through rastrillo, migrations numbered as
+the scaffold does.
+
+**`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), `created_by`, `created_at`, `revoked_at`. One meeting
+may hold several links so a revoked one can be replaced. The token is
+shown to its maker once, on the page that created it, and never again.
+
+**`guest_grants`** — `id`, `meeting_id`, `person_id`, `granted_at`,
+`expires_at`, `admitted_at` (nullable), `admitted_by` (actor, nullable).
+Minted at sign-in when a non-member follows a live link (§3.2). One row
+per person per meeting; following the link twice touches nothing.
+
+**`persons`** — the scaffold's users table: `id`, `email`, `name`,
+`created_at`. `member` is a boolean derived at request time from the
+allowlist in lone shape, and from the roster copy once the home exists.
+It is not stored, so a change to the allowlist takes effect on the next
+request.
+
+Sealing reuses the shape of calendar's `internal/calendar/seal`: one
+DEK from platform config (`MEET_DEK`), HKDF purpose keys, AES-GCM, and
+the DEK never written to the database it opens. Meet seals one column.
+Lifting the package into estilo or rastrillo is the extraction trigger
+the calendar spec already names; this is the second consumer, so the
+plan carries a task to propose it.
+
+Nothing else is at rest. Rooms, peers, admission state, chat and
+presence live in the hub (§4.2).
+
+---
+
+## 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. `POST /meetings/{sqid}/links`
+mints a replacement and revokes the rest.
+
+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 redirects:
+
+- **A signed-in member** goes to the holding screen, `/m/{sqid}/join`.
+- **A signed-in non-member** with no grant gets one minted now, then
+ goes to the holding screen. With a grant, straight there.
+- **Nobody signed in** goes to `/signin?return=/j/<token>`. The token
+ rides in the return path and nowhere else, so sign-in completes and
+ the same route runs again with a session. Sign-in refuses a return
+ path that is not a `/j/` path on this host.
+
+A non-member may sign in at all because the allowlist gates
+**membership**, not sign-in, on this app alone: the magic link is sent
+to any syntactically valid address, and what the address gets is a
+grant or nothing. This is the one place Meet departs from Docs'
+`auth.go`, and it is stated here because the departure is the whole
+guest feature. An address that signs in without following a live link
+lands on a page that says it is not a member of this team and has no
+meetings, and can do nothing else.
+
+The expiry of a grant is set at mint time from ruling 4. The grant is
+checked, not cached, on every request under `/m/{sqid}` (doctrine
+ruling 3).
+
+### 3.3 The holding screen
+
+`/m/{sqid}/join` is the only page in Meet that requires scripts, and it
+says so in place when they are off (§9). It shows the meeting's title,
+its start time if it has one, the person's own name, a camera preview,
+device pickers for camera, microphone and speaker, and mute toggles.
+Choices persist in the browser's local storage per device id, nothing
+of them reaches the server. If `CARLOS_RELAY` is absent the screen
+carries the floor sentence from §5.5 above the join button.
+
+Pressing join opens the room stream (§4) and posts `join`. A member
+enters. A guest sees "waiting for someone to let you in" until a member
+in the room posts `admit` for them, and leaves the queue if they close
+the tab. Every member in the room sees the knock with the guest's name
+and one button.
+
+### 3.4 The room
+
+`/m/{sqid}` renders the grid of participants, own controls (mute,
+camera, share screen, leave), the chat panel and the admission queue.
+The creator or any member can end the meeting from the room or from
+the list, which sets `ended_at`, closes every stream with an `ended`
+message and revokes every link. Leaving is a `leave` message and the
+tab closing; the server also treats a stream that has been closed for
+thirty seconds as a leave.
+
+---
+
+## 4. Signalling
+
+### 4.1 Transport
+
+One SSE stream per room and participant, `GET /m/{sqid}/stream`, under
+the session, with the Docs live-sync heartbeat (15 s) and connection TTL
+(5 min) so hibernation cannot be held off by a dead stream. Upstream
+messages are `POST /m/{sqid}/signal` with a JSON body, under CSRF. A
+client that loses its stream reconnects and posts `join` again; peers
+that see a second `join` from the same peer id renegotiate.
+
+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
+
+An in-process room hub in the shape of Sheets' `live/hub.go`: opaque
+payloads, a bounded per-subscriber buffer, a slow subscriber declared
+lost and told so. It holds, per room: the set of peers (peer id, actor,
+display name, member or guest, admitted or waiting, joined at), and
+nothing else. Peer ids are random per tab. The hub is per process,
+which is fine on one host and would not survive a multi-host fleet;
+that is noted, not solved.
+
+### 4.3 Messages
+
+Every message carries `type`, `from` (set by the server, never trusted
+from the client) and, where targeted, `to`. Types:
+
+| Type | Direction | Who may send | Server does |
+|---|---|---|---|
+| `join` | up | anyone with access | Adds the peer. Member: admitted, broadcast `peer` to the room, send the roster to the joiner. Guest: waiting, broadcast `knock` to members only. Refuses with `full` at four admitted peers. |
+| `admit` | up | a member in the room | Marks the guest admitted, broadcasts `peer`, sends the guest the roster. |
+| `leave` | up | the peer | Removes the peer, broadcasts `gone`. |
+| `offer`, `answer`, `candidate` | up, targeted | an admitted peer | Relays to `to` unchanged. Never broadcast. |
+| `chat` | up | an admitted peer | Broadcasts with `from` and the sender's display name. Not stored. 2 KB limit. |
+| `share` | up | an admitted peer | Broadcasts `{on: bool}` so peers can label the tile. |
+| `roster`, `peer`, `gone`, `knock`, `full`, `ended`, `lost` | down | server | As named. |
+
+The server validates type and shape and the sender's standing, and
+reads nothing inside `offer`, `answer` or `candidate`. Which peer makes
+the offer is decided by peer id order, so two peers never both offer.
+
+---
+
+## 5. Media
+
+### 5.1 The mesh
+
+One `RTCPeerConnection` per other admitted peer, created when a `peer`
+message arrives or the roster is received. The lower peer id offers.
+Tracks: microphone and camera from the holding screen's choices, and a
+display-media track when sharing. All ICE candidates are trickled as
+`candidate` messages. A connection that reaches `failed` shows the
+tile as "could not connect" with a retry that restarts ICE; nothing is
+silently spun.
+
+### 5.2 ICE servers from the platform
+
+At startup the instance reads `CARLOS_RELAY`, standard base64 of a JSON
+array, nearest region first, each element `{region, urls, key,
+username_suffix}`. Absent or empty means §5.5. The decoded value is
+held in memory and never logged.
+
+Per call, `GET /m/{sqid}/ice` (under the session and the room's access
+check) returns the `RTCIceServer` list: every `stun:` URL bare, every
+`turn:` URL with `username = "<unix expiry>:" + username_suffix` and
+`credential = base64(HMAC-SHA256(rawkey, username))`, where `rawkey` is
+the element's key decoded from base64 to 32 bytes. Expiry is three
+hours ahead, inside the four-hour maximum. The list is built from
+whatever URLs are present; no shape is assumed, and no `turns:` URL is
+expected in version one.
+
+The minting function has one test that reproduces the platform's
+vector: key `uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=`, username
+`1900000000:abc:meet:0123456789abcdef:k1`, credential
+`jRU479Y3GO45nrRHqxBf3BMeBHE6Fip1cwAccNv28MQ=`.
+
+### 5.3 Re-mint before the deadline
+
+Credential expiry is a hard deadline on the relay: the allocation ends
+whether or not the browser sends anything. The client fetches a fresh
+list at two and a half hours, calls `setConfiguration` on every peer
+connection and restarts ICE. Under three hours nothing happens. This
+is a small client task, and it is the reason §5.2 mints at three
+hours rather than four.
+
+### 5.4 Screen share
+
+`getDisplayMedia`, added as a track to every peer connection, with a
+`share` message so peers label the tile, and renegotiation on each.
+Stopping removes the track and renegotiates. One sharer at a time is
+not enforced; the grid shows every shared screen it receives.
+
+### 5.5 The floor
+
+With no `CARLOS_RELAY`, the ICE list is empty and browsers gather host
+candidates only. Calls connect between participants on the same
+network and nowhere else. The pre-join screen says, above the button:
+"This team's Meet has no relay yet, so calls connect only between
+people on the same network." The README's first paragraph says the
+same. Nothing pretends.
+
+With `CARLOS_RELAY` and a network that permits only TCP 443, the call
+still fails, because the relay's TLS listener is deferred by the
+platform. The failed tile's text names that case.
+
+### 5.6 What changes at stage 4b
+
+When the platform SFU exists and a team opts in, media passes through
+a platform process. §0.1's claim becomes "media never touches this
+app's server". The mesh code is replaced by one connection to the SFU,
+the cap is lifted, and this spec gets a revision, not a footnote.
+
+---
+
+## 6. Chat
+
+A `chat` message with text up to 2 KB, rendered in the panel with the
+sender's name and a local timestamp, escaped as text. It is not stored,
+not sent to a peer who joins later, and gone with the tab. Links are
+not linkified in version one. This is the suite's first chat, and
+estilo's rule makes Meet the first builder: the panel is written as one
+self-contained template partial and one script file so a second app can
+adopt it.
+
+---
+
+## 7. Identity and the instance
+
+### 7.1 Lone shape
+
+Sign-in is Docs' `internal/docs/auth.go` adopted with the one change in
+§3.2: `MEET_ALLOWED_ADDRESSES` decides membership, not whether a magic
+link is sent. `MEET_ORIGIN`, `MEET_INSTANCE_KEY`, `MEET_SMTP_*` and
+`MEET_DEK` are startup refusals outside `-env development`. The same
+303 for every address. Slides already recorded that its copy of this
+file is the extraction trigger; Meet's is the third, and the plan
+carries the same task rather than a new one.
+
+### 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).
+
+### 7.3 Deploy
+
+One static binary, `CGO_ENABLED=0`, `linux-arm64`, `make release` with
+the version stamped, `carlos deploy --app meet --account oficina` with
+the build probe on `/api/version`. `carlos domains attach
+meet.oficina.works`. Secrets `MEET_INSTANCE_KEY`, `MEET_SMTP_PASS` and
+`MEET_DEK` via `carlos secrets`, the rest via `carlos env`. The route is
+a unit route, which is what `carlos relay enable --app meet` needs. The
+relay itself is switched on by an operator when stage 4a reaches the
+account's region; Meet needs no change for it beyond reading the value
+at the restart the platform performs.
+
+---
+
+## 8. The drive surface
+
+### 8.1 Principals
+
+Adopted from Slides §7.1 unchanged: a member mints an agent token from
+Settings, `agents(id, name, operator, token_hash, created_at,
+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.
+
+### 8.2 Operations
+
+`GET /api/v1/meetings` (list, with `sqid`, title, `starts_at`,
+`ended_at`, `link_present`), `POST /api/v1/meetings` (title, optional
+`starts_at`; returns the meeting and its link, the only time the API
+returns a token), `POST /api/v1/meetings/{sqid}/links` (replace the
+link), `POST /api/v1/meetings/{sqid}/end`, `GET /api/v1/meetings/{sqid}`
+(the meeting and, while it runs, the count and names of admitted
+peers, never the chat). No operation joins a call (ruling 11).
+
+### 8.3 MCP
+
+A thin adapter over §8.2, deferred until a consumer asks by name.
+
+---
+
+## 9. The scriptless floor
+
+Everything except the holding screen and the room works with scripts
+off: the list, creating a meeting, seeing and copying a link, replacing
+it, ending a meeting, signing in, the agent token page. The holding
+screen with scripts off renders the title, the start time, the floor
+sentence if it applies, and one paragraph saying a call needs
+JavaScript and why, with nothing hidden behind a broken button. This
+is the suite's first app whose core surface needs scripts, and
+PRODUCT.md says so in the "what is deliberately not here" list rather
+than letting the scaffold's claim stand.
+
+Hand-written static JavaScript, no bundler, no npm, in three files:
+`devices.js` (the holding screen), `room.js` (signalling and the mesh),
+`chat.js` (the panel). Roughly two thousand lines between them, the
+largest hand-written script in the suite so far, which is why they are
+three files and not one.
+
+---
+
+## 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.
+
+---
+
+## 11. Deliberately not built yet
+
+Recurring meetings. Calendar integration. Recording. A relay of Meet's
+own. Rooms above four. Persisted chat. Reactions. Dial-in. Captions.
+Home integration. Multi-host signalling. Push-to-talk. Virtual
+backgrounds. Each is named so the next session does not rediscover it.
+
+---
+
+## 12. Slices
+
+1. **Meetings and links.** The four tables, sealing, the list, create,
+ the link shown once, replace, end. Scriptless throughout. Sign-in
+ with the §3.2 change and the `/j/` route with all three redirects and
+ the one 404. Grants minted and checked. No room yet: joining lands on
+ a page that says the room is the next slice.
+2. **Holding screen and room, same network.** `devices.js`, the hub,
+ the stream, the signalling messages except `chat` and `share`, `room.js` with the mesh,
+ admission, leave, end. `CARLOS_RELAY` absent. Two headless browsers
+ complete a call on localhost.
+3. **Relay.** `CARLOS_RELAY` parsing, minting with the vector test,
+ `/ice`, the re-mint. Testable without a relay by asserting the list
+ the browser receives; tested against the real relay when stage 4a
+ reaches flagship.
+4. **Chat and screen share.** `chat.js`, the `chat` and `share`
+ messages, display-media tracks and renegotiation.
+5. **Drive surface.** Agent tokens, the five operations, the badge.
+6. **Deploy.** Release, domain, secrets, canary, `PRODUCT.md`,
+ `AGENTS.md`, the README with the floor sentence in its first
+ paragraph, and the `Consumed suite rulings` commit that suite
+ Appendix A.6 will point at.
+
+Each slice is its own branch, discussion and review, per the suite's
+overnight protocol (`oficina/suite` discussion 5).
+
+---
+
+## 13. Testing
+
+Go: store and grant tests including expiry and the one-404 rule; hub
+tests for cap, admission, targeted relay, slow subscriber; handler
+tests for every redirect in §3.2 and every refusal in §4.3; the sealing
+grep test from §0.1; the minting vector from §5.2; `make ci` green.
+
+Browser, with chromedp as calendar does, and Chrome's
+`--use-fake-device-for-media-stream` and `--use-fake-ui-for-media-stream`:
+the holding screen enumerates devices and previews; two tabs in one
+headless Chrome complete a call on localhost with host candidates and
+each sees the other's track; a guest knocks and is admitted; chat
+arrives; a share labels a tile; the scriptless holding page renders its
+paragraph. No test touches a relay until one exists.
+
+---
+
+## 14. Review record
+
+Revision 1: awaiting Paul's review of the written form and an
+independent review by a model at least as capable as the author, per
+the suite protocol. Neither has happened; this line is the record.