carlos / platform Public

Merge branch 'relay-stun-turn-spec' into main

claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 29232514b80189723cab596c56977fdb32965583 parent 8291cb2parent 202e09b
9 files changed, +1555 −33
  • docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md +56 −33
  • docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md +725 −0
  • docs/superpowers/specs/CODEX-FINDINGS-RELAY-R2.md +205 −0
  • docs/superpowers/specs/CODEX-FINDINGS-RELAY-R3.md +156 −0
  • docs/superpowers/specs/CODEX-FINDINGS-RELAY-R4.md +61 −0
  • docs/superpowers/specs/CODEX-FINDINGS-RELAY-R5.md +31 −0
  • docs/superpowers/specs/CODEX-FINDINGS-RELAY-R6.md +15 −0
  • docs/superpowers/specs/CODEX-FINDINGS-RELAY.md +237 −0
  • docs/superpowers/specs/CODEX-REVIEW-BRIEF-RELAY.md +69 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md b/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md
index 50950e42c..d8b39f56d 100644
--- a/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md
+++ b/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md
@@ -610,11 +610,13 @@ box, built on `pion/turn`, pure Go, in the same binary as the edge, a
- **Ingress.** UDP 3478 and a relay allocation range (49152 to 65535)
opened in the region security group and configured as the allocator's
- only range; TCP 3478 and TLS 5349 for networks that block UDP. The
- relay advertises the box's public Elastic IP. TURN over TLS on 443
- would need a second address on the box because 443 is the edge's;
- version one does not provide it and the docs say a 443-only network
- is the remaining case that fails. **(codex 15)**
+ only range; TCP 3478 for networks that block UDP. The relay advertises
+ the box's public Elastic IP. **No TURN over TLS in version one**
+ (Paul, 2026-09-07, stage 4a spec ruling 4): 5349 needs certificate
+ issuance for a non-HTTP listener, which is its own small design, and
+ 443 would need a second address on the box because 443 is the edge's.
+ The docs say a network that blocks 3478 is the remaining case that
+ fails. **(codex 15; amended by the stage 4a spec)**
- **Credentials, scoped per app and per region.** Each region has a
relay master key. At `carlos relay enable` the instance receives,
sealed through `config.json`, one derived key per allowed region:
@@ -622,16 +624,22 @@ box, built on `pion/turn`, pure Go, in the same binary as the edge, a
`CARLOS_RELAY_KEYS` alongside `CARLOS_RELAY_URLS`. The app mints TURN
REST credentials per call, `username =
expiry:account:app:incarnation`, `credential = HMAC(derived key,
- username)`, with a one-hour maximum. The relay holds a **relay
- authority record** it syncs from the route table the way the edge
- syncs routes: for each enabled app, its allowed regions and current
- incarnation. On a request it parses the username, refuses it unless
- the app is enabled and the incarnation matches the record, re-derives
- the key from its own region master and the username's fields, and
- verifies. Verification is local and per-app metering is real. Disable
- bumps the incarnation in the record, which ends existing allocations
- and makes every outstanding credential fail, and the incarnation is
- never reused across deletion or name reuse. A relay whose authority
+ username)`, with a four-hour maximum (Paul, 2026-09-07, stage 4a
+ spec ruling 1; the relay ends the allocation at expiry, so one hour
+ would end any longer call). The exact wire shape, a base64-wrapped
+ JSON `CARLOS_RELAY` with a platform-supplied username suffix and a
+ key id, is the stage 4a spec's §2. The relay holds a **relay
+ authority record**, `control/relay.json`, rebuilt by the console from
+ per-app relay records and read by every relay every minute: for each
+ enabled app, its regions and current incarnation. On a request it
+ parses the username, refuses it unless the app is enabled and the
+ incarnation matches the record, re-derives the key from its own
+ region master and the username's fields, and verifies. Verification
+ is local and per-app metering is real. Disable removes the app from
+ the record, which ends existing allocations and makes every
+ outstanding credential fail; re-enable draws a new random
+ incarnation, so nothing minted before a disable ever works again and
+ nothing is reused across deletion or name reuse. A relay whose authority
record is older than fifteen minutes refuses new allocations **and
terminates the ones it is carrying**, the same lease rule the edge
applies to media: a disable the relay never heard about must not
@@ -641,27 +649,39 @@ box, built on `pion/turn`, pure Go, in the same binary as the edge, a
- **What that does and does not protect.** Per-region masters mean a
relay box holds no other region's master. They do not mean a
compromised host learns nothing about other regions: sealed config is
- decryptable on every host (§1), so a compromised host can read every
- enabled app's derived keys for every region, exactly as it can read
- every app's store keys today. Relay keys are no worse than the secrets
- the platform already delivers this way, and no better. A short-lived
- issuance API would narrow that and is the alternative if it is ever
- wanted. **(codex R2-2)**
+ decryptable on every host (§1), and the console must hold every
+ region's master as sealed config to derive app keys, so **a
+ compromised host can read every region's master** and mint for any
+ app in any region, present and future, until the operator rotates
+ (Paul, 2026-09-07, stage 4a spec ruling 3). That is wider than the
+ derived-key exposure alone, and it is stated in those words in the
+ operator docs. Rotation of one region recovers that region. A design
+ where only a region's boxes ever hold its master, the relay deriving
+ and writing app keys itself, is filed as the tightening. **(codex
+ R2-2; amended by the stage 4a spec)**
- **Policy and limits.** Peer permissions refuse loopback, link-local,
- RFC 1918, the VPC's ranges and the relay's own addresses. Allocations,
+ RFC 1918, the VPC's ranges and the box's private addresses; the box's
+ own Elastic IP is reachable, but only on ports holding a live relay
+ allocation, so two clients on one relay can reach each other and
+ nothing else on the box is exposed. Allocations,
allocation rate, bandwidth and lifetimes are bounded per app, per
account and per box. Established allocations end when their
credential expires or the app is disabled. These are operational
limits in 4a; commercial quotas wait for measurement. **(codex 14)**
-- **Placement.** `CARLOS_RELAY_URLS` lists the allowed regions with the
- instance's own first. The browser gathers candidates from all of them
+- **Placement.** The app's `CARLOS_RELAY` lists its regions in the
+ console's order, one list for the whole app, since placement names
+ are not regions and an app may have several placements (stage 4a
+ spec). The browser gathers candidates from all of them
and ICE nominates a working pair by its own procedures; that is not a
lowest-latency guarantee and the roadmap does not claim one.
Client-aware relay selection is a separate later decision.
**(codex 15)**
-- **Metering.** Bytes received by the relay, bytes sent by it, and
- inter-region bytes are three meters, per app, because they are billed
- differently; the unit-economics spec gets the definitions. **(codex 26)**
+- **Metering.** Bytes forwarded from the client and bytes forwarded to
+ the client are two meters, per app, written by the relay to its own
+ metrics objects; the inter-region meter the first draft named, and
+ the account usage line, move to stage 5 with the usage view (Paul,
+ 2026-09-07, stage 4a spec ruling 5). The unit-economics spec gets the
+ definitions. **(codex 26; amended by the stage 4a spec)**
- **Custody language.** A platform relay is not egress under ruling 6:
the recipient is the operator the team already trusts with its pages.
A third-party relay is egress, and under doctrine §8 which third
@@ -929,14 +949,16 @@ failing, then playback working. **(codex 27, R3-9)**
job for a non-Ireland bucket refused at the door; the ladder measured,
not promised.
- **Stage 4a**: a Meet call between two symmetric-NAT networks connecting
- through the Ireland relay with a credential that expires in an hour;
- a credential minted for one app refused for another; a credential
+ through the Ireland relay with a credential that expires in four
+ hours, and both browsers forced to relay connecting through it; a
+ credential minted for one app refused for another; a credential
minted before a disable refused after re-enable, and the allocation
it held ended; a relay with an authority record older than fifteen
minutes refusing a new allocation and ending one already carrying
traffic; a relay permission to a private
- address refused; the three meters moving on the account's usage line;
- a 443-only network failing the way the docs say it will.
+ address refused; the two meters moving in the relay's metrics object;
+ a network that blocks 3478 failing the way the docs say it will. The
+ full gate is the stage 4a spec's §7.
- **Stage 4b**: a token minted for one app refused for another app's
room; a participant removed by the app dropped within a second and
unable to rejoin with its old token or a LiveKit-refreshed one, with
@@ -966,8 +988,9 @@ Every finding from revisions 2 to 5 was accepted.
for apps; the platform's own relay and SFU units are the exception and
are specified in stage 4.
- `2026-08-11-unit-economics-design.md`: a media section, at stage 1
- for served bytes, at 4a for the three relay meters, at 4b for SFU
- egress.
+ for served bytes, at 4a for the two relay meters (bytes forwarded in
+ each direction), at stage 5 for the inter-region meter and the usage
+ line, at 4b for SFU egress.
- `infra/modules/region-host/main.tf:6` header: residency by
construction now has a named exception, the per-region media bucket
policy, and the exception is itself region-scoped.
diff --git a/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md b/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md
new file mode 100644
index 000000000..bdc935a46
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md
@@ -0,0 +1,725 @@
+# Stage 4a: a platform STUN and TURN relay
+
+**Date:** 2026-09-07, revision 6
+**Status:** rulings taken. Paul ruled on the six decisions in §9 on
+2026-09-07, each marked **(ruling)**; the roadmap's stage 4a text and
+gate are amended so the two documents agree. Reviewed five times by
+GPT-6 via Codex: revision 1's 20 findings are in
+`CODEX-FINDINGS-RELAY.md`, revision 2's 12 in
+`CODEX-FINDINGS-RELAY-R2.md`, revision 3's 8 in
+`CODEX-FINDINGS-RELAY-R3.md`, revision 4's 3 in
+`CODEX-FINDINGS-RELAY-R4.md`, revision 5's 1 in
+`CODEX-FINDINGS-RELAY-R5.md`, unedited; every one is taken, marked
+**(codex R1-N)** through **(codex R5-N)**. **Round 6 returned accept**
+with no findings (`CODEX-FINDINGS-RELAY-R6.md`). Stage 4a of the media roadmap
+(`2026-09-07-media-platform-roadmap-design.md`), built under rulings 8,
+10 and 11 there.
+
+**Why now.** Oficina's Meet is peer-to-peer WebRTC and cannot name a
+third-party STUN server, because suite ruling 6 forbids egress that is a
+property of the product. Without STUN, browsers gather only LAN
+candidates and calls connect on the same network only. A platform STUN
+listener is the smallest thing that makes Meet work across the
+internet; TURN on the same listener is what makes the one call in ten
+that cannot hole-punch connect anyway. Meet is blocked on nothing else
+(Meet session, 2026-09-07), and it has reviewed the app-facing contract
+in §2.
+
+## 1. What is built
+
+One new platform unit, `carlos-relay.service`, on every region box the
+operator arms, running `carlos relay serve` from the platform binary,
+built on `pion/turn` v4.1.4, pure Go, which fetches and cross-compiles
+clean for the CI shards (checked 2026-09-07). It answers STUN binding
+requests to anyone and TURN allocations to holders of a credential an
+enabled app minted. It learns which apps are enabled from a record the
+console writes, binds every allocation to the identity that made it,
+ends allocations when their credential, app, region, master or its own
+authority lapses, meters what it forwards per app, and refuses peers on
+private networks.
+
+Three console doors and a CLI verb, `carlos relay enable|status|disable`,
+that turn the relay on for an app and deliver its keys as sealed config,
+plus an operator verb `carlos relay rotate`. An operator-side master key
+per region, staged in a file on the region's boxes and, as a console
+secret, in the console. A config-adoption acknowledgement the host's
+config sync writes, so status can say "adopted" truthfully. A
+security-group change opening the relay's ports. A DNS name per region
+relay.
+
+Not built, by ruling: TURN over TLS, an inter-region byte meter. Not
+built, by scope: an SFU, recording, an issuance API, client-side relay
+selection, commercial quotas. §8 lists them with reasons.
+
+## 2. The app-facing contract
+
+This is what Meet codes against. It is fixed by this spec; the rest of
+the design may change without touching it.
+
+**Environment.** One variable reaches the instance through the sealed
+`config.json` path, the way store credentials do. Because the config
+renderer refuses quotes, backslashes and newlines in any value
+(`internal/appconfig/materialize.go:23`), the value is the **standard
+base64 encoding, with padding, of a JSON document**; the app decodes
+the base64, then parses the JSON. **(codex R1-1)**
+
+- `CARLOS_RELAY`: base64 of a JSON array, one element per region the
+ app may use, in the console's order (§5). Absent or empty means no
+ relay: the app shows its floor.
+
+ [{"region":"eu-west-1",
+ "urls":["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":"<standard base64, with padding, of 32 raw bytes>",
+ "username_suffix":"abc:meet:0123456789abcdef:k1"}]
+
+ `region` is the AWS region name, which is also the vocabulary of the
+ relay hostnames. `username_suffix` is `<account>:<app>:<incarnation>:
+ <kid>`, supplied by the platform so the app never has to know its own
+ canonical account sqid or app name. **(codex R1-11, R1-12)** There is
+ no separate incarnation variable; the suffix carries it. The app
+ builds its ICE server list from whatever `urls` are present and
+ assumes nothing about their number or schemes.
+
+**Minting, per call.** For the region it wants, the app computes:
+
+ key = base64_decode( element.key ) // 32 raw bytes
+ username = "<expiry>:" + element.username_suffix // ASCII
+ credential = base64_std( HMAC-SHA256( key, username ) )
+
+where `expiry` is a decimal Unix time in seconds, at most four hours
+ahead **(ruling 1)**. The username has exactly five colon-separated
+fields: `expiry` (`[0-9]+`), `account` (`[a-z0-9]+`), `app`
+(`^[a-z][a-z0-9-]{0,31}$`), `incarnation` (`[0-9a-f]{16}`) and `kid`
+(`[a-z0-9]{1,8}`). No field can contain a colon. The app hands the
+browser an `RTCIceServer` list: `stun:` entries bare, `turn:` entries
+carrying `username` and `credential`. STUN needs no credential.
+
+**Expiry.** The relay checks the expiry on every authenticated request
+the browser's ICE agent sends (allocate, refresh, permission, channel
+bind) **and** ends the allocation at the expiry regardless of what the
+client sends (§3, "termination"), so a credential's deadline is a hard
+deadline on relayed traffic. An app that needs a call to outlive its
+credential re-mints and performs an ICE restart before the deadline.
+Four hours is the maximum, not a promise about meeting lengths.
+**(codex R1-4, R1-20)**
+
+**What changes the incarnation.** Only `carlos relay disable` followed by
+`carlos relay enable`. A repeated enable, a region change, a deploy, a
+restart and a master rotation all keep it. **(codex R1-13)**
+
+**What changes the key.** A master rotation in a region changes that
+region's key and `kid`; a new incarnation changes every region's key.
+Either re-renders the app's config within about a minute, as any config
+change does today, and what happens next depends on the route's shape
+(`internal/appconfig/sync.go:30`, `syncexec.go:1`): a unit-backed route
+and a socket-backed exec route are restarted onto the new file; a
+TCP-address exec route whose bundle opts into file delivery receives
+the file and is **not** restarted, because its process belongs to an
+external supervisor CARLOS cannot bounce, so the app must re-read it
+and then tell the platform it has (`carlos relay adopted`, §5); a
+TCP-address route without file delivery receives nothing, and **an app
+with such a route cannot enable the relay** until it opts that route
+into file delivery or moves it to a unit. `carlos relay status` names
+which of these each route is. **(codex R1-10, R1-14, R2-6, R3-4, R5-1)**
+
+**A credential keeps working for its whole life** provided the master
+that signed its key is not force-retired before its expiry. The
+rotation procedure in §5 guarantees that when followed: the old master
+is retired no earlier than four hours after the last instance stopped
+minting from it, as observed by the console, and no earlier than 28
+hours after rotation regardless. **(codex R2-4)**
+
+**Test vector.** Master is `SHA-256("carlos-relay-test-master")`,
+account `abc`, app `meet`, incarnation `0123456789abcdef`, kid `k1`,
+expiry `1900000000`:
+
+ master 841d374df509340d6ab18a139a27ed0d9c5d61d0bdf306e4124ff913a7a92fb5 (hex)
+ key uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=
+ username 1900000000:abc:meet:0123456789abcdef:k1
+ credential jRU479Y3GO45nrRHqxBf3BMeBHE6Fip1cwAccNv28MQ=
+
+The key derivation is in §4; an app never needs it, only the key it is
+given. The vector will be checked into `internal/relay/vector_test.go`
+by the plan, and Meet's minting test uses the same numbers.
+
+**Logging.** The app logs which region it used and nothing else about
+the credential. The relay logs the account, app and outcome of an
+authentication, never the credential or a key.
+
+## 3. The relay unit
+
+`carlos-relay.service` is a media service in the roadmap's §3 terms: a
+network-facing platform unit like the edge, not a codec helper.
+
+- **Process.** `carlos relay serve`, from the platform binary, so it
+ rides the same release and promote path as the edge. A new file in
+ `internal/host/units/`, embedded and written by bootstrap like every
+ unit, with `ConditionPathExists=/etc/carlos/relay-masters` so a box
+ without a master file never listens. Added to `enabledUnits` and to
+ the list of units the systemctl plan **starts**, not only enables,
+ and to the adopt restart path beside `restartEdge`
+ (`internal/host/systemapply.go:568`), so a platform update restarts
+ the relay onto the new binary and a fresh box runs it without a
+ reboot. The staging bootstrap plan prints the same steps without
+ running them, as it does for the edge. **(codex R1-16)**
+- **Hardening and memory.** From `carlos-edge.service` and then cut
+ down: no `CAP_NET_BIND_SERVICE` (3478 is unprivileged),
+ `User=carlos`, `ProtectSystem=strict`, `ReadWritePaths=/data/relay
+ /run/carlos`, `RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX`.
+ Memory: `MemoryMax=256M`, `MemoryHigh=160M`, `OOMScoreAdjust=1000`.
+ What that guarantees, stated exactly: the relay can never take more
+ than 256 MiB of the box; above 160 MiB it refuses new allocations and
+ ends its oldest until it is under; and it is the kernel's preferred
+ victim among processes of similar footprint, since 1000 is the
+ largest adjustment there is. What it does not guarantee: that the
+ relay dies before a tenant under every host-wide pressure. The
+ kernel's score counts swap and page tables as well as RSS, tenants
+ may swap up to their memory cap (`internal/cgroup/cgroup.go:313`),
+ and a kill inside the edge's own 80% cgroup considers only the edge's
+ tree. So the relay does not wait for the kernel: it watches the
+ box's memory pressure (`/proc/pressure/memory`, the `some` ten-second
+ average) and sheds allocations, oldest first, whenever it exceeds
+ 20%, whatever caused the pressure, resuming admission when it falls
+ under 10%. The gate in §7 tests that shedding, not a kill order.
+ `Nice=5` and `CPUWeight=50` for CPU. **(codex R1-19, R2-8, R3-7)**
+- **Listeners.** UDP 3478 and TCP 3478 on the box's primary address.
+ No TLS listener **(ruling 4)**. The relay allocation range is UDP
+ 49152 to 65535, bound through the relay's own address generator
+ (below) with the box's Elastic IP as the advertised relay address,
+ learned as the edge learns it, from IMDS (`readPublicIP`,
+ `internal/host/agent.go:1808`), with `CARLOS_RELAY_PUBLIC_IP` as the
+ override for a box without IMDS.
+- **STUN.** Binding requests are answered on every listener without
+ authentication, which is pion's default and the reason this stage
+ exists.
+- **The relay owns every socket pion uses.** pion has no public call to
+ end an allocation; its allocation manager is internal and `Server`
+ exposes only `AllocationCount` and `Close`. What it does expose is the
+ `RelayAddressGenerator` that creates each allocation's relay socket
+ (`server_config.go:20`, returning a `net.PacketConn`), the listener
+ sockets the relay passes in, and an `OnAllocationCreated` callback
+ that supplies the relay address before pion starts reading from it
+ (`internal/allocation/allocation_manager.go:140`). pion's read loop
+ deletes the allocation when the relay socket returns an error
+ (`internal/allocation/allocation.go:301`). So the relay implements
+ the generator and wraps both kinds of socket: every relay socket is
+ recorded against its allocation at `OnAllocationCreated` and can be
+ closed by the relay at any time, which ends that allocation inside
+ pion within one read; and both relay and listener sockets count what
+ they forward and apply the token buckets below. That one mechanism is
+ how every deadline is enforced, how metering is attributed, and how
+ bandwidth is bounded. **(codex R1-4, R1-17)**
+- **Owner binding.** pion locates a refresh, permission or channel bind
+ by the client's five-tuple and does not compare the authenticating
+ username to the one that created the allocation
+ (`internal/server/turn.go:34`); it also tags every allocation's tuple
+ as UDP even for TCP clients, so the event's protocol string cannot
+ distinguish transports. The relay keys its binding on the **typed
+ source address**: the Go type of the `net.Addr` it receives
+ (`*net.UDPAddr` or `*net.TCPAddr`) plus its string, which pion passes
+ unchanged to `AuthHandler` and to the events. On
+ `OnAllocationCreated` it records that key to the identity fields
+ (account, app, incarnation); its `AuthHandler` refuses a username
+ whose identity differs from the one bound to a live allocation from
+ that key. Cleanup belongs to the wrapped relay socket, not to
+ `OnAllocationDeleted`, which carries no relay address
+ (`internal/allocation/event_handler.go:20`): each wrapper knows the
+ binding it belongs to, and on `Close`, whether pion closed it or the
+ relay did, it removes that binding only if the map still points at
+ itself, so a replacement allocation bound while the old one was
+ closing (pion removes from its manager before closing,
+ `allocation_manager.go:150`) is never erased. **(codex R1-3, R2-3,
+ R3-3)**
+- **Termination.** An allocation is ended, by closing its relay socket,
+ at the earliest of: its credential's expiry (a timer set at creation
+ from the username's expiry field); the app being disabled, its
+ incarnation changing, or this region leaving its list (on the next
+ authority read); the master named by its `kid` reaching `retire_at`
+ (on the next masters-file read or timer); the relay's authority lease
+ expiring (below); the app or account crossing a byte budget (below);
+ the relay shedding under memory pressure. Ending one allocation ends
+ its permissions and channels with it. pion's own maximum requested
+ lifetime is one hour; the relay does not rely on it. **(codex R1-4,
+ R2-4)**
+- **Authentication.** A custom `AuthHandler` (§4), called by pion on
+ every authenticated request, which is where the username's expiry and
+ the authority record are checked; pion verifies the message integrity
+ against the key the handler returns.
+- **Peer policy.** A `PermissionHandler` refuses `CreatePermission` and
+ `ChannelBind` to loopback, link-local (including the IMDS address),
+ RFC 1918 and unique-local ranges, the box's own private address and
+ VPC CIDR (from `CARLOS_RELAY_DENY_CIDRS`, set by the operator from
+ tofu output), and any other relay's private address. The box's own
+ Elastic IP is allowed by the permission handler, because two clients
+ that both fall back to this relay hold relay candidates on that
+ address and must reach each other, **but only live relay allocation
+ ports are reachable there**: the permission handler sees only an IP,
+ so the wrapped relay socket enforces the port on `WriteTo`, dropping
+ any packet to the EIP whose destination port is not a currently
+ allocated relay port. Nothing else on the box is reachable through
+ the relay, whatever the security group says about source addresses.
+ Refusals and drops are counted per app. **(codex R1-8, R2-9)**
+- **Limits (ruling 6)**, all admitted under one mutex across every
+ listener so counts cannot race, with counters reset on process start
+ and the rolling-hour byte windows rebuilt on start from every hourly
+ metrics object that intersects the window, normally the previous
+ hour's and the current one **(codex R3-6)**:
+ - per app per box: 64 live allocations, 30 new a minute, 20 GB a
+ rolling hour;
+ - per account per box: 256 live, 60 new a minute, 60 GB a rolling
+ hour, whatever the app count;
+ - per box: 2048 live, 500 new a minute;
+ - instantaneous: a token bucket of 8 Mbps per allocation and 200 Mbps
+ per box applied in the wrapped sockets on both forwarding legs,
+ dropping packets beyond the bucket, so a burst is bounded before it
+ reaches the edge's share of the NIC;
+ - crossing a byte budget refuses new allocations with 486 for the
+ app or account and **ends its live allocations**, because a budget
+ that only stops new ones bounds nothing. Failed allocations count
+ against the per-minute rates.
+ These are operational limits with those defaults, tunable by env;
+ commercial quotas are out of scope. **(codex R1-9, R2-7)**
+- **Metering.** Two meters per app and one gauge, measured where pion
+ forwards and only for what it forwards: **(codex R1-17, R1-18, R2-5;
+ ruling 5)**
+ - `in`: payload bytes the relay accepted from the client and forwarded
+ to a peer, counted at the relay socket's `WriteTo`;
+ - `out`: payload bytes the relay forwarded from a peer to the client,
+ counted at the listener socket's `WriteTo` to the client's address,
+ attributed through the owner binding;
+ - `allocs`: live allocations at sample time.
+ Both meters are host egress and are what the bill is made of.
+ Peer bytes that arrive at a relay socket and are dropped for lack of
+ permission are counted separately as `rejected` and never against a
+ budget. Every sixty seconds the relay appends one sample
+ `{at, apps: {"<account>/<app>": {in, out, allocs, rejected}}}`, with
+ `in`, `out` and `rejected` as **deltas since the previous sample**,
+ to its own hourly object `metrics/relay/<box>/<hour>.json`, which it
+ alone writes: read, append, CAS-write, retry on conflict. On restart
+ it reads the current hour's object back, so earlier samples survive
+ and its rolling-hour budgets resume from them. The raw readers and
+ rollups learn the new key in the plan. An inter-region meter is not
+ built (§8).
+- **Authority record and lease.** The relay reads `control/relay.json`
+ (§5) from the deployment bucket **every sixty seconds**, and
+ additionally whenever `control/serial` changes as seen by its own
+ poll of that object (the host agent's `fastConvergeLoop`,
+ `internal/host/agent.go:1437`, is a separate process; the relay runs
+ the same shape of poll itself). The lease clock is the time of the
+ last **successful** read; a failed read renews nothing. If that time
+ is more than fifteen minutes ago the relay refuses new allocations
+ and ends the ones it carries, per the roadmap's serving lease, and
+ resumes on the next successful read. A relay with no record yet
+ serves STUN only. Each read also writes a heartbeat,
+ `control/relay-ack/<box>.json`, carrying the record's `written_at`,
+ the read time, which listeners are up, the `kid`s currently loaded
+ from the masters file, and the last masters-file reload error if
+ any. **(codex R1-5, R1-14, R2-4)**
+
+## 4. Keys and verification
+
+**Region master.** Thirty-two random bytes per region, generated by the
+operator with `carlos relay keygen`, which prints a `kid` and the key.
+On each of the region's boxes they live in `/etc/carlos/relay-masters`,
+root-owned, mode 0640, group `carlos`, one per line:
+
+ <kid> <base64 key> <retire_at RFC 3339 or "-">
+
+The relay reads the file at start and re-reads it when it changes, so
+a rotation never restarts the process. A master past its `retire_at` is
+ignored for new requests and its live allocations are ended (§3).
+**(codex R1-10, R2-4)**
+
+**Where the console's copy lives, and what that exposes (ruling 3).**
+The console must hold every region's masters to derive app keys at
+enable. It holds them as console secrets, `CARLOS_RELAY_MASTERS_<REGION>`
+(region name uppercased with hyphens as underscores, since variable
+names refuse hyphens, `internal/appconfig/appconfig.go:103`), one or
+more `kid:key` pairs per region, of which the one named by the issuer
+pointer `control/relay-issuer.json` is the one keys are derived from
+(§5, rotation); and console secrets are sealed config like any app's. Sealed config is
+decryptable on every host (roadmap §1). So the honest statement is: **a
+compromised host can read every region's master**, not only the derived
+keys the roadmap already conceded, and with a master it can mint
+credentials for any app in that region, present and future, until the
+operator rotates. Per-region masters still mean a relay box itself
+holds only its own region's, and rotation of one region recovers that
+region. The operator docs say this in those words. A design where only
+a region's boxes ever hold its master, the region's relay deriving app
+keys and writing them into config itself, is filed as the tightening
+and is not version one, because it makes the relay a writer of app
+config with all the CAS and ordering that implies. **(codex R1-7,
+R2-10)**
+
+**Derived key.** For an app, region and incarnation:
+
+ key = HKDF-SHA256( ikm = master,
+ salt = "carlos-relay-v1",
+ info = account || 0x00 || app || 0x00 || incarnation,
+ length = 32 )
+
+**Verification**, in the relay's `AuthHandler(username, realm, src)`,
+which pion calls once per authenticated request and which returns one
+key, so the master is chosen from the username, not by trial:
+**(codex R1-2)**
+
+1. Split the username on `:` into exactly five fields; anything else is
+ refused.
+2. Parse `expiry`; refuse if in the past or more than four hours ahead.
+3. If the typed source address has a live allocation bound to a
+ different identity (§3, owner binding), refuse.
+4. Look up `account/app` in the authority record; refuse if absent, if
+ the incarnation differs, or if this relay's region is not in the
+ app's list.
+5. Look up `kid` in the masters file; refuse if absent or retired.
+6. Derive the key, compute the expected credential, and return
+ `turn.GenerateAuthKey(username, realm, credential)`. pion verifies
+ `MESSAGE-INTEGRITY` against that key, so a wrong credential fails
+ inside pion; the handler never sees what the client sent.
+7. Count the refusal reason per app: bad shape, expired, bound
+ elsewhere, unknown app, wrong incarnation, wrong region, unknown
+ kid.
+
+The MD5 inside `GenerateAuthKey` is the long-term credential key of RFC
+8489's compatibility mechanism, which pion implements; RFC 8489 also
+defines a SHA-256 variant that pion does not. The HMAC-SHA256 above is
+ours. **(codex R1-20)**
+
+## 5. Enable, disable, rotate
+
+**Records.** Two, both additive. **(codex R1-6, R2-1, R2-2)**
+
+- `apps/<acct>/<app>/relay.json`, CAS-guarded like `store.json`, is the
+ **source of truth** for the app: `{enabled, incarnation, regions,
+ op, phase, config_generation, enabled_at, disabled_at}`. `op` is a
+ counter bumped by every operation; `phase` is one of `config`,
+ `aggregate`, `done` and records how far the operation got. The
+ incarnation is sixteen hex characters drawn at random at first
+ enable and at each re-enable after a disable, never a counter, so
+ reuse across delete and name reuse is not a question.
+- `control/relay.json`, the authority record the relays read:
+ `{apps: [{account, app, incarnation, regions}], written_at}`. It is
+ **derived**, never patched. Publishing it is one fixed sequence: read
+ the aggregate's current etag; list every `relay.json`; build the
+ aggregate from records whose `enabled` is true, whatever their
+ `phase`; write with a CAS on the etag read **first**; on conflict
+ start the whole sequence again. Because the etag is read before the
+ listing, a disable that lands during the listing makes the write
+ fail rather than restore the app. Followed by a `control/serial` bump
+ so relays notice within seconds; the bump is a latency hint, not the
+ lease.
+
+**Every operation is a walk the reconcile can finish, and the
+reconcile converges everything.** An operation writes its intent to
+`relay.json` first (`op` bumped, `phase` set), then does the config
+write, then the aggregate publish, advancing `phase` after each step
+with a CAS that carries the `op`. **One writer, enforced.** Every relay
+operation, handler or reconcile, runs only in a console process that
+holds the relay-writer lease, `control/relay-writer.json`: a CAS-written
+record naming the holder with a sixty-second expiry, renewed every
+twenty seconds, taken over by another process only after it lapses. A
+process that fails a renewal stops every relay write in flight before
+its next bucket write and abandons the operation, which the next
+holder's reconcile finishes. Inside the holder, operations on one app
+are serialised behind a per-app lock. So two console processes,
+during a deploy overlap or otherwise, never both write relay config,
+and a superseded key selection cannot reach config after a migration:
+the writer that selected it either holds the lease, in which case it
+is the only writer and no migration happened under it, or lost it and
+stopped. **(codex R3-2, R4-1)** On top of that fence, the **reconcile**
+does not only finish unfinished records, it **converges both derived
+artefacts from every record, every five minutes and at console
+start**: for each enabled
+record it derives the expected `CARLOS_RELAY` value from the record and
+the active masters and re-applies it if the config bundle holds
+anything else; for each disabled record it removes the entry if
+present; and it always rebuilds and publishes the aggregate whether or
+not any record is unfinished. So a stale config write that lands after
+a disable is undone within five minutes, an interrupted enable
+delivers its config within five minutes, an interrupted disable
+removes it within five minutes, a repeated `enable` while a previous
+one is unfinished resumes it rather than skipping the config write, and
+a deletion that removed the record but died before publishing is
+repaired by the next unconditional rebuild. **(codex R2-2, R3-1, R3-2)**
+
+**When a disable is complete.** The app is out of every relay's
+authority when the aggregate has been published without it **and**
+every armed relay's heartbeat carries a `written_at` at or after that
+publish. `carlos relay status` shows "disable pending on <regions>"
+until then, and "revoked" after. Bounds: the publish is immediate on the
+happy path and within five minutes after a crash; each relay reads
+within sixty seconds of the publish; a relay that cannot read stops
+serving on its own fifteen-minute lease. **(codex R2-1)**
+
+**State transitions** for `relay.json`: **(codex R1-13)**
+
+| From | Operation | Incarnation | Keys re-derived | Allocations |
+|---|---|---|---|---|
+| none | enable | new | yes | none |
+| enabled, done | enable (same regions) | kept | no, no config write | untouched |
+| enabled, not done | enable | kept | resumes the unfinished operation | untouched |
+| enabled | enable `--regions` (change) | kept | only for added regions; removed regions' entries dropped | ended in removed regions on next authority read |
+| enabled | disable | kept in record, app removed from aggregate | entry removed | ended everywhere on next authority read |
+| disabled | enable | **new** | yes, all regions | none |
+| any | app deleted | record removed with the app prefix; the deletion walk publishes the aggregate | | ended on next authority read |
+| any | rotate `--region r` | kept | region `r` only, new kid | untouched; old kid accepted until retirement |
+
+**`carlos relay enable [--regions r1,r2]`** (member, API-mode only, on
+the store model in `store.go` and `internal/console/apistore.go`;
+ruling 2):
+
+1. `apiAccountApp` gate; unknown and foreign are one byte-identical 404.
+2. Refuse if the deployment has no relay regions (`CARLOS_RELAY_REGIONS`
+ in console config is empty) with a message naming the operator.
+ Refuse if any of the app's routes has no config delivery (a
+ TCP-address route whose bundle does not opt into file delivery),
+ naming the route, because such a route could never adopt a rotated
+ key and would block retirement forever. **(codex R5-1)**
+3. Regions are every region in `CARLOS_RELAY_REGIONS`, **in the
+ console's order**, or `--regions` narrows them keeping that order.
+ There is no placement-first promise: placement names (`ie`,
+ `fleet/x`) are not AWS regions and an app may have several, so the
+ order is one fixed list for the app and every browser tries every
+ listed relay. **(codex R1-12, R2-11)**
+4. Apply the transition above to `relay.json` (CAS, `op` bumped,
+ `phase: config`).
+5. For each region needing keys, derive from that region's current
+ master and its kid, and write `CARLOS_RELAY` as one `kind:"secret"`
+ entry through `appconfig.Apply` (the `writeEnv` shape in
+ `internal/objstore/walk.go:602`). `Apply` touches the app's config
+ generation, a nonce plus timestamp (`internal/appconfig/
+ generation.go:40`); record that generation string in `relay.json`
+ and advance to `phase: aggregate`. **(codex R2-6, R2-12)**
+6. Publish `control/relay.json`, bump `control/serial`, advance to
+ `phase: done`.
+7. Answer with the record. The CLI prints regions, incarnation, and
+ "config generation <g> written; delivery is reported by `carlos
+ relay status`", never "delivered".
+
+**Config adoption, observed per route.** The host's config sync, after
+it renders a route's env file and does whatever that route's shape
+calls for, writes an additive acknowledgement
+`control/config-ack/<box>/<route host>.json`: the route's shape (unit,
+socket exec, TCP exec with file delivery), the time, whether a restart
+happened and succeeded, and **the relay key identities it rendered**:
+the `username_suffix` per region parsed out of the `CARLOS_RELAY` value
+it just wrote (account, app, incarnation and kid together), or none. A
+kid alone would not do: a disable and re-enable between two host syncs
+changes the incarnation and the key while keeping the kid, and an ack
+naming only kids would report the old credentials as adopted.
+**(codex R4-2)** The ack is keyed on the route, not the app, because an
+app can have several routes on one box and one can adopt while another
+does not; and it names identities rather than a generation string,
+because the app-wide generation object is a change notification that
+can coalesce or fail after the manifest write
+(`internal/appconfig/generation.go:28`) and is not an identity of what
+was rendered. Nothing reads the ack today but `carlos relay status`;
+it is the first per-route statement of "this is what the process has"
+the platform has, and the plan adds it to `internal/appconfig/sync.go`
+as its own small step. **(codex R1-14, R2-6, R3-4)**
+
+**`carlos relay status`** reports, separately: desired state from
+`relay.json` with its `op` and `phase`; the identities (username
+suffixes) the console last issued per region; delivery per route,
+"adopted at <time>, restarted" when a config-ack names exactly the
+issued identities and a successful restart,
+"written, awaiting delivery" when no ack names them yet, "file
+delivered, awaiting `carlos relay adopted`" for a TCP exec route with
+file delivery until a member acknowledges it, "adopted by member at
+<time>" after, "restart failed" when the ack says so, and
+"hibernating, delivered on wake" when the route is asleep (a route
+added without delivery after enable shows "no config delivery: relay
+will not reach this route" and blocks retirement until fixed); disable
+progress as above; and per region, from
+`control/relay-ack/<box>.json`, the heartbeat age, whether the record
+it holds is current, whether its lease is live, which listeners are
+up, and which `kid`s it has loaded. A heartbeat older than five minutes
+is shown as "stale", so a dead relay cannot look current.
+
+**`carlos relay disable`** applies the transition (`phase: config`),
+removes the `CARLOS_RELAY` entry through `appconfig.Apply`, publishes
+the aggregate, marks `done`.
+
+**Rotation** (`carlos relay rotate --region r`, operator): **(codex
+R1-10, R2-4)**
+
+1. Operator runs `carlos relay keygen`, which prints a new `kid` and key.
+2. Operator adds the line to `/etc/carlos/relay-masters` on each of the
+ region's boxes with `retire_at` `-`. The relay re-reads the file;
+ no restart, no allocation touched. Its next heartbeat lists the new
+ `kid`.
+3. Operator **adds** the new `kid:key` to the console secret for the
+ region, which holds every master the console may need,
+ `CARLOS_RELAY_MASTERS_<REGION>="<kid>:<key>[,<kid>:<key>]"`. Adding
+ it issues nothing: **which master the console issues from is a
+ separate, unsealed pointer**, `control/relay-issuer.json`
+ `{"<region>": "<kid>"}`, and every path that derives a key for an
+ app (enable, region change, re-enable, reconcile) reads that
+ pointer and uses the master it names. A staged master is inert
+ until the pointer moves. **(codex R3-5)**
+4. `carlos relay rotate --region r` **refuses until every armed relay
+ in the region has heartbeated the new `kid`**, then moves the
+ issuer pointer to it, re-derives and re-delivers keys for every app
+ enabled in that region as an operation on each record, and prints
+ the earliest permitted retirement time for the old `kid`: 28 hours
+ from now.
+5. `carlos relay status --region r` shows, per route of every enabled
+ app, which identities its config-ack names and when. **Each route's
+ cutover** is established one of two ways, by route shape. A
+ unit-backed or socket-exec route: the config sync's ack naming the
+ issued identities, since the platform restarted it. A TCP-address
+ exec route with file delivery: its process belongs to an external
+ supervisor the platform cannot restart, so the cutover is a
+ **member acknowledgement**, `carlos relay adopted --host <route>`,
+ given after the member has restarted the process; the console
+ records it in `relay.json` against that route together with the
+ identities issued at that moment, so an acknowledgement given
+ before a later rotation does not count for it. A route in neither
+ category cannot exist under an enabled relay (enable refuses it),
+ and a route added later without delivery blocks retirement until
+ it is fixed. The old master's `retire_at` may be set no earlier
+ than `max(rotation + 28h, latest cutover + 4h)` across every route
+ of every enabled app in the region, and `status` prints that time
+ once every route has a cutover, or names the routes still without
+ one. Setting it earlier is refused by nothing but the docs, and ends
+ any allocation still on the old `kid`, which is the force-retirement
+ case §2 excludes from its guarantee. **(codex R3-4, R4-3, R5-1)**
+6. After `retire_at` the operator removes the line from the boxes and
+ the console secret at leisure.
+
+**App deletion** removes `relay.json` with the rest of the app prefix,
+and the deletion walk publishes the aggregate so the app is gone from
+authority at once. If the walk dies between the two, the reconcile's
+unconditional rebuild, which lists records rather than looking for
+unfinished ones, publishes without the app within five minutes.
+**(codex R3-1)**
+
+## 6. Infrastructure
+
+- `infra/modules/region-host/main.tf`: a `relay` variable, default off,
+ that adds ingress for UDP 3478, TCP 3478 and UDP 49152 to 65535 from
+ `0.0.0.0/0`, and outputs the VPC CIDR for `CARLOS_RELAY_DENY_CIDRS`.
+ Flagship turns it on per region.
+- DNS: an A record `relay.<aws region>.<platform domain>` to the region's
+ Elastic IP, next to the existing `aws_eip.host` resource.
+- Each armed box gains `/etc/carlos/relay-masters` (staged by the
+ operator, like `host.env`) and `CARLOS_RELAY_DENY_CIDRS` and
+ optionally `CARLOS_RELAY_PUBLIC_IP` in `host.env`.
+- Console config gains `CARLOS_RELAY_REGIONS` and one
+ `CARLOS_RELAY_MASTERS_<REGION>` secret per region.
+- Egress cost: every forwarded byte leaves the box once. The
+ unit-economics spec gets a line for relay egress per region, and the
+ two meters in §3 are what fill it.
+
+## 7. Testing
+
+- `internal/relay`: derivation and credential against the §2 vector;
+ the `AuthHandler` against a fixture authority record and masters file
+ covering every refusal reason in §4, including a valid credential for
+ app B presented from a typed source bound to app A, and a UDP and a
+ TCP client with the same address string holding separate bindings;
+ the permission handler against each denied range and the allowed
+ EIP; the relay socket dropping a packet to the EIP on a non-allocated
+ port; limits against a fake clock, including an account spreading
+ allocations across apps, an allocation ended by the byte budget, and
+ a token bucket dropping a burst; the meter against wrapped in-memory
+ sockets, checking that rejected peer ingress is not counted as `in`
+ and that both forwarding legs are counted; the hourly object surviving
+ a restart, including a restart across an hour boundary that rebuilds
+ the rolling window from both objects; a wrapper's close removing only
+ its own binding while a replacement binding for the same typed
+ source survives; shedding triggered by a fake pressure reading.
+- End to end, on loopback with a fixture master: a pion TURN client
+ allocates, refreshes, and keeps relaying until the credential's
+ expiry, at which point traffic stops with no further client request;
+ the same after a disable written to the fixture authority record;
+ two clients on one relay reaching each other's relay candidates; a
+ rotation, by rewriting the masters file, during which an allocation
+ on the old kid carries traffic uninterrupted, then a `retire_at`
+ ending it; a lease lapse ending allocations and a successful read
+ restoring service.
+- `internal/host`: the unit is embedded, written and started by the
+ bootstrap plan when the staging root carries a masters file, and not
+ started when it does not; the adopt restart path names the relay; the
+ config sync writes a config-ack.
+- `internal/console`: the doors through `rawReq` and a `relayURL`
+ fixture: the byte-identical 404, the no-regions refusal, each row of
+ the transition table, the config entry written and removed, the
+ aggregate after concurrent enable and disable of two apps with the
+ etag-first ordering, an enable interrupted after `phase: config`
+ finished by the reconcile, a superseded writer refused by its CAS, a
+ stale config write landing after a disable and undone by the next
+ reconcile, a deletion that removed the record without publishing
+ repaired by the next rebuild, an enable during a staged-but-unmoved
+ rotation issuing from the old kid, a process that lost the writer
+ lease making no further relay write, a delayed old-key publisher
+ refused after a migration, a disable and re-enable between two host
+ syncs not reported as adopted by the old ack, and `rotate` refusing
+ until the fixture heartbeats list the kid; the config sync writing a
+ per-route ack naming the identities it rendered; the retirement time
+ computed with a manual cutover after hour 28; enable refused for an
+ app with a route lacking config delivery; a `carlos relay adopted`
+ given before a later rotation not counting as that rotation's
+ cutover; an externally supervised TCP route with file delivery
+ blocking retirement until acknowledged.
+- `docs/site/cli.md` gains `### carlos relay` with a fenced example, or
+ the docsite gate fails.
+- Flagship gate, as amended in the roadmap: a Meet call between two
+ symmetric-NAT networks connecting through the Ireland relay; both
+ browsers forced to relay and connecting through it **on the real
+ EIP**, and a probe to a non-allocated port on the EIP dropped; a
+ credential minted for one app refused for another, including on an
+ existing allocation's tuple; a credential from before a disable
+ refused after re-enable and its allocation ended; traffic stopping at
+ a credential's expiry with the browser doing nothing; a relay with a
+ lease older than fifteen minutes refusing new allocations and ending
+ carried ones; a permission to a private address refused; the relay
+ meter object showing the call's bytes on both legs; a rotation
+ during a call with the call unaffected; the relay shedding
+ allocations under host memory pressure caused by a tenant, with the
+ edge still serving.
+
+## 8. Not built
+
+- **TURN over TLS (5349 or 443)** **(ruling 4)**. Certificate issuance
+ for a non-HTTP listener needs the relay hostname registered with the
+ deployment's ACME issuer, which is elected and may be another box
+ (`internal/edge/certsync.go`), and its warmer excludes such names
+ (`internal/edge/certwarm.go`); a listener that cannot start until a
+ certificate exists cannot trigger issuance by being visited. That is
+ its own small design, to be written when a network that needs it is
+ reported. 443 additionally needs a second address per box. The
+ roadmap's stage 4a text is amended to match. **(codex R1-15, R2-10)**
+- **An inter-region byte meter** **(ruling 5)**. Classifying a peer
+ address by AWS region needs the published IP ranges on the box and a
+ rule for non-AWS peers; version one meters what is forwarded in each
+ direction, which is what the bill is made of. Filed for stage 5 with
+ the usage view; the roadmap's meters and gate are amended.
+ **(codex R1-18, R2-10)**
+- An issuance API: apps mint offline from delivered keys.
+- Client-aware relay selection: the list is in console order; ICE picks.
+- Commercial quotas and an app-page usage view: the meters exist, the
+ page is stage 5.
+- An SFU: stage 4b, after this ships.
+- Any change to how apps signal: SSE plus POST stays Meet's.
+
+## 9. Rulings
+
+Paul ruled on each of these on 2026-09-07. **(codex R2-10)**
+
+1. **(ruling)** Maximum credential life is four hours. The roadmap's
+ one-hour figure is amended.
+2. **(ruling)** Enable is member-level; the operator's consent is the
+ region list and the limits.
+3. **(ruling)** The console holds every region's master as sealed
+ config; the exposure is stated in §4 and the operator docs; the
+ relay-derives-and-writes design is filed as the tightening.
+4. **(ruling)** No TURN over TLS in version one; the roadmap's stage 4a
+ ingress text is amended.
+5. **(ruling)** Two meters in version one, in and out; the inter-region
+ meter and the usage line move to stage 5; the roadmap's metering
+ text and gate are amended.
+6. **(ruling)** The limits in §3 with their stated defaults, including
+ the token buckets.
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-RELAY-R2.md b/docs/superpowers/specs/CODEX-FINDINGS-RELAY-R2.md
new file mode 100644
index 000000000..d26ee4143
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-RELAY-R2.md
@@ -0,0 +1,205 @@
+# Findings on the relay spec, revision 2
+
+Reviewed by **GPT-6 (Codex), 2026-09-07**, second pass, against revision 2 at `014339b`. Unedited apart from this note. Revision 3 records how each finding was taken.
+
+| Revision-1 finding | Disposition | Reason |
+|---|---|---|
+| R1-1 — JSON delivery | Closed | Standard padded base64 passes the existing config renderer. |
+| R1-2 — Master selection | Closed | `kid` selects one master before Pion’s single integrity check. |
+| R1-3 — Allocation ownership | Partial | Binding is specified, but its transport key remains ambiguous across UDP and TCP. |
+| R1-4 — Active termination | Closed | Wrapped relay sockets support the proposed expiry and revocation mechanism. |
+| R1-5 — Lease renewal | Closed | Independent sixty-second reads renew the lease; failed reads do not. |
+| R1-6 — Aggregate concurrency | Partial | Aggregate CAS helps, but ordering and incomplete-operation recovery remain defective. |
+| R1-7 — Master custody | Partial | The wider exposure is now explicit, but its departure from the parent remains a proposed decision. |
+| R1-8 — Same-relay peers | Partial | Allowing the EIP removes the blanket refusal; the hairpin security argument is insufficient. |
+| R1-9 — Operational limits | Partial | Account allocation limits and active byte-budget termination are added; aggregate bandwidth and rate bounds remain missing. |
+| R1-10 — Rotation | Partial | Hot reload and durable retirement are specified; late minting still breaks the lifetime guarantee. |
+| R1-11 — Minting inputs | Closed | `username_suffix`, raw-key decoding and byte encoding supply the required inputs. |
+| R1-12 — Regions and ordering | Partial | Vocabulary and app-wide ordering are chosen; placement-to-AWS mapping is unspecified. |
+| R1-13 — Repeated enable | Partial | Normal transitions preserve incarnation, but retries cannot repair interrupted delivery. |
+| R1-14 — Status and delivery | Partial | Heartbeat freshness improves status; config adoption has no defined observation mechanism. |
+| R1-15 — TLS certificates | Partial | Removing TLS avoids the issuance deadlock but leaves an unresolved parent-scope change. |
+| R1-16 — Process activation | Closed | Initial start and platform-update restart are now explicit implementation requirements. |
+| R1-17 — Metrics integration | Partial | Separate objects establish one writer, but measurement points and counter semantics remain incomplete. |
+| R1-18 — Inter-region usage | Open | The required meter and account-usage gate are explicitly deferred without an approved parent amendment. |
+| R1-19 — Memory priority | Partial | The ceiling is smaller and OOM bias explicit; neither establishes the claimed kill order. |
+| R1-20 — Factual claims | Partial | The substantive corrections are made; a stale code citation and nonexistent section reference remain. |
+
+Reviewed commit `014339b1fd5295656595d0dddf0d370699a3976e`. Section and line references below refer to the [revision-2 spec][spec]. I independently reproduced all three cryptographic vector values. No files were changed and no live TURN or EC2 test was run.
+
+The requested Pion checks pass: [`RelayAddressGenerator` returns `net.PacketConn`][pion-generator], permitting a wrapper; a relay-socket [read error calls `DeleteAllocation`][pion-reader]. [`OnAllocationCreated` supplies the relay address before the reader starts][pion-created], so the implementation can associate a registered wrapper with its allocation at that callback.
+
+## R2-1 — P1 — Aggregate CAS does not yet fence stale publication
+
+**Section:** §5, lines 319–328.
+
+The spec requires a listing and a conditional aggregate write, but does not require reading the aggregate’s ETag **before** starting that listing. This ordering is necessary:
+
+1. Builder B reads A’s enabled record.
+2. Disable A publishes an aggregate without A.
+3. B reads that aggregate’s new ETag.
+4. B conditionally publishes its earlier listing, restoring A.
+
+Every operation follows the stated text, and the final CAS succeeds.
+
+Even with the correct ordering, “the aggregate can never carry an app whose record says disabled” is false. A crash after the per-app disable leaves the old aggregate until reconciliation, potentially an hour later. Successful sixty-second reads keep renewing that stale authority; the fifteen-minute lease does not bound this failure.
+
+**Correction:** Specify ETag read → complete listing → conditional publication, restarting the entire sequence on conflict. Define when revocation is complete, how pending revocation is exposed, and a recovery bound consistent with the serving guarantee.
+
+## R2-2 — P2 — An interrupted enable can permanently omit credentials
+
+**Section:** §5, transition table and enable steps 4–6.
+
+Enable first records `enabled`, then writes config. If it stops between those writes, retrying the same enable takes the transition that expressly performs “no config write.” The reconciler repairs only the aggregate. The app can therefore become authorized indefinitely without ever receiving `CARLOS_RELAY`.
+
+Separate object CAS also does not fence stale config writers. An enable can pause before `appconfig.Apply`, a disable can finish removing credentials, and the old enable can subsequently restore them. Re-enable and rotation introduce equivalent ordering problems.
+
+**Evidence:** The specified sequence is at lines 335 and 353–360; [`appconfig.Apply`][apply] conditionally changes one bundle, not the relay record and bundle together.
+
+**Correction:** Record a durable operation version and incomplete phase. Reconcile both config and aggregate toward that version, prevent superseded operations from publishing config, and make repeated enable resume unfinished delivery.
+
+## R2-3 — P2 — The owner-binding key can collide across transports
+
+**Section:** §3 owner binding; §4 verification step 3.
+
+The spec moves between “five-tuple” and “source address” without defining their mapping. A global map built from Pion’s event protocol and address strings is unsafe: Pion uses separate allocation managers for UDP and TCP listeners, but constructs the allocation tuple with `Protocol: allocation.UDP` even for TCP clients. UDP and TCP connections can have identical textual source and destination endpoints.
+
+The callbacks therefore permit two independent allocations to collide in the proposed owner map, causing false refusals, overwritten ownership or deletion of another allocation’s binding.
+
+**Evidence:** Pion’s [listener-specific managers][pion-server] and [`handleAllocateRequest` tuple construction][pion-turn].
+
+**Correction:** Define the binding key using the actual client transport and typed source address, with listener scope where necessary. Do not use the event’s protocol string as the client transport. Define the bound identity fields explicitly and require deletion to remove only the binding belonging to that allocation. This is buildable with the specified listeners; it does not require abandoning the adapter.
+
+## R2-4 — P2 — Retirement still contradicts the credential-lifetime guarantee
+
+**Section:** §2, lines 106–108; §4 master retirement; §5 rotation.
+
+Twenty-eight hours of overlap does not guarantee four hours of remaining validity for every old-key credential. An instance retaining the old config can mint at hour 27 with expiry hour 31. The master retires at hour 28, so subsequent authenticated requests fail before the promised expiry.
+
+Retirement also is not among §3’s active termination deadlines. Thus retirement can reject refreshes while existing traffic continues on that allocation. The spec must choose the intended behavior.
+
+The rollout has a second gap: heartbeat records identify authority and listeners, but not loaded masters. Updating a file is not an acknowledgement that every relay successfully loaded the new `kid` before the console distributes it.
+
+**Correction:** Define an issuer cutoff and maximum credential deadline, or begin the final lifetime allowance only after acknowledged issuer migration. Specify forced-retirement behavior and narrow the uninterrupted-lifetime guarantee accordingly. Include loaded `kid` and reload failure state in relay status before switching issuance.
+
+## R2-5 — P2 — Socket counters do not measure the promised traffic
+
+**Section:** §3 metering, lines 221–233; §6 egress, lines 416–418.
+
+Relay-socket `WriteTo` measures client-to-peer payload. Relay-socket `ReadFrom` measures peer ingress **before** permission checks. Peer-to-client traffic leaves through a different socket, `TurnSocket`.
+
+Consequently:
+
+- The proposed `out` measurement misses return-leg egress.
+- Counting raw relay reads against an app’s budget lets unsolicited packets consume that budget and terminate its allocations, although Pion never forwards those packets.
+- The hourly object schema does not say whether `in`, `out` and `allocs` are minute deltas, hourly totals or process totals. It also does not specify how a restart preserves earlier measurements in the same hourly object.
+
+**Evidence:** Pion’s [relay read, permission checks and client writes][pion-reader]; the existing host writer’s [first-write merge behavior][metrics-writer] illustrates the restart issue that a separate object key alone does not solve.
+
+**Correction:** Define directions relative to the host, payload versus wire accounting, and the treatment of rejected ingress. Measure successful forwarding on both outbound legs. Specify counter intervals, allocation-count semantics, restart recovery and retry deduplication before assigning the schema to rollups.
+
+## R2-6 — P2 — Config adoption cannot be inferred from the named API
+
+**Section:** §2 config delivery; §5 enable response, status and rotation.
+
+The spec asks `appconfig.Apply` for a resulting config generation and asks the instances API to establish that an instance restarted after adopting it. These are not the existing contracts:
+
+- [`Apply`][apply] returns an unset count.
+- [`Generation`][generation] is a timestamp and nonce, not a numeric generation `N`.
+- The [instances response][instances] contains release version, health, upstreams and serving boxes, without an applied-config identity or restart acknowledgement.
+
+A later restart alone would not prove which config the process loaded.
+
+TCP-address delivery is also overstated. [`SyncExec`][syncexec] delivers only when the bound bundle opts into `exec_delivery:"file"`; an unopted route receives nothing.
+
+**Correction:** Specify an additive config identity and per-instance acknowledgement of successful application/restart, carried through host status and the instances API. Define hibernating and failed-delivery states. Distinguish TCP routes awaiting external restart from routes with file delivery disabled.
+
+## R2-7 — P2 — Account and box bandwidth and rate bounds remain absent
+
+**Section:** §3 limits, lines 208–220.
+
+The account cap bounds concurrent allocations, but not allocation churn or traffic. An account can spread requests across apps to multiply the 30-per-minute allowance and the 20 GB budget. The box likewise has no aggregate admission-rate or traffic bound.
+
+A rolling-hour byte allowance also permits a concentrated burst before termination; it does not establish bandwidth sharing with other apps or the edge.
+
+**Evidence:** The [parent’s policy][roadmap-policy] requires allocation, rate, bandwidth and lifetime bounds per app, account and box. Revision 2 supplies only some of those dimensions.
+
+**Correction:** Specify aggregate account and box rate/traffic limits, including burst behavior and enforcement on ongoing forwarding. Distinguish the rolling-hour allowance from the mechanism that bounds instantaneous traffic.
+
+## R2-8 — P2 — The memory settings do not guarantee that the relay dies first
+
+**Section:** §3 hardening, lines 145–153; §7 flagship gate.
+
+`OOMScoreAdjust=800` is a bias added to a memory-dependent score, not a strict priority. A sufficiently large tenant with the existing `+500` adjustment can outrank a smaller relay. The repository permits a [1 GiB children tier][cgroup]. Linux’s [OOM scoring implementation](https://raw.githubusercontent.com/torvalds/linux/v6.12/mm/oom_kill.c) combines memory usage and the adjustment.
+
+The host budget also remains incomplete. The [edge ceiling][edge-unit] is 80% of physical memory; adding 256 MiB on a 2 GiB box leaves roughly 154 MiB beneath those ceilings for the console, other services and kernel. Their requirements are not budgeted.
+
+**Correction:** State a complete supported-box budget and the memory-pressure conditions under which relay-first shedding is required. Establish that behavior through an explicit shedding mechanism or a justified scoring envelope. A single successful load test cannot establish the unconditional ordering currently claimed.
+
+## R2-9 — P2 — The EIP hairpin argument does not establish peer isolation
+
+**Section:** §3 peer policy, lines 197–207; §7 tests.
+
+The exception permits every UDP port on the EIP, not just live relay allocations. “The same security group” does not imply “nothing the public cannot reach”: rules can distinguish source addresses, and relayed traffic originates from the box’s address. The infrastructure already accepts source-restricted additional rules through [`extra_ingress`][infra]. AWS also documents [stateful connection tracking](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/security-group-connection-tracking.html), rather than a universal public-access predicate.
+
+This finding does **not** assert that EC2 hairpinning fails. The problem is that successful hairpinning would not prove the claimed isolation. The specified loopback test cannot establish the EC2 address translation and security-group behavior either.
+
+**Correction:** Restrict same-EIP forwarding to live relay allocation destinations using the wrapped socket’s destination-port visibility, or explicitly define and approve the broader public-service policy and its infrastructure constraints. Require the same-relay gate on the actual EIP deployment as well as loopback.
+
+## R2-10 — P2 — Proposed deferrals are not yet an authoritative stage boundary
+
+**Section:** §§8–9 and the parent’s stage 4a contract.
+
+Revision 2 clearly labels proposed departures, which improves honesty, but it still declares the app contract fixed while the parent requires:
+
+| Parent requirement | Revision-2 replacement |
+|---|---|
+| TCP 3478 and TLS 5349; 443-only networks are the documented remaining transport gap | No TLS listener |
+| Received, sent and inter-region bytes on the account’s usage line | Two byte counters in raw chunks; inter-region meter and usage view deferred |
+| One-hour credential maximum | Four hours |
+| Regional master custody with deployment-wide derived-key exposure | Deployment-wide master exposure through console secrets |
+
+**Evidence:** [Parent ingress and credentials][roadmap-ingress], [custody and policy][roadmap-policy], [metering][roadmap-meters], and [stage 4a acceptance gate][roadmap-gate]. Section 9 contains recommendations for Paul, not recorded decisions.
+
+**Correction:** Obtain and record the decisions, amend the parent and stage gate, and make the documents agree before freezing the implementation-plan inputs. If those decisions are not taken, retain the parent requirements. This is a contract-resolution requirement, not an objection to choosing a smaller stage.
+
+## R2-11 — P2 — Placement ordering still lacks its conversion rule
+
+**Section:** §5 enable step 3, lines 348–352.
+
+The spec chooses AWS names for relay regions but still orders them using “the app’s placement region.” Placement values such as `ie` and `fleet/<name>` are not AWS region names. For multiple placements, the text also does not select which one determines the first relay.
+
+**Evidence:** [`edgesFile`][edgesfile] explicitly distinguishes and publishes `Placement` and AWS `Region`.
+
+**Correction:** Specify resolution through the published placement/region mapping, with deterministic behavior for multiple placements, missing mappings and local regions outside the allowed list. Alternatively, use console order unconditionally and remove the placement-first promise.
+
+## R2-12 — P3 — Two references remain incorrect
+
+**Section:** §3 STUN; §5 enable step 5.
+
+The STUN paragraph refers to “§0,” which this spec does not contain. The `writeEnv` citation points to `internal/objstore/walk.go:554`, which discusses deletion concurrency; [`writeEnv` begins at line 602][writeenv].
+
+**Correction:** Name the intended section and cite the function at its current location.
+
+**Verdict: request changes.** P1 and P2 problems remain. Aggregate publication can restore revoked authority under an ordering permitted by the text; delivery recovery, rotation, metering and status still need concrete contracts before the implementation plan.
+
+[spec]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md
+[pion-generator]: /home/paulca/.claude/jobs/f1f6099c/tmp/gomodcache/github.com/pion/turn/v4@v4.1.4/server_config.go:20
+[pion-reader]: /home/paulca/.claude/jobs/f1f6099c/tmp/gomodcache/github.com/pion/turn/v4@v4.1.4/internal/allocation/allocation.go:301
+[pion-created]: /home/paulca/.claude/jobs/f1f6099c/tmp/gomodcache/github.com/pion/turn/v4@v4.1.4/internal/allocation/allocation_manager.go:140
+[pion-server]: /home/paulca/.claude/jobs/f1f6099c/tmp/gomodcache/github.com/pion/turn/v4@v4.1.4/server.go:77
+[pion-turn]: /home/paulca/.claude/jobs/f1f6099c/tmp/gomodcache/github.com/pion/turn/v4@v4.1.4/internal/server/turn.go:34
+[apply]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/appconfig/generation.go:113
+[generation]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/appconfig/generation.go:40
+[instances]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/console/apiinstances.go:132
+[syncexec]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/appconfig/syncexec.go:11
+[metrics-writer]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/host/metrics_loop.go:338
+[edge-unit]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/host/units/carlos-edge.service:57
+[cgroup]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/cgroup/cgroup.go:60
+[infra]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/infra/modules/region-host/main.tf:77
+[edgesfile]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/host/edgesfile.go:72
+[writeenv]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/objstore/walk.go:602
+[roadmap-ingress]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:611
+[roadmap-policy]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:641
+[roadmap-meters]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:662
+[roadmap-gate]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:931
\ No newline at end of file
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-RELAY-R3.md b/docs/superpowers/specs/CODEX-FINDINGS-RELAY-R3.md
new file mode 100644
index 000000000..aa9a6d447
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-RELAY-R3.md
@@ -0,0 +1,156 @@
+# Findings on the relay spec, revision 3
+
+Reviewed by **GPT-6 (Codex), 2026-09-07**, third pass, against revision 3 at `6b64d75`, with Paul's rulings treated as fixed. Unedited apart from this note. Revision 4 records how each finding was taken.
+
+| Revision-2 finding | Disposition | Reason |
+|---|---|---|
+| R2-1 — Aggregate publication | Partial | ETag-first ordering fixes stale publication, but interrupted deletion escapes the specified reconciler. |
+| R2-2 — Interrupted operations | Partial | Phases recover interrupted enable; checking `op` before a separate config write does not fence superseded writers. |
+| R2-3 — Owner binding | Partial | Typed addresses distinguish transports, but the deletion callback lacks the relay address required by the text. |
+| R2-4 — Rotation | Partial | Retirement and loaded-key heartbeats are specified; adoption evidence remains insufficient and other issuance paths bypass the gate. |
+| R2-5 — Metering | Partial | Forwarding directions and rejected ingress are corrected; restart recovery loses the preceding-hour portion of the budget. |
+| R2-6 — Config adoption | Partial | An acknowledgement is specified, but its identity and granularity cannot establish per-instance adoption. |
+| R2-7 — Operational limits | Closed | Account and box admission limits and token buckets supply the bounds chosen by ruling 6. |
+| R2-8 — Memory priority | Partial | The new arithmetic omits swap and page tables; local shedding does not establish the stated host-pressure behavior. |
+| R2-9 — EIP isolation | Closed | Forwarding is restricted to live relay ports, with a real-EIP acceptance test. |
+| R2-10 — Authoritative stage boundary | Partial | Paul’s decisions are recorded, but several contradictory roadmap passages remain. |
+| R2-11 — Region ordering | Closed | Console order removes the placement-conversion ambiguity; the stale parent promise is covered below. |
+| R2-12 — Incorrect references | Closed | The nonexistent section reference is removed and `writeEnv` now points to its definition. |
+
+Reviewed commit `6b64d753c9e62d5a4b8550fa5d2cd317ec98c0f4`. Paul’s six rulings are treated as fixed. All three cryptographic vector values reproduce. No files were changed and no live TURN or EC2 tests were run.
+
+## R3-1 — P1 — Interrupted deletion can leave authority enabled indefinitely
+
+**Section:** §5, reconciliation and app deletion.
+
+The reconciler processes existing records whose phase is not `done`. App deletion removes the record itself before publishing the aggregate. If deletion stops between those steps, no unfinished record remains for reconciliation to find.
+
+The old aggregate can therefore continue authorizing the deleted app. Successful sixty-second reads renew the relay’s lease against that stale aggregate, so the fifteen-minute lease does not bound the failure. A holder of the derived key can keep minting credentials while that authority remains.
+
+**Evidence:** The [reconciliation algorithm][reconcile] only resumes unfinished records, whereas [app deletion][deletion] claims that reconciliation catches a missing record within five minutes.
+
+**Correction:** Require an unconditional aggregate rebuild at console start and every five minutes, including when no unfinished records exist, or preserve a deletion tombstone until publication completes. Specify recovery after deletion removes the record but before aggregate publication.
+
+## R3-2 — P2 — The operation check still permits stale config publication
+
+**Section:** §5, operation walk.
+
+The following sequence satisfies the text:
+
+1. Enable operation A re-reads `relay.json` and confirms its `op`.
+2. A pauses before calling `appconfig.Apply`.
+3. Disable operation B removes the credentials, publishes the aggregate and reaches `done`.
+4. A resumes. `Apply` reads the current config bundle and successfully writes A’s credentials back.
+5. A’s subsequent phase CAS fails, but its config write has already landed.
+
+Reconciliation skips B because it is `done`. The wrong config can persist indefinitely. Re-enable or rotation can similarly leave an enabled app holding superseded credentials.
+
+**Evidence:** The [operation check][reconcile] precedes the config write. [`applyAt`][apply] obtains its own config ETag and conditionally writes that object; it does not condition publication on the relay operation.
+
+**Correction:** Define a fence enforced at config publication, covering API handlers and reconciliation. The config write must reject a superseded operation, rather than discovering supersession afterward. Merely adding another record read leaves the same race.
+
+## R3-3 — P2 — Allocation deletion requires an argument Pion does not provide
+
+**Section:** §3, owner binding.
+
+The spec requires `OnAllocationDeleted` to remove only the binding whose relay address matches the event’s. Pion v4.1.4 supplies no relay address in that callback. It supplies source and destination addresses, protocol, username and realm. The creation callback does supply the relay address.
+
+This matters because Pion removes the allocation from its manager before closing it and issuing the deletion event. Cleanup must not erase a replacement binding created while the old allocation is closing.
+
+**Evidence:** [Owner-binding requirement][binding]; Pion’s [callback signatures][events] and [deletion sequence][pion-delete].
+
+**Correction:** Associate cleanup with the registered relay-socket wrapper, which knows its allocation identity, or specify another available discriminator that cannot remove a replacement binding. Correct the callback claim.
+
+## R3-4 — P2 — The config acknowledgement cannot prove the rotation cutoff
+
+**Section:** §2, config delivery and credential lifetime; §5, config adoption and rotation.
+
+The proposed object is keyed by box, account and app, but adoption happens per route. One app can have multiple routes and environments on one box. If one route restarts successfully while another remains on old config, the successful acknowledgement can make the box appear adopted without identifying the stale instance.
+
+The generation string also lacks the required relationship to rendered content. The existing `config-gen.json` is a separate change notification across all bundles. Its update can fail after the manifest write, and concurrent touches can coalesce. It is not an immutable identifier embedded in the config being rendered.
+
+There is also a direct delivery contradiction: §2 says exec routes opting into file delivery restart, while §5 says they are not restarted. Existing socket-exec routes receive files and are bounced; opted-in TCP-address routes receive files but are never bounced.
+
+These distinctions determine whether the last issuer actually stopped using the old key. They cannot be collapsed into “a config-ack for that generation exists.”
+
+**Evidence:** [Acknowledgement schema and status rules][ack]; [delivery and lifetime promises][delivery]; existing [per-route/environment synchronization][sync], [generation semantics][generation], and [exec delivery contract][syncexec].
+
+**Correction:** Define acknowledgements per route, including the effective relay key identity actually adopted and the result of application/restart. Distinguish running adoption, hibernating delivery, failed restart and externally managed processes. Bind the acknowledgement to the rendered config identity rather than relying on the app-wide notification alone. Retirement must wait for evidence covering every issuer; file delivery alone must not satisfy that condition.
+
+## R3-5 — P2 — Enable and reconciliation can issue the new key before the rotation gate passes
+
+**Section:** §5, enable, reconciliation and rotation.
+
+Rotation updates the console’s master secret before invoking the command that checks relay heartbeats. During that interval, a first enable, region addition or resumed config operation derives credentials from the region’s current console master.
+
+Those paths have no heartbeat gate. They can distribute the new `kid` while a relay has failed to load it—even while `rotate` correctly refuses to proceed. A member then receives credentials that an advertised relay rejects.
+
+**Evidence:** [Enable selects the current master][enable]; [reconciliation derives from the record and masters][reconcile]; [rotation changes the console secret before checking acknowledgements][rotation].
+
+**Correction:** Separate staged masters from the active issuance master, and switch issuance only after the heartbeat gate succeeds. Every credential-writing path must use that gated selection, including reconciliation and region additions.
+
+## R3-6 — P2 — Restart recovery drops part of the rolling-hour budget
+
+**Section:** §3, limits and metering.
+
+The spec explicitly recovers only the current hour’s metrics object. At 12:01, the rolling-hour budget still includes traffic from 11:01 onward, most of which resides in the preceding object.
+
+A restart therefore forgets almost the entire window and can immediately readmit an app or account whose budget remains exhausted. This contradicts the stated recovery behavior.
+
+**Evidence:** [Rolling-hour limits and current-hour recovery][meters].
+
+**Correction:** Load every hourly object intersecting the rolling window—normally the previous and current hour—and rebuild budgets from the relevant samples. Include a restart across an hour boundary in the acceptance tests.
+
+## R3-7 — P2 — The OOM arithmetic does not establish the claimed ordering
+
+**Section:** §3, hardening and memory; §7, flagship gate.
+
+The stated tenant maximum of 1000 follows from RSS alone. Linux’s score also includes swap entries and page tables, and global scoring uses RAM plus swap. This repository permits a tenant to use swap equal to its memory cap, so a 1 GiB memory ceiling does not establish the claimed scoring bound. [Linux scoring implementation](https://raw.githubusercontent.com/torvalds/linux/v6.12/mm/oom_kill.c); [tenant swap allowance][swap].
+
+There is a separate eligibility problem: exhausting the edge’s 80% cgroup ceiling invokes a kill within that tree. The relay, in its own unit, is not a candidate. Shedding only when the relay exceeds its own 160 MiB threshold does not respond to pressure created elsewhere while the relay remains small.
+
+**Evidence:** [The arithmetic and shedding trigger][memory]; [the edge’s cgroup boundary][edge-memory]; [the relay-first gate][relay-gate].
+
+**Correction:** State which pressure conditions the guarantee covers and correct the scoring envelope to include swap and page tables. If relay-first shedding is required under host-wide pressure, specify a host-pressure trigger. Align the gate with that scope; the fixed defaults alone do not establish it.
+
+## R3-8 — P2 — The roadmap still contradicts the approved relay contract
+
+**Sections:** Roadmap stage 4a and §11; relay §§2–5 and §9.
+
+The amendments resolve TLS, credential duration and the main metering gate, but leave these conflicting instructions:
+
+| Topic | Remaining roadmap text | Revision-3 contract |
+|---|---|---|
+| Master exposure | The custody paragraph describes exposure of enabled apps’ derived keys and calls the boundary no worse than existing secrets. | Ruling 3 explicitly exposes every region’s master through console sealed config, including authority to derive future app keys. |
+| Region order | Placement puts the instance’s own region first. | Every instance receives console order. |
+| Peer policy | Permissions refuse the relay’s own addresses. | The EIP is permitted, with forwarding restricted to live relay ports. |
+| Authority lifecycle | Authority is synced from the route table; disable bumps incarnation. | Authority derives from per-app relay records; disable removes authority and re-enable creates the new incarnation. |
+| Accounting amendment | §11 assigns three relay meters to stage 4a. | Ruling 5 assigns two meters to 4a and moves the inter-region meter to stage 5. |
+
+**Evidence:** Roadmap [authority and custody][roadmap-authority], [peer policy and placement][roadmap-policy], and [unit-economics amendment][roadmap-accounting].
+
+**Correction:** Update these passages to reflect the fixed rulings and revision-3 mechanisms. No further policy decision is needed; the parent must stop giving the implementation plan conflicting instructions.
+
+**Verdict: request changes.** One P1 and seven P2 findings remain.
+
+[reconcile]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md:389
+[deletion]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md:505
+[apply]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/appconfig/store.go:146
+[binding]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md:199
+[events]: /home/paulca/.claude/jobs/f1f6099c/tmp/gomodcache/github.com/pion/turn/v4@v4.1.4/internal/allocation/event_handler.go:20
+[pion-delete]: /home/paulca/.claude/jobs/f1f6099c/tmp/gomodcache/github.com/pion/turn/v4@v4.1.4/internal/allocation/allocation_manager.go:150
+[ack]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md:455
+[delivery]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md:108
+[sync]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/appconfig/sync.go:30
+[generation]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/appconfig/generation.go:28
+[syncexec]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/appconfig/syncexec.go:1
+[enable]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md:442
+[rotation]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md:481
+[meters]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md:242
+[swap]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/cgroup/cgroup.go:313
+[memory]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md:157
+[edge-memory]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/host/units/carlos-edge.service:57
+[relay-gate]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md:560
+[roadmap-authority]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:620
+[roadmap-policy]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:656
+[roadmap-accounting]: /home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:979
\ No newline at end of file
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-RELAY-R4.md b/docs/superpowers/specs/CODEX-FINDINGS-RELAY-R4.md
new file mode 100644
index 000000000..f548a83bd
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-RELAY-R4.md
@@ -0,0 +1,61 @@
+# Findings on the relay spec, revision 4
+
+Reviewed by **GPT-6 (Codex), 2026-09-07**, fourth pass, against revision 4 at `ee4b78a`, with Paul's rulings treated as fixed. Unedited apart from this note. Revision 5 records how each finding was taken.
+
+| Revision-3 finding | Disposition | Reason |
+|---|---|---|
+| R3-1 — Interrupted deletion | Closed | Unconditional aggregate rebuilds recover deletion even after its source record disappears. |
+| R3-2 — Stale config publication | Partial | The lock prevents in-process interleaving; cross-process repair still permits old-key delivery after the rotation cutoff. |
+| R3-3 — Allocation cleanup | Closed | Wrapper-owned cleanup has the identity needed to preserve a replacement binding. |
+| R3-4 — Config adoption and retirement | Partial | Per-route acknowledgements improve granularity, but omit incarnation; the retirement formula also omits the manual cutover time. |
+| R3-5 — Premature key issuance | Closed | Every derivation path uses the gated issuer pointer; staging a master alone issues nothing. |
+| R3-6 — Rolling-window recovery | Closed | Recovery explicitly reads every hourly object intersecting the window. |
+| R3-7 — Memory-pressure behavior | Closed | The OOM claim is narrowed and host-pressure shedding has an explicit trigger. |
+| R3-8 — Roadmap contradictions | Closed | The amended passages agree with the fixed rulings and stage contract. |
+
+Reviewed commit `ee4b78ab3956800448d4df47d87749340133b6b4`, treating Paul’s six rulings as fixed. The cryptographic vector reproduces. Pion v4.1.4’s [documented API](https://pkg.go.dev/github.com/pion/turn/v4) and local source were checked. No files were changed; no live TURN or EC2 tests were run.
+
+## R4-1 — P2 — Eventual config repair cannot establish the rotation cutoff
+
+**Sections:** §2, credential lifetime; §5, reconciliation and rotation.
+
+The revised mechanism explicitly permits stale config writes across console processes and repairs them within five minutes. That bounds config inconsistency, but does not preserve the promised lifetime of credentials minted during it.
+
+A permitted sequence is:
+
+1. An operation reads the old issuer and derives its config, then pauses.
+2. Another console process rotates, delivers the new key, and obtains every required adoption acknowledgement.
+3. The old operation resumes shortly before the scheduled retirement and publishes its old-key config. A route adopts it and mints a four-hour credential.
+4. Retirement ends that credential’s allocation early. Subsequent reconciliation cannot restore the interrupted call.
+
+This requires a delayed writer, not force-retirement by the operator: the operator can follow the stated retirement procedure throughout.
+
+**Evidence:** [The reconciliation mechanism](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md:408) expressly allows cross-process stale publication. [Rotation](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md:536) treats migration acknowledgements as establishing when old issuance has stopped.
+
+**Correction:** Require a publication fence that prevents superseded key selections from reaching config after migration, or establish an enforceable single-writer model covering takeover between console processes. Keep reconciliation for recovery, but do not use eventual repair as the fence. Test a delayed old-key publisher resuming after the migration acknowledgements.
+
+## R4-2 — P2 — Acknowledgements cannot distinguish credentials before and after re-enable
+
+**Sections:** §2, incarnation and key changes; §5, config adoption and status.
+
+The acknowledgement records only the `kid` per region. A disable followed by re-enable changes the incarnation and derived key while retaining the same regional master and `kid`.
+
+If both operations finish before the host’s next config sync, its previous successful acknowledgement still names all the newly issued kids. The prescribed status comparison therefore reports “adopted” while the process holds the previous incarnation’s credentials, which the relay rejects.
+
+**Evidence:** [The re-enable transition](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md:450) creates a new incarnation. [The acknowledgement and status rules](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md:482) identify and compare credentials using kids alone.
+
+**Correction:** Record and compare the complete derived-key identity, including account, app, incarnation, region and kid. The delivered `username_suffix` already supplies most of it. Test disable/re-enable between host syncs: the previous acknowledgement must not establish adoption.
+
+## R4-3 — P2 — Retirement does not explicitly allow four hours after manual cutover
+
+**Sections:** §2, credential lifetime; §5, rotation step 5.
+
+The retirement formula uses the later of 28 hours after rotation and four hours after the last **observed route acknowledgement**, with unobservable routes restarted by hand. It does not include those manual restart times in the four-hour calculation.
+
+Suppose observed routes acknowledge at hour 1, while a TCP exec route continues minting from its old in-memory key until its manual restart at hour 28. The written formula permits retirement at hour 28. A credential minted just before that restart can consequently lose almost its entire four-hour lifetime.
+
+**Evidence:** [Step 5’s formula](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md:542) names only the observed acknowledgement as the grace-period clock, whereas [§2’s guarantee](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md:121) requires four hours after the last instance stops minting from the old key.
+
+**Correction:** Define retirement as no earlier than `max(rotation time + 28h, latest issuer cutover + 4h)`, where the latest cutover includes manually verified adoption. Restarting an unobservable route must establish that it actually loaded the new key. Include a manual cutover after hour 28 in the rotation test.
+
+**Verdict: request changes.** No P1 remains. Three P2 defects remain in publication fencing, adoption identity and the retirement rule.
\ No newline at end of file
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-RELAY-R5.md b/docs/superpowers/specs/CODEX-FINDINGS-RELAY-R5.md
new file mode 100644
index 000000000..f2f06a902
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-RELAY-R5.md
@@ -0,0 +1,31 @@
+# Findings on the relay spec, revision 5
+
+Reviewed by **GPT-6 (Codex), 2026-09-07**, fifth pass, against revision 5 at `600a8f1`, with Paul's rulings treated as fixed. Unedited apart from this note. Revision 6 records how the finding was taken.
+
+| Revision-4 finding | Disposition | Reason |
+|---|---|---|
+| R4-1 — Rotation publication fence | Closed | Lease ownership checked at each bucket write, combined with config CAS and per-app serialization, provides a buildable fence. |
+| R4-2 — Acknowledgement identity | Closed | Comparing the full `username_suffix` per region distinguishes credentials across disable/re-enable. |
+| R4-3 — Retirement after manual cutover | Partial | The formula includes late cutovers, but the prescribed restart cannot establish cutover for every unobservable route. |
+
+Reviewed commit `600a8f15912513eb4fb538a343a0877531b6fac3`, treating Paul’s six rulings as fixed. The cryptographic vector reproduces. [Pion v4.1.4’s API](https://pkg.go.dev/github.com/pion/turn/v4) and local authentication source were checked. No files were changed; no live TURN or EC2 tests were run.
+
+## R5-1 — P2 — Rotation has no cutover path for externally managed TCP routes
+
+**Sections:** §2, config delivery; §5, rotation step 5.
+
+Rotation step 5 uses an activator-recorded `carlos restart` time as the cutover for TCP-address routes whose adoption config sync cannot observe. CARLOS does not necessarily control those processes.
+
+Consider a TCP-address route with file delivery enabled, but no socket, unit backing or named unit. Its external supervisor started the process with the old relay key. Rotation delivers the new file, but `carlos restart` cannot restart that supervisor: the activator has no child to bounce, and the root fallback cannot reach the external process.
+
+The required cutover therefore never arrives. Recording the restart request or a no-op as cutover instead would permit retirement while that process still mints old-key credentials.
+
+Routes **without config delivery**, also included in step 5, have an additional contradiction: their cutover requires a restart “after the file was delivered,” but no such delivery occurs.
+
+**Evidence:** The [retirement procedure](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md:557) requires these restart times. [TCP config delivery](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/appconfig/syncexec.go:11) explicitly leaves process ownership with the external supervisor. [`Bounce`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/activator/activator.go:872) affects only a running activator entry; [restart dispatch](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/host/agent.go:2532) supplies no general external-supervisor mechanism.
+
+**Correction:** Define which route shapes can establish cutover. Either require managed delivery and a controllable restart path, or specify an external adoption acknowledgement bound to the newly issued identities. A route without delivery must remain a retirement blocker until that prerequisite is resolved. Preserve `max(rotation + 28h, latest cutover + 4h)`.
+
+Test an externally supervised TCP route with no named unit: a restart request must not establish cutover. Also test a route without config delivery.
+
+**Verdict: request changes.** No P1 remains. One P2 remains in the rotation cutover mechanism.
\ No newline at end of file
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-RELAY-R6.md b/docs/superpowers/specs/CODEX-FINDINGS-RELAY-R6.md
new file mode 100644
index 000000000..817da8165
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-RELAY-R6.md
@@ -0,0 +1,15 @@
+# Findings on the relay spec, revision 6
+
+| Revision-5 finding | Disposition | Reason |
+|---|---|---|
+| R5-1 — P2 — Rotation has no cutover path for externally managed TCP routes | **Closed** | Enable refuses routes without config delivery. Externally supervised TCP routes establish cutover through `carlos relay adopted --host`, recorded against the issued identities. Routes lacking cutover block retirement and are named by status. The retirement bound remains `max(rotation + 28h, latest cutover + 4h)`. |
+
+Evidence: §5’s [enable prerequisite](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md:475) and [cutover and retirement procedure](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md:567).
+
+Reviewed commit `fccd93f8d1bcedc4187ed6b91e35e1a5e8293b05`, treating Paul’s six rulings as fixed and applying the requested stage-spec threshold. Checked the prior findings, parent roadmap, suite doctrine, referenced code, and [Pion v4.1.4’s API](https://pkg.go.dev/github.com/pion/turn/v4@v4.1.4) and authentication source. All three cryptographic test-vector values reproduce.
+
+**No new or remaining P1 or P2 problems found.** No R6 findings to report. The stated mechanisms are sufficient for an implementation plan.
+
+No files were changed; no live TURN or EC2 tests were run.
+
+**Verdict: accept.**
\ No newline at end of file
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-RELAY.md b/docs/superpowers/specs/CODEX-FINDINGS-RELAY.md
new file mode 100644
index 000000000..8ce662ea8
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-RELAY.md
@@ -0,0 +1,237 @@
+# Findings on the relay spec, revision 1
+
+Reviewed by **GPT-6 (Codex), 2026-09-07**, independently of the draft author, from the brief in `CODEX-REVIEW-BRIEF-RELAY.md`. Unedited apart from this note. Revision 2 of the spec records how each finding was taken.
+
+Reviewed commit `3d453c6b1f50b9c1fe8dc0a835e688e913e8c548`. Spec line references below refer to the [relay spec at that revision](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md). I independently reproduced all three cryptographic values in the test vector; they match. Pion findings use v4.1.4’s published API and source. No files were changed; no live TURN test was run.
+
+## R1-1 — P1 — The advertised JSON cannot pass through sealed config
+
+**Section:** §2, lines 44–57; §5, lines 241–246.
+
+`CARLOS_RELAY` contains JSON double quotes. The existing renderer rejects quotes in every value, including decrypted secrets. Writing it as `kind:"secret"` therefore does not solve delivery: the envelope can be stored, but rendering the app’s configuration fails. This blocks the contract Meet is supposed to implement.
+
+**Evidence:** [`ValidVarValue`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/appconfig/materialize.go:23) rejects quotes; [`RenderEnv`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/appconfig/materialize.go:49) decrypts secrets and applies that same check.
+
+**Correction:** Either specify a quote-free wire encoding and its app-side decoding, or explicitly extend config delivery to support JSON safely across both systemd and exec consumers. Test the complete seal → store → render → child-environment path before freezing §2.
+
+## R1-2 — P1 — `AuthHandler` cannot try the previous master
+
+**Section:** §4, verification step 4; §2’s rotation guarantee.
+
+The handler receives no password or request integrity value and returns exactly one key. Computing the current credential and returning its key ends the callback. Pion does not call it again with the previous master after integrity verification fails. Consequently, putting the new master first immediately rejects credentials derived from the old one.
+
+**Evidence:** Pion’s [`AuthHandler` signature](https://pkg.go.dev/github.com/pion/turn/v4#AuthHandler) returns one `[]byte`; [`authenticateRequest`](https://raw.githubusercontent.com/pion/turn/v4.1.4/internal/server/util.go) calls it once and performs one integrity check.
+
+**Correction:** Specify an implementable key-selection mechanism before declaring the username format fixed. Options include an authenticated key identifier in the credential contract or an explicit request-verification adapter supporting multiple candidate keys. Add interoperability tests exercising old and new credentials concurrently.
+
+## R1-3 — P1 — Authentication is not bound to the allocation’s owner
+
+**Section:** §3 authentication and metering; §4 verification.
+
+Pion v4.1.4 authenticates subsequent requests but locates their allocation by connection tuple without comparing the authenticated username to its original owner. A client can allocate using app A’s credential and then refresh or modify that allocation using app B’s valid credential on the same tuple. The proposed handler accepts B because B is enabled; nothing in the spec connects that decision to A’s allocation.
+
+This breaks the cross-app credential boundary and can separate the identity authorizing traffic from the identity being metered.
+
+**Evidence:** [`handleRefreshRequest`, `handleCreatePermissionRequest`, and `handleChannelBindRequest`](https://raw.githubusercontent.com/pion/turn/v4.1.4/internal/server/turn.go) use tuple-based lookup without an owner comparison. [RFC 8656 §21.3.3](https://www.rfc-editor.org/rfc/rfc8656.html#section-21.3.3) requires subsequent credentials to match the allocation’s original credentials.
+
+**Correction:** Bind each allocation immutably to its authenticated identity and enforce that binding on subsequent requests. Name the patched library or adapter that supplies this check. Test credential substitution on an existing tuple, rather than only changing fields in a new Allocate request.
+
+## R1-4 — P1 — Refusing refresh does not terminate expired traffic
+
+**Section:** §2, lines 75–81; §3 lifetime limits; §4 expiry verification.
+
+Rejecting an expired Refresh request does not delete the allocation. Existing ChannelData and Send indications bypass `AuthHandler`, so traffic can continue until existing allocation, permission, or channel state expires. The spec’s claim that the next refresh ends the relay is therefore false.
+
+The ten-minute allocation lifetime is also a default, not the stated cap: Pion accepts requested lifetimes below one hour, including values well above ten minutes.
+
+**Evidence:** Pion’s [request handlers](https://raw.githubusercontent.com/pion/turn/v4.1.4/internal/server/turn.go) return on authentication failure without deleting the allocation. Its [`allocationLifeTime`](https://raw.githubusercontent.com/pion/turn/v4.1.4/internal/server/util.go) accepts client-requested durations below one hour. The [roadmap’s stage 4a policy](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:650) requires established allocations to end at credential expiry.
+
+**Correction:** Give allocations an absolute credential deadline and an active termination mechanism independent of client refreshes. Specify how the implementation closes individual allocations for expiry, disable, region removal, and lease expiry. Enforce the ten-minute maximum explicitly. Test continued traffic after the deadline with no further authenticated requests.
+
+## R1-5 — P1 — The serving lease has no renewal mechanism
+
+**Section:** §3 authority record; §5 records.
+
+The console rewrites the authority record only on enable and disable. Relays fetch it only at startup or after a serial change. After fifteen quiet minutes, every relay must terminate its allocations and refuse new ones—even when the deployment is healthy.
+
+Measuring age since receipt does not fix the missing refresh path. Nor may an unrelated serial bump renew authority merely by causing an old record to be reread.
+
+**Evidence:** Spec lines 169–175 and 226–230 define those triggers. [`BumpSerial`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/release/serial.go:46) is explicitly a latency hint whose failure is tolerated, not a lease renewal.
+
+**Correction:** Define periodic authority revalidation independently of serial changes, the exact freshness clock, and what successful observation renews the lease. Failed reads must not renew it. Test both a healthy deployment with no writes for more than fifteen minutes and an outage that continues past the lease deadline.
+
+## R1-6 — P1 — Concurrent rebuilds can restore revoked authority
+
+**Section:** §5 records, enable, disable, and deletion.
+
+CAS protection on each app’s `relay.json` does not serialize replacement of the shared `control/relay.json`. An enable for B can read a snapshot containing A, pause, and overwrite the aggregate after A’s disable has removed A. The replacement can carry a fresh `written_at`, restoring A’s authority.
+
+The multi-object operation also has no recovery protocol. Failure after changing the app record but before publishing the aggregate leaves contradictory state indefinitely.
+
+**Evidence:** Spec lines 221–230 give CAS semantics only to the per-app record. Lines 240–259 describe sequential writes without aggregate fencing or a reconciliation loop. The existing [`appconfig.Apply`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/appconfig/generation.go:113) guarantees one config-manifest write, not a transaction across these objects.
+
+**Correction:** Specify a fenced aggregate publisher that rebuilds after conflicts, plus durable reconciliation of incomplete operations. Include deletion and rotation in the same concurrency model. Test overlapping enable/disable operations, stale publishers, and failure after every durable write.
+
+## R1-7 — P1 — Console master custody contradicts the claimed exposure boundary
+
+**Section:** §4 region master; §5 rotation.
+
+The console holds every region’s masters as “sealed console secrets,” while the same paragraph says no master exists in the bucket, sealed or otherwise. If these use the existing sealed-config mechanism, both statements cannot be true: a compromised host can decrypt the console bundle and obtain every region’s master.
+
+That exposure is stronger than the roadmap’s conceded access to enabled apps’ derived keys. Masters also derive keys for future incarnations and future enables.
+
+**Evidence:** Spec lines 179–187. The [roadmap §1](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:52) explicitly describes deployment-wide decryption; [stage 4a’s custody statement](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:641) distinguishes regional masters from derived-key exposure.
+
+**Correction:** Name the console’s actual secret store, encryption authority, and readers. To preserve the stated boundary, its master copies must not use the deployment-wide sealed-config key. Keep the already-accepted derived-key exposure explicit.
+
+## R1-8 — P1 — The peer policy blocks two clients using the same relay
+
+**Section:** §3 peer policy; §7 flagship gate.
+
+Two clients forced to use TURN can receive relay candidates on the same region EIP at different allocation ports. Each then needs permission to reach the other’s candidate. The unconditional refusal of the relay’s own addresses rejects both.
+
+This affects the promised fallback when both clients cannot send UDP directly and connect to the relay over TCP or TLS. Falling through among URLs on the same server does not resolve it.
+
+**Evidence:** Spec lines 129–151 assign one public relay address and reject it as a peer. Pion’s [`PermissionHandler`](https://raw.githubusercontent.com/pion/turn/v4.1.3/server_config.go) receives a peer IP without its port, so that hook alone cannot distinguish a live relay allocation from another service on the box.
+
+**Correction:** Specify a safe relay-to-relay mechanism that preserves the prohibition on reaching arbitrary host services. An allocation-aware internal forwarding path needs an explicit design; an IP-only exception is insufficient. Add a test with both browsers forced to relay through the same region.
+
+## R1-9 — P2 — The operational limits do not bound bandwidth or accounts
+
+**Section:** §3 limits; §9 decisions 2 and 3.
+
+The 20 GB threshold refuses only new allocations. Existing allocations can keep transferring and refreshing after crossing it. One enabled app can therefore consume sustained bandwidth beyond the advertised budget.
+
+There is also no account limit. One account controlling 32 apps can fill all 2,048 allocation slots while every app remains within its individual cap.
+
+**Evidence:** Spec lines 153–160. The [roadmap’s policy](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:650) requires allocation, rate, bandwidth, and lifetime bounds per app, account, and box.
+
+**Correction:** Define ongoing traffic enforcement and aggregate account/box limits. Specify atomic admission across all listeners, failed-allocation accounting, and restart behavior for counters. Test an established allocation crossing the byte limit and one account spreading allocations across multiple apps.
+
+## R1-10 — P2 — Rotation drops calls and has no durable retirement deadline
+
+**Section:** §2 rotation promises; §5 rotation procedure.
+
+Restarting `carlos-relay.service` destroys its in-memory allocations and sockets. Even after fixing R1-2, step 1 drops calls rather than preserving them.
+
+The master list also contains no activation or retirement timestamp. “Previous for twenty-four hours” cannot survive process restarts reliably without additional state. Finally, an old derived key can mint a fresh four-hour credential late in the grace period; removing its master at hour 24 does not imply every such credential has expired.
+
+**Evidence:** Spec lines 87–91, 181, and 263–271. Pion’s [server lifecycle](https://raw.githubusercontent.com/pion/turn/v4.1.4/server.go) keeps allocation state inside the running server.
+
+**Correction:** Specify master reload without process replacement, durable key epochs and retirement deadlines, and acknowledgements for relay installation and app key adoption. Define how minting near retirement is bounded. Test uninterrupted traffic during rotation, delayed app delivery, partial rollout, and relay restart during grace.
+
+## R1-11 — P2 — The minting contract does not supply its identity inputs
+
+**Section:** §2 environment and minting.
+
+The app receives the regional keys and incarnation but must independently know the canonical account sqid and CARLOS app name. §2 does not specify where either comes from. An app’s own team identifier or application name is not necessarily its deployment identity.
+
+The encoding instructions should also explicitly require decoding the supplied base64 key into 32 raw bytes before HMAC.
+
+**Evidence:** Spec lines 44–73. The existing [agent-owned config variables](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/appconfig/agentvars.go:19) do not supply these identities; the [exec backend’s injected variables](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/activator/backend_exec.go:660) do not supply them either.
+
+**Correction:** Deliver the canonical account/app identity, or an exact username suffix, as part of the contract. State the base64 alphabet, padding, key decoding, and username byte encoding explicitly. Supply app-side pseudocode using only documented inputs.
+
+## R1-12 — P2 — Region vocabulary and per-instance ordering are unresolved
+
+**Section:** §2 regional array; §5 enable; §6 console configuration.
+
+The example uses AWS region names, while CARLOS placement uses values such as `ie`. The spec requires the instance’s own region first but writes one ordered array into an app-wide config bundle. Two instances of one app in different regions cannot both receive their own region first through that operation alone.
+
+The master-variable template is also incomplete: literal substitution of `eu-west-1` produces a name containing hyphens, which the config API rejects.
+
+**Evidence:** [`edgesFile`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/host/edgesfile.go:72) explicitly separates AWS region from CARLOS placement. [`Apply`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/appconfig/generation.go:113) writes the app’s default bundle. [`ValidVarName`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/appconfig/appconfig.go:103) excludes hyphens.
+
+**Correction:** Choose one relay-region vocabulary, define its placement mapping and environment-variable normalization, and specify where per-instance ordering occurs. Test one app placed in two regions and the case where its local region is outside the allowed relay list.
+
+## R1-13 — P2 — Repeated enable contradicts the incarnation contract
+
+**Section:** §2 incarnation; §5 enable.
+
+§2 says incarnation changes only after disable followed by enable, and that region changes preserve it. Enable step 4 instead writes a fresh incarnation unconditionally. Retrying enable after a lost response can therefore invalidate live credentials. Using enable to change regions has the same problem.
+
+**Evidence:** Spec lines 83–85 versus 221–224 and 238–240.
+
+**Correction:** Define explicit state transitions: first enable, repeated enable, region update, repeated disable, and re-enable. Repeated enable must preserve the existing incarnation. Specify how removing a region terminates allocations there without rotating credentials in retained regions.
+
+## R1-14 — P2 — Status cannot establish delivery or current service health
+
+**Section:** §2 config restarts; §5 enable response and status.
+
+A successful config write does not prove that an instance received or loaded it. The proposed response nevertheless reports delivery and a restart. Some supported TCP-address routes receive files without ever being restarted by CARLOS; others are not eligible for delivery.
+
+The relay acknowledgement is also insufficient as specified. A dead relay’s last acknowledged `written_at` can equal the current record indefinitely. That establishes which record it once held, not whether it is alive, within lease, or listening on the advertised transports.
+
+**Evidence:** [`SyncExec`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/appconfig/syncexec.go:11) documents the route exceptions. [`store.go`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/store.go:24) explicitly distinguishes recorded writes from runtime delivery.
+
+**Correction:** Report desired state, publication state, config generation, observed delivery, fresh relay heartbeat, lease state, and listener readiness separately. Define acknowledgement expiry and supported route shapes. The member command must expose incomplete enable and disable operations without requiring box access.
+
+## R1-15 — P2 — Allowing the hostname does not cause certificate issuance
+
+**Section:** §3 certificate for 5349.
+
+Adding a relay hostname to the edge’s allowed set does not initiate an ACME order. TURN clients connect to 5349, where no listener starts until a certificate exists, so they cannot trigger the edge’s 443 certificate path.
+
+The existing proactive certificate warmer does not automatically solve this: it enumerates routed/advertised hosts and excludes hosts under the apps domain. Issuance also belongs to the elected deployment issuer, which may be another box.
+
+**Evidence:** [`warmSet`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/edge/certwarm.go:351) and [`issuerHostPolicy`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/edge/certsync.go:191).
+
+**Correction:** Register relay names with the deployment-wide issuance and challenge machinery, explicitly trigger issuance and renewal, and define certificate propagation. Specify how 5349 starts when its first certificate arrives. Test from an empty cache with a different box holding the issuer role.
+
+## R1-16 — P2 — Installing the unit does not activate or update its process
+
+**Section:** §3 process.
+
+Adding a service to `enabledUnits` enables it for boot but does not start it. Bootstrap deliberately starts only timers and paths. Platform self-update uses the same arming plan and explicitly restarts only the edge.
+
+The relay can therefore remain inactive after installation or continue running the old executable after a platform update. Sharing the binary does not provide process adoption.
+
+**Evidence:** [`systemctlPlan`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/host/bootstrap.go:116), [`systemApplier.arm`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/host/systemapply.go:661), and [`restartEdge`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/host/systemapply.go:568).
+
+**Correction:** Specify initial activation, conditional enable/disable convergence, required directory creation, and update/rollback handling for the relay process. Include the staging/bootstrap-plan path, which cannot assume it contains the live box’s `host.env`. Test installation and update without rebooting.
+
+## R1-17 — P2 — The metrics integration lacks a compatible schema and single writer
+
+**Section:** §3 metering.
+
+Existing metrics samples have no per-app relay fields. More seriously, the relay is a separate process, while the host metrics writer retains its own hourly sample buffer. Independently appending relay samples to the same objects is unsafe: subsequent host flushes can replace them using the host’s buffered view.
+
+**Evidence:** [`metrics.Sample`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/metrics/sample.go:29) contains host and unit samples only. [`writeHourChunk`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/host/metrics_loop.go:338) merges existing data only on the writer’s first write for that hour.
+
+**Correction:** Define an additive relay sample schema and one writer per object. Either let the host sampler collect relay counters over a local interface or give the relay separate chunk keys. Specify authenticated allocation attribution, counter reset semantics, and updates to raw readers and rollups.
+
+## R1-18 — P2 — The inter-region meter and acceptance requirement were dropped
+
+**Section:** §3 metering; §6 egress cost; §7 testing; §8 exclusions.
+
+`relay_allocs` does not replace the roadmap’s third meter: inter-region bytes. The proposed samples cannot distinguish those bytes from other traffic. The flagship gate also reduces the roadmap’s account-usage requirement to unspecified meters moving, while deferring the usage view.
+
+**Evidence:** The [roadmap’s metering contract](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:662) requires received, sent, and inter-region bytes. Its [stage 4a gate](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:931) requires those meters on the account’s usage line.
+
+**Correction:** Restore the inter-region meter, define how traffic is classified and counted without double charging, and retain allocation count as an additional metric. Align the acceptance gate and stage boundary with the parent rather than silently weakening them.
+
+## R1-19 — P2 — The unit does not establish that the relay loses first
+
+**Section:** §3 hardening.
+
+The edge already permits 80% of physical memory; the proposed relay permits another 25%, before console and other services. Those independent ceilings allow host-wide memory pressure before either service reaches its own ceiling. `Nice` and `CPUWeight` do not determine which process loses under memory pressure.
+
+**Evidence:** The [edge unit](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/host/units/carlos-edge.service:57) explains its existing memory allocation. Systemd documents [memory ceilings and CPU weights](https://raw.githubusercontent.com/systemd/systemd/main/man/systemd.resource-control.xml) separately from [OOM victim adjustment](https://raw.githubusercontent.com/systemd/systemd/main/man/systemd.exec.xml).
+
+**Correction:** Budget memory across the shared box and specify the intended memory-pressure priority explicitly. Validate that priority under combined edge, tenant, and relay load on a `t4g.small`; the listed settings alone do not establish it.
+
+## R1-20 — P2 — Remaining factual claims and references need correction
+
+**Sections:** §2, §3, §4, and §9.
+
+The following defects are separate from the mechanism failures above:
+
+| Claim | Evidence | Correction |
+|---|---|---|
+| The vector is already checked into `internal/relay/vector_test.go` (§2:103). | `internal/relay` does not exist at the reviewed commit. | State that the test will be added. Preserve the verified numerical vector. |
+| `agent.go:1761` is the public-IP lookup (§3:134). | The lookup is [`readPublicIP`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/host/agent.go:1808); line 1761 is blank. | Cite the function and its IMDS leaf read. |
+| `agent.go:1304` is the fast serial poll (§3:172). | That location is restart-failure bookkeeping. [`fastConvergeLoop`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/host/agent.go:1418) documents the actual loop. | Correct the citation and specify the relay’s separate polling or IPC integration. |
+| RFC 8489 mandates MD5 with no alternative (§4:214–215). | [RFC 8489 §18.5.1](https://www.rfc-editor.org/rfc/rfc8489.html#section-18.5.1) defines both MD5 and SHA-256; MD5 is the legacy compatibility algorithm. | Attribute MD5 to the selected Pion compatibility mechanism, not an unconditional RFC mandate. |
+| Pion sends refreshes, and longer calls must restart ICE “before the hour is up” (§2:77–80). | The app contract hands credentials to a browser; that client controls refreshes. The same section specifies four hours. | Describe client-driven refreshes and use the actual credential deadline. Remove the unsupported promise that four hours covers every meeting. |
+
+**Verdict: request changes.** The app contract cannot be delivered through the current config path, and the proposed mechanisms do not yet establish allocation ownership, timely expiry, reliable revocation, or the claimed master-rotation behavior.
\ No newline at end of file
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-RELAY.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-RELAY.md
new file mode 100644
index 000000000..2a17ca9ba
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-RELAY.md
@@ -0,0 +1,69 @@
+# Review brief: stage 4a relay spec
+
+You are GPT-6 reviewing a stage design spec independently of its author.
+Your output is a findings report, not an edit. Do not modify any file.
+
+## What to read
+
+1. The spec under review:
+ `docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md`.
+2. Its parent, which it must honour: the media roadmap
+ `docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md`,
+ especially §1, §3, stage 4a, §7 rulings 8, 10 and 11, and §9's stage
+ 4a gate. Rulings are fixed; report a mechanism that fails one, do not
+ re-argue one.
+3. The code it fits into, in this worktree: `internal/host/bootstrap.go`
+ and `internal/host/units/carlos-edge.service` (units, hardening,
+ `enabledUnits`); `internal/host/agent.go` (IMDS reads near line 1761,
+ fast-converge loop near 1304, config generation loop near 985);
+ `internal/host/edgesfile.go`; `internal/host/metrics_loop.go`;
+ `internal/objstore/walk.go` (`writeEnv` near 554) and
+ `internal/appconfig/` (Apply, generation, sync); `store.go`,
+ `internal/console/apistore.go`, `internal/console/apicli.go`
+ (`apiAccountApp`); `internal/release/serial.go`;
+ `infra/modules/region-host/main.tf`; `go.mod`; `AGENTS.md`.
+4. pion/turn v4's API as documented at pkg.go.dev
+ (`ServerConfig`, `AuthHandler`, `PermissionHandler`, `QuotaHandler`,
+ `EventHandler`, `RelayAddressGeneratorPortRange`, `GenerateAuthKey`).
+ Check the spec's claims about when pion calls the auth handler and
+ what it does with the returned key.
+5. The suite doctrine ruling 6 and §8 at
+ `/home/paulca/amadan.net/oficina/suite/docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md`.
+
+## What matters most
+
+- **The app-facing contract in §2** is what the Meet app will code
+ against today. Is it complete, unambiguous and implementable from the
+ text alone? Does the test vector follow from §4 exactly? Is anything
+ the app needs missing?
+- **Verification in §4.** Look for a way to obtain a TURN allocation
+ without a key an enabled app was given, to use one app's credential
+ for another, to keep using a credential after disable, or to make the
+ relay accept something the authority record does not cover. Check the
+ interaction with pion's long-term credential mechanism.
+- **Key custody in §4 and §5.** Masters in `host.env` and as console
+ secrets, two hand-staged copies. Is the exposure stated honestly and
+ consistently with roadmap §1's statement that sealed config is
+ decryptable on every host? Does rotation as written ever leave a
+ window where a credential stops working or an old master is accepted
+ too long?
+- **The unit in §3.** Hardening, ports, the allocation range, the EIP
+ as relay address, the certificate for 5349, the authority record and
+ its fifteen-minute lease. Is anything unbuildable as written on a
+ `t4g.small` sharing the box with the edge?
+- **Limits and metering.** Can one app starve another, exhaust the port
+ range, or relay to somewhere it should not? Can the meters be
+ attributed wrongly?
+- **Claims about the code.** Every `file:line` and every "already
+ exists" claim: check it. Report each one that is wrong.
+- **Operability.** Can a member, with no box access, enable, see status
+ and disable? Can an operator rotate a master without a call dropping?
+
+## How to report
+
+Number findings R1-1 onward. For each: severity (P1 breaks a trust
+guarantee or makes the stage unbuildable; P2 a contract or claim defect
+to fix before the plan; P3 advisory), the section, the problem, the
+evidence with citations, a proposed correction. End with a verdict:
+accept, accept with corrections, or request changes. Do not soften, do
+not pad, do not restate the spec.