oficina / meet Public

spec: codex round 5 findings and Meet revision 6

Round 5: three R4 findings closed, one half, three new (two P1 in the
takeover revision 5 added, one P2 in the cursor), request changes.
Revision 6: takeover is a confirm page plus a CSRF-protected POST with
the same state-dependent departure as leave, and a GET never ends a
participation; seq is monotonic for the life of a participation so
the resume cursor needs no generation.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 61475ed787d945b2e76ccff372ded6295d409c5d parent bbfe440
2 files changed, +127 −20
  • docs/superpowers/specs/2026-09-07-meet-design.md +60 −20
  • docs/superpowers/specs/CODEX-FINDINGS-MEET-R5.md +67 −0
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md
index bc31c34..7b6bdb2 100644
--- a/docs/superpowers/specs/2026-09-07-meet-design.md
+++ b/docs/superpowers/specs/2026-09-07-meet-design.md
@@ -1,8 +1,8 @@
# 🤖 Meet — design
Design, 2026-09-07. Direction approved by Paul in conversation; this is the
-written form. **Revision 5**, answering the four findings of the
-fourth Codex review (`CODEX-FINDINGS-MEET-R4.md`); §14 has all four
+written form. **Revision 6**, answering the three findings of the
+fifth Codex review (`CODEX-FINDINGS-MEET-R5.md`); §14 has all five
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
@@ -261,6 +261,7 @@ One rule per endpoint, so §4 and §5 do not each restate it:
|---|---|---|
| `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. |
@@ -498,13 +499,26 @@ 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, unless it carries
-`?takeover=1`, which is sent only by the "Use this tab instead" button
-(§4.5). A takeover **ends** the old participation: a new participation
-with a new peer id is created, `gone` goes to admitted peers for the
-old id, `lost {reason: "replaced"}` goes to the old stream if one is
-attached, and the new one proceeds as a fresh `join`. Nothing automatic
-ever displaces a tab.
+**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).
@@ -622,18 +636,19 @@ browser's response is fixed per case:
| Resume answer | Meaning | Browser does |
|---|---|---|
-| `410 {reason: "replaced", by: <peer>}` | 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. | Terminal: close every peer connection, show "This meeting is open in another tab" and the button "Use this tab instead", which opens a fresh stream with `?takeover=1` (§4.2). Never automatic. |
+| `410 {reason: "replaced", by: <peer>}` | 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. | 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 take-over button. |
+| `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 button moves on.
+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
@@ -650,8 +665,14 @@ 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; the server relays
-`seq` unchanged and keeps a copy in the target's replay ring (§4.5). A
+(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
@@ -673,7 +694,7 @@ 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, resets its `seq` counters for the pair, creates a new
+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
@@ -912,7 +933,7 @@ A thin adapter over §8.2, deferred until a consumer asks by name.
The exception is **browser media participation** and nothing wider:
reaching a camera and another browser needs script. Every other screen
works with scripts off, including the meeting list, creating a meeting,
-seeing and copying a link, all three confirm pages, sign-in and
+seeing and copying a link, all four confirm pages, 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
@@ -1023,7 +1044,12 @@ peer and only `state` to a waiting one, replay from the ring above
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
-stale-`gen` POST refused with 409, `reset` yielding one `pair` to each
+takeover of an admitted, a waiting and an unjoined participation
+producing `gone`, `knock-gone` and nothing respectively with an
+admitted guest observing, a takeover POST without the CSRF token
+refused, 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,
@@ -1057,8 +1083,10 @@ 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 displaces the first, which shows the take-over
-button and does not reconnect on its own (2); after a server restart both contexts have
+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
@@ -1150,7 +1178,19 @@ in revision 5:
| 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). |
-Revision 5 awaits Paul's review of the written form and a fifth Codex
+**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). |
+
+Revision 6 awaits Paul's review of the written form and a sixth Codex
round. Neither has happened; this line is the record.
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-MEET-R5.md b/docs/superpowers/specs/CODEX-FINDINGS-MEET-R5.md
new file mode 100644
index 0000000..a395fd4
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-MEET-R5.md
@@ -0,0 +1,67 @@
+# Findings on the Meet spec, revision 5
+
+GPT-6 via `codex exec`, read-only, round 5, reviewing commit bbfe440 against the four earlier findings files. Verbatim.
+
+Reviewed revision 5 at `bbfe4406b5cffaba11ab40e4d1cd6034efc2bfc4`. No files modified. “Closed” assesses the design against the reported failure, not implementation or test results.
+
+| R4 finding | Status | Assessment |
+|---|---|---|
+| R4-1 reset protocol | **Half-closed** | Typed reset messages, server-held generations and roster reconciliation supply coordinated recovery. Resetting sequence numbers without changing the resume cursor makes retained signalling disappear: R5-1. |
+| R4-2 lost replacement notice | **Closed** | A takeover creates a new peer; the old peer receives 410 on resume. After its tombstone expires, fresh entry still receives 409 while another participation owns the session’s room. Automatic reconnection cannot displace that participation. The new takeover operation has separate defects below. |
+| R4-3 authorization-table disagreements | **Closed** | §2.5 now permits participation-only `leave`, requires admitted members for admission and removal, and supplies the page-access rule that §3.3 explicitly adopts. |
+| R4-4 revoked-continuation response | **Closed** | §3.2 distinguishes the inherited 403 at `Authorize` from the authenticated continuation fallback. §13 matches those branches. Neither creates a grant through the revoked originating link. |
+
+The half-closed residues from R4’s two tables, checked individually:
+
+| Earlier residue | Status | Assessment |
+|---|---|---|
+| R3-7 downstream signalling loss | **Half-closed** | Replay works within one generation; recovery across generations remains defective: R5-1. |
+| R3-9 replacement loop | **Closed** | Lost `replaced` delivery no longer permits automatic displacement of another live participation. |
+| R2-4 resume/restart | **Closed** | Admission-branched resume, roster reconciliation, connection disposal on restart and fresh-entry conflict handling answer the recorded failures. |
+| R2-5 delivery | **Half-closed** | Ordering and replay remain, but their sequence cursor cannot identify the new generation: R5-1. |
+| R1-9 reconnect | **Closed** | Admission survives ordinary renewal; missed departures reconcile; restart closes old connections; stale tabs cannot automatically take over. |
+| R1-10 negotiation | **Half-closed** | Perfect negotiation and renewal completion remain specified, but reset recovery can withhold the offer needed to complete negotiation: R5-1. |
+
+**R5-1 — P2 — §§4.5–4.6: The resume cursor cannot distinguish generations after sequence numbers restart.**
+
+`last` maps a sender ID to a sequence number. The hub replays only messages above that number. A pair rebuild resets both browsers’ sequence counters, but the resume request carries no generation. [Resume cursor and replay filter](/home/paulca/amadan.net/oficina/meet-spec/docs/superpowers/specs/2026-09-07-meet-design.md:589), [counter reset](/home/paulca/amadan.net/oficina/meet-spec/docs/superpowers/specs/2026-09-07-meet-design.md:673).
+
+A concrete failure:
+
+1. A has received B’s generation-1 messages through sequence 20, then loses its stream.
+2. B triggers a reset. The hub advances the pair to generation 2. B receives `pair`, rebuilds and sends its new offer at sequence 1. The hub accepts it and retains it for A.
+3. A resumes with `last[B]=20`. Its roster says generation 2, so A rebuilds. The hub nevertheless suppresses B’s retained offer because `1 <= 20`.
+
+Resetting A’s local receive counter when processing the roster cannot change the cursor already supplied to the server. If B is the impolite peer and still has its local offer outstanding, B ignores A’s colliding offer. A never receives the offer it must answer. The protocol has lost an accepted, retained message without necessarily emitting `gap`.
+
+The server also cannot infer whether `20` belongs to generation 1 or generation 2. Both are valid browser states under the declared wire format. The added browser gate expressly requires recovery when `pair` was missed and its generation is learned through the roster; it needs this missing distinction. [Recovery gate](/home/paulca/amadan.net/oficina/meet-spec/docs/superpowers/specs/2026-09-07-meet-design.md:1056).
+
+**Correction:** Carry `{gen, seq}` per sender in `last`, and scope replay, gap detection and receive deduplication to that generation. When the client’s generation is older, send the authoritative generation and replay its retained messages from the beginning, or explicitly initiate another coordinated reset if that history is incomplete. Alternatively, keep sequence numbers monotonic across pair resets. Add a slice-2 test with a nonzero old cursor, a missed `pair`, and a new-generation offer accepted before resume.
+
+**R5-2 — P1 — §§4.2–4.3: Taking over a waiting participation broadcasts its departure into the call.**
+
+The new takeover procedure unconditionally sends `gone` for the old peer to admitted peers. It does not branch on whether that peer was admitted, waiting or unjoined. [Takeover procedure](/home/paulca/amadan.net/oficina/meet-spec/docs/superpowers/specs/2026-09-07-meet-design.md:498).
+
+A waiting guest opens a second tab and chooses takeover. The guest’s old waiting peer now produces `gone` for everyone admitted, including admitted guests who are not entitled to see the waiting queue. This discloses a waiting peer’s existence and lets an unadmitted participant inject a call-presence event. Members also receive the wrong event for removing the old knock.
+
+This recreates R1-1’s waiting-departure failure through a new endpoint. Ordinary `leave` correctly sends `knock-gone` only to members; the roster likewise restricts the waiting queue to members. [Departure permissions](/home/paulca/amadan.net/oficina/meet-spec/docs/superpowers/specs/2026-09-07-meet-design.md:528), [queue visibility](/home/paulca/amadan.net/oficina/meet-spec/docs/superpowers/specs/2026-09-07-meet-design.md:538).
+
+**Correction:** Make takeover use the same state-dependent departure operation as leave and removal: admitted → `gone` to admitted peers; waiting → `knock-gone` to admitted members; unjoined → no room announcement. Test all three states, with an admitted guest observing the room.
+
+**R5-3 — P1 — §§4.2, 4.5: Takeover is a destructive GET that bypasses the reused CSRF protection.**
+
+`GET /m/{sqid}/stream?takeover=1` ends the session’s existing participation. Saying the parameter is sent only by the button does not enforce that condition at the server. [Mutation and button assumption](/home/paulca/amadan.net/oficina/meet-spec/docs/superpowers/specs/2026-09-07-meet-design.md:498).
+
+The scaffold mounts rastrillo’s `csrf.Protect`, which expressly passes GET requests without checking their origin. The inherited session cookie uses `SameSite=Lax`. [Scaffold middleware](/home/paulca/amadan.net/oficina/meet-spec/internal/meet/app.go:55), [GET exemption](/home/paulca/go/pkg/mod/amadan.net/rastrillo/rastrillo@v0.26.0/csrf/csrf.go:47), [cookie configuration](/home/paulca/go/pkg/mod/amadan.net/rastrillo/rastrillo@v0.26.0/sessions/sessions.go:119).
+
+An attacker who knows the meeting sqid can send a signed-in member through a cross-site, top-level navigation to that URL. The browser supplies the session cookie, standing succeeds, and the GET ends the member’s call in another tab. The attacker needs neither the session token nor a successful `join`. The unauthorized disconnection happens when the stream opens.
+
+**Correction:** Move takeover to a CSRF-protected POST, with the repository-required confirmation page using `ui/confirm-form`. Make the ownership change atomic and return the new peer for subsequent stream attachment. A GET must not end an existing participation. Add a slice-2 browser test proving that cross-site navigation cannot trigger takeover.
+
+The remaining checked source claims introduce no new blocking defect. The relay list and credential shape match relay §2 at `dcf0cad`; the three-hour credential and two-and-a-half-hour renewal schedule remain within its hard deadline. The browser APIs support replacing ICE configuration followed by restart, and candidate statistics expose the generation field used by §5.3. [W3C configuration semantics](https://www.w3.org/TR/webrtc/#set-the-configuration), [candidate statistics](https://www.w3.org/TR/webrtc-stats/#dom-rtcicecandidatestats-usernamefragment).
+
+The scriptless floor remains buildable. No new dependency on a later slice was found; all three corrections belong to slice 2 and its gates.
+
+Verification limits remain for `rastrillo/rastrillo` discussion 17, `oficina/suite` discussion 5 and the claimed live relay deployment. The supplied discussion dumps do not establish those claims.
+
+**Verdict: request changes.**
\ No newline at end of file