carlos / platform Public

spec: media roadmap revision 2, folding in codex findings

GPT-6 via Codex reviewed revision 1 and requested changes; its 27
findings are committed unedited as CODEX-FINDINGS-MEDIA.md. Revision 2
makes visibility a property of the object's namespace rather than the
URL, serves private media on the app's own origin so the session cookie
reaches it, carries the secret capability as a path segment so HLS
segments inherit it, routes the private check through the activator so
no cache can answer it, carries the store's infected-object deny into
the media grant, makes purge a distributed log, enforces allowed regions
in the media record, derives relay credentials per region and per app,
and lets stage 4a start independently of stage 1.
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 6424a4f320821fd72667625c1aa7dbb35baff0cc parent fd3a671
2 files changed, +776 −383
  • docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md +580 −383
  • docs/superpowers/specs/CODEX-FINDINGS-MEDIA.md +196 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md b/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md
index 0e222a69e..b1d322eb2 100644
--- a/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md
+++ b/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md
@@ -1,16 +1,19 @@
# Media on CARLOS: a staged roadmap
-**Date:** 2026-09-07
-**Status:** draft, awaiting Paul's rulings on the numbered decisions in §6.
+**Date:** 2026-09-07, revision 2
+**Status:** draft, awaiting Paul's rulings on the numbered decisions in §7.
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)**.
+requirements the Meet session sent on 2026-09-07. Revision 1 was reviewed
+independently by GPT-6 via Codex; its 27 findings are in
+`CODEX-FINDINGS-MEDIA.md` beside this file, unedited. Revision 2 accepts
+them, in whole or in part, and marks each place it changed **(codex N)**.
+§10 names the two parts it declines and why.
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.
+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
@@ -29,57 +32,80 @@ throughout.
## 1. Where things stand, measured on `main`
-Discussion #107 recorded most of this on 2026-08-31. Re-checked today.
+Discussion #107 recorded most of this on 2026-08-31. Re-checked today,
+and corrected where the review 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.
+- **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 a `Deny` on
+ `GetObject` for objects tagged infected, written into the store user's
+ policy (`internal/objstore/aws.go:211`). 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, or it is a bypass. **(codex 4, 26)**
- **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.
+ (`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. `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.
+ 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)**
+- **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 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.
+ 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, not the three words here.**
+ Ruling 15: an item is `private`, `granted` or `team`, and may
+ orthogonally carry a read-only link. `team` is not public; it still
+ needs authenticated delivery. §2 maps to that honestly. **(codex 11)**
+- **Doctrine §8 says the recipient of any egress is the app's choice,
+ never the team's.** A team-pasted relay URL is not admissible; only an
+ app-named provider with per-team opt-in and disclosure in the README's
+ first paragraph is. **(codex 12)**
+- **Wildcard certificates cover exactly two labels** under the apps
+ domain (`internal/edge/wildcard.go:186`), so a media hostname must be
+ `media-<app>.<sqid>.<apps domain>`, not a third label. **(codex 24)**
- **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.
+ `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: 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.
+ 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, with credentials delivered the way store keys already are.
+ capability.
- **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.
+ 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
@@ -87,231 +113,299 @@ Discussion #107 recorded most of this on 2026-08-31. Re-checked today.
## 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
+Three words, three delivery mechanisms. They 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 them
+as the last row of the table says. **(codex 11)**
+
+| Visibility | Who can fetch | What the edge checks | Bytes cached | Served from |
+|---|---|---|---|---|
+| **public** | anyone with the URL | nothing | by object key, long-lived | media host |
+| **secret** | anyone holding a valid capability | the capability's signature, scope and expiry | by object key, once per object | media host |
+| **private** | a person the app admits, now | asks the app on every request | by object key, after the app says yes | the app's own origin |
+| *doctrine* | `private`, `granted`, `team` rungs all deliver as **private**; a read-only link delivers as **secret**; nothing in the doctrine is **public** unless the app has its own rule making an item world-readable | | | |
+
+**Visibility is a property of the object, never of the URL.** The media
+prefix has three disjoint namespaces, `public/`, `secret/` and
+`private/`, and the app writes an object into exactly one. The edge maps
+the visibility door in the URL onto the matching namespace and reads
+nowhere else, so rewriting `/v/x` to `/p/x` reads `public/x`, which does
+not exist. Moving an object between visibilities is a server-side copy
+and a purge. Derivatives and renditions live under the same namespace
+as their source. Stage 1's acceptance test tries every substitution
+against a cold and a warm cache. **(codex 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 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.
+Cloudinary do: a bearer capability, signed by the app, naming an object
+or a prefix, 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 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
+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
+three properties the review forced into the open:
+
+- **Private media is served on the app's own origin**, under a reserved
+ path the edge intercepts before proxying, 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 are blocked by every current browser
+ regardless of `SameSite=None`. 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)**
+- **The check never touches a cache**: it travels through the activator
+ into the instance's own socket, the path scheduled ticks already use,
+ not through the public route. It fails closed on timeout, redirect,
+ or anything but a 200. It runs before `HEAD`, before a 304, before a
+ range is answered and before a cache hit is admitted. **(codex 3)**
+- **Private responses carry `Cache-Control: private, no-store`**, so no
+ browser or intermediary reuses one without asking again. Secret
+ responses carry a `max-age` no longer than the capability's remaining
+ life. The edge's own byte cache is internal and never a substitute
+ for either. **(codex 3)**
+
+The costs: the app's instance is in the hot path for private media, one
+small request per fetch, and a hibernating instance wakes for it; the
+app must expose the check endpoint, which rastrillo provides once for
+every app; the edge stays identity-free, forwarding a cookie it never
+reads. The edge never caches the app's answer. A bounded positive cache
+on the answer for tenants outside the suite is a later per-app knob, off
+by default. **(decision 1 in §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; it is a **helper
-binary**, and the rule for one is:
+a `t4g.small`. The answer is not a cgo dependency. Two classes, with
+different rules: **(codex 16, 22)**
+
+**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++, 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.
+ 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, an SFU 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. LiveKit, if chosen, is a media
+service, not a helper.
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.
+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
+## 4. The stages and how they depend on each other
-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.
+Six pieces of work. The dependency graph replaces a single sequence;
+anything with no arrow into it can start now. **(codex 27)**
+
+ stage 1 serving class ──▶ stage 2 image transforms
+ │ └──▶ stage 3 audio/video on demand ◀──┐
+ │ │
+ └──────────────────▶ stage 5 app-facing helpers │
+ │
+ stage 4a STUN + TURN ──▶ stage 4b SFU, recording, live ─────┘
| 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 |
+| 1 | `kind:media`: serving class with the three visibilities | edge read grant with the scan deny, signed capabilities, streaming handler, isolated cache, distributed purge, hostnames, CLI | one spec, one plan |
+| 2 | image transforms behind preset URLs | a codec helper, limits, derivative identity and write-back | one spec, one spike first |
+| 3 | audio and video on demand: renditions, 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; **can start now** |
+| 4b | SFU, recording, live streaming | a media service, a bigger box, a live serving extension | one spec, decide build vs host |
+| 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 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.
+cached, with the three visibilities of §2. This is piece A of #107 with
+its open questions answered.
+
+**What an app sees.** `carlos media enable` writes an additive `media`
+block into `store.json` (prefix, namespaces, allowed regions, key ids)
+and mints `media-<app>.<sqid>.<apps domain>`, one label so the account
+wildcard already covers it. **(codex 24)** The app writes objects under
+`<prefix>/public/`, `<prefix>/secret/` or `<prefix>/private/` 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 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 a platform constant (seven
+days by default). Rotation is a second key id with an overlap; emergency
+revocation removes a key id from the route record and every edge stops
+honouring it on its next sync. 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 a statement letting each allowed
+region's host role `GetObject` on the three namespaces of that one
+bucket, **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, decision 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. **(decision 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, 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 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.
+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.** 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, so a box that was down catches up on reconnect, and an
+in-flight fill cannot repopulate an entry the purge already covers
+because entries carry the generation they were filled under. The API
+and CLI answer "accepted" and separately report completion per region.
+The existing `Carlos-Purge` header and the local socket stay as fast
+paths on the box that sees them. Purge cannot withdraw bytes a browser
+already holds, which is why private responses are `no-store`. A scan
+verdict turning infected files a purge for the key and its derivatives.
+**(codex 4, 9)**
+
+**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, and the handler on any other edge answers 421 with
+the right host, 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. Processing residency is the same list: stage 3's worker only
+touches buckets in its own region. **(codex 10, decision 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.
-**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.
+**What stage 1 must prove** (its gate, §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.
+per-viewer analytics, signed cookies, bandwidth quotas, dashboards
+beyond a bytes-served counter, multipart upload helpers.
### 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.
+`/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 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 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)**
+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, so replacing `photos/cat.jpg` cannot serve yesterday's
+thumbnail and a transform in flight during a purge cannot publish stale
+output. `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. **(codex 16, decision 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 a second bucket-policy statement scoped to
+`derived/`, and count against the tenant's storage. Purging a source
+key 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.
@@ -321,135 +415,183 @@ SVG rasterisation, anything needing a model.
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.
+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.
+- **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.
+
+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
-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.
+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)**
+- **Authority.** The worker reads the source through the media bucket
+ policy and writes renditions under `derived/` through the same second
+ statement stage 2 adds, on the bucket in its own region only.
+- **Leases, not claims.** A worker takes a lease with an expiry and a
+ fencing token; publication of a rendition set is atomic and carries
+ the token, so an expired lease's late write is refused. Bounded
+ retries, cancellation, a terminal failure state, and app deletion
+ cancelling work and fencing late writes. **(codex 18)**
+- **Completion** reaches the app as a once-timer filed through the
+ schedule runner, which already delivers to instances across hosts,
+ and as a result record the app can poll. **(codex 18)**
+- **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 with hard admission limits and the measurements
+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, decision 7)**
**Not in stage 3:** caption generation, DRM, per-viewer watermarking,
-server-side ad insertion. Caption files the app supplies are just
-objects.
+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, 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.
+Meet's requirements, in the order Meet needs them. Stage 4a depends on
+nothing above and starts when Paul says so. **(codex 27)**
+
+**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.** No box holds a fleet-wide secret and no app
+ holds another app's. 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)`, as `CARLOS_RELAY_KEYS` alongside `CARLOS_RELAY_URLS`. The app
+ mints TURN REST credentials per call, `username =
+ expiry:account:app`, `credential = HMAC(derived key, username)`, with
+ a maximum life the platform sets (one hour). The relay re-derives the
+ key from the username and its own region master, so verification is
+ local, per-app, and a compromised box yields one region's master and
+ nothing durable about any other. Rotation is a new region master and
+ a re-render of every enabled app's config; disable removes the app
+ from the derivation allow-list the relay syncs from route records.
+ An issuance API returning short-lived credentials is the alternative
+ if offline minting is ever the wrong trade. **(codex 13, decision 8)**
+- **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. Meet's README says which one it uses.
+ A third-party relay is egress, and under doctrine §8 which third
+ party is the app's decision, never the team's.
**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,
+and is where recording becomes possible. It is a media service,
`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)**
+server as a media service 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 per-track or composite files into the app's bucket
+and filing a stage 3 single-rendition job, so a recorded call becomes a
+playable asset with no new serving code. Live streaming to many viewers
+is a **distinct long-running workload**: the SFU or a WHIP 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, decision 9)**
**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.
+app's own HTTP. §8 answers #113.
### 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
+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 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.
+ 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 and the three relay meters 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.
+- **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 signed URLs. Not needed while links are
- prefix-scoped; revisit if a tenant needs a whole site of private media.
+- **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. Blocked
+ by third-party cookie blocking on custom domains; the app-origin path
+ 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,
@@ -460,52 +602,81 @@ Named so nobody wonders whether it was forgotten.
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
+## 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 stage 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 replace an object and not serve the old bytes (ETag-bound
+ cache, distributed purge);
+- 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. 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.
+1. **Private means ask-the-app, per request, never cached, served on the
+ app's own origin under a reserved path.** The only reading that
+ honours suite ruling 3 and the only delivery that reaches a session
+ cookie on a custom domain. Recommended.
+2. **Edge read is granted by bucket policy on the three media
+ namespaces**, per allowed region host role, carrying the infected
+ deny, attached and removed by a reconciled walk. Recommended over
+ widening the host role fleet-wide.
+3. **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. Recommended over a
+ shared HMAC secret in the registry.
+4. **Allowed regions are explicit in the media block and enforced** in
+ the bucket policy and the handler; default is the regions the app's
+ routes are placed in. Recommended over inheriting steering.
+5. **The transform engine is chosen by a measured spike** among a Go
+ wazero helper, `vips`, and a Rust helper, all as separate processes,
+ bar set at one second for a WebP thumbnail with serving latency flat
+ under concurrency on a `t4g.small`. Recommended to run 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
+ `derived/` in the source's namespace, counted as the tenant's
+ storage, removed with their source. Recommended.
+7. **The media worker starts on the Ireland `t4g.medium` behind hard
+ admission limits**, with the stated expectation that ladders move it
+ to a `c7g.large`. Recommended, as the cheapest honest start.
+8. **Relay credentials are per-region HKDF-derived per app**, verified
+ locally, no fleet-wide secret anywhere. Recommended over a shared
+ account secret on every box and over an issuance API for version one.
+9. **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.
+10. **Native helpers are allowed under the §3 rule**, as separate
+ processes only, with codec helpers and media services as distinct
+ classes. Recommended.
+11. **Order.** Stage 1 and stage 4a start independently. Stage 2 and the
+ stage 3 single-rendition product follow stage 1. The ladder and
+ stage 4b follow measurement. Recommended.
-## 7. Answers to #112 and #113
+## 8. 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.
+stage 4a, an SFU is 4b, and 4a can start now. 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. Meet should
+pick one of those two, not the free-text field. **(codex 12)**
**#113.** Confirmed as a standing constraint: no `Connection: Upgrade`
over a tunnel-backed host, and no app-visible placement signal or
@@ -515,7 +686,47 @@ 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
+## 9. What each stage must prove before the next starts
+
+The gates are attacks failing, then playback working. **(codex 27)**
+
+- **Stage 1**, on flagship: `/p/` of a private key is 404 cold and warm;
+ a capability with the expiry edited, the scope widened past a `/`
+ boundary, or the key id revoked is refused; a private fetch is refused
+ the request after the app revokes the grant, on a warm cache; 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 outside the allowed regions
+ refuses to fill. Then: a public hit served from a second region's
+ cache, 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;
+ 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 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.
+
+## 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.
+- **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.
+
+## 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.
@@ -523,21 +734,7 @@ and keep SSE as the fallback, which it would need anyway.
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.
+ for served bytes and at 4a for the three relay meters.
+- `infra/modules/region-host/main.tf:6` header: residency by
+ construction now has a named exception, the per-region media bucket
+ policy, and the exception is itself region-scoped.
diff --git a/docs/superpowers/specs/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