oficina / meet Public

docs: slice 3 record and spec notes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
Paul Campbell pushed by paul@keymail.dev b9b852ec3aa0c2aad0e491076abc3e1da88c38f3 parent 67f2292
2 files changed, +292 −0
  • docs/superpowers/reviews/2026-09-10-slice-3-record.md +196 −0
  • docs/superpowers/specs/2026-09-07-meet-design.md +96 −0

CI passed — run details

diff --git a/docs/superpowers/reviews/2026-09-10-slice-3-record.md b/docs/superpowers/reviews/2026-09-10-slice-3-record.md
new file mode 100644
index 0000000..a0f7695
--- /dev/null
+++ b/docs/superpowers/reviews/2026-09-10-slice-3-record.md
@@ -0,0 +1,196 @@
+# Meet slice 3 — the record
+
+Branch `slice/3-relay`, `0690a7a..67f2292` (started on main `2215f26`).
+Spec: `docs/superpowers/specs/2026-09-07-meet-design.md`, revision 9
+(§15.13 and §15.14 carry this slice's two wire/behaviour rulings).
+
+Slice 3 built the platform relay: `CARLOS_RELAY` parsing and minting
+against the platform's own vector, `GET /m/{sqid}/ice`, and the
+browser client's renewal before the credential's deadline — six tasks,
+each with its own review and fix rounds, kept in
+`.superpowers/sdd/2026-09-10-meet-slice-3-relay/progress.md` and the
+`task-N-report.md` files. Like slice 2's, that ledger is gitignored and
+does not survive the worktree; this file is its substance.
+
+---
+
+## 1. Rulings and decisions
+
+From the ledger's pre-flight scan and the tasks that followed it:
+
+- **`/ice` carries the peer as `?peer=`**, 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.
+ Cost if wrong: none observable. Written into spec §15.13.
+- **A member's `/ice` re-derives standing and, when it is gone, ends
+ the participation** — mutating, exactly like `/signal`'s own
+ §15.12 correction — while a guest never consults standing at all
+ (ruling 14). Cost if wrong: one extra standing read per `/ice` call,
+ which is rare — once per two and a half hours per call, on the
+ client's own schedule. Written into spec §15.13.
+- **The client's fetch-failure retry stays at one minute.** The gate
+ proves the warning appears and the call stays connected on a failed
+ fetch; it does not prove the one-minute recovery, which would cost
+ the gate roughly seventy seconds per scenario for no more assurance.
+- **The relay is enabled on the app after the edge deploy**, verified
+ by `carlos relay status` and the instance's own startup line; no test
+ in this branch touches a real relay (§13, and §4 below).
+- **The renewal schedule is the earlier of a ratio estimate and a
+ clock estimate**, never the server's `renewAt` read as a bare
+ instant: `(expiresAt − renewAt) × 5` survives clock skew because it
+ is built from two of the server's own instants, while
+ `renewAt − fetchedAt` catches the truncation the ratio reading can
+ mishandle at short lives. Found during Task 4's fix round 2, after
+ round 1's schedule amplified the server's own second-truncation at
+ short test lives; written into spec §15.14.
+- **`memberStandingGone` returns `(bool, error)`**, not the brief's bare
+ `error` — `hub.Authorize` mirrors `Relay`'s own
+ `gone, err := …; if err != nil { return err }; if gone { return
+ ErrNotYours }` shape. An implementation decision the spec has no
+ opinion about (Task 3).
+- **The development header's TTL test uses 12s/10s, not the brief's 2s
+ example** — at two seconds, `life*5/6`'s fractional remainder can
+ round to the same whole second as `life` once both are truncated by
+ `.Unix()`, intermittently making `renewAt == expiresAt`. Any TTL that
+ is a multiple of six seconds keeps the margin clear of that rounding
+ (Task 3; the same fact governs the browser gate's own TTL choice,
+ §4 below).
+- **`icerenewal` uses an 18-second TTL, not the brief's 12s** — twelve
+ leaves two seconds between the renewal starting and the deadline that
+ prints the expired sentence, which this scenario asserts stays away;
+ eighteen leaves three, and the run measures the margin directly so a
+ change to either end is caught (Task 5). `icefail` keeps 12s, where
+ the deadline arriving *is* the point.
+- **Recovery from a failed `/ice` fetch is left unproved in the browser
+ gate**, exactly as the ledger's own ruling allows: `ICE_RETRY` is a
+ fixed minute by design, and watching it clear costs roughly seventy
+ seconds for no more assurance than the warning appearing already
+ gives.
+
+The rest of each task's own decisions — the suffix validation regexes,
+the JSON golden-shape pins, `atomic.Value`'s nil-but-typed slice, the
+`--host-resolver-rules` Chromium flag that keeps the platform's relay
+hostname off the network entirely — are in the task reports themselves
+and are not repeated here; §2 gives each task's disposition, not its
+full diff.
+
+---
+
+## 2. Per-task review dispositions
+
+| Task | Built | Review | Fix rounds | Final |
+|---|---|---|---|---|
+| 1 — `internal/meet/relay` | `Parse`, `Region`, `Mint`, `Credential`, `ICEServer`; the platform vector test | Needs fixes: HIGH — `Parse` echoed the raw region field in its errors; MEDIUM — no redacting `String()` on `Region`; MEDIUM — the no-echo test didn't prove its own name; six LOW items (`[]`/`null` unification, an empty URL reaching `RTCIceServer`, an undocumented expiry precondition, unpinned no-mutation/JSON-shape/suffix-regex/base64-alphabet/TURN-case-insensitivity behaviour) | Round 1 `991352e` (combined with Task 2): all ten fixed. Re-review: three test-only pins remained, folded into Task 3's fix round. | Complete; pins verified in Task 3's fix round (`b5c29b3`, items 9–12). |
+| 2 — `Config.Relay`, `resolve`, the atomic floor read | `app.relay atomic.Value`, `relayRegions()`/`relayConfigured()`, `TestHandle.SetRelay`, the room-page `Floor` value wired to it | Needs fixes (production code judged right): the Task-1-inherited echo at `config.go:146/148`; a test that printed the key on failure; the with-relay build path and its one INFO line untested; the floor sentence's "relay configured ⇒ absent" direction untested (a hardcoded `Floor` was passing `make ci`); the refusal test satisfied by `relay.Parse`'s text rather than `config.go`'s own phrase; a false doc comment on the since-deleted `RelayConfigured` field; `SetRelay` unproven before Task 3 became its first caller; malformed cases exercising only the outer error | Round 1 `62853ed` (combined with Task 1): all eight fixed, including deleting the dead field once `grep` confirmed nothing else read it. | Complete. |
+| 3 — `GET /m/{sqid}/ice`, `hub.Authorize` | The wire shape, the §2.5 authority check, the development TTL header | Needs fixes: MEDIUM — `/ice` read the store before authority and was unlimited (no `LiveForSession` gate, no limiter); MEDIUM — the logging test passed even with a credential logged on success; MEDIUM — the dev header had no ceiling; several test gaps (ended-meeting refusal, `validPeerID`/`validSqid` load-bearing against a newline, byte-equality against a real `/signal` 404, a malformed sqid); five LOW items (the 500 arm's `path`, an overstated doc comment, log level, an unlogged meeting refusal, a duplicated test helper, an unpinned JSON entry); the guest half of the standing test not discriminating | Round 1 `b5c29b3`: all fixed except four items carried forward — see §3. Re-review: clean. | Complete. Carried: the sqid guard on `/ice` (unproved); the shared-bucket pin; the log-test comment; the family-wide `path` nit. `N4` (`Retry-After` on `/ice` 429) was folded into Task 4's fix round instead of carried. |
+| 4 — `room.js`, the ICE list and renewal | `fetchIce`, `scheduleRenewal`, `renewAll`/`renewOne`, `proveRenewed`, the must-succeed-by watchdog, the `data-relay` floor sentence | Needs fixes: HIGH — the watchdog's exemption compared against the wrong stamp and was unreachable; HIGH — a stale selected pair could answer for a fresh one in Chromium; MEDIUM — manual Retry erased the note and did nothing on a failed fetch; MEDIUM — the warning showed even on the floor, with no relay to fail; MEDIUM — the schedule trusted the client's clock, not a duration; six LOW items | Round 1 `9fed86c`: nine of eleven items fixed outright, two (the 429 loop, the schedule) added or partly addressed. Re-review after round 1: two half-closed — MEDIUM A, the ratio/clock amplification of the server's second-truncation at short lives (a 2s life could silently never renew; a 10s life landed the deadline 2s late); LOW B, `failedSince` stamped once per outage rather than per attempt, letting a visibility catch-up jump the retry queue — plus three nits. | Round 2 `67f2292`: A and B fixed (earlier-of-ratio-and-clock scheduling per spec §15.14; `lastTryAt` stamped every attempt); the nits fixed; and, not asked for but needed, the stale-roster-after-await defect Task 5 traced (see below) and an `adoptServers` guard against restarting a negotiation already in flight. Still owed: a Go tripwire pinning `iceRenewAfter == iceLife*5/6` against the client's own ratio arithmetic drifting from it silently, and the residual gate flake's diagnostics — see §3. |
+| 5 — the browser gate: `ice`, `icerenewal`, `icefail` | The three scenarios, `state().ice`/`state().renewals`, the credential-never-in-console check, the Chromium flag keeping the relay hostname off the network | No separate review pass: the task's own result was 23 passed, 3 skipped, with the skip's cause fully diagnosed and reproduced rather than papered over — `applyRoster` reconciled the roster it captured *before* awaiting `/ice`, so a `peer` event landing during that fetch was closed by the stale roster and its §4.6 counters dropped, discarding the answer that followed as an already-seen replay. The same defect, reached through a different door, explained slice 2's `staletarget` flake. | Fixed as part of Task 4's fix round 2 (`67f2292`), which also fixed a second door onto the same symptom (a superseded roster applied at all) and reordered `accept`'s generation check ahead of its cursor check. | Complete; the three scenarios un-skipped, `make browser` 26/26. |
+| 6 — this record and spec §15.13/§15.14 | Documentation only | — | — | This commit. |
+
+---
+
+## 3. Carried items, and where they go
+
+- **The sqid guard on `/ice` is unproved.** Task 3's own peer-newline
+ test (`TestIceNeverLogsAMalformedPeerVerbatim`) has no sqid
+ equivalent: nothing proves an oversized or newline-bearing sqid never
+ reaches the limiter key or the log the way an oversized peer is
+ proven not to. **Destination: the next fix wave**, alongside whatever
+ review of the whole branch runs next — mirror the peer test with a
+ sqid-shaped input.
+- **`/signal` and `/ice` sharing one rate-limiter bucket is
+ implemented but not pinned.** `TestIceRateLimitsOneSessionsBurst`
+ proves `/ice` alone is rate-limited; nothing spends the budget on one
+ endpoint and shows the other refused because of it. **Destination:
+ the next fix wave** — a test that exhausts the bucket through
+ `/signal` and then gets 429 from `/ice` in the same session and room,
+ or the reverse.
+- **The log-test comment says authority where the arm is unbound.**
+ `ice.go`'s refusal logging distinguishes an `outcome=unbound` refusal
+ (no live participation at all, checked first, before the store or the
+ limiter) from an `outcome=authority` refusal (`hub.Authorize`'s own
+ four sentinel errors, checked last); a comment near the log-content
+ test names the wrong one. **Destination: the next fix wave** — a
+ one-line comment fix, found while writing this record and not worth
+ a separate commit on its own.
+- **The family-wide `path` nit.** Every no-session-subject 500 across
+ the handler family (`auth.go`, `stream.go`, `signal.go`, `ice.go`,
+ `handlers_room.go`'s four arms) logs `r.URL.Path` on an arm that
+ should be unreachable if the framework's own session middleware is
+ doing its job — an unbounded, attacker-shaped value on a path that
+ exists only for a bug elsewhere. Not `/ice`-specific; carried under
+ its own name because fixing it in `/ice` alone would leave six other
+ call sites inconsistent. **Destination: a family-wide pass**, not
+ this slice's fix wave — touching all seven call sites at once is a
+ different-shaped commit from anything slice 3 built.
+- **The Go tripwire `iceRenewAfter == iceLife*5/6`.** Task 4's fix
+ round 2 moved the client's own schedule to the earlier of a ratio and
+ a clock estimate (spec §15.14) specifically so the client's
+ arithmetic stays correct even if the server's numbers drift — but
+ nothing on the Go side pins that `ice.go`'s `iceRenewAfter` constant
+ is still five sixths of `iceLife`, which is the assumption the
+ ratio's own "renewal at five sixths of the life" shape depends on.
+ Named as owed in Task 4's own fix-round-2 report. **Destination: the
+ next fix wave**, in `internal/meet/ice_test.go` — a one-line
+ assertion, not a behaviour change.
+- **The residual gate flake, `connectionState: "new"`.** Roughly one
+ full `make browser` run in three loses one scenario — a different one
+ each time — always at the first pairing, always with the same
+ signature on both browsers: descriptions exchanged and `stable`, but
+ `connectionState: "new"` — no ICE transport ever left the new state,
+ meaning gathering and checking never started. This is not the
+ stale-roster defect (whose signature was `peers: []`); it matches the
+ Makefile's own documented environment symptom, hitting one scenario
+ at a time rather than every scenario in the run. Three things were
+ tried against it in Task 4's fix round 2 and none moved it: the
+ `accept`-ordering fix (kept regardless — it closes a real hole), a
+ negotiation nudge for a lost `negotiationneeded` event (reverted — no
+ evidence it was the cause), and re-running affected scenarios in
+ isolation, where all of them pass every time. **Destination: the next
+ diagnostics pass**, in order of cost — run the gate on CI hardware and
+ see whether the rate changes; add a check to `call.mjs` that reports
+ `getStats`'s own candidate counts when a pair fails to connect, to
+ settle "gathered nothing" against "gathered and could not pair" in
+ one run; only then look at the client again.
+
+---
+
+## 4. Deploying this slice
+
+Enabling the relay is not free the way slice 2's ordinary deploys are.
+**`carlos relay enable --app meet` re-renders the app's config and
+restarts the instance**, which drops every live call exactly as an
+ordinary deploy does (spec §15.11 — the hub is per process and dies
+with it). So: **enable the relay in the same deploy window as the edge
+deploy**, not as a separate step days later that catches a room full of
+people by surprise a second time.
+
+Verification is two commands, not one: `carlos relay status --app
+meet` confirms the platform's own side, and the instance's own startup
+line — `meet: relay configured regions=N` — confirms the app actually
+parsed what the platform handed it, at the region count expected. Both
+matter; the platform reporting a relay attached and the app logging
+zero regions would be the config having reached the process malformed,
+caught only outside development (§15.13's startup refusal) or silently
+accepted as no relay if the value were simply empty.
+
+**No test on this branch touches a real relay.** Every gate — `relay`'s
+own vector test, the config tests, `/ice`'s tests, and the three
+browser scenarios — proves the contract against fixtures and a fake
+`/ice`. The platform relay itself is verified only by the two commands
+above, after the deploy that enables it.
+
+---
+
+## 5. The gates, and how to run them
+
+| Gate | Command | Notes |
+|---|---|---|
+| The one gate | `CGO_ENABLED=0 make ci` | vet, gofmt, tests, migration check — unchanged from slice 2. |
+| Race | `CGO_ENABLED=1 go test -race ./internal/...` | Run repeatedly through this slice's fix rounds; green throughout. |
+| Browser | `make browser` | 26 scenarios, three of them (`ice`, `icerenewal`, `icefail`) new this slice. **Needs the sandbox off** — inside the Claude Code bash sandbox Chromium gathers zero ICE candidates and every call fails, as slice 2's record already noted. **A development TTL header must be a multiple of six seconds** — `X-Meet-Test-Ice-TTL`'s life is split `renewAt = life*5/6`, `.Unix()`-truncated on both ends, and only a multiple of six keeps the margin clear of that truncation (a twelve-second life reads exactly 10 and 12; a ten-second life reads 8 and 10, where the same arithmetic renews at the very moment the credential dies). **The run takes about 5.5 minutes** — 317.95 s measured over ten full runs in Task 4's fix round 2, five of the ten and the last two clean at that mark, against 287 s in Task 5's own run with the three ICE scenarios skipped. |
+
+`make browser` is still deliberately outside `make ci`, for the reason
+slice 2's record already gives. The residual flake in §3 above is why a
+red run here is worth a second run before it is worth debugging, exactly
+as slice 2's `rosterpair` flake was.
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md
index 022e7c8..9e2ff51 100644
--- a/docs/superpowers/specs/2026-09-07-meet-design.md
+++ b/docs/superpowers/specs/2026-09-07-meet-design.md
@@ -1486,3 +1486,99 @@ 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.