# 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/calls/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. **Done** in fix
  round 3: `TestIceRenewAtIsFiveSixthsOfTheLife` in
  `internal/callstest/ice_test.go` (not `internal/calls` — the constants
  are unexported, so the tripwire reads the ratio off the wire, which
  is the form the client actually depends on). Verified against a
  deliberately moved constant.
- **A margin too small to schedule on. Decided, not carried.** A
  development-only `X-Meet-Test-Ice-TTL` under about six seconds leaves
  a renew-to-expiry margin below a second, and the client's own
  one-second floor under the renewal delay would then turn that list
  into a fetch roughly once a second for as long as the tab stayed open
  — a client that can be made to hammer its own server, even if only a
  developer can ask it to. The choice was between clamping the client
  and recording a gate rule that no scenario may use such a TTL; the
  Codex fix wave **clamped the client**, because a rule that lives only
  in a document is a rule the next scenario breaks. `schedulable()` is
  false below a one-second margin: nothing is timed, and
  `state().ice.schedulable` says so rather than the client looking idle
  for no reason. Node-tested (W3-12).
- **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.

  The reviewer's reading of that signature, which the fix-round-3
  diagnostic was written to settle: `stable` with `connectionState:
  "new"` does not mean "negotiated and could not gather" — it is also
  what a connection that *never negotiated at all* looks like, because
  a peer connection is born `stable` and `new`. The decisive value is
  `localDescription`: null says nothing was ever offered, so nothing
  was ever gathered, and the fault is upstream of ICE entirely. Fix
  round 3 prints it. `diagnose()` in `call.mjs` now carries a
  `pcState` array for every connection the page still holds —
  `signaling`, `connection`, `ice`, `gathering`, `local`, `remote`,
  `senders`, `transceivers` as `direction:kind`, and `tracks` as
  `kind:readyState` — collected through the construct trap that scenario
  already used for its own `getStats` walk, now installed on every
  context. It changes no assertion.

  **What the fix-round-3 diagnostic then showed** (whole-branch review
  W3-7, from a lost `playfallback` run and confirmed by a lost
  `staletarget` one): negotiation is *not* where it stops. Both sides
  report a local offer and a remote answer applied, `signalingState`
  stable, two `sendrecv` transceivers, and live tracks on the senders —
  and `iceConnectionState: "new"` all the same, with `iceGatheringState`
  `complete` on one side and still `gathering` on the other. So the
  fault is inside ICE, after a negotiation that finished properly, and
  the reading in the paragraph above — "nothing was ever offered" — is
  wrong for these runs. The Codex fix wave adds the next four numbers,
  per peer, to `state()` and to `diagnose()`: candidates gathered
  (`onicecandidate`), candidates posted (`send` of type `candidate`),
  candidates received, candidates added (`addIceCandidate`), and the
  pending buffer's length. Counts only — an address is not something
  the client publishes. Between them they say which of the four ways
  ICE can stall this is, in one losing run.

  Round 3 also closed the likeliest client-side cause the *earlier*
  reading admitted: `createPC` registered the connection before attaching its
  listeners (so a `negotiationneeded` fired by `addTrack` could be lost
  outright, leaving exactly a `stable`/`new` connection that never
  offers), and its recvonly fallback was all-or-nothing, so a browser
  that came down `startLocal`'s ladder to audio alone offered no video
  m-line and the peer's tile could never get a frame. Whether either was
  the flake is unproved; both were real, and W3-7's reading now says
  neither was it.

  **Settled, in the Codex fix wave itself.** The counts were read from
  three losing runs — `ice`, `icerenewal` and `staletarget`, on separate
  gate runs — and all three say the same thing:

  | side | gathered | delivered | received | added | pending | gatheringState |
  | --- | --- | --- | --- | --- | --- | --- |
  | A | 4 | 4 | 0 | 0 | 0 | complete |
  | B | 0 | 0 | 4 | 4 | 0 | **gathering** |

  (`delivered` was `posted` when these were captured; Codex round 2
  moved the count to where the hub accepts a candidate rather than
  where one is handed to a queue, and the readings are unchanged.)

  One browser gathers **no ICE candidate at all** and never leaves
  `gathering`. The other gathers, posts every one it gathers, and the
  first adds every one it receives. There is nothing for the client to
  do differently: it sends what the browser gives it and applies what
  arrives. The stall is inside one Chromium's own candidate gathering,
  which is the Makefile's documented environment symptom — "Chromium
  gathers zero ICE candidates and every call fails with frames stuck at
  0" — reaching one context at a time instead of all of them.

  It correlates with load: every losing run of this wave took 560-590 s
  against a healthy 318-420 s, one hit `go test`'s own ten-minute
  timeout, and the wave ran while a whole-branch reviewer was working
  the same tree. **No further client change is owed.** What is owed, if
  anyone wants it gone: run the gate where nothing else is running, and
  give `assertPair` a failure message that names `gathered: 0` for what
  it is, so the next person spends a minute on it rather than a
  fix round.

---

## 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.

## Codex round 1

`codex exec` read the branch at `6b316cc` and asked for changes: no P1,
five P2 and two P3. The whole-branch reviewer read the same tree
concurrently and numbered its own findings W3-1..W3-12, of which
W3-1..W3-6 are the same defects. One commit answered all of them.

| Finding | What it was | Disposition | Test that would have caught it |
| --- | --- | --- | --- |
| S3-1 / W3-1 (P2) | `expireUnrenewed` exempted any connection with `renewed=true`, and nothing ever cleared that flag: one successful renewal exempted a connection from every later deadline, so a second credential could expire in silence | Fixed. The watchdog reads `entry.listAt` — the stamp of the list a connection is actually on, which moves only on proof — and exempts only a connection on a list newer than the expiring one | `a renewal in an earlier cycle does not exempt a connection from a later one` |
| S3-2 / W3-2 (P2) | `retryRenewal` fetched for the whole call and renewed only its own tile, while resetting the shared timers: every other connection stayed on a credential about to die | Fixed. `renewOutdated(expiring)` hands a list that has just arrived to every connection still on an older one, and both the scheduled renewal and a tile's Retry go through it | `a Retry on one tile renews every connection the fetch re-credentialed` |
| S3-3 / W3-3 (P2) | With no baseline fragment — including after a rejected `getStats` — any succeeded selected pair counted as proof, and `''` passed a `!== null` check; a connection could be stamped onto a credential it had never gathered on | Fixed. No baseline is no proof, and `''` is not evidence. The poller takes its own baseline from the first reading after the restart and then requires a different one; if none can be read the restart stays unproven | `nothing recorded before the restart is not proof of anything`, `a connection that never moves is unproven, whatever it started with` |
| S3-4 / W3-4 (P2) | A successful fetch followed by one failed restart near the old deadline reported "expired" after one attempt of three, with a replacement credential in hand | Fixed. `renewOne` no longer takes a deadline: with a fresh credential it spends all three restarts and its verdict is always `stuck`. `expired` belongs to the watchdog, the only place that knows the other case | `a fresh credential buys three restarts and the verdict is stuck, never expired`, and the `icestuck` scenario |
| S3-5 / W3-5, W3-6 (P2) | The gate asserted the client's cached list length, not that any connection received it: `ice` would pass with `iceServers: []` in every constructor, and `icerenewal` with `setConfiguration` deleted | Fixed. The connection trap records every configuration installed — count, credentialed count, and an FNV-1a digest, never a credential — and the two scenarios assert that a real connection got the two-entry list and that the renewal installed a *different* one. The §13 successful-fetch/failed-restart ending gets its own scenario, `icestuck` | `ice`, `icerenewal`, `icestuck` |
| S3-6 (P3) | The startup-logging test matched substrings, so duplicate lines, a WARN, or an extra decoded attribute would all have passed | Fixed. A recording `slog.Handler` keeps whole records; the test asserts exactly one INFO record with exactly one attribute, `regions=1` | `TestBuildLogsOneINFOLineWithTheRegionCountAndNotTheKey`, verified against a WARN and against an extra attribute |
| S3-7 (P3) | The burst test exercised `/ice` alone, so a limiter of its own would have passed it; the malformed-room case passed with `validSqid` removed, because the liveness gate refuses that room a step later | Fixed. One test exhausts the budget entirely through `POST /signal` and then asks `/ice`; another sends an oversized room id and asserts the log names it `invalid` and that no bucket was minted | `TestIceSpendsTheSignalEndpointsOwnBucket`, `TestIceNeverLogsAMalformedRoomOrMintsABucketForOne`, both verified against the guards removed |
| W3-7 | The residual flake's `pcState` showed negotiation complete and ICE at `new`, so the fault is in ICE | Instrumented, then **settled** — see the flake entry above. Per-peer candidate counts (gathered, posted, received, added, pending) in `state()` and `diagnose()`; three losing runs then showed one browser gathering zero candidates | no assertion changed |
| W3-12 | A sub-six-second development TTL leaves a margin under a second, which the delay floor would turn into a 1 Hz fetch loop | Fixed, not carried: `schedulable()` declines below a one-second margin and `state().ice.schedulable` says so | `a margin under a second is not a schedule` |

Commit: `6b316cc..c3e2318`.

## Codex round 2

`codex exec` read the branch again at `d62e93b`. S3-3, S3-4 and S3-7
closed; the other four were half-closed, each for a reason that had
survived under the fix rather than because the fix was wrong. Five new
findings, one commit.

| Finding | What it was | Disposition | Test |
| --- | --- | --- | --- |
| S3-8 (P2) | S3-1's other half: the watchdog compared generations correctly and then called `markTrouble`, which returned early on `record.renewed`. A connection that renewed once was never told its *current* credential had expired — and the round-1 test passed a spy for the marking function, so it proved nothing about the path that swallowed it | Fixed. `markTrouble(id, kind, upTo)` compares `entry.listAt` with the list the verdict is about; the boolean is gone from the decision entirely. The watchdog test now runs the real marking functions and reads the sentence `tileNote` would render | `a renewal in an earlier cycle does not exempt a connection from a later one`, `a connection already proven onto a newer list is left alone by the deadline` |
| S3-9 (P2) | S3-2's other half: a restart installs list B, another tile's Retry fetches C, `renewOutdated` skips the running restart — and B's proof then stamped the connection with C, a credential it had never been given, whose schedule then governed it | Fixed. Each attempt captures the list it installs and stamps only that generation on proof; when a newer list arrived during the attempt, `followUp` renews onto it once the attempt settles. The follow-up is bounded by the list that run had to work with, so a connection that simply cannot renew does not restart itself for ever | `a proof stamps the list it installed, not one that arrived while it was proving`, `a Retry reaches a connection whose own restart was already running` |
| S3-10 (P3) | The trap recorded `setConfiguration` before calling it, so a throw counted as an installation, and the counts were global, so one connection's second update could stand in for another left on the empty list | Fixed. Recorded after the real call returns, keyed by the connection's index; the assertions fold the log to each *live* connection's last configuration | `ice`, `icerenewal` |
| S3-11 (P3) | The recording handler put attributes in a map (collapsing duplicate keys) and gave every derived handler its own store, so a logger reached through `WithAttrs` wrote where nobody read and its bound attributes were invisible | Fixed. One shared store, bound attributes and groups replayed onto each record, and the assertion reads an ordered slice with a typed value | `TestBuildLogsOneINFOLineWithTheRegionCountAndNotTheKey`, plus `TestTheRecordingHandlerSeesWhatALoggerCarries` — a test of the test |
| S3-12 (P3) | `posted` counted candidates before the stale-target return and before the queue, so it counted ones that never reached the wire | Fixed. Renamed `delivered` and counted where the hub answers 200 | the flake captures above, unchanged in shape |

Two test claims were tightened rather than added: the three-restarts
test passes an old deadline that the current code ignores, so restoring
the removed optional-deadline branch fails it; and the Retry test gains
a connection whose own restart is already running, with the extra
assertion that it *restarted again* rather than merely acquiring the
new stamp — without which the S3-9 bug satisfied it.

One behaviour changed that no finding asked for, and it is worth
naming: `renewals[peer].attempts` accumulates across runs now instead of
starting again at 1 with each one. A follow-up renewal reset it to 1
within milliseconds of the first verdict, which made `icestuck`'s
"it spent the whole budget" assertion race, and a count that starts
again says nothing a gate can wait on.

Commit: `d62e93b..15b2f38`.

## Codex round 3

Read at `9bb0994`. S3-8, S3-10, S3-11 and S3-12 closed; S3-9 half-closed
through one remaining door, S3-13. One P3 on a test claim. One commit.

| Finding | What it was | Disposition | Test |
| --- | --- | --- | --- |
| S3-13 (P2) | The last door onto S3-9: `installedAt` was recorded before `setConfiguration`, and a throw from it was swallowed. The restart that followed re-gathered on the *previous* servers, and a new fragment then read as proof and stamped the connection onto a list it had never taken — `listAt` moved, `renewed` went true, no trouble was shown | Fixed. The stamp moves only after `setConfiguration` returns. A refused configuration restarts nothing, proves nothing and stamps nothing: it asks for a fresh generation, whose rebuilt connection takes `ice.servers` at construction and keeps §4.6's counters, and the run ends `stuck` | `a configuration the browser refuses is not a credential the connection has` |
| S3-14 (P3) | `icestuck` asserted `attempts >= 3` against a lifetime counter, which cannot say how many restarts *this* verdict followed — a later run raises it while the first verdict is still on the screen | Fixed. `renewals[peer].lastRun = {attempts, verdict}` is captured with the verdict itself; the scenario asserts that run spent three and ended `stuck`, and keeps the cumulative count as a second, weaker line | `a run records the budget it spent, alongside the lifetime count`, `a proven run says so, and says what it cost`, and `icestuck` |

`markRenewalStuck` is gone: every verdict now goes through one function
that records what the run spent and then says it on the tile, so the
two cannot drift.

Commit: `9bb0994..8448bee`.
