carlos / platform Public

Merge branch 'worktree-media-roadmap-spec' into main

claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 8291cb25552022e403ab6d96c67f9b1fe3b778f3 parent 09a22d6parent 397cde4
7 files changed, +1676 −0
  • docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md +977 −0
  • docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R2.md +169 −0
  • docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R3.md +106 −0
  • docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R4.md +96 −0
  • docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R5.md +36 −0
  • docs/superpowers/specs/CODEX-FINDINGS-MEDIA.md +196 −0
  • docs/superpowers/specs/CODEX-REVIEW-BRIEF-MEDIA.md +96 −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
new file mode 100644
index 000000000..50950e42c
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md
@@ -0,0 +1,977 @@
+# Media on CARLOS: a staged roadmap
+
+**Date:** 2026-09-07, revision 6
+**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 four 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`, revision 4's 8 in
+`CODEX-FINDINGS-MEDIA-R4.md`, and revision 5's 2 in
+`CODEX-FINDINGS-MEDIA-R5.md`, all unedited. **Round 5 returned accept:
+no P1 or P2 remained, and its two wording corrections are in this
+revision.** Each place a finding changed the text is marked
+**(codex N)**, **(codex R2-N)**, **(codex R3-N)** or **(codex R4-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
+stage is built with the later ones in view, so nobody builds a stage out
+of order, and so the security properties are named before any URL shape
+is.
+
+## 0. The goal in one sentence
+
+Any app on CARLOS can serve, transform, stream and relay media at the
+performance people expect from a CDN or a media API, with nothing to
+install and no third-party service in the path, and every object can be
+public, secret or private.
+
+"No third party" is not taste. Suite ruling 6 forbids egress that is a
+property of the product, and a media CDN, an image API, a TURN service
+or a hosted SFU that every app dials by default is exactly that. The
+platform is the one recipient a team has already chosen, so media
+handled by the platform is not egress at all. That distinction is what
+makes this a platform feature rather than a per-app settings field, and
+the plan keeps that language throughout.
+
+## 1. Where things stand, measured on `main`
+
+Discussion #107 recorded most of this on 2026-08-31. Re-checked today,
+and corrected where the reviews found the citations wrong.
+
+- **Apps already have object storage**, bucket-per-app, credentials
+ delivered sealed through `config.json` (object-storage spec §5). Reads
+ and writes go app-to-S3 directly. The spec says at line 27 that a
+ byte-proxy in the data path is "permanently out of scope". Reads must
+ cross that line for a CDN to exist. Writes never will.
+- **Sealed config is decryptable on every host.** The deployment's
+ decryption key sits in `host.env` on every box (config-secrets spec
+ §4) and `internal/appconfig/materialize.go:30` renders secrets to env
+ at adopt time. A compromised host therefore already exposes every
+ app's store keys and secrets, not only its own residents'. Nothing in
+ this plan makes that better or worse, and no claim below may pretend
+ a per-region secret changes it. **(codex R2-2)**
+- **The store already scans.** `internal/objstore` has a ClamAV scanner,
+ a resumable sweep that tags every object with a verdict, a scan queue
+ of one object per item under the app's own prefix, and, when a store's
+ scan block has enforcement on, a `Deny` on `GetObject` for objects
+ tagged infected, written into the store user's policy
+ (`internal/objstore/aws.go:211`, `objstore.go:82`). There is no upload
+ hook by design; the sweep counts what it has not seen. Any new reader
+ of the bucket must carry the same deny whenever the store user does,
+ or it is a bypass. **(codex 4, 26, R4-8)**
+- **The edge cannot read a store bucket.** The region host role is scoped
+ to the deployment bucket and its own region's data bucket
+ (`infra/modules/region-host/main.tf:106`, residency argument in the
+ header at line 6). A new grant is most of the serving design.
+- **The shared response cache is unsafe for anything authorised.** The
+ key is host, method, path and query; a hit returns before any handler
+ runs (`internal/edge/edge.go:1300`). `kind:status` already opts out
+ and caches for itself because an unbounded keyspace in a
+ process-global LRU lets one tenant evict every other. Media has that
+ problem and an authorisation one.
+- **Purge is local to one box.** The purge listener is a unix socket on
+ the host it runs on, and the `Carlos-Purge` response header acts on
+ the edge that saw the response. Nothing distributes an invalidation
+ across regions today. **(codex 9)**
+- **An edge keeps serving from last-converged state through a control
+ bucket outage** (multi-region ingress spec, line 123). Any authority
+ media adds to the route record inherits that: a disconnected edge
+ will honour a revoked key until told otherwise. **(codex R2-6)**
+- **The tick path is local.** `activatorDeliverer` reaches an instance
+ through the activator and its unix socket on the same box, and refuses
+ routes without one (`internal/host/schedule_wiring.go:220`). Once-timers
+ are filed on an instance's control socket, bound to that host
+ (`internal/schedule/control.go:28`). Neither crosses a box.
+ **(codex R2-4, R2-8)**
+- **Range and conditional handling come from `http.ServeFile`** for
+ `kind:static`, and only there. A streaming S3 handler has to build its
+ own HTTP mapping, and S3 `GetObject` serves one range per request.
+ **(codex 7)**
+- **The edge is identity-free by design** (`internal/edge/edge.go:1`).
+ It never reads a cookie. Suite identity is a home-minted Ed25519
+ assertion, audience-bound, single-use, two minutes long, delivered by
+ auto-POST; it cannot be replayed per image. Suite doctrine ruling 3
+ says authorisation is never cached at any scope, and leak item 11
+ names a cacheable personalised response at the platform edge as a
+ cross-person leak.
+- **The doctrine's visibilities are rungs plus an orthogonal link.**
+ Ruling 15: an item is `private`, `granted` or `team`, and may
+ additionally carry a read-only link. `team` is not public. One item
+ can be reachable by its roster and by a link at the same time, so the
+ storage model must let both doors reach one object. **(codex 11, R2-3)**
+- **Doctrine §8 says the recipient of any egress is the app's choice,
+ never the team's**, disclosed in the README's first paragraph and on
+ every artefact produced. A team-pasted relay URL is not admissible.
+ **(codex 12, R2-12)**
+- **Members already control placement.** `carlos apps place` and
+ `carlos instances create --region` exist, and the instances API
+ reports which boxes and regions serve a host
+ (`internal/console/instancesplacement.go:21`). What does not exist is
+ a runtime signal an app can read about its host's transport
+ capabilities. **(codex R2-13)**
+- **Wildcard certificates cover exactly two labels** under the apps
+ domain (`internal/edge/wildcard.go:186`), and app names are
+ `^[a-z][a-z0-9-]{0,31}$` (`internal/release/release.go:239`), so a
+ one-label media name for any app of up to 26 characters is also a
+ valid app name, and an app name may end in a hyphen, which a DNS
+ label may not (`internal/provision/provision.go:223`). Hostnames in
+ the account namespace are also claimed by the instances door
+ (`internal/console/apiinstances.go:571`), which lets an app take any
+ unclaimed label, not only its own name. **(codex 24, R2-10, R4-4, R4-5)**
+- **The fleet is small ARM.** Four production regions on Graviton
+ `t4g.small`, one `t4g.medium`, burstable at a 20% per-vCPU baseline
+ with the same two vCPUs on both sizes; the medium adds memory, not
+ CPU. No GPU, no ffmpeg, no package installs at bootstrap. Remote hosts
+ are Raspberry Pis. **(codex 20)**
+- **The Go tree is pure and CI keeps it so** (`.amadan/ci.d/20-cross@5`
+ sets `CGO_ENABLED=0` deliberately). That rule exists so the CLI
+ cross-compiles for the Macs people run it on. It constrains the tree,
+ not the box, and §3 sets the rule for native helpers.
+- **There is no generic media job queue.** The scan queue is the one
+ bucket-backed queue in the tree and its header explains where jobs
+ must live so app deletion finds them. Stage 3 reuses that shape rather
+ than inventing a second one. **(codex 19)**
+- **UDP is a stated non-goal of the capabilities spec** (line 931). A
+ relay is therefore a platform service next to the edge, not an app
+ capability.
+- **A tunnel-backed host cannot carry `Connection: Upgrade`**
+ (remote-host-pi design, line 63).
+- **Real demand exists.** Docs ships an "insert image" menu item disabled.
+ Meet is being designed now and needs STUN for calls to connect across
+ most home and mobile networks. Woodstar runs its own media service as
+ a tenant app, the shape this plan makes unnecessary.
+- **Apps already hold immutable keys.** rastrillo's blob store is
+ content-addressed and presigns locally. A content-addressed key can be
+ cached forever, and a sealed (E2EE) blob is opaque bytes the platform
+ can serve but never transform.
+
+## 2. Public, secret, private
+
+Three words, three delivery doors, two storage namespaces. The doors are
+how bytes reach a browser, not a new authorisation model: the app still
+decides who may see what, from its own grants, and the doctrine's rungs
+map onto the doors as the last row of the table says.
+
+| Door | Who can fetch | What the edge checks | Bytes cached | Reads from |
+|---|---|---|---|---|
+| **public** `/p/` | anyone with the URL | nothing | by object key, long-lived, in every allowed region | `public/` only |
+| **secret** `/s/<cap>/` | anyone holding a valid capability | the capability's signature, scope, namespace and expiry | by object key, once per object, in every allowed region | `protected/` |
+| **private** `/_carlos/media/` on the app's origin | a person the app admits, now | asks the app on every request | by object key, on the app's home edge, after the app says yes | `protected/` |
+| *doctrine* | `private`, `granted` and `team` rungs deliver through the **private** door; a read-only link on any of them is a **secret** capability naming the same object; nothing is **public** unless the app's own rule makes an item world-readable | | | |
+
+**Two namespaces, not three.** The media prefix has `public/` and
+`protected/`. The public door reads `public/` and nothing else, so
+rewriting a URL from one door to another cannot reach a protected
+object: `/p/x` reads `public/x`, which does not exist for a protected
+key. The secret and private doors both read `protected/`, which is what
+lets one object be reachable by its roster through the app's check and
+by a link through a capability at the same time, as the doctrine's
+orthogonal link requires. Derivatives and renditions live under the same
+namespace as their source. **(codex 1, R2-3)**
+
+**Changing visibility is a lifecycle, not a copy.** Between the two
+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 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
+and warm caches. **(codex R2-1)**
+
+**Public** is a plain URL on the media host. Content-addressed keys get
+immutable caching. Mutable keys get whatever the app says with
+`Cache-Control` on the object, defaulting to a short lifetime, and the
+app can purge.
+
+**Secret** is the doctrine's read-only link and what S3 presign, Mux and
+Cloudinary do: a bearer capability, signed by the app, naming an object
+or a prefix in `protected/`, expiring. Anyone holding it can fetch; the
+bytes are the same for every holder, so a popular file is cached once
+and revocation lags by the expiry. **The capability is a path segment,
+not a query string**, so a playlist's relative references to its
+segments resolve under the same capability and every child request is
+authorised without rewriting. **(codex 6)** What the signature covers is
+defined in stage 1, not left to the implementer. **(codex 5)**
+
+**Private** is identity-bound, and the only way to honour ruling 3 is to
+not decide at the edge. On every request the edge asks the app: "may
+this request read this key?" The app answers from its own grants and
+session, in a millisecond, and the edge serves from its byte cache or
+fetches the object. This is the shape of nginx `auth_request`, with
+these properties the reviews forced into the open:
+
+- **Private media is served on the app's own origin**, under a reserved
+ path the edge intercepts ahead of the shared-cache lookup and ahead of
+ any proxy branch, because that is the only place the app's session
+ cookie reaches. A separate media host never sees a host-only cookie, a
+ custom domain cannot set one for the apps domain, and cross-site
+ cookies cannot be relied on across supported browsers and privacy
+ settings. The cost is user content on the app's origin, and the
+ mitigations in stage 1 (nosniff, a CSP sandbox on every response,
+ attachment disposition for anything not image, audio, video, font or
+ PDF, and no inline SVG or HTML ever) are what make that acceptable.
+ **(codex 2, R2-14)**
+- **The check travels over the route's own backend transport**, never
+ through a cache: the activator and unix socket for an instance on the
+ same box, the route's TCP dial for a service route, the tunnel for a
+ remote host. A private request for an app whose instance lives on
+ another box reaches the home edge by the existing backhaul, as every
+ other request for that route does, and the home edge's media cache is
+ the one that serves it. Private bytes are therefore cached on the
+ app's home edge only; public and secret bytes are cached in every
+ allowed region. The check fails closed on timeout, redirect, or
+ anything but a 200, and runs before `HEAD`, before a 304, before a
+ range is answered and before a cache hit is admitted. **(codex 3, R2-4)**
+- **Private responses carry `Cache-Control: private, no-store`.** Secret
+ responses carry `Cache-Control: private, max-age=<remaining life>,
+ must-revalidate`, generated per request from the capability presented
+ and overriding any directive on the object, so no HTTP cache may serve
+ a stale copy past the capability's expiry. The edge's own byte cache is
+ internal and never a substitute for either. **(codex 3, R2-5)**
+
+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, 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
+ingest the source item; custody keys on the app's grants, as the
+doctrine says. **(codex 11)**
+
+## 3. Native helpers and media services: the rule
+
+Images and video are the two places where pure Go is not fast enough on
+a `t4g.small`. The answer is not a cgo dependency. Two classes, with
+different rules. **(codex 16, 22; ruling 10)**
+
+**Codec helpers** transform bytes and nothing else.
+
+- The Go tree stays `CGO_ENABLED=0`. A helper is a separate program in
+ whatever language wins the benchmark: Rust, C, C++, Go with wazero,
+ or an existing tool such as `vips` or `ffmpeg`. Every candidate,
+ including a Go one, runs as its own process. Nothing decodes inside
+ the edge.
+- A helper is built and shipped as its own artifact, linux/arm64 only,
+ with an authenticated manifest naming its version, hash and the
+ libraries and codecs it carries. A box with the role that needs it
+ fetches it at bootstrap and at each promotion, and can roll back the
+ way it rolls back the platform binary. It never enters the CLI build.
+- A platform unit spawns it under a hardened systemd scope with no
+ network, a read-only filesystem apart from scratch, and memory,
+ CPU, wall-time, scratch-space and process limits. It talks over
+ stdin/stdout or a unix socket, and the Go side owns the protocol, so
+ `vips` today and a Rust helper tomorrow answer the same calls.
+
+**Media services** are network-facing long-running units: the relay in
+stage 4a, LiveKit in 4b, a live packager. They are platform units like
+the edge, with their own units, ports and hardening, and the no-network
+rule above does not apply to them.
+
+This is the answer to "could we build some dependencies in Rust or C for
+throughput": yes, as codec helpers, and the spikes in stages 2 and 3
+decide whether the first ones are ours or borrowed.
+
+## 4. The stages and how they depend on each other
+
+Six pieces of work. The graph replaces a single sequence; anything with
+no arrow into it can start now. Stage 1 carries two things later stages
+share, the derivative-write grant and the publication fence, so that
+whichever of 2 or 3 ships first finds them ready. **(codex 27, R2-11;
+ruling 11)**
+
+ stage 1 serving class ─┬─▶ stage 2 image transforms
+ ├─▶ stage 3 audio/video on demand ─▶ recording (in 4b)
+ └─▶ stage 5 app-facing helpers
+ stage 4a STUN + TURN ────▶ stage 4b LiveKit SFU, live streaming
+
+| Stage | Delivers | Needs from the platform | Size |
+|---|---|---|---|
+| 1 | `kind:media`: serving class with the three doors | edge read grant with the scan deny, signed capabilities, streaming handler, isolated cache, distributed purge, hostname claim, derivative-write grant, publication fence, CLI | one spec, one plan |
+| 2 | image transforms behind preset URLs | a codec helper, limits, derivative identity | one spec, one spike first |
+| 3 | audio and video on demand: single rendition, then ladders, posters, waveforms | a media worker, a job queue under the app prefix, ffmpeg helper | one spec, one infra change |
+| 4a | STUN and TURN | UDP ingress, a relay unit, per-app per-region credentials as config, limits | one spec, one infra change; **starts now** |
+| 4b | LiveKit as a media service, recording, live streaming | a `c7g.large`, LiveKit's server unit, a live serving extension | one spec |
+| 5 | uploads, rastrillo helpers, accounting, residency disclosure | small pieces spread across 1 to 4 | folded into each stage's plan |
+
+### Stage 1: `kind:media`, the serving class
+
+A new serving class, the fourth app-mintable route kind beside
+`instance`, `service` and `static` (the edge also dispatches the
+platform's own `status`), that serves an app's store bucket through the
+edge, cached, with the three doors of §2. This is piece A of #107 with
+its open questions answered. **(codex R4-8)**
+
+**What an app sees.** `carlos media enable` writes an additive `media`
+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>` can
+also be 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. It is one entry in the account's hostname namespace, and every
+door that acquires a hostname there consults it: new app names,
+renames, and the instances door that lets an app take an unclaimed
+label. Enable is refused, with the error saying so, when `media-<x>`
+is already an app's name or an existing route or instance host, and
+those doors refuse a label a media claim holds; the acquisitions are
+serialised on the claim so two racing requests cannot both win. An app
+whose name ends in a hyphen cannot form a DNS label with `media-`
+prefixed and is refused enable before any claim or grant is written,
+with the error saying to rename. The generated label can be up to 38
+characters, inside a DNS label; the shared `release.ValidApp` grammar
+is not narrowed, because existing manifests and channel pointers parse
+through it. **(codex R2-10, R3-4, R4-4, R4-5)** The app writes objects
+under `<prefix>/public/` or
+`<prefix>/protected/` with the store credentials it already has, and
+forms URLs:
+
+ https://media-docs.abc.oncarlos.com/p/<key> public
+ https://media-docs.abc.oncarlos.com/s/<capability>/<key or rest> secret
+ https://docs.abc.oncarlos.com/_carlos/media/<key> private, on the app's origin
+
+The capability is one URL-safe base64 path segment encoding the signed
+claims and the signature. Everything after it is resolved against the
+scope the claims name, so `.../s/<cap>/videos/abc/index.m3u8` and the
+`720p/seg001.m4s` it references are both authorised by the same
+capability. The cache key strips the capability and keys on the object.
+
+**What a capability signs.** Domain separator (`carlos-media-v1`), the
+app's store identity, the key id, the namespace (`protected/`), the
+scope as either an exact key or a prefix that is matched only at a `/`
+boundary after percent-decoding and normalising the path with no `..`,
+the expiry, and in stage 2 the transform presets allowed. Maximum life
+is seven days **(ruling 3)**. Rotation is a second key id with an
+overlap; emergency revocation removes a key id from the route record.
+The capability never appears in edge logs, and every media response
+carries `Referrer-Policy: no-referrer`. **(codex 5)**
+
+**How the edge is granted read.** The provisioner gains a bucket-policy
+operation. At enable it attaches statements on that one bucket letting
+each allowed region's host role `GetObject` on both namespaces and
+`PutObject` and `DeleteObject` under `derived/` within them, **with the
+same `s3:ExistingObjectTag` deny the store user carries**, so an object
+the scanner has tagged infected is unreadable through media exactly as
+it is through the app's credentials. Enable and disable are a
+reconciled walk like the store grant, with partial completion, store
+revocation and route replacement handled, and disable also retires
+cached bytes. Adopted and non-AWS stores are refused in version one.
+**(codex 4, 25, R2-11; ruling 2)**
+
+**Signing keys.** An Ed25519 pair per app, minted at enable. The public
+half goes in the route record, which every edge already syncs. The
+private half reaches the app as `CARLOS_MEDIA_SIGNING_KEY` through the
+sealed `config.json` path that already carries store keys. The edge
+holds no secret. **(ruling 3)**
+
+**The handler.** `mediaHandlerFor` streams: one S3 `GetObject` per
+request with a single normalised range, the body copied through, never
+buffered. It defines its own HTTP mapping rather than borrowing
+`http.ServeFile`'s: single-range only, multi-range answered with the
+whole object, `If-Range` against the ETag, `If-None-Match` before
+bytes, `HEAD` answered from metadata, 416 on unsatisfiable. Every cache
+entry is bound to the object's ETag and published only after a complete
+fill, so a range from one version is never stitched to metadata from
+another. **(codex 7)** Response headers set on every media response,
+whatever the object carries: `X-Content-Type-Options: nosniff`,
+`Content-Security-Policy: sandbox`, `Referrer-Policy: no-referrer`, a
+`Content-Type` from the object's metadata with a small allow-list
+fallback, `Content-Disposition: attachment` for anything that is not an
+image, audio, video, font or PDF, the door's `Cache-Control` from §2,
+and a real CORS policy for the app's own hostnames with `Vary: Origin`,
+because `Cross-Origin-Resource-Policy` is not CORS and an HLS player in
+JavaScript needs the latter. **(codex 23)**
+
+**Its own cache.** `kind:media` is exempt from the shared LRU in both
+directions and keeps a disk cache under `/data/media-cache/` that stores
+objects as fixed-size blocks (1 MiB), so overlapping ranges share
+storage. Isolation is per box: a total disk budget with filesystem
+headroom, a fair share per route within it, eviction that takes first
+from whichever route is furthest over its share, an account-level cap
+across routes, and a bound on concurrent fills. The fleet-wide figure
+is accounting, not a limit any one box can enforce. **(codex 8)**
+
+**Purge, distributed, with a serving gate.** A purge is a record
+appended to a log in the platform bucket, sequence-numbered per app.
+Every edge tails the log and applies it; entries carry the generation
+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. **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 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 no exception**: a synchronous transform's first
+requester waits for the publish door's answer and receives the bytes
+only if it was 200, because an attempt that stalled past its lease or
+across an infected verdict must not reach even the request that caused
+it. One console round trip on a first miss is the cost. 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, R4-3)**
+
+**Residency, enforced.** The `media` block names the regions allowed to
+serve and cache the app's media. The bucket policy grants only those
+regions' host roles, the media host's DNS records exist only in those
+regions, and the handler on any other edge answers 421 with the right
+host as a backstop, so a client addressing a foreign edge directly
+cannot fill a cache there. Default at enable: the regions the app's
+routes are already placed in, shown back by `carlos media status` and
+on the media docs page. **(codex 10, R2-9; ruling 4)**
+
+**Store spec amendment.** Line 27 of the object-storage spec gains one
+sentence: reads may cross the data plane through `kind:media`; writes
+never do. Uploads stay app-to-S3 on the app's credentials.
+
+**What stage 1 must prove** (§9) is the attacks in §2 and this section
+failing, not only playback succeeding. **(codex 27)**
+
+**Not in stage 1:** transforms, transcoding, uploads through the edge,
+per-viewer analytics, signed cookies, bandwidth quotas, dashboards
+beyond a bytes-served counter, multipart upload helpers.
+
+### Stage 2: image transforms
+
+`/p/<key>?t=thumb`, `/s/<cap>/<key>?t=thumb` and the private form, where
+`thumb` is a preset the app declared. A transform is synchronous, pure
+CPU in a codec helper, and its result is written back to the app's
+bucket through the publication fence so the second region never redoes
+the first's work.
+
+**Abuse surface first.** A public transform URL is CPU amplification on
+the box that terminates TLS for every tenant. From the first commit,
+transform parameters are a **named preset** the app declared, or for
+secret objects the presets the capability names, or for private objects
+the presets the app's check endpoint approves. Free-form parameters are
+refused everywhere. **(codex 5)** On top of that: single-flight per
+derived identity, a per-box concurrency cap with per-tenant fair
+admission so one tenant cannot hold every slot, a queue that turns into
+503 after a short wait, a source pixel-count cap, output dimension caps,
+and a per-route derivative byte budget. **(codex 17)**
+
+**Derivative identity.** A derivative is named by the source key, the
+source ETag, the normalised preset, the resolved output format and the
+helper version, and committed through the fence. `f_auto` picks AVIF,
+WebP or JPEG from `Accept`, and the response says `Vary: Accept`.
+**(codex 17)**
+
+**The engine, decided by a spike.** Three candidates, all run as separate
+processes under the §3 rule. First, a Go helper embedding wazero-hosted
+`libwebp`, `libavif` and `jpegli` (the `gen2brain/*` packages), which
+keeps the build pure Go end to end. Second, `vips` as a fetched helper.
+Third, our own Rust helper using the `image` and `ravif` crates or
+linking libvips, persistent over a unix socket. The spike measures a
+4000×3000 JPEG to an 800-wide WebP and AVIF on a `t4g.small` that is
+also serving: thumbnail latency, peak memory, sustained throughput
+under eight concurrent requests, behaviour on malformed input, and the
+serving latency of the box while it runs. Under a second for WebP with
+serving latency flat is the bar, and the candidate that clears it with
+the least to operate wins. The bar is not a ceiling on the fleet: if
+nothing clears it on a `t4g.small`, the choices are a bigger edge box,
+a dedicated transform box the fleet shares, or telling apps what to
+expect, and the spike's numbers decide which. **(codex 16; ruling 5)**
+
+**What a transform does.** Resize, crop modes, format, quality, and
+always strips metadata. EXIF GPS in a public photo is a privacy leak the
+platform should not require every app to remember. Animated GIF and
+WebP are passed through untransformed in version one. Sealed blobs are
+refused with 415.
+
+**Derivatives** land in the tenant's bucket under
+`<namespace>/derived/<source key>/<identity>.<fmt>`, written by the
+region host role under stage 1's `derived/` statement, and count
+against the tenant's storage. Purging a source key removes its
+derivatives. **(ruling 6)**
+
+**Not in stage 2:** face detection, smart crop, watermarks, uploads,
+SVG rasterisation, anything needing a model.
+
+### Stage 3: audio and video on demand
+
+Stage 1 already plays a well-formed MP4 or MP3 progressively: `Range`
+passes through and the browser's `<video>` seeks. For a short clip or a
+voice note that is the whole feature and it ships in stage 1. Stage 3 is
+for what progressive cannot do, and it has two products the review
+insisted be kept apart. **(codex 21)**
+
+- **A single rendition, fast.** Probe the input; if it is H.264 and AAC
+ in MP4 with sane keyframe spacing, remux to CMAF and one HLS playlist
+ in seconds. If it is WebM from a browser recording, re-encode one
+ rendition at the source's size. This is what a phone upload in Docs
+ or a recorded call needs first, and it follows stage 1 directly.
+- **An adaptive ladder, slower.** Re-encode to three rungs (1080p, 720p,
+ 360p, capped at the source's height), CMAF, one multivariant playlist.
+ Remuxing cannot make lower rungs; this always encodes. AV1 and DASH
+ are one flag away and off. This follows the worker measurements.
+
+Both products also write posters at three timestamps for video, and a
+waveform JSON and duration for audio. Everything is served by stage 1
+under one prefix-scoped capability or one private door, which is why
+the capability is a path segment.
+
+**The media worker.** A platform unit, `carlos-media.service`, on one box
+per region that has media-enabled buckets. Jobs live where the scan
+queue lives and for the same reason: under the app's own prefix,
+`apps/<acct>/<app>/media-jobs/<id>.json`, one object per job, so app
+deletion finds them and the name-claim gate sees them. **(codex 19)**
+
+- **Submission** is a console door, `POST
+ /a/<sqid>/apps/<app>/media/jobs`, reached from the app through a
+ rastrillo helper with the app's route-scoped credential, and from
+ `carlos media transcode <key>`. The door derives the source and
+ destination scope itself; the app names a key, never a path. Delivered
+ store credentials never gain the right to write job records.
+ **(codex 18)**
+- **Eligibility, decided at submission.** The processing region is the
+ bucket's region, and it must be in the app's allowed list and have a
+ worker. Version one runs one worker, in Ireland, so a job for a bucket
+ elsewhere is refused at the door with an error naming the reason, not
+ queued to wait for a worker that does not exist. **(codex R2-9)**
+- **Authority.** The worker reads the source through the media bucket
+ policy and writes renditions under `derived/` through stage 1's
+ statement, on buckets in its own region only.
+- **Leases and the fence.** A worker takes a lease with an expiry; its
+ output goes to attempt keys and is committed through the stage 1
+ publication fence, which refuses a commit from an expired lease, a
+ purged source or a deleted app. Bounded retries, cancellation, a
+ terminal failure state. **(codex 18, R2-7)**
+- **Completion** is a result record the app polls in version one.
+ Callbacks come later as a durable completion outbox that the
+ destination host converges into a local once-timer, because nothing
+ files a timer across boxes today. **(codex R2-8)**
+- **Limits from the first job**: admitted source size and duration,
+ concurrent jobs per box, memory and scratch caps on the ffmpeg helper,
+ and measurement of serving latency, I/O and CPU credit consumption
+ under sustained encoding before any promise about co-location is
+ made. **(codex 20)**
+
+**Which box.** A `t4g.medium` has the same two burstable vCPUs as a
+`t4g.small`; it is not a transcoding box. Version one runs the worker
+on the Ireland medium behind the limits above, and the honest
+expectation is that the first tenant wanting ladders moves it to a
+dedicated `c7g.large`. Encode throughput is the first sustained
+bottleneck; host memory and scratch pressure fail earlier if left
+unbounded, which is why the limits come first. **(codex 20; ruling 7)**
+
+**Not in stage 3:** caption generation, DRM, per-viewer watermarking,
+server-side ad insertion, live packaging. Caption files the app supplies
+are just objects.
+
+### Stage 4: real time
+
+Meet's requirements, in the order Meet needs them. Stage 4a depends on
+nothing above and starts now. **(codex 27; ruling 11)**
+
+**4a. STUN and TURN.** A STUN listener the platform runs is what lets a
+WebRTC app connect across most home and mobile networks without an
+egress exception; TURN is the same listener relaying, and it is what
+turns "one call in ten cannot connect" into "calls connect". Both are
+UDP. The capabilities spec's non-goal stands: UDP is not something an
+app binds. The platform runs `carlos-relay.service` on every region
+box, built on `pion/turn`, pure Go, in the same binary as the edge, a
+§3 media service.
+
+- **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)**
+- **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:
+ `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: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 **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
+ outlive the outage by the length of a credential. Rotation is a new
+ region master and a re-render of every enabled app's config.
+ **(codex 13, R2-2, R3-3, R4-6; 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
+ 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)**
+- **Policy and limits.** Peer permissions refuse loopback, link-local,
+ RFC 1918, the VPC's ranges and the relay's own addresses. 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
+ 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)**
+- **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
+ party is the app's decision, never the team's.
+
+**4b. LiveKit as a media service, recording, live streaming.** An SFU
+lifts the four-participant cap, changes the privacy story from
+peer-to-peer to server-in-the-path, and is where recording becomes
+possible. **Paul ruled to host LiveKit's open-source server** rather
+than build on `pion/webrtc`, as a §3 media service, `carlos-sfu.service`,
+fronted by the platform's own API. **(ruling 9)** LiveKit Cloud is not an
+option for suite apps: a third party in every call's media path is
+egress that is a property of the product. It is also not cheaper past
+about sixty room-hours a month, since both are dominated by bandwidth.
+
+- **Box.** The server is one Go binary on pion and needs no Redis as a
+ single node. A `c7g.large` in Ireland (2 vCPU, 4 GB, about sixty
+ dollars a month) carries a handful of concurrent eight-person video
+ rooms; a `c7g.xlarge` gives headroom for tens. CPU is spent per
+ forwarded track, and LiveKit's own figures put thousands of tracks on
+ sixteen vCPUs, so the box is not the limit at this scale.
+- **Cost is egress.** Each participant downloads everyone else's stream.
+ 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 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. Port 7880 is reachable only
+ from that proxy. TCP 7881 and UDP 50000 to 60000 are opened directly,
+ the same shape as the relay. **(codex R3-5)**
+- **Tenant boundary, enforced at admission.** 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's backend 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
+ ten-minute token, and returns it for the app to hand to its browser.
+ **The proxy in front of 7880 is the admission gate**: on every join
+ and reconnect it verifies the token, checks the room's revocation
+ list, and only then opens the upstream connection, serialised against
+ removals so a rejoin cannot race a removal. LiveKit's own token
+ refresh for connected clients does not matter, because admission is
+ keyed on identity and room against platform state, not on a token
+ being unexpired. Removal is an app-to-platform door that records the
+ revocation, calls LiveKit's remove-participant, which is what ends the
+ active media session, and has the proxy close that participant's
+ signalling connection and refuse its next admission, none of it
+ waiting for anything the client does. Webhooks are for
+ observation and reconciliation, never enforcement. Management
+ operations are the door's, scoped to the app's rooms. **(codex R3-6,
+ R4-1, R4-2)**
+- **No third-party defaults.** Stock LiveKit hands participants Google's
+ and Twilio's STUN servers when none are configured and uses them for
+ its own external-IP discovery. The platform's LiveKit is configured
+ with the stage 4a relay as its only STUN and TURN, for the server's
+ discovery and the client ICE configuration alike, and the 4b gate
+ runs a call with all third-party network access blocked to prove no
+ fallback remains. This is why 4b follows 4a. **(codex R4-7)**
+- **Recording and ingest are separate LiveKit services** and are
+ 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.** 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
+
+Each of these is small and belongs in the plan of the stage that first
+needs it. None of them is in stage 1's gate. **(codex 27)**
+
+- **Direct browser uploads.** Apps already presign PUTs, but a browser
+ cannot PUT to the bucket until the bucket has a CORS rule, and S3 CORS
+ is bucket-wide with no prefix selector. `carlos media enable`
+ therefore owns the bucket's CORS configuration for the app's
+ hostnames, merging with nothing (a store with CORS already set is
+ refused until the operator clears it), exposing `ETag` and the
+ multipart headers, and removing it at disable. Multipart upload for
+ anything over 100 MB is a rastrillo helper. **(codex 23)**
+- **rastrillo helpers.** One package, `rastrillo/media`: a URL builder
+ for the three doors, a capability signer that reads
+ `CARLOS_MEDIA_SIGNING_KEY`, the private-check handler that answers the
+ edge's subrequest from the app's grants, a visibility-move helper
+ that drives the lifecycle in §2, a presigned-upload helper with
+ CORS-correct headers, a job filer, and a TURN credential minter.
+ Docs' disabled "insert image" is the first consumer.
+- **Accounting.** Bytes served and cached in stage 1 as one counter;
+ transform seconds, transcode minutes, the three relay meters and SFU
+ egress when their stages land; on the console's app page and in
+ `carlos media status`. Quotas come after measurement, not before.
+- **Scanning.** Nothing to hook: the sweep already tags what it finds,
+ and the media grant carries the deny. The one addition is the verdict
+ purge in stage 1.
+- **Residency disclosure.** One paragraph on the media docs page and in
+ `carlos media status` naming the allowed regions and, for private
+ media, the home edge.
+- **Docs page.** `docs/site/media.md`, and `docs/site/cli.md` headings for
+ every new verb, because the docsite gate fails otherwise.
+
+## 5. What apps might want that is deliberately not here
+
+Named so nobody wonders whether it was forgotten.
+
+- **Signed cookies** instead of path capabilities. Not needed while
+ capabilities are prefix-scoped; revisit if a tenant needs a whole site
+ of secret media.
+- **A media-session cookie on the media host** for private media. Cannot
+ be relied on across browsers and privacy settings on custom domains;
+ the app-origin door in §2 is the answer.
+- **Per-viewer watermarking or DRM.** Different product, different buyer.
+- **AI on media**: captions, transcription, moderation, smart crop. Each
+ is a model-provider egress under ruling 6 and a per-app opt-in there,
+ not a platform primitive.
+- **A platform upload door** through the edge. Writes never cross the
+ data plane; uploads stay app-to-S3.
+- **Peer-to-peer file transfer** over the relay. TURN would carry it, but
+ it is an app protocol, not a platform one.
+- **A GPU tier.** Nothing in stages 1 to 4 needs one on a fleet this size.
+- **LiveKit Cloud, or any hosted SFU.** Egress that is a property of the
+ product.
+
+## 6. What must be true before an app can build on this
+
+The review's last question was what an app hits in its first month. The
+answers are all inside stages 1 and 5, and are the reason stage 1's gate
+is a security gate, but they are worth listing plainly:
+
+- a way to put a private image on a page that works on a custom domain
+ in Safari (the app-origin door);
+- a way to hand a video player one URL and have every segment load (the
+ path capability);
+- a way to share a roster-visible video by link without moving it (the
+ shared `protected/` namespace);
+- a way to replace an object and not serve the old bytes (ETag-bound
+ cache, distributed purge, the publication fence);
+- a way to know which regions hold copies (the media block);
+- an upload that does not fail at the last byte of a phone video (CORS
+ ownership, multipart helper);
+- a helper package so none of the above is hand-rolled per app.
+
+## 7. Rulings
+
+Paul ruled on each of these on 2026-09-07. They are the roadmap's fixed
+points; a stage spec that wants to move one comes back here first.
+
+1. **(ruling)** Private means ask-the-app, per request, never cached,
+ served on the app's own origin under a reserved path.
+2. **(ruling)** Edge read is granted by bucket policy on the two media
+ namespaces, per allowed region host role, carrying the infected deny,
+ attached and removed by a reconciled walk.
+3. **(ruling)** Ed25519 per app for capabilities, public half in the
+ route record, private half as env through sealed `config.json`, key
+ id in the capability for rotation, seven-day maximum life.
+4. **(ruling)** Allowed regions are explicit in the media block and
+ enforced in the bucket policy, DNS and the handler; default is the
+ regions the app's routes are placed in.
+5. **(ruling)** The transform engine is chosen by a measured spike among
+ a Go wazero helper, `vips` and a Rust helper, all as separate
+ processes, benchmarked on a `t4g.small`, with a bigger or dedicated
+ box on the table if the numbers say so.
+6. **(ruling)** Derivatives and renditions land in the tenant's bucket
+ under `derived/` in the source's namespace, counted as the tenant's
+ storage, removed with their source.
+7. **(ruling)** The media worker starts on the Ireland `t4g.medium`
+ behind hard admission limits, with a dedicated `c7g.large` when
+ ladders are wanted.
+8. **(ruling)** Relay credentials are per-region HKDF-derived per app and
+ incarnation, verified locally; the exposure through sealed config is
+ stated, not hidden.
+9. **(ruling)** The SFU is LiveKit's open-source server hosted as a
+ platform media service on a `c7g.large`, recording and ingest
+ services deferred until asked for.
+10. **(ruling)** Native helpers are allowed as separate processes only,
+ with codec helpers and media services as distinct classes.
+11. **(ruling)** Stage 1 and stage 4a start now, independently. Stage 2
+ and the stage 3 single-rendition product follow stage 1. The ladder
+ follows the worker measurements; 4b follows 4a.
+
+## 8. Answers to #112 and #113
+
+**#112.** Direction A: the platform runs the relay. STUN and TURN are
+stage 4a and start now; LiveKit as a hosted media service is 4b. UDP
+ingress is something the platform will carry, as a platform unit on the
+region boxes, never as an app-bindable port. A platform relay is not
+egress; a third-party one is.
+
+One correction to Meet's interim plan: under doctrine §8 the recipient
+of an egress is the app's decision, never the team's, so a settings
+field where a team pastes any TURN URL is not admissible. What is
+admissible until 4a ships is either no relay, with the disclosed floor
+of calls that connect without one, or an app-named provider that a team
+can switch on, disclosed in the README's first paragraph **and on every
+artefact the app produces while it is on**, which for Meet means any
+recording or export. Meet should pick one of those two, not the
+free-text field. **(codex 12, R2-12)**
+
+**#113.** Confirmed as a standing constraint: no `Connection: Upgrade`
+over a tunnel-backed host. The placement picture is more nuanced than
+the question assumed: a member can place an app with `carlos apps place`
+and `carlos instances create --region`, and the instances API reports
+which boxes serve a host, so a team can keep Meet off tunnel-backed
+hosts by choice. What does not exist, and is not planned before extended
+CONNECT, is a runtime signal the app can read saying "this host carries
+upgrades", or a manifest field that refuses placement without it.
+**(codex R2-13)** So SSE plus POST remains the right default: it works
+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. Acceptance gates
+
+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
+ `/` boundary, the namespace changed, or the key id revoked is refused;
+ a private fetch is refused the request after the app revokes the
+ grant, on a warm cache; a public-to-protected move leaves nothing
+ readable through `/p/` in any region once it reports complete; an
+ 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,
+ 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, and
+ the request that caused a stalled synchronous transform receives no
+ bytes; an app named `media-<x>` created before enable keeps working
+ and enable for `<x>` is refused with the right error; an instance
+ host `media-<x>` taken by another app before enable, and one
+ attempted after, are each refused in the right direction; an app
+ name ending in a hyphen is refused enable. 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
+ `t4g.small` without the box paging.
+- **Stage 2**: the spike's numbers, then 9,999 distinct transform URLs
+ against a public object producing zero decodes, and one tenant's
+ burst leaving another's thumbnails under a second.
+- **Stage 3**: a 1080p H.264 phone recording of two minutes uploaded
+ through Docs and playing in Safari as a single HLS rendition within
+ thirty seconds, with serving latency on the same box flat; a WebM
+ browser recording of the same length playable within three minutes; a
+ 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
+ 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.
+- **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
+ the webhook delayed and then lost; a browser on an app served from a
+ tunnel-backed host joining an SFU room; a call completing with all
+ third-party network access blocked from the SFU box; an eight-person
+ room's egress appearing on the account's usage line.
+
+## 10. Findings not accepted
+
+- **Codex 12, the part asking for a doctrine amendment** to allow
+ team-selected external relays. Not requested; the roadmap instead
+ corrects Meet's interim plan to what §8 already allows, now including
+ artefact disclosure.
+- **Codex 15, TURN over TLS on 443.** Acknowledged as a real gap and
+ documented as such; a second address per box is deferred until a
+ 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 finding from revisions 2 to 5 was accepted.
+
+## 11. Amendments to existing specs
+
+- `2026-08-10-object-storage-primitive-design.md:27`: reads may cross the
+ data plane through `kind:media`; writes never do.
+- `2026-08-03-app-capabilities-design.md:931`: UDP remains a non-goal
+ 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.
+- `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.
+- The console's name-claim gate (`internal/console/server.go:2904`) and
+ the instances door (`internal/console/apiinstances.go:571`): new app
+ names, renames and instance hosts consult the media-host claim table.
+ The `release.ValidApp` grammar is unchanged. **(codex R3-4, R4-4)**
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R2.md b/docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R2.md
new file mode 100644
index 000000000..e94a5c5c8
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R2.md
@@ -0,0 +1,169 @@
+# Findings on the media roadmap, revision 2
+
+Reviewed by **GPT-6 (Codex), 2026-09-07**, second pass, against revision 2 at `6424a4f`, with its own revision-1 findings in hand. Unedited apart from this note. Revision 3 records which findings were accepted and how.
+
+Reviewed commit `6424a4f320821fd72667625c1aa7dbb35baff0cc` against the review brief, the 27 original findings, the cited platform code and private specs, and the suite doctrine. No files were changed.
+
+“Closed” means the roadmap now supplies the missing decision or requirement; implementation still needs to prove it. Severity remains: **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.
+
+## Disposition of the original findings
+
+| Original | Status | One-line reason |
+|---|---|---|
+| 1 — Visibility-door bypass | Closed | Disjoint storage namespaces prevent an unsigned URL rewrite from reaching the protected object. |
+| 2 — Private browser identity | Closed | The app-origin path carries its session cookie, including on custom domains. |
+| 3 — Authorization and downstream caching | Partial | Private checks and responses bypass caching, but secret `max-age` alone does not prohibit stale reuse. |
+| 4 — Malware enforcement | Partial | The reader deny and derivative removal are specified; late derivative publication can still undo removal. |
+| 5 — Signed authorization contract | Partial | Claims, normalization, presets and rotation are defined; stale-edge revocation behavior remains incomplete. |
+| 6 — HLS capability transport | Closed | Relative child paths retain the capability segment in the stated playlist layout. |
+| 7 — Range and conditional handling | Closed | Explicit HTTP handling, ETag-bound entries and complete-fill publication replace the claimed automatic reuse. |
+| 8 — Cache isolation | Closed | Per-box budgets, fair eviction, account caps, bounded fills and reusable blocks address the original defects. |
+| 9 — Distributed purge | Partial | The durable log and generations are explicit, but catch-up-before-serving and stale-state limits are not. |
+| 10 — Residency | Partial | Grants and handlers enforce a region list; its interaction with bucket location and worker placement is unresolved. |
+| 11 — Doctrine mapping | Partial | The terminology is corrected, but exclusive namespaces do not support an item’s orthogonal read-only link. |
+| 12 — Third-party relay doctrine | Partial | Team-selected endpoints are removed; disclosure on produced artefacts is still omitted. |
+| 13 — Relay minting authority | Partial | Keys are app- and region-derived, but their delivery contradicts the claimed regional compromise boundary. |
+| 14 — Relay abuse controls | Closed | Peer restrictions and allocation, rate, bandwidth and lifetime limits are explicit. |
+| 15 — Relay connectivity and placement | Closed | Public-address/range configuration and the 443-only limitation are stated; the ICE latency claim is removed. |
+| 16 — Helper isolation | Closed | Every codec candidate runs separately with resource limits and authenticated update/rollback support. |
+| 17 — Derivative identity | Partial | Versioned representations and fair admission are specified; identity alone does not fence publication after purge. |
+| 18 — Job authority and recovery | Partial | Submission and leases are named, but publication enforcement and remote completion remain missing mechanisms. |
+| 19 — Existing queue and deletion ownership | Closed | The roadmap acknowledges the scan queue and places media jobs under the app’s deletion prefix. |
+| 20 — Worker capacity | Closed | Admission limits and sustained-load measurements replace the unsupported co-location guarantee. |
+| 21 — Remux versus adaptive encoding | Closed | Single-rendition remux and ladder generation are separate products with input probing. |
+| 22 — LL-HLS and network services | Closed | Live serving is a distinct extension, and network services are separated from codec helpers. |
+| 23 — CORS | Closed | Media-response CORS and bucket-wide upload CORS ownership are specified separately. |
+| 24 — Media hostname | Partial | Wildcard coverage is fixed, but the new name collides with an ordinary app’s hostname. |
+| 25 — Enable/disable lifecycle | Partial | Reconciliation and backend refusals are explicit; stale readers and retirement completion still need a contract. |
+| 26 — Original factual corrections | Closed | Scanner integration, infrastructure citations, STUN wording and relay meter distinctions were corrected. |
+| 27 — Stage dependencies | Partial | Relay work is independent, but the graph still reverses an SFU dependency and hides a stage-2 prerequisite. |
+
+## New and remaining findings
+
+### R2-1 — P1 — §2: changing visibility leaves the old readable object behind
+
+**Problem and evidence:** [Roadmap lines 128–135](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:128) define a visibility change as a server-side copy and a purge. Copying `public/x` to `private/x` does not remove `public/x`. After its cache is purged, an anonymous request simply fetches the old public object again. Moving `secret/x` to `private/x` similarly leaves the old capability usable against the surviving secret copy.
+
+This is a transition defect introduced by the namespace design, distinct from the original URL-substitution bypass.
+
+**Correction:** Define a move as a coordinated lifecycle that removes or tombstones the old source namespace and its derivatives, fences concurrent publication, and reports completion after regional invalidation. State separately that copies already delivered to clients cannot be withdrawn. Test public-to-private and secret-to-private transitions against cold caches as well as warm ones.
+
+### R2-2 — P1 — Stage 4a: sealed config defeats the claimed regional compromise boundary
+
+**Problem and evidence:** [Roadmap lines 500–512](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:500) deliver durable derived keys for every allowed region to each enabled instance, while claiming that compromising a box yields nothing durable about other regions.
+
+At minimum, a compromised box can read its resident apps’ other-region minting keys. The existing delivery architecture permits a wider exposure: [the config spec’s crypto section](/home/paulca/github.com/carlosframework/platform-infrastructure/platform-design/superpowers/specs/2026-08-02-app-config-secrets-design.md:86) places the deployment’s decryption key on every host, and [the host policy](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/infra/modules/region-host/main.tf:121) permits reading deployment-bucket objects. Sealing therefore does not isolate those app keys from a compromised deployment host. [Materialization explicitly decrypts them into environment values](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/appconfig/materialize.go:30).
+
+The one-hour credential lifetime does not expire the stolen minting key. Separate regional masters prevent deriving another master; they do not prevent using another region’s already-distributed app keys.
+
+**Correction:** Either withdraw the regional compromise guarantee and describe the actual exposure, or change delivery so a box cannot recover durable remote-region minting material. Short-lived issuance is one option; a genuinely compartmentalized key-delivery system is another.
+
+Also include an app incarnation or revocation epoch in derivation. With only `(region master, account, app)`, disabling and re-enabling an app restores the same key until the regional master changes.
+
+### R2-3 — P2 — §2: exclusive namespaces conflict with orthogonal read-only links
+
+**Problem and evidence:** [The doctrine mapping and namespace rule](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:125) require authenticated delivery from `private/`, link delivery from `secret/`, and an object written into exactly one namespace.
+
+Consider a team-readable video whose owner enables a read-only link. Existing team viewers must continue using authenticated delivery while link holders use the capability. Neither door can reach the other namespace. Moving the object breaks one audience; duplicating it requires a second copy and a synchronization/revocation contract that the roadmap does not define.
+
+[Doctrine §4.2](/home/paulca/amadan.net/oficina/suite/docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md:276) makes links an additional axis, not a replacement visibility.
+
+**Correction:** Specify how both delivery mechanisms address one logical asset. For example, a verified capability could name a protected canonical source, with its namespace included in the signed claims, while the public door remains strictly confined to `public/`. Alternatively, explicitly design linked copies and their shared lifecycle. Do not leave this to each app’s URL builder.
+
+### R2-4 — P2 — §2 and stage 1: the activator check only supplies a local-socket transport
+
+**Problem and evidence:** [Roadmap lines 170–174](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:170) select the scheduled-tick path for private authorization. That path expressly never leaves the box and [refuses routes without a Unix socket](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/host/schedule_wiring.go:220).
+
+The platform also serves TCP-backed and tunnel-backed routes. [Edge routing explicitly handles those separately](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/edge/edge.go:457), and [remote app requests can be backhauled to their home edge](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/edge/edge.go:1197).
+
+The selected transport therefore does not establish private-media support for every app/backend or private byte serving at an allowed edge that does not host the app.
+
+**Correction:** Define the supported topology. Either private requests travel to the app’s home edge and use its local media cache, or remote media edges gain an authenticated, cache-bypassing authorization transport. Specify TCP and tunnel support or refuse those backends explicitly. The reserved path must bypass shared-cache lookup before reaching any authorization or proxy branch.
+
+### R2-5 — P2 — §2: secret `max-age` does not enforce capability expiry
+
+**Problem and evidence:** [Roadmap lines 175–179](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:175) cap freshness at the capability’s remaining life but do not prohibit stale responses.
+
+A cache may serve stale content under permitted conditions, including disconnection. `max-age` determines freshness; it is not an unconditional prohibition on reuse after that age. [RFC 9111 §§4.2.4 and 5.2.2.2](https://www.rfc-editor.org/rfc/rfc9111.html#section-4.2.4) distinguish those properties.
+
+**Correction:** Require `must-revalidate` alongside the bounded freshness lifetime, or use `no-store`. Override source-object directives that permit stale delivery. Generate expiry-dependent headers for the current capability on every response, including internal byte-cache hits. This governs HTTP cache reuse, not a recipient’s ability to retain downloaded bytes.
+
+### R2-6 — P2 — Stage 1: purge catch-up and authorization-state freshness need a serving gate
+
+**Problem and evidence:** [The purge design](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:327) says an offline edge catches up on reconnect. It does not say that catch-up precedes serving its persistent disk cache. [Key revocation takes effect on the next sync](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:277), with no maximum age for that state.
+
+This matters because [the ingress spec explicitly permits continued serving from last-converged state during a control-bucket outage](/home/paulca/github.com/carlosframework/platform-infrastructure/platform-design/superpowers/specs/2026-08-20-multi-region-ingress-design.md:123). Removing bucket permission does not stop a disconnected edge serving cached bytes. A removed region, disabled media route or revoked key can remain usable under stale local state.
+
+**Correction:** Define maximum stale-state windows and fail-closed behavior for media authority. Require a returning edge to reconcile route authority and its purge cursor before admitting persistent cache hits. Define recovery from missing log history, and report retirement against the relevant boxes, not merely one acknowledgement per region. Local purge fast paths must not count as distributed completion.
+
+### R2-7 — P2 — Stages 2 and 3: identifiers and fencing tokens do not themselves prevent late publication
+
+**Problem and evidence:** [Stage 2’s derivative identity](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:377) is said to prevent publication after purge. [Stage 3 grants direct bucket writes and then promises atomic publication carrying a fencing token](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:449).
+
+Neither names the component that rejects an obsolete writer. A transform can finish after its derivative was removed by a scan-triggered purge. An expired worker can complete an already-started write after another worker takes the lease. Its bucket permission remains valid. Including an ETag in a filename or a token in metadata does not make that write fail.
+
+S3 conditional writes compare the state of the addressed object; they do not automatically consult a separate job lease. See [AWS’s conditional-write contract](https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html).
+
+**Correction:** Name an enforced publication mechanism: immutable attempt-specific output plus a conditional commit record tied to the current job lease, source/purge generation and app incarnation, for example. The serving path must expose only committed output. Purge, infection, cancellation and deletion must invalidate that same publication authority. Apply the rule to synchronous image transforms as well as transcodes.
+
+### R2-8 — P2 — Stage 3: the schedule runner does not provide the claimed remote completion door
+
+**Problem and evidence:** [Roadmap lines 457–459](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:457) claim the runner already delivers completion across hosts.
+
+Existing once-timers are filed through [an instance-local control socket, bound to that host and its administrative token](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/schedule/control.go:28). The runner processes its local registry. A worker on another box has no described door for filing the promised once-timer on the destination.
+
+**Correction:** Add a durable completion outbox that the destination host converges into an idempotent local delivery, with destination selection, retries and deletion handling. Alternatively, make polling the initial completion contract and defer callbacks explicitly. Reusing local timer execution does not eliminate the cross-host handoff.
+
+### R2-9 — P2 — Stages 1 and 3: allowed regions, bucket location and the Ireland-only launch can disagree
+
+**Problem and evidence:** [Stage 1 defaults allowed regions to app-route placements](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:339). Stage 3 requires workers to read buckets only in their own region, first describes one worker per relevant region, then [launches only on the Ireland medium](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:436).
+
+Store location is a separate fact: [the object-storage spec configures it through `CARLOS_STORE_REGION`](/home/paulca/github.com/carlosframework/platform-infrastructure/platform-design/superpowers/specs/2026-08-10-object-storage-primitive-design.md:197). An app placed outside its bucket’s region can therefore receive a default list that excludes the only worker permitted to process its source. A non-Ireland bucket has no version-one worker at all.
+
+**Correction:** State launch eligibility and refuse unsupported jobs before enqueueing. Resolve processing region from both bucket location and the allowed list. Either launch regional workers or explicitly limit version one to eligible Ireland buckets. Also specify media DNS placement within the allowed set; a 421 response alone is not a routing plan.
+
+### R2-10 — P2 — Stage 1: `media-<app>` occupies another valid app’s hostname
+
+**Problem and evidence:** [The generated hostname](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:255) fits the wildcard, but `media-docs` is also an ordinary valid app name under [the existing app-name grammar](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/release/release.go:239).
+
+In account `abc`, the media host for `docs` and the normal platform host for app `media-docs` are both `media-docs.abc.oncarlos.com`. Certificate coverage does not resolve ownership.
+
+**Correction:** Define an atomic hostname claim and collision policy covering both media enable and ordinary app creation. Existing apps must not be displaced. Either reserve a namespace with a migration/refusal policy or choose another naming scheme with explicit certificate support.
+
+### R2-11 — P2 — §4 and §9: the dependency graph contradicts the implementation dependencies
+
+**Problem and evidence:** [The graph](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:228) puts an arrow from stage 4b into stage 3, making SFU work a prerequisite for on-demand media. The prose instead has SFU recording submit stage-3 jobs.
+
+Meanwhile, stage 3 depends on [the derivative-write policy “stage 2 adds”](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:449), although decision 11 says the single-rendition product follows stage 1. Section 9 still describes each gate as preceding “the next” stage.
+
+**Correction:** Make stage 3 independent of SFU work and make recording depend on stage 3. Extract derivative-write authority and publication into shared prerequisites available to whichever of stages 2 or 3 ships first. Express gates against those dependencies rather than a global next-stage sequence.
+
+### R2-12 — P2 — §8: the external-relay exception still omits artefact disclosure
+
+**Problem and evidence:** [The revised interim guidance for Meet](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:673) requires the README’s first paragraph but omits disclosure on every produced artefact. [Doctrine §8 requires both](/home/paulca/amadan.net/oficina/suite/docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md:934).
+
+Removing arbitrary team-selected endpoints closes the main original defect. It does not make the shortened exception complete.
+
+**Correction:** Carry the entire disclosure condition into the recommendation. Where no artefact is produced, say so; recordings and other produced artefacts must retain the required disclosure.
+
+### R2-13 — P2 — §1 and §8: the blanket claim that placement is invisible and unconstrainable is outdated
+
+**Problem and evidence:** [Roadmap lines 681–683](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:681) say no app-visible placement signal or constraint exists or is planned before extended CONNECT.
+
+The current member-facing interfaces already include:
+
+- [`carlos instances create --region`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/instances_cli.go:182).
+- [`carlos apps place`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/apps.go:65).
+- [Instance API placement information naming serving boxes and regions](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/console/instancesplacement.go:21).
+
+These do not establish a runtime “WebSocket transport supported” flag, but they contradict the broad absence claim.
+
+**Correction:** Distinguish member-controlled placement, observable placement and a runtime transport-capability contract. Keep the tunnel upgrade limitation and SSE fallback recommendation; identify the missing runtime capability precisely.
+
+### R2-14 — P3 — §2: the universal third-party-cookie claim is false
+
+**Problem and evidence:** [Roadmap lines 160–165](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:160) claim every current browser blocks cross-site cookies regardless of `SameSite=None`. Chrome retains user choice rather than universal blocking; Google’s [published update](https://privacysandbox.google.com/blog/update-on-plans-for-privacy-sandbox-technologies) explicitly describes that approach.
+
+**Correction:** Say third-party cookies cannot be relied on across supported browsers and privacy settings. The app-origin decision remains justified without the universal claim.
+
+**Verdict: request changes.** Revision 2 closes the original unsigned visibility bypass, private-cookie transport problem, HLS query propagation failure, and most isolation and feasibility defects. Remaining changes are still architectural: visibility transitions must retire the old source, orthogonal links need a coherent object model, publication needs an enforced fence, and the relay’s regional compromise claim conflicts with existing secret delivery. Resolve those alongside stale-edge behavior, backend coverage and job placement before treating the roadmap as ready for stage specifications.
\ No newline at end of file
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
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R4.md b/docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R4.md
new file mode 100644
index 000000000..d8dbcf2da
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R4.md
@@ -0,0 +1,96 @@
+# Findings on the media roadmap, revision 4
+
+Reviewed by **GPT-6 (Codex), 2026-09-07**, fourth pass, against revision 4 at `0ae7da6`, with Paul's rulings treated as fixed. Unedited apart from this note. Revision 5 records which findings were accepted and how.
+
+| Revision-3 finding | Disposition | One-line reason |
+|---|---|---|
+| R3-1 — Public cache retirement | Closed | All doors now require fresh serving authority; retirement covers every relevant box through acknowledgement or lease expiry. |
+| R3-2 — Publication fence | Partial | The console can reject expired commits, but synchronous transforms may expose output before that check succeeds. |
+| R3-3 — Relay incarnation | Partial | Username, authority record and incarnation retention are defined; stale authority closes new allocations only. |
+| R3-4 — Hostname reservation | Partial | The separate claim fixes length and historical parsing, but misses instance-host acquisition and trailing-hyphen names. |
+| R3-5 — SFU signalling | Closed | A dedicated SFU hostname carries LiveKit WebSockets directly, including for tunnel-backed apps. |
+| R3-6 — SFU tenant authority and revocation | Partial | Namespacing and signing custody are specified; webhook-based rejection and the refresh mechanism do not enforce revocation. |
+| R3-7 — Positive authorization cache | Closed | The exception is removed for every tenant; changing it requires a new ruling. |
+| R3-8 — Recording prerequisites | Closed | Redis, separate recording workers and Chrome sandbox requirements are named and remain deferred. |
+| R3-9 — Sequential gate heading | Closed | Acceptance gates now follow the dependency graph. |
+
+Reviewed commit `0ae7da6fdebd198ecddfad419d2d05049dcf73f1`. Paul’s eleven rulings are treated as fixed. No files were changed. “Closed” means resolved at roadmap level, not implementation-proven.
+
+**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.
+
+## R4-1 — P1 — Stage 4b: the rejoin deny list is checked after access is restored
+
+**Problem and evidence:** The [tenant-boundary mechanism](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:675) checks removed identities on token refresh and on LiveKit’s participant-joined webhook, claiming an old token cannot restore access.
+
+LiveKit emits `participant_joined` after the participant becomes active and establishes its media connection. Webhook delivery is also not guaranteed: retries can end in abandonment. This is notification after admission, not an admission veto. [LiveKit webhook contract](https://docs.livekit.io/intro/basics/rooms-participants-tracks/webhooks-events/).
+
+A removed participant can therefore present an unexpired token, reconnect and receive room media before being removed again. If webhook delivery fails, the proposed check never runs. The stage-4b gate’s “unable to rejoin” guarantee does not follow.
+
+**Correction:** Enforce the deny list before admitting a join or reconnect, through an unavoidable SFU admission gate. The platform WebSocket endpoint is a possible enforcement point, provided direct signalling cannot bypass it and admission is serialized against removal. Keep webhooks for observation and reconciliation. Test repeated rejoin attempts with delayed and lost webhooks, asserting that no room media becomes accessible.
+
+## R4-2 — P2 — Stage 4b: platform token refresh does not govern established sessions
+
+**Problem and evidence:** [Lines 681–685](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:681) say participants refresh through the platform door and describe removal as declining refresh plus calling remove-participant.
+
+Stock LiveKit refreshes connected clients’ tokens itself. Its server starts a refresh loop and sends newly signed tokens without consulting the platform door. Token expiry does not terminate an established session. [LiveKit token lifecycle](https://docs.livekit.io/frontends/reference/tokens-grants/), [server refresh implementation](https://github.com/livekit/livekit/blob/master/pkg/service/roommanager.go#L702).
+
+A client need not request platform refresh. Refusing that request therefore cannot be the mechanism that discovers or enforces loss of authorization. This remains a defect even after fixing admission under R4-1.
+
+**Correction:** Define an explicit app-to-platform removal operation that records revocation and reliably terminates the active participant, independently of client refresh. If continued participation requires periodic app authorization, specify a server-enforced session lease and its failure behavior. Account for LiveKit-issued refreshed tokens in reconnect enforcement. Browser requests for platform tokens must go through the app’s authenticated backend; the route credential remains server-side.
+
+## R4-3 — P2 — Stages 1–2: synchronous transforms bypass the publication decision
+
+**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:410) gives the console exclusive publication authority, then expressly allows the computing edge to serve a synchronous transform while publication is still in flight.
+
+A transform can read its source, stall until its attempt lease expires or the source receives an infected verdict, and then finish. The console correctly refuses its commit, but the exception permits the initiating response to receive those same rejected bytes. Manifest-only serving protects subsequent readers; it does not protect this response.
+
+**Correction:** Require successful publication before releasing transformed bytes, or define an equally authoritative response-admission decision covering the attempt lease, source generation and incarnation. Merely retaining a fresh edge serving lease does not establish that this attempt remains valid. Add tests where the initiating request receives no transformed bytes after publication is refused, including expiry with no successor and an infected-source purge during computation.
+
+## R4-4 — P2 — Stage 1 and §11: app-name claims do not cover instance-host claims
+
+**Problem and evidence:** The [media-host claim](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:310) is checked by new app names and renames. Enable checks whether the generated label is an existing app name.
+
+There is another acquisition door. The [instances API](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/console/apiinstances.go:571) permits an app to create a hostname using another, unclaimed label in the account’s platform namespace. Its ownership check calls `HasAppIn`; an [existing test expressly permits unclaimed labels](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/console/apiinstancesenable_test.go:210).
+
+Thus app `widget` can create `media-docs.abc.oncarlos.com` without any app named `media-docs`. The roadmap’s enable-time check misses that existing host. Conversely, checking app creation alone does not prevent a later instance request from claiming a media-owned label.
+
+**Correction:** Make instance-host acquisition consult the media-host claim, and make media enable check existing instance and route ownership. Serialize these competing acquisitions against the same hostname claim. Test both acquisition orders and concurrent requests; preserve existing owners.
+
+## R4-5 — P2 — Stage 1: some valid app names still produce invalid media hostnames
+
+**Problem and evidence:** [Lines 312–318](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:312) promise that the claim accepts `media-` plus any valid app name.
+
+[`release.ValidApp`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/release/release.go:239) permits trailing hyphens. App `docs-` therefore passes, but its generated label `media-docs-` fails the platform’s [hostname grammar](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/provision/provision.go:223).
+
+The length fix is sound: a 38-character media label can be a hostname without being an app name. However, the accompanying assertions in §1 and stage 1 that every generated label is also a valid app name remain false for source names longer than 26 characters.
+
+**Correction:** Define a collision-safe hostname mapping that covers trailing-hyphen names, or explicitly refuse media enable for them before writing any claim or grant. Preserve historical app parsing. Qualify the app-name collision explanation to the subset of generated labels that satisfy `ValidApp`.
+
+## R4-6 — P2 — Stage 4a: stale relay authority does not retire existing allocations
+
+**Problem and evidence:** The [relay authority rule](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:605) refuses **new allocations** after fifteen minutes, calling this the same lease rule as media serving. The [allocation policy](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:627) ends established allocations on credential expiry or observed disable.
+
+A relay can establish an allocation, lose authority updates, and miss a subsequent disable. At fifteen minutes it stops accepting new allocations but continues carrying the existing one, potentially until the original one-hour credential expires. That is a different stale-authority contract from closing every media door.
+
+**Correction:** Apply authority expiry to existing allocations and their refreshes, terminating them at the deadline. If the intended contract deliberately permits established traffic until credential expiry, state that longer disable bound explicitly and remove the “same lease rule” claim. Test disable during an authority outage with an allocation already carrying traffic.
+
+## R4-7 — P2 — Stage 4b: self-hosting LiveKit does not remove its external STUN defaults
+
+**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:648) specifies the hosted server and network ports but does not configure its STUN dependencies.
+
+Current upstream LiveKit supplies default STUN servers to participants when no configured STUN source applies. Those defaults include Twilio and Google. External-IP discovery also uses those defaults when enabled without an explicit server list. [LiveKit ICE-server selection](https://github.com/livekit/livekit/blob/master/pkg/service/roommanager.go#L1061), [default server list](https://github.com/livekit/mediatransportutil/blob/master/pkg/rtcconfig/config.go#L36), [external-IP discovery](https://github.com/livekit/mediatransportutil/blob/master/pkg/rtcconfig/ip.go#L32).
+
+Consequently, hosting the SFU does not by itself satisfy the roadmap’s no-third-party path or the brief’s requirement that STUN be platform-operated. This concerns connection metadata, not a claim that STUN receives call media.
+
+**Correction:** Require explicit platform-owned STUN configuration for both server discovery and client ICE configuration, with no external fallback. Verify the chosen LiveKit version’s effective configuration and test a call with third-party network access blocked. This implements the existing doctrine without changing ruling 9.
+
+## R4-8 — P3 — §1 and stage 1: two descriptions of existing code remain inaccurate
+
+**Problem and evidence:**
+
+- [§1’s scanning description](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:55) presents the infected-object read deny as an unconditional consequence of scanning. [`ScanBlock.Enforce` is optional](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/objstore/objstore.go:82), and [policy construction adds the deny conditionally](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/objstore/aws.go:211).
+- [“A fourth route kind”](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:302) overlooks the existing `status` serving class in the [edge dispatch](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/edge/edge.go:533). It is fourth only in the app-mintable vocabulary.
+
+**Correction:** Qualify the existing scanner claim as enforcement mode, while preserving ruling 2’s requirement that the new media grant carry the infected deny. Call media a new serving class or the fourth app-mintable route kind.
+
+**Verdict: request changes.** One P1 and six P2 findings remain. The public serving lease and per-box retirement condition close the previous public-cache defect, but SFU revocation still permits access after removal. Publication, hostname ownership, relay staleness and LiveKit’s default external dependencies need the corrections above before their stage specs proceed. None requires reopening Paul’s eleven rulings.
\ No newline at end of file
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R5.md b/docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R5.md
new file mode 100644
index 000000000..9cdb8f36c
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R5.md
@@ -0,0 +1,36 @@
+# Findings on the media roadmap, revision 5
+
+Reviewed by **GPT-6 (Codex), 2026-09-07**, fifth pass, against revision 5 at `1255d28`, with Paul's rulings treated as fixed. Unedited apart from this note. Verdict: accept. Revision 6 applies the two wording corrections.
+
+| Revision-4 finding | Disposition | One-line reason |
+|---|---|---|
+| R4-1 — Rejoin admission | Closed | An unavoidable proxy checks revocation before upstream admission, serialized against removal. |
+| R4-2 — Established-session removal | Closed | An explicit backend removal operation calls `RemoveParticipant`, independently of client refresh; §9 bounds termination to one second. |
+| R4-3 — Synchronous publication bypass | Closed | The initiating request receives transformed bytes only after successful console publication. |
+| R4-4 — Instance-host collisions | Closed | All hostname-acquisition doors share the claim; enable checks existing app, route and instance ownership. |
+| R4-5 — Invalid generated hostnames | Partial | Trailing-hyphen refusal and length handling are sound; one blanket app-name assertion remains false. |
+| R4-6 — Existing relay allocations | Closed | Authority expiry now terminates established allocations as well as refusing new ones. |
+| R4-7 — External STUN defaults | Closed | Stage 4b explicitly requires platform-only discovery and client ICE configuration, with no external fallback. |
+| R4-8 — Existing-code descriptions | Closed | Scanning enforcement is qualified, and media is correctly described as the fourth app-mintable kind. |
+
+Reviewed commit `1255d28529a1db3def775c46618df5e98e5eac21`, with Paul’s eleven rulings fixed. “Closed” means complete at roadmap level, not implementation-proven. No files were changed.
+
+**Severity:** P1 breaks a trust guarantee or makes a stage unbuildable; P2 requires correction before the stage spec; P3 is advisory.
+
+## R5-1 — P3 — Stage 1: the blanket app-name assertion remains false
+
+**Problem and evidence:** [Lines 319–320](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:319) still say that `media-<app>` “is also a valid app name.” [`release.ValidApp`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/release/release.go:242) permits at most 32 characters. Prefixing a valid 27–32-character app name produces a 33–38-character label.
+
+The surrounding mechanism correctly accepts these as hostname claims without using the app-name validator, and §1 already states the 26-character qualification. Only the explanatory sentence remains wrong.
+
+**Correction:** Say that `media-<app>` **can also be a valid app name**, which creates the collision the separate claim prevents.
+
+## R5-2 — P3 — Stage 4b: attribute media termination to `RemoveParticipant`
+
+**Problem and evidence:** [Lines 709–712](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:709) describe dropping the participant’s signalling connection as ending the session. LiveKit’s `HandleSignalSourceClose` closes the participant only when ICE has **not** connected; an established media connection can survive signalling loss. [LiveKit implementation](https://github.com/livekit/livekit/blob/master/pkg/rtc/participant.go#L977).
+
+The same paragraph already names the appropriate server operation, `RemoveParticipant`, and the admission gate prevents revoked identities from reconnecting. This closes the earlier architectural defect; the remaining issue is the explanation of which operation terminates media. [LiveKit removal API](https://docs.livekit.io/reference/other/roomservice-api/#removeparticipant).
+
+**Correction:** State that `RemoveParticipant` terminates the active media session, while the proxy closes signalling and refuses subsequent admission. Preserve §9’s one-second termination gate.
+
+**Verdict: accept.** No P1 or P2 problems remain. The two P3 corrections concern explanatory wording; the mechanisms are named, bounded and assigned to stages, and no remaining mechanism-level conflict with Paul’s fixed rulings was found.
\ No newline at end of file
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-MEDIA.md b/docs/superpowers/specs/CODEX-FINDINGS-MEDIA.md
new file mode 100644
index 000000000..6eb584aaa
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-MEDIA.md
@@ -0,0 +1,196 @@
+# Findings on the media roadmap
+
+Reviewed by **GPT-6 (Codex), 2026-09-07**, independently of the draft author, from the brief in `CODEX-REVIEW-BRIEF-MEDIA.md`. Unedited apart from this note. The roadmap's revision 2 records which findings were accepted and how.
+
+Reviewed commit `fd3a671d8c9b8876b940cdc5ae558ade3f0cdba7`. Roadmap line references below refer to [that worktree’s roadmap](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md). No files were changed.
+
+1. **P1 — §2, stage 1: changing the URL bypasses private and secret access controls.**
+
+ **Problem and evidence:** Lines 200–206 explicitly map `/p/`, `/s/` and `/v/` onto the same underlying prefix. A person who knows `/v/contracts/customer.pdf` can request `/p/contracts/customer.pdf` anonymously. An expired secret link can likewise be changed to `/p/`, with its signature removed. This works on a cold cache; whether the app previously distributed a public URL is irrelevant.
+
+ **Correction:** Make visibility an enforced property of the object or use disjoint underlying namespaces. The caller must not select a weaker access policy merely by changing the URL. Apply this rule to source objects, derivatives and renditions. Stage 1’s acceptance tests must attempt these substitutions against both cold and warm caches.
+
+2. **P1 — §2, stage 1: private browser requests have no defined way to carry identity.**
+
+ **Problem and evidence:** Lines 117–119 forward the viewer’s cookies and `Authorization`, but lines 188–198 put media on a different hostname. Host-only app cookies do not accompany requests to that hostname, and an Oficina custom domain cannot set cookies for `oncarlos.com`. Ordinary image and native video requests do not automatically carry an application bearer header. The [home identity design, §5.3](/home/paulca/amadan.net/oficina/home/docs/superpowers/specs/2026-08-30-home-design.md:327) specifies an audience-bound, single-use assertion delivered by auto-POST; it cannot be replayed for every media segment.
+
+ **Correction:** Specify a concrete browser authentication path before treating private media as buildable: for example, a media-session bootstrap whose credential identifies the person while the app still authorizes every fetch. Cover custom domains, native video, session expiry and sign-out. Broadening app cookies to a shared parent domain would require a separate security justification.
+
+3. **P1 — §2, stage 1: “authorization never cached” is enforced at only one layer.**
+
+ **Problem and evidence:** Exempting the media route from the shared cache does not exempt an authorization subrequest sent through the app’s ordinary route. [The edge cache lookup](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/edge/edge.go:1300) returns before the handler, and a cookie-authenticated, positive-`max-age` response can be admitted unless its response headers prevent that. The roadmap does not define a bypassing transport.
+
+ The mandatory media headers at lines 228–234 also omit downstream caching policy. A browser or intermediary can reuse a private response without asking this edge again. A cached secret response can similarly outlive its URL’s expiry. This is the class of failure named in [doctrine leak item 11](/home/paulca/amadan.net/oficina/suite/docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md:1026).
+
+ **Correction:** Require authorization checks to bypass every response cache, use an app-bound transport, and fail closed on timeout, redirect or malformed response. Require `no-store` on private HTTP responses and authorization responses; keep the byte cache internal. Define secret-response freshness so reuse cannot exceed token validity. Perform authorization before returning `HEAD`, `304`, range metadata or cache-hit information.
+
+4. **P1 — Stage 1 and stage 5 safety: the new reader bypasses existing malware enforcement.**
+
+ **Problem and evidence:** Lines 208–214 grant host roles `GetObject`, while lines 437–440 defer scanning. Existing enforcement is already implemented as a deny on the **store user’s inline policy**, keyed on an infected-object tag. A separate host role does not inherit it. See [policy construction](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/objstore/aws.go:129) and [the infected-object deny](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/objstore/aws.go:211). An infected object refused through app credentials would remain readable through media. A warm cache adds another bypass after a verdict changes.
+
+ **Correction:** Preserve enforcement for the new principal and cached bytes. Define how verdict changes invalidate or block cached objects and their derivatives. Prove that an enforced infected object fails through direct storage and cold/warm media access.
+
+5. **P2 — Stage 1 signing: the roadmap selects cryptography without defining the authorization it signs.**
+
+ **Problem and evidence:** Lines 193–197 and 216–222 specify URL fields and Ed25519 keys, but not the signed claims. There is no stated binding for app/store identity, object versus prefix scope, expiry, path normalization or permitted operations. A textual prefix such as `photos/a` must not silently authorize `photos/another`; encoded separators and duplicate query parameters must not be interpreted differently by the signer and reader.
+
+ Stage 2 compounds this at lines 285–286 by requiring parameters to be covered by “the signature” for private objects, although `/v/` has no signature.
+
+ **Correction:** State the required signed claims and scope rules now; leave the serialization format to the stage spec. Include domain separation, canonical object identity, boundary-aware prefix matching, expiry and allowed transformations. Private transforms must be approved by the app check or restricted to presets. Define maximum token life, overlapping-key retirement, emergency revocation and stale-edge behavior. Also require capability-query redaction from logs and an explicit referrer policy.
+
+6. **P1 — Stages 1 and 3: a prefix signature in the playlist query does not authorize its segments.**
+
+ **Problem and evidence:** Lines 104–111 and 350–352 promise HLS playback through one prefix-scoped URL. A relative reference such as `720p/index.m3u8` or `segment001.m4s` does **not** inherit the base playlist’s query parameters. Resolving the roadmap’s signed playlist URL therefore produces unsigned child requests. This follows [URI reference resolution](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.2) and [HLS’s relative-URI rules](https://www.rfc-editor.org/rfc/rfc8216#section-4.1).
+
+ **Correction:** Choose a transport that carries the capability to every playlist, initialization section and segment: a capability-bearing path, playlist rewriting, or another explicitly supported mechanism. If rewriting playlists per capability, do not cache the resulting bearer-bearing playlist solely by object key. Prefix scope alone does not remove this requirement.
+
+7. **P2 — Stage 1: range and conditional handling do not come free from S3.**
+
+ **Problem and evidence:** Lines 48–51 and 225–227 equate passing headers to S3 with `http.ServeFile`. The [static handler](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/edge/static.go:361) delegates local-file behavior to Go; a streaming S3 handler must implement its own HTTP mapping. S3 also does not support multiple ranges in one `GetObject` request. See [GetObject’s documented behavior](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html).
+
+ The proposed object-key-and-range cache needs rules for `If-Range`, validators, changed object lengths, suffix ranges and partial fills. Otherwise a cached range can be combined with metadata or other ranges from a different object version.
+
+ **Correction:** Define supported range forms, conditional precedence, `HEAD`, response statuses and validator handling. Bind cached data to an object version and publish cache entries only after a complete successful fill. Evaluate request conditions against that version before replaying bytes.
+
+8. **P2 — Stage 1 cache: per-route caps plus a global LRU do not guarantee tenant isolation.**
+
+ **Problem and evidence:** Lines 236–243 specify per-route byte limits and a “fleet-wide budget, LRU eviction,” without saying which route loses entries when total capacity is exhausted. A busy tenant can remain below its own cap while repeatedly displacing another tenant’s entries from a global LRU. This is the same failure the roadmap identifies in the shared response cache; [current capacity enforcement](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/edge/cache.go:330) illustrates it.
+
+ Arbitrary overlapping ranges also allow many stored copies of substantially the same bytes.
+
+ **Correction:** Specify per-box total capacity, tenant-fair admission and eviction, and account-level bounds across multiple routes. Reserve filesystem headroom and bound in-flight fills. Normalize ranges into reusable blocks or impose equivalent limits. A fleet-wide accounting total is distinct from a disk budget enforceable on one machine.
+
+9. **P2 — Stage 1 purge: the claimed regional fanout does not exist.**
+
+ **Problem and evidence:** Lines 245–248 say purge reaches every region through the existing listener. [That listener](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/edge/purge.go:108) is a local Unix socket operating on one host’s in-process cache. [The response-header mechanism](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/edge/purge_header.go:64) can target a same-app sibling hostname, but only on the current edge. Neither mechanism distributes invalidations or addresses the proposed disk cache.
+
+ **Correction:** Make durable regional invalidation an explicit stage 1 deliverable, including disconnected-edge recovery and protection against an in-flight fetch repopulating an entry after purge. The API and CLI must distinguish request acceptance from completion. Define disable, source deletion and derivative deletion through the same lifecycle. Purge cannot withdraw copies already delivered to browsers.
+
+10. **P2 — Stage 1 residency: DNS steering is not a data-residency boundary.**
+
+ **Problem and evidence:** Lines 250–262 promise that unsteered media stays in its bucket’s region, yet lines 208–210 grant every regional role access. The [multi-region ingress design](/home/paulca/github.com/carlosframework/platform-infrastructure/platform-design/superpowers/specs/2026-08-20-multi-region-ingress-design.md:18) deliberately makes every edge serve every app and separates serving capability from DNS steering. A client can address a different edge directly; DNS defaults do not prevent a foreign-region cache fill.
+
+ Steering is also per route, so an app with mixed route settings has no single existing value for media to inherit.
+
+ **Correction:** Put allowed serving/cache regions in the media record and enforce them in the handler and grants. Define an explicit media steering default. Include derivative processing and the Ireland worker in residency policy: foreign-region source processing is another copy, even without an edge cache.
+
+11. **P2 — §2: the doctrine mapping is not exact.**
+
+ **Problem and evidence:** Lines 90–98 present public/secret/private as the doctrine’s three visibilities. [Ruling 15 and §4.2](/home/paulca/amadan.net/oficina/suite/docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md:283) instead define `private`, `granted` and `team`, with a read-only link capability on a separate axis. A team-readable item still needs authenticated delivery; it is not publicly readable. A link does not confer sibling custody.
+
+ **Correction:** Describe these as media delivery mechanisms. Explicitly map all three item rungs to app-authorized delivery and preserve links as an orthogonal capability. Media publication must not become evidence that a sibling may ingest the source item.
+
+12. **P2 — §7: the configurable third-party relay contradicts the doctrine.**
+
+ **Problem and evidence:** Lines 505–508 call a per-instance ICE-server setting admissible under ruling 6 and retain it for teams choosing their own relay. [Doctrine §8](/home/paulca/amadan.net/oficina/suite/docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md:934) expressly says the app chooses the named recipient and forbids a team-configurable endpoint. It also requires disclosure in the README’s first paragraph and on produced artefacts, not merely unspecified disclosure.
+
+ **Correction:** Distinguish the operator’s platform relay from an app-selected external provider with per-team opt-in and the required working floor. Arbitrary team-selected external relays need an explicit doctrine amendment. The platform-relay custody argument does not authorize that separate exception.
+
+13. **P2 — Stage 4a: local verification does not require every relay to hold every account’s minting secret.**
+
+ **Problem and evidence:** Lines 374–385 put a durable account secret in app config and every relay’s route record, then claim this is unavoidable. Local verification requires appropriate credential-verification material; it does not require distributing the same durable minting authority to both apps and all regions. Account-wide material also lets one enabled app mint credentials indistinguishable from another app’s, undermining the promised per-app accounting.
+
+ **Correction:** Prefer an authenticated credential-issuance API that returns short-lived, region/app-scoped TURN credentials, with relay-owned secrets or temporary verifier material. Verification during TURN operation can remain local. If offline app minting is a firm requirement, state that tradeoff explicitly and define app scope, maximum expiry, rotation, disable behavior and secret storage outside ordinary route records. TURN’s [third-party authorization specification](https://www.rfc-editor.org/rfc/rfc7635) is another option to evaluate against actual browser support.
+
+14. **P2 — Stage 4a: relay authentication does not constrain where traffic goes or how much it consumes.**
+
+ **Problem and evidence:** The relay plan defines credentials and metering but no peer-address policy or allocation limits. A credential holder can use TURN outside Meet’s call protocol. Pion’s [default permission handler admits all peers](https://github.com/pion/turn/blob/main/server_config.go), so an unconfigured service can relay into addresses reachable from the box, including internal networks. Short-lived credentials alone do not prevent port exhaustion or sustained bandwidth consumption.
+
+ **Correction:** Include peer-address restrictions for loopback, link-local, private/infrastructure ranges and relay loops, with explicit exceptions where needed. Bound allocations, creation rate, bandwidth and lifetimes per app/account and box. Define what happens to established allocations when credentials expire or relay access is disabled. Operational limits belong in stage 4a even if commercial quotas wait.
+
+15. **P2 — Stage 4a: the port and placement plan promises more connectivity than it establishes.**
+
+ **Problem and evidence:** Lines 371–388 offer TCP 3478 and TLS 5349 while reserving 443 for the edge. Networks allowing only outbound 443 will still fail. The plan also needs the relay allocator to advertise the box’s public Elastic IP and allocate only inside the opened range; Pion exposes these as [explicit configuration](https://raw.githubusercontent.com/pion/turn/master/relay_address_generator_range.go).
+
+ Ordering servers by the app instance’s region does not identify the region nearest either participant. ICE selects and nominates viable candidate pairs according to its procedures; “ICE picks the fastest” is not a latency-selection guarantee. See [RFC 8445](https://www.rfc-editor.org/rfc/rfc8445).
+
+ **Correction:** Either provide TURN/TLS on a separate address using 443 or state the remaining network limitation. Define public-address advertisement, transport URLs, certificates and constrained allocation. Test a 443-only network and geographically separated participants. Treat client-aware relay selection as a separate decision.
+
+16. **P2 — §3 and stage 2: the in-process transform candidate does not satisfy the helper isolation rule.**
+
+ **Problem and evidence:** Section 3 requires a separate program under a hardened unit with a CPU quota. Lines 293–295 put the WASM candidate inside the edge binary. That computation shares the edge’s CPU and memory limits; it does not acquire a separate systemd resource boundary. The [edge unit](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/host/units/carlos-edge.service:72) already permits substantial host-memory use.
+
+ The spike measures one image and one latency threshold. It does not establish behavior under concurrent requests, expensive AVIF encoding, malformed inputs or sustained load. “Roughly twice as slow” is also unsupported by a pinned dependency and fleet measurement.
+
+ **Correction:** Run every candidate behind equivalent worker isolation or explicitly amend the rule. Benchmark peak memory, sustained throughput and serving latency as well as thumbnail time. Set output-dimension, memory, wall-time, scratch-space and process limits. Ship helpers with an authenticated manifest, their required libraries/codecs, and an update/rollback path for existing boxes—not bootstrap installation alone.
+
+17. **P2 — Stage 2: mutable sources and negotiated formats have no stable derivative identity.**
+
+ **Problem and evidence:** Lines 310–313 derive the storage path from source key and parameters, although stage 1 permits mutable keys. Replacing `photos/cat.jpg` leaves the old derivative at the same path unless the app performs a separate purge. A transform already running during purge can restore stale output afterward. Lines 304–305 also select format from `Accept`, which must participate in representation identity.
+
+ **Correction:** Include source version, normalized parameters, resolved output format and relevant preset/engine version in derivative identity. Bind authorization to that source identity, and fence publication against deletion or replacement. Define `Vary: Accept` or redirect to explicit-format URLs. Apply resource limits fairly across tenants; a process-wide single-flight and concurrency cap alone do not prevent one tenant monopolizing all work slots.
+
+18. **P2 — Stage 3: job submission, worker authority and crash recovery are missing architectural dependencies.**
+
+ **Problem and evidence:** Lines 328–335 say an app writes a job into a platform data bucket, after which the worker reads source objects, writes derivatives and calls the app’s socket. Delivered store credentials authorize the app’s own bucket, and stage 1’s new host-role grant is read-only. No app submission authority or worker output grant is specified. A worker in Ireland also cannot directly call a Unix socket on another host.
+
+ A conditional claim alone does not define crash recovery. A permanent claim strands a job after a crash; an expiring claim allows overlapping workers unless publication is fenced.
+
+ **Correction:** Specify an authenticated app/API submission door, server-derived source and destination scope, worker read/write authority and remote completion delivery. Use leases with fencing, bounded retries, cancellation, terminal failure states and atomic publication of complete rendition sets. App deletion must cancel work and prevent late writes.
+
+19. **P2 — §1 and stage 3: the proposed queue overlooks an existing queue and repeats its documented deletion trap.**
+
+ **Problem and evidence:** Line 70 says no job queue exists. There is no generic media queue, but [the scan queue](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/objstore/queue.go:1) already stores one bucket object per job, with enqueue and bounded-drain machinery. Its header explicitly explains why jobs live under the app prefix: top-level queues survive app deletion invisibly. Stage 3 proposes a top-level `jobs/` prefix without addressing that problem.
+
+ **Correction:** Narrow the claim to “no generic media job queue.” Reuse or explicitly adapt the existing queue’s ownership and lifecycle rules. Put jobs where app deletion finds them, or extend deletion and name-reuse checks deliberately.
+
+20. **P2 — Stage 3 and §9: the Ireland worker’s capacity guarantee is unsupported.**
+
+ **Problem and evidence:** Lines 339–342 say `Nice=` and a CPU quota ensure serving is never starved. They do not isolate memory, disk capacity, I/O or network contention. A `t4g.medium` has the same two vCPUs and 20% baseline per vCPU as a `t4g.small`; it primarily adds memory. Sustained encoding spends CPU credits or incurs surplus charges, depending on configuration. See [AWS’s baseline table](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/burstable-credits-baseline-concepts.html).
+
+ The two-minute acceptance test at lines 538–540 has no source duration, resolution, codec or queue-depth bound.
+
+ **Correction:** Limit the first worker’s admitted source size, duration and concurrent jobs. Set memory and scratch limits and measure serving latency, I/O and credit consumption under sustained load. The first expected sustained bottleneck is encode throughput; without memory and scratch limits, host pressure can fail earlier. Move workers based on those measurements, before queues reach hours.
+
+21. **P2 — Stage 3: remuxing cannot produce the promised adaptive ladder.**
+
+ **Problem and evidence:** Lines 344–348 promise three resolutions and a remux-only fast path whenever input is H.264/AAC in MP4. Remuxing preserves encoded video; it cannot create lower-resolution rungs. Codec names alone also do not establish suitable keyframe spacing, profile or other playback properties. FFmpeg’s [segmenting documentation](https://ffmpeg.org/ffmpeg-formats.html#hls-2) makes keyframe placement material to segment boundaries.
+
+ **Correction:** Separate single-rendition remux from adaptive-ladder generation. Define fast-path eligibility and probe the actual input. State when lower rungs arrive and when re-encoding is required. Bound the Safari acceptance test to representative input fixtures.
+
+22. **P2 — Stage 4b: LL-HLS needs serving behavior beyond an ordinary object cache.**
+
+ **Problem and evidence:** Lines 406–408 describe live streaming as the same worker packaging LL-HLS for the existing serving path. LL-HLS requires partial segments, delivery directives, blocking playlist reloads and freshness behavior that an S3 object reader does not automatically supply. These are explicit [Apple delivery requirements](https://developer.apple.com/documentation/http-live-streaming/enabling-low-latency-http-live-streaming-hls).
+
+ LiveKit is also proposed as a §3 helper at lines 401–402, although that rule forbids network access. An SFU requires network access.
+
+ **Correction:** Keep live packaging as a distinct long-running workload with an explicit serving extension and latency contract. Separate network-facing media services from no-network codec helpers. The implementation may reuse components, but the roadmap must not promise reuse of unchanged serving semantics.
+
+23. **P2 — Stages 1 and 5: browser CORS requirements are incomplete, and upload CORS cannot be prefix-scoped.**
+
+ **Problem and evidence:** Lines 420–424 say S3 CORS is set “on the media prefix.” S3 CORS is a bucket-wide configuration; its [rule elements](https://docs.aws.amazon.com/AmazonS3/latest/userguide/ManageCorsUsing.html) have no object-prefix selector.
+
+ Separately, `Cross-Origin-Resource-Policy: cross-origin` at line 230 is not CORS permission. Cross-origin JavaScript HLS playback, fonts, authenticated fetches and canvas use need an actual media-response CORS policy.
+
+ **Correction:** Define bucket-wide CORS ownership, merge and disable behavior. Include multipart initiation/completion and exposed response headers. Specify media-origin CORS independently, including allowed origins, credentials, preflights and `Vary: Origin` where necessary. Keep CORS distinct from object authorization.
+
+24. **P2 — Stage 1: the chosen hostname falls outside the existing wildcard certificate shape.**
+
+ **Problem and evidence:** Lines 186–198 introduce `media.<app>.<sqid>.<apps-domain>`. [Wildcard parent derivation](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/edge/wildcard.go:186) recognizes exactly two labels below the apps domain and obtains `*.<sqid>.<apps-domain>`. That certificate does not cover the proposed three-label media name. Per-host issuance may provide a fallback, but it changes issuance volume and operational assumptions.
+
+ **Correction:** Choose a hostname compatible with the existing wildcard, such as `media-<app>.<sqid>.<apps-domain>`, or explicitly budget a different certificate strategy. Include platform namespace ownership and custom-domain support in the stage spec.
+
+25. **P2 — Stage 1: enable/disable needs an authority lifecycle, not just a bucket-policy edit.**
+
+ **Problem and evidence:** Lines 208–222 cover attaching read permission and distributing keys, but not partial completion, store revocation, route replacement or stale readers. The current [provisioner interface](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/objstore/provisioner.go:12) has no bucket-policy operation. The new policy writer therefore needs explicit privileged authority and ownership of its statements. Removing bucket access also does not remove bytes already cached on disk.
+
+ **Correction:** Define an additive authoritative media record and a reconciled enable/disable lifecycle. Include safe policy composition, permitted regional principals, key propagation, cache retirement and observable completion through the API/CLI. Define support or explicit refusal for adopted and non-AWS stores. Stage 2 must separately identify who may write and delete derivatives.
+
+26. **P2/P3 — §1, stage 5 and §8: remaining factual claims need correction.**
+
+ **Problem and evidence:**
+
+ - **P2:** The scanner does not await #19’s outcome. It is implemented, and [the sweep explicitly says there is no upload to hook](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/objstore/sweep.go:1), because writes go directly to S3. “One callback” does not discover those uploads.
+ - **P3:** `infra/modules/region-host/main.tf:76` points to dynamic ingress configuration. The residency header is at [lines 6 onward](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/infra/modules/region-host/main.tf:6), and bucket permissions begin at [line 106](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/infra/modules/region-host/main.tf:106). Correct both the evidence and amendment target.
+ - **P3:** “Meet needs STUN to work across the internet at all” is too absolute. Directly reachable ICE candidates can work without STUN; STUN and TURN improve coverage across different network conditions.
+ - **P3:** TURN bytes traversing the box twice are not automatically twice the billable internet egress. Received, transmitted and inter-region bytes need separate meter definitions.
+
+ **Correction:** Update the scanner integration to the existing sweep/queue model, repair the citations, and replace absolute networking and billing claims with explicit coverage and accounting definitions.
+
+27. **P3 — §4, §6 and §9: the stage order imposes unnecessary dependencies.**
+
+ **Problem and evidence:** Lines 493–495 call stage 4a independent and urgently needed, yet still place it after stage 1. Section 9 additionally frames every stage’s proof as a prerequisite for starting the next. Meanwhile stage 1 carries uploads, multipart helpers and a substantial accounting surface that are not all necessary to prove the serving class.
+
+ **Correction:** Let relay design and implementation start independently. Keep access control, browser identity, cache isolation, revocation, enforcement and residency in the stage 1 gate. Upload conveniences and richer dashboards can follow a working serving class. Replace the single sequence with explicit dependencies, and expand security gates to cover the attacks above rather than only successful playback.
+
+**Verdict: request changes.** The roadmap’s current URL mapping defeats its central access guarantees, private browser authentication is unspecified, caching can bypass live authorization, and the new reader bypasses existing scan enforcement. HLS capability transport also fails as written. These require roadmap-level decisions before stage specifications proceed. The remaining corrections identify missing operational contracts and overstated reuse claims; they do not require implementing every later-stage feature now.
\ No newline at end of file
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-MEDIA.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-MEDIA.md
new file mode 100644
index 000000000..85caa1bf4
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-MEDIA.md
@@ -0,0 +1,96 @@
+# Review brief: media roadmap
+
+You are GPT-6 reviewing a design roadmap independently of its author. Your
+output is a findings report, not an edit. Do not modify any file.
+
+## What to read
+
+1. The roadmap under review:
+ `docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md`
+ (in this worktree).
+2. The prior planning it descends from, as text below (amadan discussions
+ #107, #112, #113 on carlos/platform).
+3. The platform code it cites, in this worktree: `internal/edge/edge.go`,
+ `internal/edge/cache.go`, `internal/edge/static.go`,
+ `internal/edge/storedoor.go`, `internal/edge/purge_header.go`,
+ `internal/provision/provision.go`, `internal/caps/`,
+ `internal/objstore/`, `internal/schedule/`, `internal/activator/`,
+ `infra/modules/region-host/main.tf`, `.amadan/ci.d/20-cross@5`,
+ `go.mod`.
+4. The private specs it cites, at
+ `/home/paulca/github.com/carlosframework/platform-infrastructure/platform-design/superpowers/specs/`
+ (ignore `.worktrees/`): the object-storage primitive (2026-08-10),
+ app capabilities (2026-08-03), app config and secrets (2026-08-02),
+ route-scoped config delivery (2026-08-16), static platform host
+ (2026-08-23), multi-region ingress (2026-08-20), remote host Pi
+ (2026-08-04), unit economics (2026-08-11).
+5. The suite doctrine the roadmap must not contradict:
+ `/home/paulca/amadan.net/oficina/suite/docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md`,
+ especially rulings 3, 6, 8 and 15, §4.2, §8, and leak item 11.
+6. The home identity design:
+ `/home/paulca/amadan.net/oficina/home/docs/superpowers/specs/2026-08-30-home-design.md` §5.
+
+## What matters most
+
+- **Doctrine.** Does the public/secret/private mapping in §2 honour the
+ suite doctrine, in particular ruling 3 (authorisation never cached) and
+ leak item 11? Is the ask-the-app subrequest for private media sound,
+ and what does it leak or cache that the roadmap does not admit?
+- **Security of the serving class.** The bucket-policy grant, the Ed25519
+ signed links (URL shape, what the signature covers, prefix scope,
+ rotation), the separate media origin and headers, the per-route cache.
+ Look for a way to read a private or secret object without
+ authorisation, to poison a cache entry, or to make one tenant's cache
+ evict another's.
+- **The relay.** TURN REST credentials with the per-account secret held
+ on every region box. Is there a better shape that keeps verification
+ local to the relay? Anything wrong about the port plan, placement, or
+ the egress claim under ruling 6?
+- **Feasibility on the fleet as it is**: `t4g.small` ARM boxes, no cgo in
+ the Go tree, no package installs today. Are the helper-binary rule in
+ §3, the transform engine candidates, and the media worker on the
+ Ireland `t4g.medium` realistic? Name what will fall over first.
+- **Staging and order.** Is anything in a later stage secretly required
+ by an earlier one? Is anything in stage 1 that could wait?
+- **Claims about the code.** Every `file:line` and every "the platform
+ already does X" claim: check it. Report each one that is wrong.
+- **What is missing** that an app building on this would hit in the
+ first month.
+
+## How to report
+
+Number findings. For each: 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), the section it concerns, the
+problem, the evidence with citations, and a proposed correction. End
+with a one-paragraph verdict: accept as a roadmap, accept with the
+listed corrections, or request changes. Do not soften findings, do not
+pad with praise, and do not restate the roadmap.
+
+## Prior planning, verbatim
+
+### #107 Media handling: a `kind:media` serving class over an app's store
+
+(Brainstormed 2026-08-31. Decided: build the serving class first; media
+serves public and private; kind:media caches for itself. Open questions
+1 to 3 were: what private means, how the edge is granted read, where the
+signing key lives. Open 4 and 5: abuse surface for transforms, who pays
+for derivatives. The roadmap answers all five.)
+
+### #112 A P2P WebRTC app has no relay
+
+Meet is browser-to-browser WebRTC, rooms capped at four, signalling over
+HTTP on the app's instance. Platform has no UDP, no TURN, ports
+capability is 1 to 1023 with no protocol. Asked: is the direction a
+platform-hosted relay (A) or per-team bring-your-own TURN (B) under
+suite ruling 6? Meet's later requirements: STUN is the minimum and
+cannot be a third party's under ruling 6; TURN next with short-lived
+HMAC credentials as platform config; SFU later; credentials scoped per
+account; nearest-region placement; keep the "platform relay is not
+egress, third-party relay is" distinction.
+
+### #113 No upgrade over a tunnel-backed host
+
+`infra/remote-host.md`: a tunnel-backed host cannot carry `Connection:
+Upgrade`. Asked: confirm as a design constraint; can an app know or
+refuse its placement? Meet defaults to SSE plus POST if unanswered.