carlos / platform Public

spec: media roadmap draft and codex review brief

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V676C9hPD1oP4Njdw3B26L
Paul Campbell pushed by paul@keymail.dev fd3a671d8c9b8876b940cdc5ae558ade3f0cdba7 parent 09a22d6
2 files changed, +639 −0
  • docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md +543 −0
  • docs/superpowers/specs/CODEX-REVIEW-BRIEF-MEDIA.md +96 −0
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..0e222a69e
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md
@@ -0,0 +1,543 @@
+# Media on CARLOS: a staged roadmap
+
+**Date:** 2026-09-07
+**Status:** draft, awaiting Paul's rulings on the numbered decisions in §6.
+Brainstormed with Claude from discussions #107, #112 and #113 and the
+requirements the Meet session sent on 2026-09-07. Reviewed independently
+by GPT-6 via Codex; its findings are in `CODEX-FINDINGS-MEDIA.md` beside
+this file, and the ones accepted are folded in and marked **(codex)**.
+
+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, and so nobody builds a stage
+out of order.
+
+## 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 or a TURN service
+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.
+
+- **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.
+- **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:76`). 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. `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.
+- **Range requests, conditional GET and content types come free.**
+ `kind:static` gets them from `http.ServeFile`. A media handler that
+ streams from S3 with `Range` passed through gets the same, and that
+ alone is progressive MP4 and MP3 playback.
+- **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 with a two-minute life, verified by each app, and suite
+ doctrine ruling 3 says authorisation is never cached at any scope.
+ Leak item 11 names a cacheable personalised response at the platform
+ edge as a cross-person leak.
+- **The doctrine already has three visibilities.** Ruling 15: an item is
+ private, granted or team, and may orthogonally carry a read-only link,
+ which is a bearer capability that never makes the item team-visible.
+- **The fleet is small ARM.** Four production regions on Graviton
+ `t4g.small`, one `t4g.medium`, burstable, no GPU, no ffmpeg, no
+ package installs at bootstrap. Remote hosts are Raspberry Pis. Nothing
+ here has CPU to spare for transcoding.
+- **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: a native helper binary that only ever runs on a Linux ARM
+ host is allowed, and §3 sets the rule for shipping one.
+- **There is no job queue.** `internal/schedule` is cron and one-shot
+ timers delivered to an instance's own socket. The platform's control
+ plane is a bucket; a worker polling a jobs prefix is its native shape.
+- **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, with credentials delivered the way store keys already are.
+- **A tunnel-backed host cannot carry `Connection: Upgrade`** and an app
+ cannot learn or constrain its host type (remote-host-pi design, lines
+ 63 and 140).
+- **Real demand exists.** Docs ships an "insert image" menu item disabled.
+ Meet is being designed now and needs STUN to work across the internet
+ at all. Woodstar runs its own media service as a tenant app, which is
+ 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 mechanisms. They map onto the suite doctrine exactly,
+so an Oficina app can use them without an exception, and they are simple
+enough that a non-suite app can use them without reading the doctrine.
+
+| Visibility | Who can fetch | What the edge checks | What the edge caches |
+|---|---|---|---|
+| **public** | anyone with the URL | nothing | bytes, by object key, long-lived |
+| **secret** | anyone holding a valid link | the link's signature and expiry | bytes, by object key, once per object regardless of holder |
+| **private** | a person the app admits, now | asks the app on every request | bytes only, by object key; the decision, never |
+
+**Public** is a plain URL on the app's media hostname. 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 link is signed by the app, names an object or a prefix,
+and expires. Anyone holding it can fetch; the bytes are identical for
+every holder, so one popular file is cached once and revocation lags by
+the expiry. Prefix scope is not optional: an HLS stream is one manifest
+and hundreds of segments, and signing each segment is why other systems
+fall back to signed cookies. The signature lives in the URL, not a
+cookie, so a link pasted into chat works and nothing about the viewer's
+browser is involved.
+
+**Private** is identity-bound, and the only way to honour ruling 3 is to
+not decide at the edge. On every request for a private object the edge
+makes one small subrequest to the app: method, path, the viewer's
+cookies and `Authorization` header forwarded, no body. The app answers
+200 or 403 in a millisecond from its own grants table. The edge then
+serves from its byte cache or fetches the object. This is the shape of
+nginx `auth_request`. Costs, stated plainly:
+
+- 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: it forwards credentials it never reads.
+
+An app that wants cheaper private can mint short secret links per
+viewer, which is what presigned URLs are today. The doctrine treats that
+as a link, not a grant, so suite apps use it only where a link is right.
+The edge never caches the app's answer. If a tenant outside the suite
+wants a bounded positive cache on the answer, that is a per-app knob to
+add later, off by default. **(decision 1 in §6)**
+
+Everything else in the plan hangs off this table. Transforms and HLS
+renditions inherit the visibility of their source. A relay is private by
+construction because credentials are per account and short-lived.
+
+## 3. Native helpers: 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; it is a **helper
+binary**, and the rule for one is:
+
+- The Go tree stays `CGO_ENABLED=0`. A helper is a separate program in
+ whatever language wins the benchmark: Rust, C, C++, or an existing
+ tool such as `vips` or `ffmpeg`.
+- A helper is built and shipped as its own pinned artifact, linux/arm64
+ only, fetched at bootstrap by a box that has the role that needs it,
+ verified by hash. It never enters the CLI build.
+- A platform unit spawns it and talks to it over stdin/stdout or a unix
+ socket. The Go side owns the protocol, so a helper is swappable
+ without touching the edge: wazero today, Rust tomorrow, same calls.
+- A helper runs under the same systemd hardening the tenant sandboxes
+ get, with no network, a read-only filesystem apart from its scratch
+ directory, and a CPU quota.
+
+This is the answer to "could we build some dependencies in Rust or C for
+throughput": yes, as helpers, and the spikes in stages 2 and 3 decide
+whether the first ones are ours or borrowed.
+
+## 4. The stages
+
+Five stages. Stage 1 is independently valuable and every later stage
+serves through it. Stages 2 and 3 are worthless without it. Stage 4 is
+independent of 1 to 3 except that recording lands in 3. Stage 5 is the
+cross-cutting work that makes each stage usable from an app without a
+platform engineer.
+
+| Stage | Delivers | Needs from the platform | Size |
+|---|---|---|---|
+| 1 | `kind:media`: serving class with the three visibilities | edge read grant, signing keys, streaming handler, own cache, purge, hostnames, CLI | one spec, one plan |
+| 2 | image transforms behind preset URLs | an image helper, CPU limits, derivative write-back | one spec, one spike first |
+| 3 | audio and video on demand: HLS ladder, posters, waveforms | a media worker, a jobs prefix, ffmpeg on one box | one spec, one infra change |
+| 4a | STUN and TURN | UDP ingress, a relay unit, per-account credentials as config | one spec, one infra change |
+| 4b | SFU, recording, live streaming | a real-time media unit, recording into stage 3 | one spec, decide build vs host |
+| 5 | uploads, rastrillo helpers, accounting, residency, safety | small pieces spread across 1 to 4 | folded into each stage's plan |
+
+### Stage 1: `kind:media`, the serving class
+
+A fourth route kind that serves an app's store bucket through the edge,
+cached, on its own hostname, with the three visibilities of §2. This is
+piece A of #107 with its open questions answered.
+
+**What an app sees.** `carlos media enable` (or a manifest block)
+declares a media prefix in the app's store and mints
+`media.<app>.<sqid>.<apps domain>`, a hostname separate from the app's
+own origin on purpose: user-uploaded SVG and HTML must never run on the
+app's origin. The app writes objects under the prefix 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/<key>?e=<exp>&k=<kid>&sig=<sig>
+ secret, one key
+ https://media.docs.abc.oncarlos.com/s/<prefix>/<rest>?e=<exp>&k=<kid>&p=<prefix>&sig=<sig>
+ secret, a prefix
+ https://media.docs.abc.oncarlos.com/v/<key> private
+
+The path prefix names the visibility so the edge knows what to check
+before it touches a cache. The bucket layout underneath is the app's
+business: the edge maps `/p/`, `/s/` and `/v/` onto the declared media
+prefix and the app decides which keys it hands out under which door. An
+object the app has ever handed out under `/p/` is public, and the docs
+say so; the edge cannot un-publish a key it has cached fleet-wide
+except by purge.
+
+**How the edge is granted read.** The provisioner attaches a bucket
+policy at `carlos media enable` time that lets each region's host role
+`GetObject` on the declared media prefix of that one bucket, and removes
+it on disable. No secret is delivered anywhere, the authority is recorded
+in `store.json` next to the existing grant, and the blast radius is one
+prefix of one bucket rather than the fleet-wide widening #351 was
+narrowing. **(decision 2)**
+
+**Signing.** 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. Rotation is a second key id: two public halves in the route
+record for the overlap, and `k=<kid>` in the URL says which one. The
+cost is an 86-character signature in each secret URL. **(decision 3)**
+
+**The handler.** `mediaHandlerFor`, modelled on `staticHandlerFor` but
+streaming: an S3 `GetObject` with the client's `Range` and conditional
+headers passed through and the body copied through, never buffered.
+`storedoor.go` buffers whole objects and is not the model. Response
+headers the edge sets on every media response, whatever the object
+carries: `X-Content-Type-Options: nosniff`, `Content-Security-Policy:
+sandbox`, `Cross-Origin-Resource-Policy: cross-origin`, a `Content-Type`
+from the object's metadata with a small allow-list fallback, and
+`Content-Disposition: attachment` for anything that is not an image,
+audio, video, font or PDF. A `?download=1` query forces attachment with
+a filename the app chose at upload.
+
+**Its own cache.** `kind:media` is exempt from the shared LRU in both
+directions and keeps a per-route bounded cache keyed by object key and
+range, on disk under `/data/media-cache/<route>/` with a byte budget per
+route and a fleet-wide budget, LRU eviction. Public and secret hits are
+served straight from it. Private hits are served from it only after the
+subrequest says 200. Variants (stage 2) and renditions (stage 3) are
+cached under the same budget. Small objects may additionally sit in
+memory; the disk tier is what makes video viable on a `t4g.small`.
+
+**Purge.** Three doors: the existing `Carlos-Purge` response header the
+app already uses, a console door `POST /a/<sqid>/apps/<app>/media/purge`
+with a key or prefix, and `carlos media purge <key|prefix/>`. Purge fans
+out to every region through the existing purge listener.
+
+**Residency.** An edge in Sydney caching an object whose bucket lives in
+Ireland is a copy of tenant data in a second region. The host role's
+current scoping enforces residency by construction, and this stage
+loosens it for media by design. Default: media follows the route's
+steering, so a latency-steered app serves and caches in every region and
+an app with no steering serves from its bucket's region only. This is
+disclosed in the media docs page. A later per-app `residency: pinned`
+flag can hold cache to the bucket's region while still steering.
+**(decision 4)**
+
+**Steering.** `kind:media` defaults to latency steering when the app's
+routes do, otherwise not. Same open question as `kind:static` (#286),
+same answer.
+
+**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.
+
+**Tasks** are the #107 task list, unchanged except that the three open
+questions are now decisions 1 to 3 here, and residency and the security
+headers are added.
+
+**Not in stage 1:** transforms, transcoding, uploads through the edge,
+per-viewer analytics, signed cookies, bandwidth quotas.
+
+### Stage 2: image transforms
+
+`/p/w_800,f_webp/photos/cat.jpg` and the same under `/s/` and `/v/`. A
+transform is synchronous, pure CPU, and its result is written back to
+the app's bucket under `derived/` so the second region never redoes the
+first's work and a cold cache is a bucket read, not a decode.
+
+**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 either a **named preset** the app declared
+(`thumb: w_200,h_200,c_fill,f_auto`) or, for secret and private objects,
+part of what the signature covers. Free-form parameters on public
+objects are refused. On top of that: single-flight per derived key
+across the process, a small per-box concurrency cap on decodes with a
+queue that turns into 503 after a short wait, a source pixel-count cap,
+and a per-route derivative byte budget.
+
+**The engine, decided by a spike.** Three candidates, all within the §3
+rule. First, wazero-hosted `libwebp`, `libavif` and `jpegli` (the
+`gen2brain/*` packages) inside the edge binary: one binary, no host
+change, WASM roughly twice as slow as native. Second, a `vips` binary on
+the host as a helper: native speed, an existing tool, a package to
+fetch. Third, our own Rust helper over a unix socket using the `image`
+and `ravif` crates or linking libvips: native speed, ours to shape,
+persistent so decode caches survive across requests. The spike measures
+a 4000×3000 JPEG to an 800-wide WebP and AVIF on a `t4g.small` while
+the box is also serving. Under a second for WebP is the bar, and the
+candidate that clears it with the least to operate wins. **(decision 5)**
+
+**What a transform does.** Resize, crop modes, format (`f_auto` picks
+AVIF, WebP or JPEG from `Accept`), 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.** They land in the tenant's bucket under
+`<media prefix>/derived/<source key>/<params>.<fmt>` and count against
+the tenant's storage, which is the honest place for them. `carlos media
+purge` on a source key also removes its derivatives. This is a decision,
+not an accident. **(decision 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: adaptive bitrate for long or large
+video, a browser recording in WebM that Safari will not play, posters,
+waveforms, and durations the app can show before playback.
+
+**The media worker.** A platform unit, `carlos-media.service`, on one box
+the operator designates. It polls a `jobs/` prefix in the platform data
+bucket, the same bucket-as-control-plane shape everything else uses. An
+app (or `carlos media transcode <key>`) writes a job record, the worker
+claims it with a conditional put, runs `ffmpeg` as a §3 helper, writes
+results under the app's `derived/` prefix, and writes a result record
+the app can poll or be told about through a schedule-style callback to
+its socket. At-least-once, one worker per job, idempotent outputs. No
+queue service and no new daemon type: it is a platform unit like the
+edge.
+
+**Which box.** The worker is not the edge's problem. Version one runs on
+the Ireland `t4g.medium` with `Nice=` and a CPU quota so serving is never
+starved; an operator moves it to a dedicated `c7g` box the day a tenant's
+queue is measured in hours. **(decision 7)**
+
+**The ladder.** CMAF fragmented MP4, one HLS multivariant playlist, H.264
+plus AAC at three rungs by default (1080p, 720p, 360p), capped at the
+source's height, with a remux-only fast path when the source is already
+H.264 and AAC in MP4 so a phone upload costs seconds not minutes. AV1
+and a DASH manifest are one flag away and off. Audio-only sources get
+an AAC HLS rendition, a waveform JSON and a duration. Video sources also
+get posters at three timestamps. Everything is served by stage 1 under
+one prefix-scoped secret link or one private door, which is why prefix
+scope was a stage 1 requirement.
+
+**Not in stage 3:** caption generation, DRM, per-viewer watermarking,
+server-side ad insertion. Caption files the app supplies are just
+objects.
+
+### Stage 4: real time
+
+Meet's requirements, in the order Meet needs them, and what each is.
+
+**4a. STUN and TURN.** The smallest thing that makes a WebRTC app work
+across the internet without an egress exception is a STUN listener the
+platform runs. TURN is the same listener with relaying turned on, and it
+is what turns "one call in ten cannot connect" into "calls connect".
+Both are UDP, and the capabilities spec's non-goal stands: UDP is not
+something an app binds. Instead the platform runs `carlos-relay.service`
+on every region box, built on `pion/turn`, which is pure Go and lives in
+the same binary as the edge.
+
+- **Ingress.** UDP 3478 plus a relay port range (49152 to 65535) opened
+ in the region security group, and TCP 3478 and TLS on 5349 for
+ networks that block UDP. TLS on 443 is the edge's and stays so.
+- **Credentials.** TURN REST. A per-account secret, minted at
+ `carlos relay enable`, reaches the instance as `CARLOS_RELAY_SECRET`
+ through the sealed `config.json` path, with `CARLOS_RELAY_URLS`
+ listing the region relays nearest the instance first. The app mints
+ `username = expiry:accountid` and `credential = HMAC(secret,
+ username)` per call, so no browser ever holds a durable credential and
+ no instance holds a fleet-wide one. The relay checks the HMAC against
+ the account's secret from the route record, so the relay holds every
+ account's secret. That is the same trust the edge already has over
+ every account's traffic, and it is a secret on the edge, which stage 1
+ avoided for signing. The relay is the one place that cannot avoid it,
+ because it must verify without calling anyone.
+- **Placement.** The relay list is ordered by the instance's own region
+ first; the browser tries all and ICE picks the fastest. No new
+ steering.
+- **Cost.** TURN relays every byte of a failed-hole-punch call twice
+ through the box. At AWS egress rates that is the first media feature
+ whose marginal cost is visible, and the unit-economics spec needs a
+ line for it. Metering per account is part of this stage.
+- **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. Meet's README says which one it uses.
+
+**4b. SFU, 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. It is a second platform unit,
+`carlos-sfu.service`, with a bigger box behind it. Two ways to get one:
+build a minimal SFU on `pion/webrtc`, or run LiveKit's open-source
+server as a §3 helper and front its API. That choice waits until 4a has
+shipped and a tenant has asked for six people in a room. Recording is
+the SFU writing a composite or per-track file into the app's bucket and
+filing a stage 3 job, so a recorded call becomes an HLS asset with no
+new serving code. Live streaming to many viewers is the same SFU taking
+a WHIP ingest and the same stage 3 worker packaging LL-HLS from it, and
+is listed here so nobody designs it as a separate system.
+**(decision 8)**
+
+**Signalling stays the app's.** Offers, answers and candidates ride the
+app's own HTTP. Discussion #113 asked whether to design around the
+tunnel-host upgrade limit; §7 answers it.
+
+### 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.
+
+- **Direct browser uploads** (stage 1). Apps already presign PUTs, but a
+ browser cannot PUT to the bucket until the bucket has a CORS rule.
+ `carlos media enable` sets CORS for the app's hostnames on the media
+ prefix. Multipart upload for anything over 100 MB, so a phone video
+ does not fail at the last byte, is a rastrillo helper.
+- **rastrillo helpers** (stages 1 to 4). One package, `rastrillo/media`:
+ a URL builder for `/p/`, `/s/` and `/v/`, a signer that reads
+ `CARLOS_MEDIA_SIGNING_KEY`, the private-check handler that answers the
+ edge's subrequest from the app's grants, a presigned-upload helper
+ with CORS-correct headers, a transcode-job filer, and a TURN
+ credential minter. Docs' disabled "insert image" is the first
+ consumer.
+- **Accounting** (stage 1, extended in 3 and 4a). Bytes served, bytes
+ cached, transform seconds, transcode minutes and relayed bytes, per
+ app, on the console's app page and in `carlos media status`. The
+ unit-economics spec gets a media section. Quotas come after
+ measurement, not before.
+- **Safety** (stage 1). The separate media origin, `nosniff`, the CSP
+ sandbox and the attachment rule are the floor. A scan hook, so an
+ operator can wire the #19 scanner to new public objects, is one
+ callback and waits on #19's outcome.
+- **Residency disclosure** (stage 1). One paragraph on the media docs
+ page and in `carlos media status` saying which regions hold cached
+ copies.
+- **Docs page** (every stage). `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 signed URLs. Not needed while links are
+ prefix-scoped; revisit if a tenant needs a whole site of private media.
+- **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.
+
+## 6. Decisions Paul is asked to make
+
+Recommendations are stated; anything else is a cheap reversal now and an
+expensive one after stage 1 ships.
+
+1. **Private means ask-the-app, per request, never cached.** The only
+ reading that honours suite ruling 3. The cost is the app's instance
+ in the hot path for private media only. Recommended.
+2. **Edge read is granted by bucket policy on the declared media prefix**,
+ per region host role, attached and removed by the provisioner. No
+ secret delivery, per-store, operator-visible in `store.json`.
+ Recommended over widening the host role fleet-wide.
+3. **Ed25519 per app for secret links**, public half in the route record,
+ private half as env through sealed `config.json`, key id in the URL
+ for rotation. Recommended over a shared HMAC secret in the registry.
+4. **Media caching follows steering**, so a latency-steered app's media is
+ cached in every region and the docs say so; an unsteered app's media
+ stays in its bucket's region. A `residency: pinned` flag is a later
+ addition, not a stage 1 one. Recommended.
+5. **The transform engine is chosen by a measured spike** among
+ wazero-hosted encoders in-binary, a `vips` helper, and our own Rust
+ helper, bar set at one second for a WebP thumbnail on a `t4g.small`.
+ Recommended to run the spike before the stage 2 spec.
+6. **Derivatives and renditions land in the tenant's bucket** under
+ `derived/`, counted as the tenant's storage, removed with their source.
+ Recommended.
+7. **The media worker starts on the Ireland `t4g.medium`** with a CPU
+ quota, and moves to a dedicated box on measured demand. Recommended.
+8. **The SFU build-versus-host choice waits** until 4a has shipped and a
+ tenant asks for a bigger room. Recommended.
+9. **Order.** 1, then 4a in parallel with 2, then 3, then 4b. Stage 4a is
+ independent of the serving class and Meet needs it soonest, which is
+ why it does not wait behind 2 and 3. Recommended.
+10. **Native helpers are allowed under the §3 rule.** Separate pinned
+ artifacts, linux/arm64 only, spawned by a platform unit, never in
+ the CLI build. Recommended.
+
+## 7. Answers to #112 and #113
+
+**#112.** Direction A: the platform runs the relay. STUN and TURN are
+stage 4a; an SFU is 4b. UDP ingress is something the platform will
+carry, as a platform unit on the region boxes, never as an app-bindable
+port. Until 4a ships, Meet's per-instance ICE-servers setting is the
+admissible interim under ruling 6, disclosed, floor of same-network
+calls, and it should stay as a field after 4a for teams who want their
+own relay. A platform relay is not egress; a third-party one is.
+
+**#113.** Confirmed as a standing constraint: no `Connection: Upgrade`
+over a tunnel-backed host, and no app-visible placement signal or
+placement constraint exists or is planned before extended CONNECT.
+SSE plus POST for signalling is the right default, and its volume is
+small enough that the socket's elegance buys nothing. When rastrillo
+grows a WebSocket helper for presence, Meet can adopt it on cloud hosts
+and keep SSE as the fallback, which it would need anyway.
+
+## 8. 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 unit is the exception and is
+ specified in stage 4a.
+- `2026-08-11-unit-economics-design.md`: a media section, at stage 1
+ for served bytes and at 4a for relayed bytes.
+- `infra/modules/region-host/main.tf:76` header: residency by
+ construction now has a named exception, the media prefix bucket policy.
+
+## 9. What each stage must prove before the next starts
+
+- Stage 1: on flagship, a public hit served from cache in a second
+ region, a secret link that stops working at expiry, a private fetch
+ refused after the app revokes the grant, and a 2 GB MP4 seeking in a
+ browser through a `t4g.small` without the box paging.
+- Stage 2: the spike's number, then 9,999 distinct transform URLs against
+ a public object producing zero decodes.
+- Stage 3: a phone recording uploaded through Docs and playing in Safari
+ as HLS within two minutes, with the worker's CPU quota holding serving
+ latency flat on the same box.
+- Stage 4a: a Meet call between two symmetric-NAT networks connecting
+ through the Ireland relay with a credential that expires in an hour,
+ and the relayed bytes appearing on the account's usage line.
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.