spec: media roadmap revision 4, codex round 3
Nine findings, all accepted. Serving media is now a lease on every door including public, so a disconnected edge stops serving within fifteen minutes; retirement completes per box or by lease expiry. The console is the publisher for derived output, since a conditional write cannot refuse an expired worker with no successor. The relay syncs an authority record carrying each app's incarnation and refuses mismatches. The media hostname is its own claim, not a narrowing of the app grammar. LiveKit gets a platform SFU hostname with WebSocket proxying, rooms namespaced per account, app and incarnation, platform-minted ten-minute tokens with a refresh door and a deny list on rejoin, and its recording prerequisites named. The positive-answer cache knob is removed under ruling 1. Section 9 is acceptance gates, not a sequence.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V676C9hPD1oP4Njdw3B26L
2 files changed,
+239
−67
docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md+133 −67docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R3.md+106 −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 ef0eddb39..1686339bc 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 |
| @@ -1,17 +1,18 @@ |
| # Media on CARLOS: a staged roadmap |
| -**Date:** 2026-09-07, revision 3 |
| +**Date:** 2026-09-07, revision 4 |
| **Status:** rulings taken. Paul ruled on all eleven decisions in §7 on |
| 2026-09-07; each is marked **(ruling)**. Everything not so marked is a |
| **(decision)** that a stage spec may overturn cheaply. |
| Brainstormed with Claude from discussions #107, #112 and #113 and the |
| -requirements the Meet session sent on 2026-09-07. Reviewed twice by |
| -GPT-6 via Codex, independently of the author: revision 1's 27 findings |
| -are in `CODEX-FINDINGS-MEDIA.md`, revision 2's 14 in |
| -`CODEX-FINDINGS-MEDIA-R2.md`, both unedited. Each place a finding changed |
| -the text is marked **(codex N)** or **(codex R2-N)**. §10 names the two |
| -parts declined. |
| +requirements the Meet session sent on 2026-09-07. Reviewed three times |
| +by GPT-6 via Codex, independently of the author: revision 1's 27 |
| +findings are in `CODEX-FINDINGS-MEDIA.md`, revision 2's 14 in |
| +`CODEX-FINDINGS-MEDIA-R2.md`, revision 3's 9 in |
| +`CODEX-FINDINGS-MEDIA-R3.md`, all unedited. Each place a finding changed |
| +the text is marked **(codex N)**, **(codex R2-N)** or **(codex R3-N)**. |
| +§10 names the two parts declined. |
| This is a roadmap, not an implementation spec. Each stage below gets its |
| own design and plan when it is picked up. The roadmap exists so the first |
| @@ -167,9 +168,10 @@ namespace as their source. **(codex 1, R2-3)** |
| namespaces it is: copy to the new namespace, delete the old key and its |
| derivatives, append a purge covering them, fence any transform or job in |
| flight against the old key (§4, stage 1, "publication fence"), and |
| -report completion only after every allowed region has applied the |
| -purge. Bytes a browser already holds cannot be withdrawn, and the docs |
| -say so. Within `protected/`, secret and private are not a move at all: |
| +report completion only when every box that could hold a copy, in any |
| +region the app has ever been allowed, has either acknowledged the purge |
| +or let its serving lease expire (§4, stage 1, "purge"). Bytes a browser |
| +already holds cannot be withdrawn, and the docs say so. **(codex R3-1)** Within `protected/`, secret and private are not a move at all: |
| the app mints a capability or stops minting them, and an existing |
| capability dies at its expiry or when its key id is revoked. Stage 1's |
| gate tests public-to-protected and link-to-roster transitions on cold |
| @@ -230,9 +232,10 @@ The costs: the app's instance is in the hot path for private media, one |
| small request per fetch, and a hibernating instance wakes for it; the |
| app must expose the check endpoint, which rastrillo provides once for |
| every app; the edge stays identity-free, forwarding a cookie it never |
| -reads. The edge never caches the app's answer. A bounded positive cache |
| -on the answer for tenants outside the suite is a later per-app knob, off |
| -by default. **(ruling 1 in §7)** |
| +reads. The edge never caches the app's answer, for any tenant, under |
| +any knob. Ruling 1 says never; a proposal to cache a positive answer |
| +comes back to §7 for a changed ruling, not to a stage spec as an option. |
| +**(ruling 1 in §7; codex R3-7)** |
| Media publication is never evidence for anything else. A sibling app |
| seeing an object under `public/` learns nothing about whether it may |
| @@ -304,12 +307,17 @@ open questions answered. |
| block into `store.json` (prefix, allowed regions, key ids, hostname) |
| and mints `media-<app>.<sqid>.<apps domain>`, one label so the account |
| wildcard already covers it. **(codex 24)** Because `media-<app>` is |
| -also a valid app name, the claim is made through the same name-claim |
| -gate apps use: enabling media for `docs` claims `media-docs` in that |
| -account, creating an app named `media-<x>` is refused from now on, and |
| -an account that already has an app named `media-<x>` cannot enable |
| -media for `<x>` until the operator renames one, with the error saying |
| -so. **(codex R2-10)** The app writes objects under `<prefix>/public/` or |
| +also a valid app name, the hostname is a **media-host claim** of its |
| +own, owned by the source app and recorded in the account, not an app |
| +row: the claim accepts `media-` plus any valid app name (up to 38 |
| +characters, well inside a DNS label), is taken atomically at enable, |
| +and is released at disable. New app names and renames check the claim |
| +table and are refused for a name a media host holds; enabling media is |
| +refused, with the error saying so, for an app whose `media-<x>` is an |
| +existing app's name until the operator renames one. The shared |
| +`release.ValidApp` grammar is not narrowed, because existing manifests |
| +and channel pointers parse through it. **(codex R2-10, R3-4)** The app |
| +writes objects under `<prefix>/public/` or |
| `<prefix>/protected/` with the store credentials it already has, and |
| forms URLs: |
| @@ -385,26 +393,39 @@ they were filled under, so an in-flight fill cannot repopulate an entry |
| a purge already covers. A returning edge reconciles its purge cursor |
| and its route authority **before** admitting any hit from the |
| persistent cache, and if the log history it needs is gone it drops its |
| -media cache rather than guess. Media authority in the route record has |
| -a maximum stale age (fifteen minutes): past it, the secret and private |
| -doors fail closed and only the public door keeps serving from cache, |
| -because a revoked key or a disabled route must not outlive an outage. |
| -The API and CLI answer "accepted" and separately report completion per |
| -box, not per region; the local `Carlos-Purge` header and socket stay as |
| -fast paths and never count as completion. A scan verdict turning |
| -infected files a purge for the key and its derivatives. **(codex 4, 9, |
| -R2-6)** |
| +media cache rather than guess. **Serving media is a lease.** An edge |
| +may serve any media door, public included, only while its media |
| +authority (the route record's media block and its purge cursor) is |
| +younger than fifteen minutes; past that every media door on that edge |
| +fails closed, because a public object that was moved to `protected/`, |
| +a disabled route, a removed region or an infected verdict must not |
| +outlive an outage on a box that clients can still reach. Retirement of |
| +a key, a route or a region is complete when every box that could hold |
| +a copy has acknowledged it or its lease has expired, and the API and |
| +CLI report completion per box on exactly that condition; the local |
| +`Carlos-Purge` header and socket stay as fast paths and never count as |
| +completion. A scan verdict turning infected files a purge for the key |
| +and its derivatives. **(codex 4, 9, R2-6, R3-1)** |
| **The publication fence.** Anything that writes derived output, a stage |
| 2 transform or a stage 3 job, writes to an attempt-specific key under |
| -`derived/_attempts/` and then commits by a conditional write to a |
| -per-source manifest that carries the source ETag, the purge generation |
| -and the app's incarnation. The serving path reads derived output only |
| -through a committed manifest. Purge, an infected verdict, cancellation |
| -and app deletion each bump the generation, so a transform that finishes |
| -after its source was purged, or a worker whose lease expired, finds its |
| -commit refused. Attempt keys are swept. This lives in stage 1 because |
| -both later stages need it and neither may reinvent it. **(codex 17, R2-7)** |
| +`derived/_attempts/` and has no authority to publish it. **The console |
| +is the publisher.** A commit is a console door, `POST |
| +/a/<sqid>/apps/<app>/media/publish`, carrying the attempt, the lease |
| +and the source ETag; the console checks the lease is current in its |
| +registry, the source's purge generation and the app's incarnation match |
| +what it holds, and only then writes the per-source manifest itself. |
| +That is one serialiser with the state to refuse, which a conditional |
| +write against the manifest is not: an expired worker with no successor |
| +would find the manifest's ETag unchanged and its `If-Match` would |
| +succeed. The serving path reads derived output only through a committed |
| +manifest, with one exception: the edge that computed a synchronous |
| +transform may serve those bytes to the request that caused it while its |
| +publish is in flight, and no other edge sees them until the manifest |
| +does. Purge, an infected verdict, cancellation and app deletion each |
| +bump the generation in the registry, so a late commit is refused by the |
| +door. Attempt keys are swept. This lives in stage 1 because both later |
| +stages need it and neither may reinvent it. **(codex 17, R2-7, R3-2)** |
| **Residency, enforced.** The `media` block names the regions allowed to |
| serve and cache the app's media. The bucket policy grants only those |
| @@ -579,13 +600,22 @@ box, built on `pion/turn`, pure Go, in the same binary as the edge, a |
| sealed through `config.json`, one derived key per allowed region: |
| `HKDF(region master, account, app, incarnation)`, as |
| `CARLOS_RELAY_KEYS` alongside `CARLOS_RELAY_URLS`. The app mints TURN |
| - REST credentials per call, `username = expiry:account:app`, |
| - `credential = HMAC(derived key, username)`, with a one-hour maximum. |
| - The relay re-derives the key from the username and its own region |
| - master, so verification is local and per-app metering is real. |
| - Disable bumps the incarnation, so re-enabling never restores an old |
| - key. Rotation is a new region master and a re-render of every enabled |
| - app's config. **(codex 13, R2-2; ruling 8)** |
| + 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 |
| + record is older than fifteen minutes refuses new allocations, the |
| + same lease rule the edge applies to media. Rotation is a new region |
| + master and a re-render of every enabled app's config. **(codex 13, |
| + R2-2, R3-3; ruling 8)** |
| - **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 |
| @@ -635,24 +665,48 @@ about sixty room-hours a month, since both are dominated by bandwidth. |
| An eight-person video room at about 1 Mbps per stream is about 25 GB |
| an hour, roughly two dollars at AWS rates. That line goes in the |
| unit-economics amendment beside the relay meters. |
| -- **Ports.** TCP 7880 for the API behind the edge; TCP 7881 and UDP |
| - 50000 to 60000 opened directly, the same shape as the relay. |
| +- **Ports and hostname.** LiveKit's clients speak WebSocket to the |
| + server's `/rtc` endpoint on 7880, so the SFU has a platform hostname |
| + of its own, `sfu.<region>.<platform domain>`, with TLS terminated by |
| + the edge on that box and the upgrade proxied to LiveKit. The browser |
| + reaches it directly, never through the app's route, so an app on a |
| + tunnel-backed host still joins SFU rooms. TCP 7881 and UDP 50000 to |
| + 60000 are opened directly, the same shape as the relay. **(codex R3-5)** |
| +- **Tenant boundary.** The platform holds LiveKit's API key and secret; |
| + no app ever does. Rooms are named `<account>/<app>/<incarnation>/ |
| + <room>` and only the platform mints tokens: the app calls `POST |
| + /a/<sqid>/apps/<app>/media/sfu/token` with its route credential, |
| + naming a room, a participant identity and grants, and the door |
| + prefixes the room, caps the grants to that app's namespace, signs a |
| + token with a ten-minute life, and returns it. Participants refresh |
| + through the same door, so removal is the app declining to refresh |
| + plus the door calling LiveKit's remove-participant, and a removed |
| + identity is held on a per-room deny list the door checks on refresh |
| + and on LiveKit's participant-joined webhook, so an old token cannot |
| + restore access. Management operations are the door's, scoped to the |
| + app's rooms. The 4b gate includes a cross-app room name refused and a |
| + remove-then-rejoin refused. **(codex R3-6)** |
| - **Recording and ingest are separate LiveKit services** and are |
| - deferred until asked for. Per-track recording is light; composite |
| - recording runs headless Chrome and wants two vCPUs per recording in |
| - progress; WHIP and RTMP ingest is a third service. Recording writes |
| - per-track or composite files into the app's bucket and submits a |
| - stage 3 single-rendition job, so a recorded call becomes a playable |
| - asset with no new serving code. Recording depends on stage 3, not the |
| - other way round. **(codex R2-11)** |
| + deferred until asked for, with their prerequisites named now so the |
| + launch box is not mistaken for them: LiveKit Egress needs Redis to |
| + talk to the server, its own worker of at least four vCPUs and 4 GB, |
| + and Chrome's sandbox requirements for composite recording; WHIP and |
| + RTMP ingest is a third service with the same Redis dependency. |
| + Recording writes per-track or composite files into the app's bucket |
| + and submits a stage 3 single-rendition job, so a recorded call |
| + becomes a playable asset with no new serving code. Recording depends |
| + on stage 3, not the other way round. **(codex R2-11, R3-8)** |
| - **Live streaming to many viewers** is a distinct long-running workload: |
| ingest feeds a live packager, and LL-HLS needs partial segments, |
| blocking playlist reloads and delivery directives that an object |
| reader does not supply, so it is a named serving extension with its |
| own latency contract, not a reuse of stage 1 unchanged. **(codex 22)** |
| -**Signalling stays the app's.** Offers, answers and candidates ride the |
| -app's own HTTP. §8 answers #113. |
| +**Signalling.** In peer-to-peer mode, with or without the stage 4a relay, |
| +offers, answers and candidates ride the app's own HTTP, and §8 answers |
| +#113 about that. In SFU mode the LiveKit client SDK carries its own |
| +signalling over the WebSocket to the SFU hostname above; the app's HTTP |
| +is then for room coordination and token refresh only. **(codex R3-5)** |
| ### Stage 5: what makes it usable from an app |
| @@ -797,9 +851,11 @@ everywhere, its volume is small, and a socket can be adopted later on |
| hosts the team has placed deliberately, with SSE as the fallback Meet |
| would need anyway. |
| -## 9. What each stage must prove before the next starts |
| +## 9. Acceptance gates |
| -The gates are attacks failing, then playback working. **(codex 27)** |
| +Each stage has its own gate; what a gate unlocks is whatever §4's graph |
| +hangs off it, not "the next stage" in this list. The gates are attacks |
| +failing, then playback working. **(codex 27, R3-9)** |
| - **Stage 1**, on flagship: `/p/` of a protected key is 404 cold and |
| warm; a capability with the expiry edited, the scope widened past a |
| @@ -810,10 +866,13 @@ The gates are attacks failing, then playback working. **(codex 27)** |
| object tagged infected is unreadable through media and its cached |
| copy is gone within a purge round; a purge issued while a fill is in |
| flight leaves no stale entry; an edge reconnecting after an outage |
| - serves nothing from disk until its cursor is current; a revoked key |
| - stops working on a disconnected edge within fifteen minutes; an edge |
| - outside the allowed regions refuses to fill; a late commit against a |
| - purged source is refused by the fence. Then: a public hit served from |
| + serves nothing from disk until its cursor is current; a revoked key, |
| + and a public object moved to `protected/`, both stop being served by |
| + a disconnected edge within fifteen minutes; an edge outside the |
| + allowed regions refuses to fill; a late commit from a worker whose |
| + lease expired with no successor is refused by the publish door; an |
| + app named `media-<x>` created before enable keeps working and enable |
| + for `<x>` is refused with the right error. Then: a public hit served from |
| a second region's cache, one object reached both by the app's check |
| and by a capability, an HLS playlist and its segments loading under |
| one capability, and a 2 GB MP4 seeking in a browser through a |
| @@ -830,10 +889,16 @@ The gates are attacks failing, then playback working. **(codex 27)** |
| - **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 |
| - minted before a disable refused after re-enable; 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. |
| + 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; 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. |
| +- **Stage 4b**: a token minted for one app refused for another app's |
| + room; a participant removed by the app unable to rejoin with its old |
| + token; a browser on an app served from a tunnel-backed host joining |
| + an SFU room; an eight-person room's egress appearing on the account's |
| + usage line. |
| ## 10. Findings not accepted |
| @@ -846,7 +911,7 @@ The gates are attacks failing, then playback working. **(codex 27)** |
| tenant reports a 443-only network, because it changes the fleet's |
| networking for every box to serve a case nobody has hit yet. |
| -Every revision-2 finding was accepted. |
| +Every revision-2 and revision-3 finding was accepted. |
| ## 11. Amendments to existing specs |
| @@ -861,5 +926,6 @@ Every revision-2 finding was accepted. |
| - `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. |
| -- `internal/release/release.go:239` app-name grammar: names beginning |
| - `media-` are reserved for media hosts. |
| +- The console's name-claim gate (`internal/console/server.go:2904`): |
| + new app names and renames consult the media-host claim table. The |
| + `release.ValidApp` grammar is unchanged. **(codex R3-4)** |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R3.md b/docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R3.md| new file mode 100644 |
| index 000000000..445b3bf4b |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R3.md |
| @@ -0,0 +1,106 @@ |
| +# Findings on the media roadmap, revision 3 |
| + |
| +Reviewed by **GPT-6 (Codex), 2026-09-07**, third pass, against revision 3 at `3f800f6`, with Paul's rulings treated as fixed. Unedited apart from this note. Revision 4 records which findings were accepted and how. |
| + |
| +| Revision-2 finding | Disposition | One-line reason | |
| +|---|---|---| |
| +| R2-1 — Visibility transitions | Partial | Deletion is now required, but stale public serving and the incomplete publication fence still weaken retirement. | |
| +| R2-2 — Relay compromise boundary | Partial | Exposure is stated correctly; the incarnation is missing from the described verification mechanism. | |
| +| R2-3 — Orthogonal read-only links | Closed | Secret and private doors now address the same object in `protected/`. | |
| +| R2-4 — Private-check transport | Closed | Backend-specific transport, home-edge serving and cache bypass are explicit requirements. | |
| +| R2-5 — Secret-response expiry | Closed | Per-request headers include bounded freshness and `must-revalidate`, overriding object directives. | |
| +| R2-6 — Purge serving gate | Partial | Catch-up and per-box reporting are specified, but public responses escape the stale-state bound. | |
| +| R2-7 — Publication enforcement | Partial | Attempt keys and committed manifests are defined; their conditional write still does not enforce lease expiry. | |
| +| R2-8 — Remote completion | Closed | Version one uses polling; callbacks explicitly require a later durable outbox. | |
| +| R2-9 — Worker and residency eligibility | Closed | Submission checks bucket region, allowed regions and worker availability; media DNS respects the allowed set. | |
| +| R2-10 — Media hostname ownership | Partial | Reservation is decided, but the proposed reuse of app-name validation breaks length and compatibility cases. | |
| +| R2-11 — Dependencies | Partial | The graph and shared prerequisites are corrected; §9 still imposes a contradictory sequential gate. | |
| +| R2-12 — Artefact disclosure | Closed | The interim exception now includes recordings and exports. | |
| +| R2-13 — Placement claims | Closed | Existing placement controls are distinguished from the missing runtime transport-capability contract. | |
| +| R2-14 — Browser-cookie claim | Closed | The universal claim is replaced with a cross-browser reliability statement. | |
| + |
| +Reviewed commit `3f800f6df80441df32ca03c806184c43840e1c77`. Paul’s eleven rulings are treated as fixed. No files were changed. |
| + |
| +**Severity:** P1 breaks a trust guarantee or makes a stage unbuildable as written; P2 is a contract or claim defect to fix before the stage spec; P3 is advisory. “Closed” means resolved at roadmap level, not implementation-proven. |
| + |
| +## R3-1 — P1 — §2 and stage 1: public cache hits bypass retirement indefinitely |
| + |
| +**Problem and evidence:** The [serving gate](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:381) closes secret and private doors after fifteen minutes without fresh authority, but explicitly lets public cached responses continue. |
| + |
| +An edge can cache `public/x`, lose control-plane connectivity while remaining reachable by clients, and continue serving it after another box moves the source to `protected/`, disables media, removes that region, or records an infected verdict. Deleting the bucket object and removing its read grant do not invalidate those local bytes. Catch-up-before-serving only helps when the edge reconnects. |
| + |
| +This also leaves inconsistent completion rules: the [visibility lifecycle](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:166) waits for every allowed **region**, whereas stage 1 correctly reports every **box**. One current box in a region cannot establish that another reachable, disconnected box has retired its copy. |
| + |
| +**Correction:** Apply a bounded freshness requirement for purge and serving authority to public cache hits too. Public requests need no viewer authorization, but still need current authority to serve that object in that region. Define retirement completion over every box that could retain a copy, including removed regions: each must acknowledge retirement or become unable to serve under an expired serving lease. Use that same condition for visibility moves, disable and infected verdicts. |
| + |
| +## R3-2 — P2 — Stages 1–3: manifest conditional writes still do not enforce lease expiry |
| + |
| +**Problem and evidence:** The [publication fence](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:398) specifies a conditional write to a per-source manifest containing source ETag, purge generation and app incarnation. It then claims an expired worker’s commit is refused. |
| + |
| +Consider a worker that reads manifest ETag `M`, takes a lease, and stalls. Its lease expires without another worker changing the manifest. Its eventual `If-Match: M` still succeeds. Merely passing time changes neither the manifest nor its ETag. Likewise, changing a generation in a separate record does not invalidate the manifest condition unless an authoritative operation ties them together. S3 compares the addressed object’s ETag; it does not evaluate an external lease or application fields. [AWS conditional-write contract](https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html). |
| + |
| +Attempt-specific output and manifest-only serving close part of R2-7, but do not supply the missing rejection mechanism. |
| + |
| +**Correction:** Name the component that authorizes and serializes publication. It must enforce the current attempt/lease, source generation and app incarnation against purge, cancellation and deletion. Workers should upload attempts without independent authority to publish them. If conditional manifests remain the mechanism, specify how those competing transitions invalidate the exact condition used by commits, and how expiry is enforced. Test an expired worker with **no successor**, as well as a worker racing lease takeover. |
| + |
| +## R3-3 — P2 — Stage 4a: the relay cannot derive the specified key from the specified username |
| + |
| +**Problem and evidence:** [Credential derivation](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:577) now includes `(account, app, incarnation)`, but the username remains `expiry:account:app`. The relay is said to derive the key from that username and its regional master. The incarnation is unavailable from those inputs. |
| + |
| +A locally converged incarnation record would resolve this, but none is specified. Including the incarnation in the username alone would also be insufficient: accepting whichever incarnation the caller supplies would continue accepting old credentials after disable and re-enable. |
| + |
| +**Correction:** Define locally converged relay authority containing the enabled state and current incarnation. Either obtain the incarnation from that record or include it in the authenticated username and compare it with the record. Define stale-state refusal, durable incarnation retention across deletion/name reuse, and allocation termination on disable. This implements ruling 8 without changing its key-delivery decision. |
| + |
| +## R3-4 — P2 — Stage 1 and §11: hostname reservation is assigned to an incompatible validator |
| + |
| +**Problem and evidence:** [Stage 1](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:303) claims `media-<app>` through the existing app-name gate. That gate calls [`release.ValidApp`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/console/server.go:2904), whose [grammar](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/release/release.go:239) permits at most 32 characters. A valid app name of 27–32 characters therefore produces an invalid synthetic claim of 33–38 characters. |
| + |
| +Separately, §11 targets that shared grammar for reserving `media-`. Changing `ValidApp` to reject the prefix would also reject existing apps’ [release manifests](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/release/release.go:272) and [channel pointers](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/release/release.go:452). That contradicts the promise to preserve existing `media-<x>` apps. |
| + |
| +The current claim operation also creates an ordinary app row; it is not a separate hostname reservation abstraction. |
| + |
| +**Correction:** Reserve the prefix at new-name acquisition, including rename, while preserving historical name parsing. Define a media-host claim owned by the source app, with validation that accepts the generated hostname for every valid source name. Make collision handling atomic and connect claim retirement to media disable. Do not implement the reservation by narrowing the shared release grammar. |
| + |
| +## R3-5 — P2 — Stage 4b and §8: stock LiveKit does not use Meet’s SSE/POST signalling |
| + |
| +**Problem and evidence:** After selecting hosted LiveKit, the roadmap still says [offers, answers and candidates stay on the app’s HTTP path](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:654), pointing to §8’s SSE/POST recommendation. |
| + |
| +LiveKit clients instead establish a WebSocket connection to `/rtc` and exchange LiveKit signalling messages there. Port 7880 serves both its API and WebSocket endpoint. [LiveKit client protocol](https://docs.livekit.io/reference/internals/client-protocol/), [ports reference](https://docs.livekit.io/transport/self-hosting/ports-firewall/). |
| + |
| +Consequently, hosting the server behind a platform API does not preserve the existing signalling contract. Routing that WebSocket through a tunnel-backed app would encounter the acknowledged upgrade refusal. |
| + |
| +**Correction:** Scope SSE/POST to Meet’s peer-to-peer mode and app-level room coordination. Specify a platform SFU hostname with TLS and WebSocket proxying to LiveKit, reached directly by the client even when the app backend is tunnel-backed. State the client SDK/protocol integration required for 4b. This preserves ruling 9. |
| + |
| +## R3-6 — P2 — Stage 4b: the shared SFU has no tenant-authority or revocation contract |
| + |
| +**Problem and evidence:** [Stage 4b](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:618) specifies the server, box, ports and recording destination, but “fronted by the platform’s own API” leaves room ownership, participant admission and management authority undefined. |
| + |
| +LiveKit tokens authorize a participant for named rooms and permissions. Its self-hosted server also does **not** invalidate an existing token merely because a participant is removed; that participant can rejoin while the token remains usable. Token expiry alone does not terminate an established session. [LiveKit tokens and lifecycle](https://docs.livekit.io/frontends/reference/tokens-grants/). |
| + |
| +Thus neither tenant separation nor removal from an app’s room follows automatically from hosting LiveKit. Reusing the private-media check does not solve this: that check governs individual object requests, not a long-lived SFU session. |
| + |
| +**Correction:** Require platform-derived account/app/incarnation room identities, app-authorized participant admission, scoped management operations, and platform custody of unrestricted LiveKit signing credentials. Define removal and reconnect behavior, including how old tokens are prevented from restoring revoked access. Add cross-app room-access and remove-then-rejoin tests to the 4b gate. |
| + |
| +## R3-7 — P2 — §2 and ruling 1: the future positive authorization cache contradicts the fixed ruling |
| + |
| +**Problem and evidence:** [Section 2](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:229) retains a later per-app positive authorization cache for tenants outside the suite. Ruling 1 fixes private delivery as ask-the-app **per request, never cached**, without that exception. |
| + |
| +With the knob enabled, a warm authorization result admits a request after the app has revoked the grant. Being disabled by default does not preserve the ruling when enabled. |
| + |
| +**Correction:** Remove the exception from the roadmap’s authorized scope. Any future proposal to cache private authorization must explicitly return for a changed ruling; it cannot be presented as an already-permitted implementation option. |
| + |
| +## R3-8 — P3 — Stage 4b: deferred recording needs additional infrastructure |
| + |
| +**Problem and evidence:** The [recording paragraph](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:640) names separate services but omits their Redis dependency and gives a two-vCPU composite-recording estimate without a capacity gate. |
| + |
| +LiveKit Egress uses Redis to communicate with the server. Its deployment guidance recommends at least four CPUs and 4 GB per Egress instance and identifies additional Chrome sandbox requirements. The bare single-node SFU’s no-Redis configuration therefore does not extend unchanged to recording. [LiveKit Egress deployment](https://docs.livekit.io/transport/self-hosting/egress/). |
| + |
| +**Correction:** Record Redis, recording-worker provisioning and measured admission limits as prerequisites of the deferred recording extension. Keep them deferred under ruling 9; do not imply that the launch SFU box or stage-3 ffmpeg worker supplies them automatically. |
| + |
| +## R3-9 — P3 — §9: the heading still contradicts ruling 11 |
| + |
| +**Problem and evidence:** [Section 9](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:800) remains “What each stage must prove before the next starts.” That imposes the sequential condition R2-11 requested removing, despite the corrected graph and the fixed independent starts for stages 1 and 4a. |
| + |
| +**Correction:** Describe these as acceptance gates for each stage, with downstream prerequisites determined by §4’s graph. This is the remaining editorial portion of R2-11, not a request to change the order Paul ruled. |
| + |
| +**Verdict: request changes.** Revision 3 resolves the shared protected-object model, private transport selection, secret HTTP-cache expiry, submission eligibility and several staging defects. The remaining corrections fit within Paul’s rulings: bound public serving during control-plane outages, make publication rejection enforceable, supply the relay’s incarnation authority, preserve existing app names, and define LiveKit’s signalling and tenant-access boundary. Those mechanisms need correction before the affected stage specifications can rely on the roadmap’s guarantees. |
| \ No newline at end of file |