oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

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 == iceLife5/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.