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 platform relay — Implementation Plan

> For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Meet reads the platform's CARLOS_RELAY contract, mints TURN credentials per call at GET /m/{sqid}/ice, and the browser renews them before their deadline with a proven ICE restart, so calls connect across networks through the platform relay and never through a third party.

Architecture: A pure package internal/calls/relay parses the contract and mints credentials (the platform's test vector is its oracle). config.go parses CARLOS_RELAY at startup and refuses a malformed value outside development. A new endpoint /m/{sqid}/ice hands an admitted participation the RTCIceServer list with a three-hour expiry; the hub authorizes it exactly as it authorizes a relay message (spec §2.5 last row, §15.12). room.js fetches the list before it builds a connection, renews per connection at the server-named time with an ICE restart proven by a new usernameFragment on the selected candidate pair, and shows the warning and the "relay expired" states. The Playwright gate proves the fetch, the renewal under a development-only shortened schedule, and the failure path against a fake /ice; nothing touches a real relay until the canary.

Tech Stack: Go 1.25 (rastrillo v0.26.0, chi, gorm), hand-written room.js, Playwright 1.62.1 on Chromium, codex exec for the independent review.

Spec: docs/superpowers/specs/2026-09-07-meet-design.md revision 9: §2.5 (the /ice row), §5.2 (ICE servers from the platform), §5.3 (renewal), §5.5 (the floor), §5.6, §12 item 3, §13 (the slice 3 gates), §15.12. Platform contract: carlos/platform docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md §2 (revision 6, frozen).

Global Constraints

  • CGO_ENABLED=0 throughout; make ci (vet, gofmt, tests, migration check) is the gate on every task; make race (CGO_ENABLED=1) and make browser (sandbox OFF: UDP is blocked inside the Claude Code sandbox) on the tasks that name them.
  • Every query touching user-owned rows goes through scope.Owned; a row that isn't yours 404s, never 403s. No schema change in this slice.
  • The decoded relay configuration is held in memory and never logged; a malformed CARLOS_RELAY is a startup refusal outside -env development, logged as one line naming the variable and not its value (§5.2).
  • No address, name, title, token, session key, payload, key or credential in any log line (§4.4). /ice logs nothing on success.
  • A 404 is never a 403. The /ice 404 body is byte-identical to /signal's not-found body ({"boot":…,"error":"not-found"}).
  • Wire contracts are client-neutral (ruling 15): /ice is plain JSON a native client can consume.
  • Expiry is three hours ahead; the contract's maximum is four. renewAt is two and a half hours ahead (§5.3 step 4).
  • Copy review is waived this round per suite discussion 5; every new string is catalogued in docs/superpowers/copy/2026-09-10-slice-2-strings.md (append a slice 3 section).
  • Screens work with JavaScript disabled; nothing in this slice changes the scriptless floor.
  • Commits: git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit, files added by name, never .claude/, node_modules or package-lock.json; trailers Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> and Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2.

---

File structure

  • internal/calls/relay/relay.go — Parse, Region, ICEServer, Mint; pure, no logging, no clock of its own.
  • internal/calls/relay/relay_test.go — the platform vector, parse refusals, scheme handling.
  • internal/calls/config.go — Config.Relay string (raw), resolved.relay []relay.Region; startup refusal.
  • cmd/calls/main.go — passes Relay: os.Getenv("CARLOS_RELAY").
  • internal/calls/hub/authorize.go — Authorize(ctx, sqid, session, peer): the §2.5 last-row check with §15.12's member re-derivation, shared by /ice.
  • internal/calls/ice.go — GET /m/{sqid}/ice.
  • internal/callstest/ice_test.go — the endpoint over HTTP.
  • internal/calls/static/room.js — ICE list, renewal state machine, warnings, state().ice.
  • internal/calls/templates/room.html — data-relay on #room.
  • test/browser/call.mjs, internal/callstest/browser_test.go — scenarios ice, icerenewal, icefail.
  • docs/superpowers/copy/2026-09-10-slice-2-strings.md — slice 3 strings appended.

---

Task 1: The relay package: parse the contract, mint a credential, the vector

Files:

  • Create: internal/calls/relay/relay.go, internal/calls/relay/relay_test.go

Interfaces:

  • Produces: ``go package relay type Region struct { Region string; URLs []string; Key []byte /* 32 raw bytes */; UsernameSuffix string } type ICEServer struct { URLs []string json:"urls"; Username string json:"username,omitempty"; Credential string json:"credential,omitempty" } func Parse(raw string) ([]Region, error) // raw = $CARLOS_RELAY; "" → nil, nil func Mint(regions []Region, expiry time.Time) []ICEServer // one ICEServer per region per scheme class: stun URLs bare, turn/turns URLs with username and credential func Credential(key []byte, username string) string // base64std(HMAC-SHA256(key, username)) ``
  • The error from Parse never contains the input (it names the position and the field, not the value).
  • [ ] Step 1: Write the failing tests
package relay

import (
	"encoding/base64"
	"encoding/json"
	"strings"
	"testing"
	"time"
)

const vectorKey = "uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU="

func encode(t *testing.T, v any) string {
	t.Helper()
	b, err := json.Marshal(v)
	if err != nil {
		t.Fatal(err)
	}
	return base64.StdEncoding.EncodeToString(b)
}

func oneRegion() []map[string]any {
	return []map[string]any{{
		"region":          "eu-west-1",
		"urls":            []string{"stun:relay.eu-west-1.oncarlos.com:3478", "turn:relay.eu-west-1.oncarlos.com:3478?transport=udp", "turn:relay.eu-west-1.oncarlos.com:3478?transport=tcp"},
		"key":             vectorKey,
		"username_suffix": "abc:meet:0123456789abcdef:k1",
	}}
}

func TestThePlatformVectorMints(t *testing.T) {
	key, _ := base64.StdEncoding.DecodeString(vectorKey)
	got := Credential(key, "1900000000:abc:meet:0123456789abcdef:k1")
	if got != "jRU479Y3GO45nrRHqxBf3BMeBHE6Fip1cwAccNv28MQ=" {
		t.Fatalf("credential %q does not match the platform vector", got)
	}
}

func TestParseAcceptsTheContractAndDecodesTheKey(t *testing.T) {
	regions, err := Parse(encode(t, oneRegion()))
	if err != nil {
		t.Fatal(err)
	}
	if len(regions) != 1 || regions[0].Region != "eu-west-1" || len(regions[0].Key) != 32 || regions[0].UsernameSuffix != "abc:meet:0123456789abcdef:k1" || len(regions[0].URLs) != 3 {
		t.Fatalf("parsed %+v", regions)
	}
}

func TestParseOfNothingIsNoRelay(t *testing.T) {
	regions, err := Parse("")
	if err != nil || regions != nil {
		t.Fatalf("empty must mean no relay: %v %v", regions, err)
	}
}

func TestParseRefusesWithoutEchoingTheValue(t *testing.T) {
	cases := map[string]string{
		"not base64":          "%%%",
		"not json":            base64.StdEncoding.EncodeToString([]byte("nope")),
		"not an array":        base64.StdEncoding.EncodeToString([]byte(`{"region":"x"}`)),
		"short key":           encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": base64.StdEncoding.EncodeToString([]byte("short")), "username_suffix": "abc:meet:0123456789abcdef:k1"}}),
		"suffix wrong fields": encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:k1"}}),
		"suffix bad app":      encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:Meet!:0123456789abcdef:k1"}}),
		"no urls":             encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1"}}),
		"empty region":        encode(t, []map[string]any{{"region": "", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1"}}),
	}
	for name, raw := range cases {
		t.Run(name, func(t *testing.T) {
			_, err := Parse(raw)
			if err == nil {
				t.Fatal("accepted")
			}
			if strings.Contains(err.Error(), vectorKey) || strings.Contains(err.Error(), "0123456789abcdef") || strings.Contains(err.Error(), raw) {
				t.Fatalf("the error echoes the value: %v", err)
			}
		})
	}
}

func TestMintCarriesCredentialsOnTurnOnlyAndPassesURLsThrough(t *testing.T) {
	regions, err := Parse(encode(t, oneRegion()))
	if err != nil {
		t.Fatal(err)
	}
	expiry := time.Unix(1900000000, 0)
	servers := Mint(regions, expiry)
	if len(servers) != 2 {
		t.Fatalf("want a bare stun entry and a credentialed turn entry, got %+v", servers)
	}
	stun, turn := servers[0], servers[1]
	if len(stun.URLs) != 1 || stun.URLs[0] != "stun:relay.eu-west-1.oncarlos.com:3478" || stun.Username != "" || stun.Credential != "" {
		t.Fatalf("stun entry %+v", stun)
	}
	if len(turn.URLs) != 2 || turn.Username != "1900000000:abc:meet:0123456789abcdef:k1" || turn.Credential != "jRU479Y3GO45nrRHqxBf3BMeBHE6Fip1cwAccNv28MQ=" {
		t.Fatalf("turn entry %+v", turn)
	}
}

func TestMintKeepsConsoleOrderAndUnknownSchemesBare(t *testing.T) {
	two := append(oneRegion(), map[string]any{
		"region": "us-east-1", "urls": []string{"stuns:relay.us-east-1.oncarlos.com:5349", "turns:relay.us-east-1.oncarlos.com:5349", "future:relay.us-east-1.oncarlos.com:1"},
		"key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1",
	})
	regions, err := Parse(encode(t, two))
	if err != nil {
		t.Fatal(err)
	}
	servers := Mint(regions, time.Unix(1900000000, 0))
	// eu-west-1 first (stun, turn), then us-east-1 (bare: stuns+future, credentialed: turns).
	if len(servers) != 4 || servers[0].URLs[0] != "stun:relay.eu-west-1.oncarlos.com:3478" || servers[2].Username != "" || len(servers[2].URLs) != 2 || servers[3].Username == "" || servers[3].URLs[0] != "turns:relay.us-east-1.oncarlos.com:5349" {
		t.Fatalf("order or scheme handling wrong: %+v", servers)
	}
}

func TestMintOfNoRegionsIsAnEmptyListNotNil(t *testing.T) {
	if got := Mint(nil, time.Now()); got == nil || len(got) != 0 {
		t.Fatalf("want [] so the JSON is [] not null, got %#v", got)
	}
}
  • [ ] Step 2: Run to see them fail

Run: GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 go test ./internal/calls/relay/ Expected: build failure, no package.

  • [ ] Step 3: Implement
// Package relay reads the platform's CARLOS_RELAY contract and mints
// per-call TURN credentials from it (platform relay design §2, frozen
// at revision 6). It is pure: no logging, no clock, no network. The
// decoded keys live only in memory; nothing here ever formats one.
package relay

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"regexp"
	"strconv"
	"strings"
	"time"
)

// Region is one element of the contract: the relay hosts for one AWS
// region, the 32-byte key the platform issued for this app and
// incarnation, and the username suffix that names account, app,
// incarnation and key id.
type Region struct {
	Region         string
	URLs           []string
	Key            []byte
	UsernameSuffix string
}

// ICEServer is one RTCIceServer as the browser wants it. A stun entry
// carries no username; a turn entry carries the minted pair.
type ICEServer struct {
	URLs       []string `json:"urls"`
	Username   string   `json:"username,omitempty"`
	Credential string   `json:"credential,omitempty"`
}

type wire struct {
	Region         string   `json:"region"`
	URLs           []string `json:"urls"`
	Key            string   `json:"key"`
	UsernameSuffix string   `json:"username_suffix"`
}

var (
	accountRe     = regexp.MustCompile(`^[a-z0-9]+$`)
	appRe         = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
	incarnationRe = regexp.MustCompile(`^[0-9a-f]{16}$`)
	kidRe         = regexp.MustCompile(`^[a-z0-9]{1,8}$`)
)

// Parse decodes $CARLOS_RELAY. An empty value is no relay (nil, nil).
// Errors name the element index and the field, never the value, so a
// startup log line built from one cannot leak a key or a suffix.
func Parse(raw string) ([]Region, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return nil, nil
	}
	b, err := base64.StdEncoding.DecodeString(raw)
	if err != nil {
		return nil, errors.New("relay: CARLOS_RELAY is not standard base64")
	}
	var elems []wire
	if err := json.Unmarshal(b, &elems); err != nil {
		return nil, errors.New("relay: CARLOS_RELAY does not decode to a JSON array of regions")
	}
	out := make([]Region, 0, len(elems))
	for i, e := range elems {
		if e.Region == "" {
			return nil, fmt.Errorf("relay: element %d has no region", i)
		}
		if len(e.URLs) == 0 {
			return nil, fmt.Errorf("relay: element %d (%s) has no urls", i, e.Region)
		}
		key, err := base64.StdEncoding.DecodeString(e.Key)
		if err != nil || len(key) != 32 {
			return nil, fmt.Errorf("relay: element %d (%s) key is not 32 base64 bytes", i, e.Region)
		}
		fields := strings.Split(e.UsernameSuffix, ":")
		if len(fields) != 4 || !accountRe.MatchString(fields[0]) || !appRe.MatchString(fields[1]) || !incarnationRe.MatchString(fields[2]) || !kidRe.MatchString(fields[3]) {
			return nil, fmt.Errorf("relay: element %d (%s) username_suffix is not account:app:incarnation:kid", i, e.Region)
		}
		out = append(out, Region{Region: e.Region, URLs: append([]string(nil), e.URLs...), Key: key, UsernameSuffix: e.UsernameSuffix})
	}
	return out, nil
}

// Credential is base64std(HMAC-SHA256(key, username)), the platform's
// TURN REST scheme.
func Credential(key []byte, username string) string {
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(username))
	return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}

// Mint builds the RTCIceServer list for one call. Per region, in the
// contract's order: one bare entry for every URL whose scheme is not
// turn or turns (stun, stuns, or a scheme the contract adds later),
// then one credentialed entry for the turn and turns URLs. Either may
// be absent. Expiry is the caller's; §5.2 uses three hours ahead.
func Mint(regions []Region, expiry time.Time) []ICEServer {
	out := []ICEServer{}
	username := strconv.FormatInt(expiry.Unix(), 10)
	for _, r := range regions {
		var bare, turn []string
		for _, u := range r.URLs {
			switch scheme(u) {
			case "turn", "turns":
				turn = append(turn, u)
			default:
				bare = append(bare, u)
			}
		}
		if len(bare) > 0 {
			out = append(out, ICEServer{URLs: bare})
		}
		if len(turn) > 0 {
			name := username + ":" + r.UsernameSuffix
			out = append(out, ICEServer{URLs: turn, Username: name, Credential: Credential(r.Key, name)})
		}
	}
	return out
}

func scheme(u string) string {
	i := strings.IndexByte(u, ':')
	if i < 0 {
		return ""
	}
	return strings.ToLower(u[:i])
}
  • [ ] Step 4: Run to see them pass

Run: GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 go test ./internal/calls/relay/ -v Expected: all PASS, the vector test first.

  • [ ] Step 5: Gate and commit

Run: GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 make ci

git add internal/calls/relay
git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "relay: parse CARLOS_RELAY and mint TURN credentials (platform vector)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"

---

Task 2: Startup: parse the relay, refuse a bad one outside development

Files:

  • Modify: internal/calls/config.go (the Config struct, resolved, resolve), cmd/calls/main.go:60, internal/calls/app.go (wherever RelayConfigured is read for the Floor value; keep the name, derive it)
  • Test: internal/calls/config_test.go (exists; add cases)

Interfaces:

  • Consumes: relay.Parse.
  • Produces: Config.Relay string (raw $CARLOS_RELAY; RelayConfigured stays as a field for tests but resolve overwrites it from Relay); resolved.relay []relay.Region; func (a *app) relayRegions() []relay.Region for Task 3; TestHandle.SetRelay(raw string) error for Task 3's and Task 5's tests (re-parses and swaps the regions under an atomic).
  • [ ] Step 1: Write the failing tests (append to internal/calls/config_test.go; follow the file's existing resolve test shape for a minimal Config in development with a logged mailer)
func TestAMalformedRelayRefusesToStartOutsideDevelopment(t *testing.T) {
	cfg := minimalConfig(t) // the file's helper; if absent, build one: Env production, Origin https://meet.example, InstanceKey/DEK 32-byte base64, Mailer a recording sender, AllowedAddresses one address
	cfg.Env = "production"
	cfg.Relay = "%%%not-base64"
	_, err := resolve(cfg)
	if err == nil || !strings.Contains(err.Error(), "CARLOS_RELAY") {
		t.Fatalf("want a refusal naming the variable, got %v", err)
	}
	if strings.Contains(err.Error(), "%%%") {
		t.Fatal("the refusal echoes the value")
	}
}

func TestAMalformedRelayIsIgnoredWithAWarningInDevelopment(t *testing.T) {
	var buf bytes.Buffer
	cfg := minimalConfig(t)
	cfg.Env = EnvDevelopment
	cfg.Logger = slog.New(slog.NewTextHandler(&buf, nil))
	cfg.Relay = "%%%not-base64"
	out, err := resolve(cfg)
	if err != nil {
		t.Fatal(err)
	}
	if out.relay != nil || out.relayConfigured {
		t.Fatal("a malformed relay must mean no relay in development")
	}
	if !strings.Contains(buf.String(), "CARLOS_RELAY") || strings.Contains(buf.String(), "%%%") {
		t.Fatalf("want one warning naming the variable and not the value, got %q", buf.String())
	}
}

func TestAGoodRelayIsParsedAndNeverLogged(t *testing.T) {
	var buf bytes.Buffer
	cfg := minimalConfig(t)
	cfg.Logger = slog.New(slog.NewTextHandler(&buf, nil))
	cfg.Relay = relayVector(t) // base64 of the one-region contract with the vector key; put this helper in the test file
	out, err := resolve(cfg)
	if err != nil {
		t.Fatal(err)
	}
	if len(out.relay) != 1 || !out.relayConfigured {
		t.Fatalf("parsed %+v", out.relay)
	}
	if strings.Contains(buf.String(), "uMhwW4P") || strings.Contains(buf.String(), "0123456789abcdef") {
		t.Fatal("the key or the suffix reached a log line")
	}
}

func TestNoRelayIsTheFloor(t *testing.T) {
	cfg := minimalConfig(t)
	cfg.Relay = ""
	out, err := resolve(cfg)
	if err != nil {
		t.Fatal(err)
	}
	if out.relay != nil || out.relayConfigured {
		t.Fatal("absent must mean the floor")
	}
}
  • [ ] Step 2: Run to see them fail

Run: GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 go test ./internal/calls/ -run Relay Expected: compile errors (cfg.Relay, out.relay, out.relayConfigured undefined).

  • [ ] Step 3: Implement

In internal/calls/config.go:

	// Relay is the raw $CARLOS_RELAY: the platform's base64-wrapped JSON
	// list of regions, keys and username suffixes (platform relay design
	// §2). Absent means the floor (§5.5). It is parsed once here and the
	// decoded value is held in memory and never logged (§5.2).
	Relay string
	// RelayConfigured is derived from Relay by resolve; callers read the
	// resolved value, not this field. Kept so existing tests that set it
	// still compile; resolve overwrites it.
	RelayConfigured bool

In resolved:

	relay           []relay.Region
	relayConfigured bool

In resolve, after the allowlist is resolved:

	regions, err := relay.Parse(cfg.Relay)
	switch {
	case err == nil:
		out.relay = regions
		out.relayConfigured = len(regions) > 0
	case cfg.development():
		// One line, the variable and not the value: the value carries a
		// key. Development runs without a relay rather than refusing.
		cfg.Logger.Warn("calls: CARLOS_RELAY is malformed and is ignored in development", "err", err.Error())
	default:
		return out, fmt.Errorf("calls: CARLOS_RELAY is present but malformed (%v); a relay the app cannot read is a relay it must not pretend to have", err)
	}

(err.Error() is safe to log: Task 1 guarantees the message never contains the value.)

Then: every read of a.cfg.RelayConfigured (the Floor value in handlers_join.go and handlers_room.go) becomes !a.relayConfigured(), where:

// relayRegions is the parsed contract, swapped atomically so a test can
// hand the app a relay after boot (TestHandle.SetRelay). Production
// sets it once in build.
func (a *app) relayRegions() []relay.Region {
	v, _ := a.relay.Load().([]relay.Region)
	return v
}

func (a *app) relayConfigured() bool { return len(a.relayRegions()) > 0 }

with relay atomic.Value on app, stored in build from rc.relay, and on TestHandle:

// SetRelay hands the app a CARLOS_RELAY value after boot. Tests only.
func (h *TestHandle) SetRelay(raw string) error {
	regions, err := relay.Parse(raw)
	if err != nil {
		return err
	}
	h.app.relay.Store(regions)
	return nil
}

In cmd/calls/main.go replace the RelayConfigured: line with Relay: os.Getenv("CARLOS_RELAY"),. Log one INFO line at startup when a relay is present: logger.Info("calls: relay configured", "regions", len(regions)) — the count only — inside build, not resolve.

  • [ ] Step 4: Run to see them pass; run the whole gate

Run: GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 make ci Expected: green; the holding and room page tests that assert the floor sentence still pass (they run with no relay).

  • [ ] Step 5: Commit
git add internal/calls/config.go internal/calls/config_test.go internal/calls/app.go internal/calls/handlers_join.go internal/calls/handlers_room.go cmd/calls/main.go
git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "config: parse CARLOS_RELAY at boot; refuse a bad one outside development

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"

---

Task 3: GET /m/{sqid}/ice: the list for an admitted participation

Files:

  • Create: internal/calls/hub/authorize.go, internal/calls/ice.go
  • Modify: internal/calls/app.go (route inside the requirePerson group, beside /stream and /signal)
  • Test: internal/calls/hub/authorize_test.go, internal/callstest/ice_test.go; update internal/callstest/stream_test.go:557 (the /ice tripwire from Task 10 now expects the real answer)

Interfaces:

  • Consumes: a.sessionKey(r), validSqid, validPeerID (signal.go), streamJSON, a.notFoundBody(), a.relayRegions(), relay.Mint, a.store.now(), hub findLocked, memberStandingGone (relay.go, Codex wave 1: memberStandingGone(ctx, sqid, session, peer, end bool) — read its signature and reuse it).
  • Produces: ``go // hub func (h *Hub) Authorize(ctx context.Context, sqid, session string, peer PeerID) error // ErrUnknownPeer for no such room/peer/session; ErrNotAdmitted for a waiting or unjoined peer; for a member-kind participation whose standing is gone: ended with lost{standing}, gone announced, ErrNotYours (§15.12, mutating like Relay). Guests never consult standing (ruling 14). ` Wire: GET /m/{sqid}/ice?peer=<32 hex> → 200 {"iceServers":[{urls,username?,credential?}...],"expiresAt":<unix seconds>,"renewAt":<unix seconds>}, Cache-Control: no-store; with no relay {"iceServers":[],"expiresAt":0,"renewAt":0}; 404 with a.notFoundBody() for a malformed sqid or peer, unknown peer, another session's peer, a waiting or unjoined peer, a member who lost standing, a meeting that has ended. Development-only header X-Meet-Test-Ice-TTL: <duration> (honoured only when a.cfg.development(), like X-Meet-Test-TTL`) sets expiry to now+d and renewAt to now+d*5/6, floor 2 s, so the gate can shorten renewal.
  • [ ] Step 1: Write the failing hub test
package hub

import (
	"context"
	"errors"
	"testing"
	"time"
)

func TestAuthorizeIsTheAdmittedParticipationRule(t *testing.T) {
	now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
	h := testHub(&now)
	ctx := context.Background()
	a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
	if err := h.Authorize(ctx, "r1", "sa", a.Peer); !errors.Is(err, ErrNotAdmitted) {
		t.Fatalf("unjoined: want ErrNotAdmitted, got %v", err)
	}
	if err := h.Join(ctx, "r1", "sa", a.Peer); err != nil {
		t.Fatal(err)
	}
	if err := h.Authorize(ctx, "r1", "sa", a.Peer); err != nil {
		t.Fatalf("admitted member: %v", err)
	}
	if err := h.Authorize(ctx, "r1", "sb", a.Peer); !errors.Is(err, ErrUnknownPeer) {
		t.Fatalf("another session: want ErrUnknownPeer, got %v", err)
	}
	if err := h.Authorize(ctx, "r2", "sa", a.Peer); !errors.Is(err, ErrUnknownPeer) {
		t.Fatalf("another room: want ErrUnknownPeer, got %v", err)
	}
	g, _ := h.Open("r1", "sg", 2, "Gus", KindGuest)
	h.Join(ctx, "r1", "sg", g.Peer)
	if err := h.Authorize(ctx, "r1", "sg", g.Peer); !errors.Is(err, ErrNotAdmitted) {
		t.Fatalf("waiting guest: want ErrNotAdmitted, got %v", err)
	}
}

func TestAuthorizeDropsAMemberWhoseStandingIsGoneAndKeepsAnAdmittedGuest(t *testing.T) {
	// Use the same StandingFor switch the relay tests use (relay_test.go:
	// TestAMemberWithNoStandingIsRefusedByTheNextRequestNotTheNextTick)
	// to revoke Ann's standing without a tick; then Authorize → ErrNotYours,
	// her participation is gone, Bob's stream received `gone` naming her.
	// Then an admitted guest whose grant has "expired" (StandingFor !ok
	// for the guest) → Authorize returns nil (ruling 14).
	// Copy the fixture shape from that test verbatim.
}

Fill the second test's body by copying the fixture from relay_test.go's standing-revocation test (it constructs a hub whose StandingFor consults a mutable map); assert as the comment says.

  • [ ] Step 2: Implement Authorize (internal/calls/hub/authorize.go)
package hub

import "context"

// Authorize is the §2.5 last-row check on its own: a participation
// bound to this session, in this room, admitted. A member-kind
// participation re-derives standing first (§15.12), exactly as Relay
// does, and one whose standing is gone is ended here with lost{standing}
// and answered ErrNotYours; a guest never consults standing (ruling 14).
// GET /m/{sqid}/ice is its caller; it changes nothing on success.
func (h *Hub) Authorize(ctx context.Context, sqid, session string, peer PeerID) error {
	if err := h.memberStandingGone(ctx, sqid, session, peer, true); err != nil {
		return err
	}
	h.mu.Lock()
	defer h.mu.Unlock()
	p, ok := h.findLocked(sqid, session, peer)
	if !ok {
		return ErrUnknownPeer
	}
	if !p.Admitted {
		return ErrNotAdmitted
	}
	return nil
}

(Read memberStandingGone's real signature and return contract in relay.go first; if it returns a bool rather than an error, mirror how Relay turns that into ErrNotYours.)

  • [ ] Step 3: Write the failing HTTP tests (internal/callstest/ice_test.go)
package callstest

import (
	"bytes"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"net/http"
	"testing"
	"time"

	"amadan.net/oficina/meet/internal/calls/relay"
)

const vectorKey = "uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU="

func relayContract(t *testing.T) string {
	t.Helper()
	b, _ := json.Marshal([]map[string]any{{
		"region": "eu-west-1",
		"urls":   []string{"stun:relay.eu-west-1.oncarlos.com:3478", "turn:relay.eu-west-1.oncarlos.com:3478?transport=udp"},
		"key":    vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1",
	}})
	return base64.StdEncoding.EncodeToString(b)
}

type iceAnswer struct {
	ICEServers []relay.ICEServer `json:"iceServers"`
	ExpiresAt  int64             `json:"expiresAt"`
	RenewAt    int64             `json:"renewAt"`
}

func TestIceHandsAnAdmittedParticipationCredentialsThatVerify(t *testing.T) {
	x, sqid, _ := memberWithMeeting(t)
	if err := x.h.SetRelay(relayContract(t)); err != nil {
		t.Fatal(err)
	}
	c := x.browser(t)
	c.signIn("paul@example.com")
	s := c.stream("/m/" + sqid + "/stream")
	defer s.close()
	peer, _ := s.hello()
	c.signal(sqid, map[string]any{"peer": peer, "type": "join"})
	s.nextOf("roster", 2*time.Second)

	rec := c.getStream("/m/" + sqid + "/ice?peer=" + peer) // any bounded GET helper; the Accept header is harmless
	if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-store" {
		t.Fatalf("ice: %d %q", rec.Code, rec.Header().Get("Cache-Control"))
	}
	var ans iceAnswer
	if err := json.Unmarshal(rec.Body.Bytes(), &ans); err != nil {
		t.Fatal(err)
	}
	if len(ans.ICEServers) != 2 || ans.ICEServers[0].Username != "" || ans.ICEServers[1].Username == "" {
		t.Fatalf("servers %+v", ans.ICEServers)
	}
	now := x.now()
	if ans.ExpiresAt < now.Add(3*time.Hour-time.Second).Unix() || ans.ExpiresAt > now.Add(3*time.Hour+time.Second).Unix() {
		t.Fatalf("expiry %d is not three hours ahead of %d", ans.ExpiresAt, now.Unix())
	}
	if ans.RenewAt != now.Add(150*time.Minute).Unix() {
		t.Fatalf("renewAt %d", ans.RenewAt)
	}
	// The credential verifies against the vector key for the username the server chose.
	key, _ := base64.StdEncoding.DecodeString(vectorKey)
	turn := ans.ICEServers[1]
	if relay.Credential(key, turn.Username) != turn.Credential {
		t.Fatal("credential does not verify")
	}
	if want := fmt.Sprintf("%d:abc:meet:0123456789abcdef:k1", ans.ExpiresAt); turn.Username != want {
		t.Fatalf("username %q, want %q", turn.Username, want)
	}
}

func TestIceIs404ForAnythingButAnAdmittedParticipationOfThisSession(t *testing.T) {
	x, sqid, _ := memberWithMeeting(t)
	x.h.SetRelay(relayContract(t))
	c := x.browser(t)
	c.signIn("paul@example.com")
	s := c.stream("/m/" + sqid + "/stream")
	defer s.close()
	peer, _ := s.hello()
	unjoined := c.getStream("/m/" + sqid + "/ice?peer=" + peer)
	if unjoined.Code != http.StatusNotFound {
		t.Fatalf("unjoined: %d", unjoined.Code)
	}
	other := x.browser(t)
	other.signIn("paul@rslw.com")
	foreign := other.getStream("/m/" + sqid + "/ice?peer=" + peer)
	if foreign.Code != http.StatusNotFound || !bytes.Equal(foreign.Body.Bytes(), unjoined.Body.Bytes()) {
		t.Fatalf("another session must get the identical 404: %d %s vs %s", foreign.Code, foreign.Body.Bytes(), unjoined.Body.Bytes())
	}
	bad := c.getStream("/m/" + sqid + "/ice?peer=zz")
	if bad.Code != http.StatusNotFound || !bytes.Equal(bad.Body.Bytes(), unjoined.Body.Bytes()) {
		t.Fatal("a malformed peer is the same 404")
	}
	room := c.getStream("/m/2222222222/ice?peer=" + peer)
	if room.Code != http.StatusNotFound || !bytes.Equal(room.Body.Bytes(), unjoined.Body.Bytes()) {
		t.Fatal("an unknown room is the same 404")
	}
	// A waiting guest: sign a guest in through the link and join; 404.
	g, gpeer := guestJoinedAndWaiting(t, x, sqid) // reuse the slice 2 helper that signs a guest in through the emailed link, opens its stream and posts join; write it if isolation_test.go's inline steps have not been lifted
	waiting := g.getStream("/m/" + sqid + "/ice?peer=" + gpeer)
	if waiting.Code != http.StatusNotFound || !bytes.Equal(waiting.Body.Bytes(), unjoined.Body.Bytes()) {
		t.Fatal("a waiting guest is the same 404")
	}
}

func TestIceWithNoRelayIsAnEmptyList(t *testing.T) {
	x, sqid, _ := memberWithMeeting(t)
	c := x.browser(t)
	c.signIn("paul@example.com")
	s := c.stream("/m/" + sqid + "/stream")
	defer s.close()
	peer, _ := s.hello()
	c.signal(sqid, map[string]any{"peer": peer, "type": "join"})
	s.nextOf("roster", 2*time.Second)
	rec := c.getStream("/m/" + sqid + "/ice?peer=" + peer)
	if rec.Code != http.StatusOK || rec.Body.String() != "{\"expiresAt\":0,\"iceServers\":[],\"renewAt\":0}\n" {
		t.Fatalf("%d %q", rec.Code, rec.Body.String())
	}
}

func TestIceTestTTLHeaderIsHonouredOnlyInDevelopment(t *testing.T) {
	// Same shape as stream_test.go's X-Meet-Test-TTL test: development
	// harness → expiresAt ≈ now+2s and renewAt < expiresAt; production
	// harness (newHarnessWithEnv or the existing production fixture) →
	// three hours regardless of the header.
}

func TestIceLogsNothingOnSuccessAndNoCredentialOnRefusal(t *testing.T) {
	// newHarnessWithLogger (signal_test.go): one success, one refusal;
	// the buffer contains no "uMhwW4P", no ":abc:meet:", no credential,
	// and the success produced no line at all mentioning "ice".
}

Fill the two sketched tests with the file's neighbouring patterns (they are named in the comments); they must assert what their names say.

  • [ ] Step 4: Implement the handler (internal/calls/ice.go)
package meet

import (
	"errors"
	"net/http"
	"time"

	"github.com/go-chi/chi/v5"

	"amadan.net/oficina/meet/internal/calls/hub"
	"amadan.net/oficina/meet/internal/calls/relay"
)

const (
	iceLife       = 3 * time.Hour        // §5.2: inside the contract's four-hour maximum
	iceRenewAfter = 150 * time.Minute    // §5.3 step 4
	testIceHeader = "X-Meet-Test-Ice-TTL" // development only, like X-Meet-Test-TTL
	minIceLife    = 2 * time.Second
)

// ice answers GET /m/{sqid}/ice?peer= with the RTCIceServer list for one
// call (§5.2). Authority is the §2.5 last row — an admitted participation
// bound to this session — through hub.Authorize, which also applies the
// member re-derivation of §15.12. Every refusal is the same 404 as
// /signal's, so nothing about another session's participation is
// observable here. Nothing is logged on success: the answer carries a
// credential.
func (a *app) ice(w http.ResponseWriter, r *http.Request) {
	sqid := chi.URLParam(r, "sqid")
	session, ok := a.sessionKey(r)
	if !ok {
		a.logger.Error("calls: ice with no session subject", "path", r.URL.Path)
		streamJSON(w, http.StatusInternalServerError, nil)
		return
	}
	peer := r.URL.Query().Get("peer")
	if !validSqid(sqid) || !validPeerID(peer) {
		a.logger.Info("calls: ice refused", "room", "invalid", "peer", "", "outcome", "shape")
		streamJSON(w, http.StatusNotFound, a.notFoundBody())
		return
	}
	if ended, err := a.store.meetingEnded(sqid); err != nil || ended { // reuse whatever stream.go calls to read ended_at before attaching; match its name
		streamJSON(w, http.StatusNotFound, a.notFoundBody())
		return
	}
	switch err := a.hub.Authorize(r.Context(), sqid, session, hub.PeerID(peer)); {
	case err == nil:
	case errors.Is(err, hub.ErrUnknownPeer), errors.Is(err, hub.ErrNotAdmitted), errors.Is(err, hub.ErrNotYours):
		a.logger.Info("calls: ice refused", "room", sqid, "peer", peer, "outcome", "authority")
		streamJSON(w, http.StatusNotFound, a.notFoundBody())
		return
	default:
		a.logger.Error("calls: ice: authorize", "room", sqid, "peer", peer, "outcome", "internal", "err", err)
		streamJSON(w, http.StatusInternalServerError, nil)
		return
	}
	now := a.store.now()
	life, renew := iceLife, iceRenewAfter
	if a.cfg.development() {
		if d, err := time.ParseDuration(r.Header.Get(testIceHeader)); err == nil && d > 0 {
			life = max(d, minIceLife)
			renew = life * 5 / 6
		}
	}
	regions := a.relayRegions()
	if len(regions) == 0 {
		streamJSON(w, http.StatusOK, map[string]any{"iceServers": []relay.ICEServer{}, "expiresAt": 0, "renewAt": 0})
		return
	}
	expiry := now.Add(life)
	streamJSON(w, http.StatusOK, map[string]any{
		"iceServers": relay.Mint(regions, expiry),
		"expiresAt":  expiry.Unix(),
		"renewAt":    now.Add(renew).Unix(),
	})
}

Route in app.go, inside the same group as /stream and /signal: r.Get("/m/{sqid}/ice", a.ice). Check that streamJSON sets Cache-Control: no-store (it does for the stream refusals; if it only sets it on non-2xx, set it here explicitly). Confirm the JSON key order matches the empty-list test (Go marshals map keys sorted: expiresAt, iceServers, renewAt).

Update internal/callstest/stream_test.go:557 (Task 10's tripwire that asserted /ice is 404 for an admitted guest past expiry): it must now assert 200 with servers for the admitted guest (ruling 14: an admitted guest continues, and /ice never consults standing for a guest).

  • [ ] Step 5: Gate and commit

Run: GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 make ci and CGO_ENABLED=1 go test -race ./internal/...

git add internal/calls/hub/authorize.go internal/calls/hub/authorize_test.go internal/calls/ice.go internal/calls/app.go internal/callstest/ice_test.go internal/callstest/stream_test.go
git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "ice: GET /m/{sqid}/ice mints the RTCIceServer list for an admitted participation

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"

---

Task 4: room.js: the ICE list and renewal (§5.3)

Files:

  • Modify: internal/calls/static/room.js (createPC, restartIce, state(), a new renewal module: fetchIce, scheduleRenewal, renewAll, renewOne, proveRenewed, iceWarning), internal/calls/templates/room.html (data-relay="1" on #room when the relay is configured; the template already receives Floor, so pass Relay: a.relayConfigured()), internal/calls/static/calls.css (the warning and the expired tile note), docs/superpowers/copy/2026-09-10-slice-2-strings.md (a "Slice 3" section)

Interfaces (the client contract):

  • On becoming admitted (the first roster), before the first createPC, await fetchIce(): GET /m/{sqid}/ice?peer=<state.peer> with credentials: 'same-origin', no special Accept. Answer {iceServers, expiresAt, renewAt} (unix seconds). Store ice = {servers, expiresAt, renewAt, fetchedAt, failedSince}. On a non-200 or a network error: keep the previous list (or [] on the first call), set ice.failedSince, show the warning sentence in #status's neighbour #ice-warning (a role="status" <p> the script creates once), and retry every minute (ICE_RETRY = 60000; the gate shortens it through X-Meet-Test-Ice-TTL's effect on renewAt, not through a client knob: keep the retry at one minute and make the failure scenario assert the warning, not the recovery timing). A 404 means this participation is gone: run the existing recoverFromNotFound-style probe ladder (reuse the probe-first recovery so /ice's 404 is handled like /signal's).
  • createPC builds new RTCPeerConnection({ iceServers: ice.servers }). An empty list is the floor (unchanged behaviour). With data-relay present, the failed tile's note reads "Could not connect, even through the relay." instead of "Could not connect." (§5.5).
  • Renewal per connection (§5.3): at renewAt (a timer from fetchedAt; on visibilitychange to visible, compare the clock with renewAt and run at once if overdue), renewAll(): await fetchIce(); on failure retry each minute and keep the old expiresAt as the must-succeed-by; on success, for every live pc: renewOne(id): record generation = { started: now, oldUfrag: <selected local candidate's usernameFragment from getStats(), or null> }, pc.setConfiguration({ iceServers: ice.servers }), pc.restartIce(), let perfect negotiation carry the offer, then proveRenewed(id) polls getStats() every 500 ms for up to 30 s until signalingState === 'stable' AND a candidate-pair with state === 'succeeded' and nominated/selected whose localCandidateId resolves to a local-candidate with usernameFragment different from oldUfrag (when oldUfrag is null, any succeeded selected pair after the restart's answer counts). Renewed → entry.renewed = {at, ufrag}. Not proven within 30 s → restart again, up to three attempts. After three failures, or when now > mustSucceedBy for a connection not yet renewed: mark entry.relayExpired = true, the tile shows "The relay credential expired for this connection." with a Retry control that runs renewOne after a fresh fetchIce().
  • restartIce(id) (the existing Retry) also renews if ice.servers is non-empty and older than renewAt.
  • state() gains ice: { fetches, lastFetchAt, expiresAt, renewAt, warning: <bool>, servers: ice.servers.length } and per-peer renewals: {peer: {attempts, renewed: bool, ufrag}} for the gate.
  • Nothing about the servers is logged; console carries no credential.
  • [ ] Step 1: Read §5.2, §5.3 and §5.5, then the current room.js around createPC (line ~751), restartIce (~1607), recoverFromNotFound and state() (~1791), and task-8-handoffs.md.
  • [ ] Step 2: Implement to the contract, keeping each concern in a named function with a header comment naming §5.2 and §5.3. Do not change any slice 2 behaviour that the 23 existing scenarios assert; run node --check.
  • [ ] Step 3: Add the strings to the copy catalogue under "## Slice 3": the warning sentence ("Meet could not reach the relay. Calls may not connect until it can."), the expired sentence, the relay-aware failed note, the Retry label.
  • [ ] Step 4: Gate — make ci; make browser with the sandbox off must stay 23/23 (the new scenarios are Task 5's).
  • [ ] Step 5: Commit
git add internal/calls/static/room.js internal/calls/static/calls.css internal/calls/templates/room.html internal/calls/handlers_room.go docs/superpowers/copy/2026-09-10-slice-2-strings.md
git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "room.js: ICE servers from /ice, renewal with a proven ICE restart, the relay warnings

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"

---

Task 5: The browser gate: ice, icerenewal, icefail

Files:

  • Modify: test/browser/call.mjs, internal/callstest/browser_test.go (the Go rig sets the relay contract on the app through TestHandle.SetRelay before the ice* scenarios; a control command relay on / relay off if the rig's per-scenario restart needs it)

Interfaces:

  • Consumes: state().ice and state().renewals from Task 4; the rig's route helpers; X-Meet-Test-Ice-TTL.
  • Scenarios (§13, slice 3): - ice: with the vector contract set, two members join; each state().ice.servers === 2 after admission, fetches === 1; the pair connects (frames both ways) — the servers point at a host that does not exist in the test, so ICE must still succeed on host candidates; assert signaling stable, connection connected. Also assert, through a route on **/ice*, that the response body carries a turn: entry with a username of five colon-separated fields and no credential appears in any console line. - icerenewal: route rewrites /ice requests to add X-Meet-Test-Ice-TTL: 12s (renewAt ≈ 10 s); after the pair connects, within 25 s assert state().ice.fetches >= 2, and for the other peer renewals[peer].renewed === true with a ufrag different from the one recorded after the first connection (read getStats in the page through a small evaluate to capture the initial ufrag), and frames still rising after the renewal. Assert no tile shows the expired sentence. - icefail: route fulfils /ice with 503 from the second fetch on; assert the warning sentence appears within 15 s of renewAt, the call stays connected (frames rising), state().ice.warning === true; then un-route and assert the warning clears within 70 s (one retry interval) — if 70 s is too long for the gate, shorten by asserting only the warning and the call staying connected, and record that the recovery timing is unproved.
  • No scenario touches a real relay (§13's last sentence).
  • [ ] Step 1: Write the scenarios following the file's shape (until, check, diagnose, stayed, state flattening, per-scenario restart), each printing one line per assertion.
  • [ ] Step 2: Run make browser with the sandbox off; every scenario passes; record the run's tail in the report.
  • [ ] Step 3: Update the Makefile comment's scenario pointer if it enumerates; update AGENTS.md's gate line if the runtime changed materially.
  • [ ] Step 4: Commit
git add test/browser/call.mjs internal/callstest/browser_test.go
git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "browser gate: /ice list, renewal under a shortened schedule, the failure warning

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"

---

Task 6: Record and spec note

Files:

  • Modify: docs/superpowers/specs/2026-09-07-meet-design.md (§15 gains §15.13: the /ice wire shape {iceServers, expiresAt, renewAt}, the ?peer= query, the development-only header, and that /ice for a member re-derives standing like /signal), docs/superpowers/reviews/2026-09-10-slice-2-record.md → create docs/superpowers/reviews/2026-09-10-slice-3-record.md (ledger, dispositions, carried items, the canary and relay-enable steps, the deploy note).
  • [ ] Step 1: Write §15.13 and the record.
  • [ ] Step 2: make ci; commit docs: slice 3 record and spec note.

---

Self-review

Spec coverage (§12 item 3): CARLOS_RELAY parsing and the startup refusal (2); minting with the vector test (1); /ice (3); renewal with repeated and failed cases under shortened timers (4, 5). §13 slice-3 gates: "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" (5). §5.5's relay-aware failed note (4). §5.5's README paragraph is slice 6's. Verified against the real relay: the controller's canary step, not a task.

Placeholders: Tasks 4 and 5 give a contract and function list (Opus-tier design tasks, as slice 2's Tasks 8 and 9 did); every behaviour is named with its spec section and the gate that proves it. Two tests in Task 3 are sketched with the neighbouring test named to copy from; the implementer fills them.

Type consistency: relay.Region, relay.ICEServer, relay.Parse, relay.Mint, relay.Credential (1) used unchanged in 2 and 3; a.relayRegions(), a.relayConfigured(), TestHandle.SetRelay (2) used in 3, 4's template value and 5; hub.Authorize (3); state().ice and state().renewals (4) read by 5; header X-Meet-Test-Ice-TTL (3) set by 5.

Rulings for the controller: /ice carries the peer as a query parameter (?peer=), the same way the stream resume does, so a native client needs no body on a GET. A member's /ice re-derives standing (mutating, like /signal), per §15.12. The renewal retry stays at one minute in the client; the gate proves the warning, not the one-minute recovery.