spec: media roadmap revision 3, Paul's rulings and codex round 2
All eleven decisions ruled by Paul on 2026-09-07 and marked (ruling); decision 9 became host LiveKit rather than wait. Codex's second pass (CODEX-FINDINGS-MEDIA-R2.md, unedited) found two P1s: a visibility move by copy left the old object readable, and the claim that per-region relay keys bound a compromised host was false because sealed config is decryptable on every host. Revision 3 makes the move a lifecycle, states the relay exposure honestly with an incarnation in the derivation, collapses three namespaces to two behind three doors so a roster-visible object can also carry a link, routes the private check over the route's own backend transport with private bytes cached on the home edge, adds must-revalidate to secret responses, a serving gate and stale-age to purge, a publication fence shared by stages 2 and 3, worker eligibility at submission, polling as the v1 completion contract, a name-claim for media-<app>, and corrects the placement answer on #113.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V676C9hPD1oP4Njdw3B26L
2 files changed,
+574
−280
docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md+405 −280docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R2.md+169 −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 b1d322eb2..ef0eddb39 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,13 +1,17 @@ |
| # Media on CARLOS: a staged roadmap |
| -**Date:** 2026-09-07, revision 2 |
| -**Status:** draft, awaiting Paul's rulings on the numbered decisions in §7. |
| +**Date:** 2026-09-07, revision 3 |
| +**Status:** rulings taken. Paul ruled on all eleven decisions in §7 on |
| +2026-09-07; each is marked **(ruling)**. Everything not so marked is a |
| +**(decision)** that a stage spec may overturn cheaply. |
| + |
| Brainstormed with Claude from discussions #107, #112 and #113 and the |
| -requirements the Meet session sent on 2026-09-07. 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. |
| +requirements the Meet session sent on 2026-09-07. Reviewed twice by |
| +GPT-6 via Codex, independently of the author: revision 1's 27 findings |
| +are in `CODEX-FINDINGS-MEDIA.md`, revision 2's 14 in |
| +`CODEX-FINDINGS-MEDIA-R2.md`, both unedited. Each place a finding changed |
| +the text is marked **(codex N)** or **(codex R2-N)**. §10 names the two |
| +parts declined. |
| 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 |
| @@ -23,23 +27,30 @@ install and no third-party service in the path, and every object can be |
| public, secret or private. |
| "No third party" is not taste. Suite ruling 6 forbids egress that is a |
| -property of the product, and a media CDN, an image API or a TURN service |
| -that every app dials by default is exactly that. The platform is the one |
| -recipient a team has already chosen, so media handled by the platform is |
| -not egress at all. That distinction is what makes this a platform feature |
| -rather than a per-app settings field, and the plan keeps that language |
| -throughout. |
| +property of the product, and a media CDN, an image API, a TURN service |
| +or a hosted SFU that every app dials by default is exactly that. The |
| +platform is the one recipient a team has already chosen, so media |
| +handled by the platform is not egress at all. That distinction is what |
| +makes this a platform feature rather than a per-app settings field, and |
| +the plan keeps that language throughout. |
| ## 1. Where things stand, measured on `main` |
| Discussion #107 recorded most of this on 2026-08-31. Re-checked today, |
| -and corrected where the review found the citations wrong. |
| +and corrected where the reviews found the citations wrong. |
| - **Apps already have object storage**, bucket-per-app, credentials |
| delivered sealed through `config.json` (object-storage spec §5). Reads |
| and writes go app-to-S3 directly. The spec says at line 27 that a |
| byte-proxy in the data path is "permanently out of scope". Reads must |
| cross that line for a CDN to exist. Writes never will. |
| +- **Sealed config is decryptable on every host.** The deployment's |
| + decryption key sits in `host.env` on every box (config-secrets spec |
| + §4) and `internal/appconfig/materialize.go:30` renders secrets to env |
| + at adopt time. A compromised host therefore already exposes every |
| + app's store keys and secrets, not only its own residents'. Nothing in |
| + this plan makes that better or worse, and no claim below may pretend |
| + a per-region secret changes it. **(codex R2-2)** |
| - **The store already scans.** `internal/objstore` has a ClamAV scanner, |
| a resumable sweep that tags every object with a verdict, a scan queue |
| of one object per item under the app's own prefix, and a `Deny` on |
| @@ -61,6 +72,16 @@ and corrected where the review found the citations wrong. |
| the host it runs on, and the `Carlos-Purge` response header acts on |
| the edge that saw the response. Nothing distributes an invalidation |
| across regions today. **(codex 9)** |
| +- **An edge keeps serving from last-converged state through a control |
| + bucket outage** (multi-region ingress spec, line 123). Any authority |
| + media adds to the route record inherits that: a disconnected edge |
| + will honour a revoked key until told otherwise. **(codex R2-6)** |
| +- **The tick path is local.** `activatorDeliverer` reaches an instance |
| + through the activator and its unix socket on the same box, and refuses |
| + routes without one (`internal/host/schedule_wiring.go:220`). Once-timers |
| + are filed on an instance's control socket, bound to that host |
| + (`internal/schedule/control.go:28`). Neither crosses a box. |
| + **(codex R2-4, R2-8)** |
| - **Range and conditional handling come from `http.ServeFile`** for |
| `kind:static`, and only there. A streaming S3 handler has to build its |
| own HTTP mapping, and S3 `GetObject` serves one range per request. |
| @@ -72,17 +93,25 @@ and corrected where the review found the citations wrong. |
| 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.** |
| +- **The doctrine's visibilities are rungs plus an orthogonal link.** |
| 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)** |
| + additionally carry a read-only link. `team` is not public. One item |
| + can be reachable by its roster and by a link at the same time, so the |
| + storage model must let both doors reach one object. **(codex 11, R2-3)** |
| - **Doctrine §8 says the recipient of any egress is the app's choice, |
| - never the team's.** 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)** |
| + never the team's**, disclosed in the README's first paragraph and on |
| + every artefact produced. A team-pasted relay URL is not admissible. |
| + **(codex 12, R2-12)** |
| +- **Members already control placement.** `carlos apps place` and |
| + `carlos instances create --region` exist, and the instances API |
| + reports which boxes and regions serve a host |
| + (`internal/console/instancesplacement.go:21`). What does not exist is |
| + a runtime signal an app can read about its host's transport |
| + capabilities. **(codex R2-13)** |
| - **Wildcard certificates cover exactly two labels** under the apps |
| - domain (`internal/edge/wildcard.go:186`), so a media hostname must be |
| - `media-<app>.<sqid>.<apps domain>`, not a third label. **(codex 24)** |
| + domain (`internal/edge/wildcard.go:186`), and app names are |
| + `^[a-z][a-z0-9-]{0,31}$` (`internal/release/release.go:239`), so any |
| + one-label media name is also a valid app name. **(codex 24, R2-10)** |
| - **The fleet is small ARM.** Four production regions on Graviton |
| `t4g.small`, one `t4g.medium`, burstable at a 20% per-vCPU baseline |
| with the same two vCPUs on both sizes; the medium adds memory, not |
| @@ -99,9 +128,8 @@ and corrected where the review found the citations wrong. |
| - **UDP is a stated non-goal of the capabilities spec** (line 931). A |
| relay is therefore a platform service next to the edge, not an app |
| capability. |
| -- **A tunnel-backed host cannot carry `Connection: Upgrade`** and an app |
| - cannot learn or constrain its host type (remote-host-pi design, lines |
| - 63 and 140). |
| +- **A tunnel-backed host cannot carry `Connection: Upgrade`** |
| + (remote-host-pi design, line 63). |
| - **Real demand exists.** Docs ships an "insert image" menu item disabled. |
| Meet is being designed now and needs STUN for calls to connect across |
| most home and mobile networks. Woodstar runs its own media service as |
| @@ -113,27 +141,39 @@ and corrected where the review found the citations wrong. |
| ## 2. Public, secret, private |
| -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)** |
| +Three words, three delivery doors, two storage namespaces. The doors are |
| +how bytes reach a browser, not a new authorisation model: the app still |
| +decides who may see what, from its own grants, and the doctrine's rungs |
| +map onto the doors as the last row of the table says. |
| -| Visibility | Who can fetch | What the edge checks | Bytes cached | Served from | |
| +| Door | Who can fetch | What the edge checks | Bytes cached | Reads 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** `/p/` | anyone with the URL | nothing | by object key, long-lived, in every allowed region | `public/` only | |
| +| **secret** `/s/<cap>/` | anyone holding a valid capability | the capability's signature, scope, namespace and expiry | by object key, once per object, in every allowed region | `protected/` | |
| +| **private** `/_carlos/media/` on the app's origin | a person the app admits, now | asks the app on every request | by object key, on the app's home edge, after the app says yes | `protected/` | |
| +| *doctrine* | `private`, `granted` and `team` rungs deliver through the **private** door; a read-only link on any of them is a **secret** capability naming the same object; nothing is **public** unless the app's own rule makes an item world-readable | | | | |
| + |
| +**Two namespaces, not three.** The media prefix has `public/` and |
| +`protected/`. The public door reads `public/` and nothing else, so |
| +rewriting a URL from one door to another cannot reach a protected |
| +object: `/p/x` reads `public/x`, which does not exist for a protected |
| +key. The secret and private doors both read `protected/`, which is what |
| +lets one object be reachable by its roster through the app's check and |
| +by a link through a capability at the same time, as the doctrine's |
| +orthogonal link requires. Derivatives and renditions live under the same |
| +namespace as their source. **(codex 1, R2-3)** |
| + |
| +**Changing visibility is a lifecycle, not a copy.** Between the two |
| +namespaces it is: copy to the new namespace, delete the old key and its |
| +derivatives, append a purge covering them, fence any transform or job in |
| +flight against the old key (§4, stage 1, "publication fence"), and |
| +report completion only after every allowed region has applied the |
| +purge. Bytes a browser already holds cannot be withdrawn, and the docs |
| +say so. Within `protected/`, secret and private are not a move at all: |
| +the app mints a capability or stops minting them, and an existing |
| +capability dies at its expiry or when its key id is revoked. Stage 1's |
| +gate tests public-to-protected and link-to-roster transitions on cold |
| +and warm caches. **(codex R2-1)** |
| **Public** is a plain URL on the media host. Content-addressed keys get |
| immutable caching. Mutable keys get whatever the app says with |
| @@ -142,41 +182,49 @@ app can purge. |
| **Secret** is the doctrine's read-only link and what S3 presign, Mux and |
| Cloudinary do: a bearer capability, signed by the app, naming an object |
| -or a prefix, 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)** |
| +or a prefix in `protected/`, expiring. Anyone holding it can fetch; the |
| +bytes are the same for every holder, so a popular file is cached once |
| +and revocation lags by the expiry. **The capability is a path segment, |
| +not a query string**, so a playlist's relative references to its |
| +segments resolve under the same capability and every child request is |
| +authorised without rewriting. **(codex 6)** What the signature covers is |
| +defined in stage 1, not left to the implementer. **(codex 5)** |
| **Private** is identity-bound, and the only way to honour ruling 3 is to |
| not decide at the edge. On every request the edge asks the app: "may |
| this request read this key?" The app answers from its own grants and |
| session, in a millisecond, and the edge serves from its byte cache or |
| fetches the object. This is the shape of nginx `auth_request`, with |
| -three properties the review forced into the open: |
| +these properties the reviews forced into the open: |
| - **Private media is served on the app's own origin**, under a reserved |
| - path the edge intercepts 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)** |
| + path the edge intercepts ahead of the shared-cache lookup and ahead of |
| + any proxy branch, because that is the only place the app's session |
| + cookie reaches. A separate media host never sees a host-only cookie, a |
| + custom domain cannot set one for the apps domain, and cross-site |
| + cookies cannot be relied on across supported browsers and privacy |
| + settings. The cost is user content on the app's origin, and the |
| + mitigations in stage 1 (nosniff, a CSP sandbox on every response, |
| + attachment disposition for anything not image, audio, video, font or |
| + PDF, and no inline SVG or HTML ever) are what make that acceptable. |
| + **(codex 2, R2-14)** |
| +- **The check travels over the route's own backend transport**, never |
| + through a cache: the activator and unix socket for an instance on the |
| + same box, the route's TCP dial for a service route, the tunnel for a |
| + remote host. A private request for an app whose instance lives on |
| + another box reaches the home edge by the existing backhaul, as every |
| + other request for that route does, and the home edge's media cache is |
| + the one that serves it. Private bytes are therefore cached on the |
| + app's home edge only; public and secret bytes are cached in every |
| + allowed region. The check fails closed on timeout, redirect, or |
| + anything but a 200, and runs before `HEAD`, before a 304, before a |
| + range is answered and before a cache hit is admitted. **(codex 3, R2-4)** |
| +- **Private responses carry `Cache-Control: private, no-store`.** Secret |
| + responses carry `Cache-Control: private, max-age=<remaining life>, |
| + must-revalidate`, generated per request from the capability presented |
| + and overriding any directive on the object, so no HTTP cache may serve |
| + a stale copy past the capability's expiry. The edge's own byte cache is |
| + internal and never a substitute for either. **(codex 3, R2-5)** |
| The costs: the app's instance is in the hot path for private media, one |
| small request per fetch, and a hibernating instance wakes for it; the |
| @@ -184,7 +232,7 @@ 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)** |
| +by default. **(ruling 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 |
| @@ -195,7 +243,7 @@ doctrine says. **(codex 11)** |
| Images and video are the two places where pure Go is not fast enough on |
| a `t4g.small`. The answer is not a cgo dependency. Two classes, with |
| -different rules: **(codex 16, 22)** |
| +different rules. **(codex 16, 22; ruling 10)** |
| **Codec helpers** transform bytes and nothing else. |
| @@ -216,10 +264,9 @@ different rules: **(codex 16, 22)** |
| `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 |
| +stage 4a, LiveKit in 4b, a live packager. They are platform units like |
| the edge, with their own units, ports and hardening, and the no-network |
| -rule above does not apply to them. LiveKit, if chosen, is a media |
| -service, not a helper. |
| +rule above does not apply to them. |
| This is the answer to "could we build some dependencies in Rust or C for |
| throughput": yes, as codec helpers, and the spikes in stages 2 and 3 |
| @@ -227,37 +274,44 @@ decide whether the first ones are ours or borrowed. |
| ## 4. The stages and how they depend on each other |
| -Six pieces of work. The dependency graph replaces a single sequence; |
| -anything with no arrow into it can start now. **(codex 27)** |
| +Six pieces of work. The graph replaces a single sequence; anything with |
| +no arrow into it can start now. Stage 1 carries two things later stages |
| +share, the derivative-write grant and the publication fence, so that |
| +whichever of 2 or 3 ships first finds them ready. **(codex 27, R2-11; |
| +ruling 11)** |
| - stage 1 serving class ──▶ stage 2 image transforms |
| - │ └──▶ stage 3 audio/video on demand ◀──┐ |
| - │ │ |
| - └──────────────────▶ stage 5 app-facing helpers │ |
| - │ |
| - stage 4a STUN + TURN ──▶ stage 4b SFU, recording, live ─────┘ |
| + stage 1 serving class ─┬─▶ stage 2 image transforms |
| + ├─▶ stage 3 audio/video on demand ─▶ recording (in 4b) |
| + └─▶ stage 5 app-facing helpers |
| + stage 4a STUN + TURN ────▶ stage 4b LiveKit SFU, live streaming |
| | Stage | Delivers | Needs from the platform | Size | |
| |---|---|---|---| |
| -| 1 | `kind:media`: serving class with the three 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 | |
| +| 1 | `kind:media`: serving class with the three doors | edge read grant with the scan deny, signed capabilities, streaming handler, isolated cache, distributed purge, hostname claim, derivative-write grant, publication fence, CLI | one spec, one plan | |
| +| 2 | image transforms behind preset URLs | a codec helper, limits, derivative identity | one spec, one spike first | |
| +| 3 | audio and video on demand: single rendition, then ladders, posters, waveforms | a media worker, a job queue under the app prefix, ffmpeg helper | one spec, one infra change | |
| +| 4a | STUN and TURN | UDP ingress, a relay unit, per-app per-region credentials as config, limits | one spec, one infra change; **starts now** | |
| +| 4b | LiveKit as a media service, recording, live streaming | a `c7g.large`, LiveKit's server unit, a live serving extension | one spec | |
| | 5 | uploads, rastrillo helpers, accounting, residency disclosure | small pieces spread across 1 to 4 | folded into each stage's plan | |
| ### Stage 1: `kind:media`, the serving class |
| A fourth route kind that serves an app's store bucket through the edge, |
| -cached, with the three visibilities of §2. This is piece A of #107 with |
| -its open questions answered. |
| +cached, with the three doors 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) |
| +block into `store.json` (prefix, allowed regions, key ids, hostname) |
| and mints `media-<app>.<sqid>.<apps domain>`, one label so the account |
| -wildcard already covers it. **(codex 24)** The app writes objects under |
| -`<prefix>/public/`, `<prefix>/secret/` or `<prefix>/private/` with the |
| -store credentials it already has, and forms URLs: |
| +wildcard already covers it. **(codex 24)** Because `media-<app>` is |
| +also a valid app name, the claim is made through the same name-claim |
| +gate apps use: enabling media for `docs` claims `media-docs` in that |
| +account, creating an app named `media-<x>` is refused from now on, and |
| +an account that already has an app named `media-<x>` cannot enable |
| +media for `<x>` until the operator renames one, with the error saying |
| +so. **(codex R2-10)** The app writes objects under `<prefix>/public/` or |
| +`<prefix>/protected/` with the store credentials it already has, and |
| +forms URLs: |
| https://media-docs.abc.oncarlos.com/p/<key> public |
| https://media-docs.abc.oncarlos.com/s/<capability>/<key or rest> secret |
| @@ -270,32 +324,32 @@ scope the claims name, so `.../s/<cap>/videos/abc/index.m3u8` and the |
| 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)** |
| +app's store identity, the key id, the namespace (`protected/`), the |
| +scope as either an exact key or a prefix that is matched only at a `/` |
| +boundary after percent-decoding and normalising the path with no `..`, |
| +the expiry, and in stage 2 the transform presets allowed. Maximum life |
| +is seven days **(ruling 3)**. Rotation is a second key id with an |
| +overlap; emergency revocation removes a key id from the route record. |
| +The capability never appears in edge logs, and every media response |
| +carries `Referrer-Policy: no-referrer`. **(codex 5)** |
| **How the edge is granted read.** The provisioner gains a bucket-policy |
| -operation. At enable it attaches 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)** |
| +operation. At enable it attaches statements on that one bucket letting |
| +each allowed region's host role `GetObject` on both namespaces and |
| +`PutObject` and `DeleteObject` under `derived/` within them, **with the |
| +same `s3:ExistingObjectTag` deny the store user carries**, so an object |
| +the scanner has tagged infected is unreadable through media exactly as |
| +it is through the app's credentials. Enable and disable are a |
| +reconciled walk like the store grant, with partial completion, store |
| +revocation and route replacement handled, and disable also retires |
| +cached bytes. Adopted and non-AWS stores are refused in version one. |
| +**(codex 4, 25, R2-11; ruling 2)** |
| **Signing keys.** An Ed25519 pair per app, minted at enable. The public |
| half goes in the route record, which every edge already syncs. The |
| private half reaches the app as `CARLOS_MEDIA_SIGNING_KEY` through the |
| sealed `config.json` path that already carries store keys. The edge |
| -holds no secret. **(decision 3)** |
| +holds no secret. **(ruling 3)** |
| **The handler.** `mediaHandlerFor` streams: one S3 `GetObject` per |
| request with a single normalised range, the body copied through, never |
| @@ -310,10 +364,10 @@ 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)** |
| +image, audio, video, font or PDF, the door's `Cache-Control` from §2, |
| +and a real CORS policy for the app's own hostnames with `Vary: Origin`, |
| +because `Cross-Origin-Resource-Policy` is not CORS and an HLS player in |
| +JavaScript needs the latter. **(codex 23)** |
| **Its own cache.** `kind:media` is exempt from the shared LRU in both |
| directions and keeps a disk cache under `/data/media-cache/` that stores |
| @@ -324,33 +378,49 @@ 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)** |
| +**Purge, distributed, with a serving gate.** A purge is a record |
| +appended to a log in the platform bucket, sequence-numbered per app. |
| +Every edge tails the log and applies it; entries carry the generation |
| +they were filled under, so an in-flight fill cannot repopulate an entry |
| +a purge already covers. A returning edge reconciles its purge cursor |
| +and its route authority **before** admitting any hit from the |
| +persistent cache, and if the log history it needs is gone it drops its |
| +media cache rather than guess. Media authority in the route record has |
| +a maximum stale age (fifteen minutes): past it, the secret and private |
| +doors fail closed and only the public door keeps serving from cache, |
| +because a revoked key or a disabled route must not outlive an outage. |
| +The API and CLI answer "accepted" and separately report completion per |
| +box, not per region; the local `Carlos-Purge` header and socket stay as |
| +fast paths and never count as completion. A scan verdict turning |
| +infected files a purge for the key and its derivatives. **(codex 4, 9, |
| +R2-6)** |
| + |
| +**The publication fence.** Anything that writes derived output, a stage |
| +2 transform or a stage 3 job, writes to an attempt-specific key under |
| +`derived/_attempts/` and then commits by a conditional write to a |
| +per-source manifest that carries the source ETag, the purge generation |
| +and the app's incarnation. The serving path reads derived output only |
| +through a committed manifest. Purge, an infected verdict, cancellation |
| +and app deletion each bump the generation, so a transform that finishes |
| +after its source was purged, or a worker whose lease expired, finds its |
| +commit refused. Attempt keys are swept. This lives in stage 1 because |
| +both later stages need it and neither may reinvent it. **(codex 17, R2-7)** |
| **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)** |
| +regions' host roles, the media host's DNS records exist only in those |
| +regions, and the handler on any other edge answers 421 with the right |
| +host as a backstop, so a client addressing a foreign edge directly |
| +cannot fill a cache there. Default at enable: the regions the app's |
| +routes are already placed in, shown back by `carlos media status` and |
| +on the media docs page. **(codex 10, R2-9; ruling 4)** |
| **Store spec amendment.** Line 27 of the object-storage spec gains one |
| sentence: reads may cross the data plane through `kind:media`; writes |
| never do. Uploads stay app-to-S3 on the app's credentials. |
| -**What stage 1 must prove** (its gate, §9) is the attacks in §2 and this |
| -section failing, not only playback succeeding. **(codex 27)** |
| +**What stage 1 must prove** (§9) is the attacks in §2 and this section |
| +failing, not only playback succeeding. **(codex 27)** |
| **Not in stage 1:** transforms, transcoding, uploads through the edge, |
| per-viewer analytics, signed cookies, bandwidth quotas, dashboards |
| @@ -361,7 +431,8 @@ beyond a bytes-served counter, multipart upload helpers. |
| `/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. |
| +bucket through the publication fence so the second region never redoes |
| +the first's work. |
| **Abuse surface first.** A public transform URL is CPU amplification on |
| the box that terminates TLS for every tenant. From the first commit, |
| @@ -376,10 +447,9 @@ 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)** |
| +helper version, and committed through the fence. `f_auto` picks AVIF, |
| +WebP or JPEG from `Accept`, and the response says `Vary: Accept`. |
| +**(codex 17)** |
| **The engine, decided by a spike.** Three candidates, all run as separate |
| processes under the §3 rule. First, a Go helper embedding wazero-hosted |
| @@ -392,7 +462,10 @@ 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)** |
| +the least to operate wins. The bar is not a ceiling on the fleet: if |
| +nothing clears it on a `t4g.small`, the choices are a bigger edge box, |
| +a dedicated transform box the fleet shares, or telling apps what to |
| +expect, and the spike's numbers decide which. **(codex 16; ruling 5)** |
| **What a transform does.** Resize, crop modes, format, quality, and |
| always strips metadata. EXIF GPS in a public photo is a privacy leak the |
| @@ -402,10 +475,9 @@ 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)** |
| +region host role under stage 1's `derived/` statement, and count |
| +against the tenant's storage. Purging a source key removes its |
| +derivatives. **(ruling 6)** |
| **Not in stage 2:** face detection, smart crop, watermarks, uploads, |
| SVG rasterisation, anything needing a model. |
| @@ -422,11 +494,11 @@ insisted be kept apart. **(codex 21)** |
| 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. |
| + or a recorded call needs first, and it follows stage 1 directly. |
| - **An adaptive ladder, slower.** Re-encode to three rungs (1080p, 720p, |
| 360p, capped at the source's height), CMAF, one multivariant playlist. |
| Remuxing cannot make lower rungs; this always encodes. AV1 and DASH |
| - are one flag away and off. |
| + are one flag away and off. This follows the worker measurements. |
| Both products also write posters at three timestamps for video, and a |
| waveform JSON and duration for audio. Everything is served by stage 1 |
| @@ -446,17 +518,23 @@ deletion finds them and the name-claim gate sees them. **(codex 19)** |
| destination scope itself; the app names a key, never a path. Delivered |
| store credentials never gain the right to write job records. |
| **(codex 18)** |
| +- **Eligibility, decided at submission.** The processing region is the |
| + bucket's region, and it must be in the app's allowed list and have a |
| + worker. Version one runs one worker, in Ireland, so a job for a bucket |
| + elsewhere is refused at the door with an error naming the reason, not |
| + queued to wait for a worker that does not exist. **(codex R2-9)** |
| - **Authority.** The worker reads the source through the media bucket |
| - policy and writes renditions under `derived/` through 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)** |
| + policy and writes renditions under `derived/` through stage 1's |
| + statement, on buckets in its own region only. |
| +- **Leases and the fence.** A worker takes a lease with an expiry; its |
| + output goes to attempt keys and is committed through the stage 1 |
| + publication fence, which refuses a commit from an expired lease, a |
| + purged source or a deleted app. Bounded retries, cancellation, a |
| + terminal failure state. **(codex 18, R2-7)** |
| +- **Completion** is a result record the app polls in version one. |
| + Callbacks come later as a durable completion outbox that the |
| + destination host converges into a local once-timer, because nothing |
| + files a timer across boxes today. **(codex R2-8)** |
| - **Limits from the first job**: admitted source size and duration, |
| concurrent jobs per box, memory and scratch caps on the ffmpeg helper, |
| and measurement of serving latency, I/O and CPU credit consumption |
| @@ -465,12 +543,11 @@ deletion finds them and the name-claim gate sees them. **(codex 19)** |
| **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)** |
| +on the Ireland medium behind the limits above, and the honest |
| +expectation is that the first tenant wanting ladders moves it to a |
| +dedicated `c7g.large`. Encode throughput is the first sustained |
| +bottleneck; host memory and scratch pressure fail earlier if left |
| +unbounded, which is why the limits come first. **(codex 20; ruling 7)** |
| **Not in stage 3:** caption generation, DRM, per-viewer watermarking, |
| server-side ad insertion, live packaging. Caption files the app supplies |
| @@ -479,7 +556,7 @@ are just objects. |
| ### Stage 4: real time |
| Meet's requirements, in the order Meet needs them. Stage 4a depends on |
| -nothing above and starts when Paul says so. **(codex 27)** |
| +nothing above and starts now. **(codex 27; ruling 11)** |
| **4a. STUN and TURN.** A STUN listener the platform runs is what lets a |
| WebRTC app connect across most home and mobile networks without an |
| @@ -497,21 +574,27 @@ box, built on `pion/turn`, pure Go, in the same binary as the edge, a |
| 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)** |
| +- **Credentials, scoped per app and per region.** Each region has a |
| + relay master key. At `carlos relay enable` the instance receives, |
| + sealed through `config.json`, one derived key per allowed region: |
| + `HKDF(region master, account, app, incarnation)`, as |
| + `CARLOS_RELAY_KEYS` alongside `CARLOS_RELAY_URLS`. The app mints TURN |
| + REST credentials per call, `username = expiry:account:app`, |
| + `credential = HMAC(derived key, username)`, with a one-hour maximum. |
| + The relay re-derives the key from the username and its own region |
| + master, so verification is local and per-app metering is real. |
| + Disable bumps the incarnation, so re-enabling never restores an old |
| + key. Rotation is a new region master and a re-render of every enabled |
| + app's config. **(codex 13, R2-2; ruling 8)** |
| +- **What that does and does not protect.** Per-region masters mean a |
| + relay box holds no other region's master. They do not mean a |
| + compromised host learns nothing about other regions: sealed config is |
| + decryptable on every host (§1), so a compromised host can read every |
| + enabled app's derived keys for every region, exactly as it can read |
| + every app's store keys today. Relay keys are no worse than the secrets |
| + the platform already delivers this way, and no better. A short-lived |
| + issuance API would narrow that and is the alternative if it is ever |
| + wanted. **(codex R2-2)** |
| - **Policy and limits.** Peer permissions refuse loopback, link-local, |
| RFC 1918, the VPC's ranges and the relay's own addresses. Allocations, |
| allocation rate, bandwidth and lifetimes are bounded per app, per |
| @@ -532,21 +615,41 @@ box, built on `pion/turn`, pure Go, in the same binary as the edge, a |
| 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 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 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)** |
| +**4b. LiveKit as a media service, recording, live streaming.** An SFU |
| +lifts the four-participant cap, changes the privacy story from |
| +peer-to-peer to server-in-the-path, and is where recording becomes |
| +possible. **Paul ruled to host LiveKit's open-source server** rather |
| +than build on `pion/webrtc`, as a §3 media service, `carlos-sfu.service`, |
| +fronted by the platform's own API. **(ruling 9)** LiveKit Cloud is not an |
| +option for suite apps: a third party in every call's media path is |
| +egress that is a property of the product. It is also not cheaper past |
| +about sixty room-hours a month, since both are dominated by bandwidth. |
| + |
| +- **Box.** The server is one Go binary on pion and needs no Redis as a |
| + single node. A `c7g.large` in Ireland (2 vCPU, 4 GB, about sixty |
| + dollars a month) carries a handful of concurrent eight-person video |
| + rooms; a `c7g.xlarge` gives headroom for tens. CPU is spent per |
| + forwarded track, and LiveKit's own figures put thousands of tracks on |
| + sixteen vCPUs, so the box is not the limit at this scale. |
| +- **Cost is egress.** Each participant downloads everyone else's stream. |
| + An eight-person video room at about 1 Mbps per stream is about 25 GB |
| + an hour, roughly two dollars at AWS rates. That line goes in the |
| + unit-economics amendment beside the relay meters. |
| +- **Ports.** TCP 7880 for the API behind the edge; TCP 7881 and UDP |
| + 50000 to 60000 opened directly, the same shape as the relay. |
| +- **Recording and ingest are separate LiveKit services** and are |
| + deferred until asked for. Per-track recording is light; composite |
| + recording runs headless Chrome and wants two vCPUs per recording in |
| + progress; WHIP and RTMP ingest is a third service. Recording writes |
| + per-track or composite files into the app's bucket and submits a |
| + stage 3 single-rendition job, so a recorded call becomes a playable |
| + asset with no new serving code. Recording depends on stage 3, not the |
| + other way round. **(codex R2-11)** |
| +- **Live streaming to many viewers** is a distinct long-running workload: |
| + ingest feeds a live packager, and LL-HLS needs partial segments, |
| + blocking playlist reloads and delivery directives that an object |
| + reader does not supply, so it is a named serving extension with its |
| + own latency contract, not a reuse of stage 1 unchanged. **(codex 22)** |
| **Signalling stays the app's.** Offers, answers and candidates ride the |
| app's own HTTP. §8 answers #113. |
| @@ -567,18 +670,20 @@ needs it. None of them is in stage 1's gate. **(codex 27)** |
| - **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 job filer, and a TURN credential minter. |
| + edge's subrequest from the app's grants, a visibility-move helper |
| + that drives the lifecycle in §2, a presigned-upload helper with |
| + CORS-correct headers, a job filer, and a TURN credential minter. |
| Docs' disabled "insert image" is the first consumer. |
| - **Accounting.** Bytes served and cached in stage 1 as one counter; |
| - transform seconds, transcode minutes 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. |
| + transform seconds, transcode minutes, the three relay meters and SFU |
| + egress when their stages land; on the console's app page and in |
| + `carlos media status`. Quotas come after measurement, not before. |
| - **Scanning.** Nothing to hook: the sweep already tags what it finds, |
| and the media grant carries the deny. The one addition is the verdict |
| purge in stage 1. |
| - **Residency disclosure.** One paragraph on the media docs page and in |
| - `carlos media status` naming the allowed regions. |
| + `carlos media status` naming the allowed regions and, for private |
| + media, the home edge. |
| - **Docs page.** `docs/site/media.md`, and `docs/site/cli.md` headings for |
| every new verb, because the docsite gate fails otherwise. |
| @@ -589,9 +694,9 @@ Named so nobody wonders whether it was forgotten. |
| - **Signed cookies** instead of path capabilities. Not needed while |
| capabilities are prefix-scoped; revisit if a tenant needs a whole site |
| of secret media. |
| -- **A media-session cookie on the media host** for private media. Blocked |
| - by third-party cookie blocking on custom domains; the app-origin path |
| - in §2 is the answer. |
| +- **A media-session cookie on the media host** for private media. Cannot |
| + be relied on across browsers and privacy settings on custom domains; |
| + the app-origin door in §2 is the answer. |
| - **Per-viewer watermarking or DRM.** Different product, different buyer. |
| - **AI on media**: captions, transcription, moderation, smart crop. Each |
| is a model-provider egress under ruling 6 and a per-app opt-in there, |
| @@ -601,140 +706,160 @@ Named so nobody wonders whether it was forgotten. |
| - **Peer-to-peer file transfer** over the relay. TURN would carry it, but |
| it is an app protocol, not a platform one. |
| - **A GPU tier.** Nothing in stages 1 to 4 needs one on a fleet this size. |
| +- **LiveKit Cloud, or any hosted SFU.** Egress that is a property of the |
| + product. |
| ## 6. What must be true before an app can build on this |
| The review's last question was what an app hits in its first month. The |
| -answers are all inside stage 1 and 5, and are the reason stage 1's gate |
| +answers are all inside stages 1 and 5, and are the reason stage 1's gate |
| is a security gate, but they are worth listing plainly: |
| - a way to put a private image on a page that works on a custom domain |
| in Safari (the app-origin door); |
| - a way to hand a video player one URL and have every segment load (the |
| path capability); |
| +- a way to share a roster-visible video by link without moving it (the |
| + shared `protected/` namespace); |
| - a way to replace an object and not serve the old bytes (ETag-bound |
| - cache, distributed purge); |
| + cache, distributed purge, the publication fence); |
| - a way to know which regions hold copies (the media block); |
| - an upload that does not fail at the last byte of a phone video (CORS |
| ownership, multipart helper); |
| - a helper package so none of the above is hand-rolled per app. |
| -## 7. 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, 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/` 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. |
| -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. Rulings |
| + |
| +Paul ruled on each of these on 2026-09-07. They are the roadmap's fixed |
| +points; a stage spec that wants to move one comes back here first. |
| + |
| +1. **(ruling)** Private means ask-the-app, per request, never cached, |
| + served on the app's own origin under a reserved path. |
| +2. **(ruling)** Edge read is granted by bucket policy on the two media |
| + namespaces, per allowed region host role, carrying the infected deny, |
| + attached and removed by a reconciled walk. |
| +3. **(ruling)** Ed25519 per app for capabilities, public half in the |
| + route record, private half as env through sealed `config.json`, key |
| + id in the capability for rotation, seven-day maximum life. |
| +4. **(ruling)** Allowed regions are explicit in the media block and |
| + enforced in the bucket policy, DNS and the handler; default is the |
| + regions the app's routes are placed in. |
| +5. **(ruling)** The transform engine is chosen by a measured spike among |
| + a Go wazero helper, `vips` and a Rust helper, all as separate |
| + processes, benchmarked on a `t4g.small`, with a bigger or dedicated |
| + box on the table if the numbers say so. |
| +6. **(ruling)** Derivatives and renditions land in the tenant's bucket |
| + under `derived/` in the source's namespace, counted as the tenant's |
| + storage, removed with their source. |
| +7. **(ruling)** The media worker starts on the Ireland `t4g.medium` |
| + behind hard admission limits, with a dedicated `c7g.large` when |
| + ladders are wanted. |
| +8. **(ruling)** Relay credentials are per-region HKDF-derived per app and |
| + incarnation, verified locally; the exposure through sealed config is |
| + stated, not hidden. |
| +9. **(ruling)** The SFU is LiveKit's open-source server hosted as a |
| + platform media service on a `c7g.large`, recording and ingest |
| + services deferred until asked for. |
| +10. **(ruling)** Native helpers are allowed as separate processes only, |
| + with codec helpers and media services as distinct classes. |
| +11. **(ruling)** Stage 1 and stage 4a start now, independently. Stage 2 |
| + and the stage 3 single-rendition product follow stage 1. The ladder |
| + follows the worker measurements; 4b follows 4a. |
| ## 8. Answers to #112 and #113 |
| **#112.** Direction A: the platform runs the relay. STUN and TURN are |
| -stage 4a, 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. |
| +stage 4a and start now; LiveKit as a hosted media service is 4b. UDP |
| +ingress is something the platform will carry, as a platform unit on the |
| +region boxes, never as an app-bindable port. A platform relay is not |
| +egress; a third-party one is. |
| One correction to Meet's interim plan: under doctrine §8 the recipient |
| of an egress is the app's decision, never the team's, so a settings |
| field where a team pastes any TURN URL is not admissible. What is |
| admissible until 4a ships is either no relay, with the disclosed floor |
| of calls that connect without one, or an app-named provider that a team |
| -can switch on, disclosed in the README's first paragraph. Meet should |
| -pick one of those two, not the free-text field. **(codex 12)** |
| +can switch on, disclosed in the README's first paragraph **and on every |
| +artefact the app produces while it is on**, which for Meet means any |
| +recording or export. Meet should pick one of those two, not the |
| +free-text field. **(codex 12, R2-12)** |
| **#113.** Confirmed as a standing constraint: no `Connection: Upgrade` |
| -over a tunnel-backed host, and no app-visible placement signal or |
| -placement constraint exists or is planned before extended CONNECT. |
| -SSE plus POST for signalling is the right default, and its volume is |
| -small enough that the socket's elegance buys nothing. When rastrillo |
| -grows a WebSocket helper for presence, Meet can adopt it on cloud hosts |
| -and keep SSE as the fallback, which it would need anyway. |
| +over a tunnel-backed host. The placement picture is more nuanced than |
| +the question assumed: a member can place an app with `carlos apps place` |
| +and `carlos instances create --region`, and the instances API reports |
| +which boxes serve a host, so a team can keep Meet off tunnel-backed |
| +hosts by choice. What does not exist, and is not planned before extended |
| +CONNECT, is a runtime signal the app can read saying "this host carries |
| +upgrades", or a manifest field that refuses placement without it. |
| +**(codex R2-13)** So SSE plus POST remains the right default: it works |
| +everywhere, its volume is small, and a socket can be adopted later on |
| +hosts the team has placed deliberately, with SSE as the fallback Meet |
| +would need anyway. |
| ## 9. 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 |
| +- **Stage 1**, on flagship: `/p/` of a protected key is 404 cold and |
| + warm; a capability with the expiry edited, the scope widened past a |
| + `/` boundary, the namespace changed, or the key id revoked is refused; |
| + a private fetch is refused the request after the app revokes the |
| + grant, on a warm cache; a public-to-protected move leaves nothing |
| + readable through `/p/` in any region once it reports complete; an |
| object tagged infected is unreadable through media and its cached |
| copy is gone within a purge round; a purge issued while a fill is in |
| - flight leaves no stale entry; an edge 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. |
| + flight leaves no stale entry; an edge reconnecting after an outage |
| + serves nothing from disk until its cursor is current; a revoked key |
| + stops working on a disconnected edge within fifteen minutes; an edge |
| + outside the allowed regions refuses to fill; a late commit against a |
| + purged source is refused by the fence. Then: a public hit served from |
| + a second region's cache, one object reached both by the app's check |
| + and by a capability, an HLS playlist and its segments loading under |
| + one capability, and a 2 GB MP4 seeking in a browser through a |
| + `t4g.small` without the box paging. |
| - **Stage 2**: the spike's numbers, then 9,999 distinct transform URLs |
| against a public object producing zero decodes, and one tenant's |
| burst leaving another's thumbnails under a second. |
| - **Stage 3**: a 1080p H.264 phone recording of two minutes uploaded |
| through Docs and playing in Safari as a single HLS rendition within |
| thirty seconds, with serving latency on the same box flat; a WebM |
| - browser recording of the same length playable within three minutes; |
| - the ladder measured, not promised. |
| + browser recording of the same length playable within three minutes; a |
| + job for a non-Ireland bucket refused at the door; the ladder measured, |
| + not promised. |
| - **Stage 4a**: a Meet call between two symmetric-NAT networks connecting |
| through the Ireland relay with a credential that expires in an hour; |
| - a credential minted for one app refused for another; a 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. |
| + a credential minted for one app refused for another; a credential |
| + minted before a disable refused after re-enable; a relay permission |
| + to a private address refused; the three meters moving on the |
| + account's usage line; a 443-only network failing the way the docs say |
| + it will. |
| ## 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. |
| + corrects Meet's interim plan to what §8 already allows, now including |
| + artefact disclosure. |
| - **Codex 15, TURN over TLS on 443.** Acknowledged as a real gap and |
| documented as such; a second address per box is deferred until a |
| tenant reports a 443-only network, because it changes the fleet's |
| networking for every box to serve a case nobody has hit yet. |
| +Every revision-2 finding was accepted. |
| + |
| ## 11. Amendments to existing specs |
| - `2026-08-10-object-storage-primitive-design.md:27`: reads may cross the |
| data plane through `kind:media`; writes never do. |
| - `2026-08-03-app-capabilities-design.md:931`: UDP remains a non-goal |
| - for apps; the platform's own relay unit is the exception and is |
| - specified in stage 4a. |
| + for apps; the platform's own relay and SFU units are the exception and |
| + are specified in stage 4. |
| - `2026-08-11-unit-economics-design.md`: a media section, at stage 1 |
| - for served bytes and at 4a for the three relay meters. |
| + for served bytes, at 4a for the three relay meters, at 4b for SFU |
| + egress. |
| - `infra/modules/region-host/main.tf:6` header: residency by |
| construction now has a named exception, the per-region media bucket |
| policy, and the exception is itself region-scoped. |
| +- `internal/release/release.go:239` app-name grammar: names beginning |
| + `media-` are reserved for media hosts. |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R2.md b/docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R2.md| new file mode 100644 |
| index 000000000..e94a5c5c8 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-MEDIA-R2.md |
| @@ -0,0 +1,169 @@ |
| +# Findings on the media roadmap, revision 2 |
| + |
| +Reviewed by **GPT-6 (Codex), 2026-09-07**, second pass, against revision 2 at `6424a4f`, with its own revision-1 findings in hand. Unedited apart from this note. Revision 3 records which findings were accepted and how. |
| + |
| +Reviewed commit `6424a4f320821fd72667625c1aa7dbb35baff0cc` against the review brief, the 27 original findings, the cited platform code and private specs, and the suite doctrine. No files were changed. |
| + |
| +“Closed” means the roadmap now supplies the missing decision or requirement; implementation still needs to prove it. Severity remains: **P1** breaks a trust guarantee or makes a stage unbuildable as written; **P2** is a contract or claim defect to fix before the stage spec; **P3** is advisory. |
| + |
| +## Disposition of the original findings |
| + |
| +| Original | Status | One-line reason | |
| +|---|---|---| |
| +| 1 — Visibility-door bypass | Closed | Disjoint storage namespaces prevent an unsigned URL rewrite from reaching the protected object. | |
| +| 2 — Private browser identity | Closed | The app-origin path carries its session cookie, including on custom domains. | |
| +| 3 — Authorization and downstream caching | Partial | Private checks and responses bypass caching, but secret `max-age` alone does not prohibit stale reuse. | |
| +| 4 — Malware enforcement | Partial | The reader deny and derivative removal are specified; late derivative publication can still undo removal. | |
| +| 5 — Signed authorization contract | Partial | Claims, normalization, presets and rotation are defined; stale-edge revocation behavior remains incomplete. | |
| +| 6 — HLS capability transport | Closed | Relative child paths retain the capability segment in the stated playlist layout. | |
| +| 7 — Range and conditional handling | Closed | Explicit HTTP handling, ETag-bound entries and complete-fill publication replace the claimed automatic reuse. | |
| +| 8 — Cache isolation | Closed | Per-box budgets, fair eviction, account caps, bounded fills and reusable blocks address the original defects. | |
| +| 9 — Distributed purge | Partial | The durable log and generations are explicit, but catch-up-before-serving and stale-state limits are not. | |
| +| 10 — Residency | Partial | Grants and handlers enforce a region list; its interaction with bucket location and worker placement is unresolved. | |
| +| 11 — Doctrine mapping | Partial | The terminology is corrected, but exclusive namespaces do not support an item’s orthogonal read-only link. | |
| +| 12 — Third-party relay doctrine | Partial | Team-selected endpoints are removed; disclosure on produced artefacts is still omitted. | |
| +| 13 — Relay minting authority | Partial | Keys are app- and region-derived, but their delivery contradicts the claimed regional compromise boundary. | |
| +| 14 — Relay abuse controls | Closed | Peer restrictions and allocation, rate, bandwidth and lifetime limits are explicit. | |
| +| 15 — Relay connectivity and placement | Closed | Public-address/range configuration and the 443-only limitation are stated; the ICE latency claim is removed. | |
| +| 16 — Helper isolation | Closed | Every codec candidate runs separately with resource limits and authenticated update/rollback support. | |
| +| 17 — Derivative identity | Partial | Versioned representations and fair admission are specified; identity alone does not fence publication after purge. | |
| +| 18 — Job authority and recovery | Partial | Submission and leases are named, but publication enforcement and remote completion remain missing mechanisms. | |
| +| 19 — Existing queue and deletion ownership | Closed | The roadmap acknowledges the scan queue and places media jobs under the app’s deletion prefix. | |
| +| 20 — Worker capacity | Closed | Admission limits and sustained-load measurements replace the unsupported co-location guarantee. | |
| +| 21 — Remux versus adaptive encoding | Closed | Single-rendition remux and ladder generation are separate products with input probing. | |
| +| 22 — LL-HLS and network services | Closed | Live serving is a distinct extension, and network services are separated from codec helpers. | |
| +| 23 — CORS | Closed | Media-response CORS and bucket-wide upload CORS ownership are specified separately. | |
| +| 24 — Media hostname | Partial | Wildcard coverage is fixed, but the new name collides with an ordinary app’s hostname. | |
| +| 25 — Enable/disable lifecycle | Partial | Reconciliation and backend refusals are explicit; stale readers and retirement completion still need a contract. | |
| +| 26 — Original factual corrections | Closed | Scanner integration, infrastructure citations, STUN wording and relay meter distinctions were corrected. | |
| +| 27 — Stage dependencies | Partial | Relay work is independent, but the graph still reverses an SFU dependency and hides a stage-2 prerequisite. | |
| + |
| +## New and remaining findings |
| + |
| +### R2-1 — P1 — §2: changing visibility leaves the old readable object behind |
| + |
| +**Problem and evidence:** [Roadmap lines 128–135](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:128) define a visibility change as a server-side copy and a purge. Copying `public/x` to `private/x` does not remove `public/x`. After its cache is purged, an anonymous request simply fetches the old public object again. Moving `secret/x` to `private/x` similarly leaves the old capability usable against the surviving secret copy. |
| + |
| +This is a transition defect introduced by the namespace design, distinct from the original URL-substitution bypass. |
| + |
| +**Correction:** Define a move as a coordinated lifecycle that removes or tombstones the old source namespace and its derivatives, fences concurrent publication, and reports completion after regional invalidation. State separately that copies already delivered to clients cannot be withdrawn. Test public-to-private and secret-to-private transitions against cold caches as well as warm ones. |
| + |
| +### R2-2 — P1 — Stage 4a: sealed config defeats the claimed regional compromise boundary |
| + |
| +**Problem and evidence:** [Roadmap lines 500–512](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:500) deliver durable derived keys for every allowed region to each enabled instance, while claiming that compromising a box yields nothing durable about other regions. |
| + |
| +At minimum, a compromised box can read its resident apps’ other-region minting keys. The existing delivery architecture permits a wider exposure: [the config spec’s crypto section](/home/paulca/github.com/carlosframework/platform-infrastructure/platform-design/superpowers/specs/2026-08-02-app-config-secrets-design.md:86) places the deployment’s decryption key on every host, and [the host policy](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/infra/modules/region-host/main.tf:121) permits reading deployment-bucket objects. Sealing therefore does not isolate those app keys from a compromised deployment host. [Materialization explicitly decrypts them into environment values](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/appconfig/materialize.go:30). |
| + |
| +The one-hour credential lifetime does not expire the stolen minting key. Separate regional masters prevent deriving another master; they do not prevent using another region’s already-distributed app keys. |
| + |
| +**Correction:** Either withdraw the regional compromise guarantee and describe the actual exposure, or change delivery so a box cannot recover durable remote-region minting material. Short-lived issuance is one option; a genuinely compartmentalized key-delivery system is another. |
| + |
| +Also include an app incarnation or revocation epoch in derivation. With only `(region master, account, app)`, disabling and re-enabling an app restores the same key until the regional master changes. |
| + |
| +### R2-3 — P2 — §2: exclusive namespaces conflict with orthogonal read-only links |
| + |
| +**Problem and evidence:** [The doctrine mapping and namespace rule](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:125) require authenticated delivery from `private/`, link delivery from `secret/`, and an object written into exactly one namespace. |
| + |
| +Consider a team-readable video whose owner enables a read-only link. Existing team viewers must continue using authenticated delivery while link holders use the capability. Neither door can reach the other namespace. Moving the object breaks one audience; duplicating it requires a second copy and a synchronization/revocation contract that the roadmap does not define. |
| + |
| +[Doctrine §4.2](/home/paulca/amadan.net/oficina/suite/docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md:276) makes links an additional axis, not a replacement visibility. |
| + |
| +**Correction:** Specify how both delivery mechanisms address one logical asset. For example, a verified capability could name a protected canonical source, with its namespace included in the signed claims, while the public door remains strictly confined to `public/`. Alternatively, explicitly design linked copies and their shared lifecycle. Do not leave this to each app’s URL builder. |
| + |
| +### R2-4 — P2 — §2 and stage 1: the activator check only supplies a local-socket transport |
| + |
| +**Problem and evidence:** [Roadmap lines 170–174](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:170) select the scheduled-tick path for private authorization. That path expressly never leaves the box and [refuses routes without a Unix socket](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/host/schedule_wiring.go:220). |
| + |
| +The platform also serves TCP-backed and tunnel-backed routes. [Edge routing explicitly handles those separately](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/edge/edge.go:457), and [remote app requests can be backhauled to their home edge](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/edge/edge.go:1197). |
| + |
| +The selected transport therefore does not establish private-media support for every app/backend or private byte serving at an allowed edge that does not host the app. |
| + |
| +**Correction:** Define the supported topology. Either private requests travel to the app’s home edge and use its local media cache, or remote media edges gain an authenticated, cache-bypassing authorization transport. Specify TCP and tunnel support or refuse those backends explicitly. The reserved path must bypass shared-cache lookup before reaching any authorization or proxy branch. |
| + |
| +### R2-5 — P2 — §2: secret `max-age` does not enforce capability expiry |
| + |
| +**Problem and evidence:** [Roadmap lines 175–179](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:175) cap freshness at the capability’s remaining life but do not prohibit stale responses. |
| + |
| +A cache may serve stale content under permitted conditions, including disconnection. `max-age` determines freshness; it is not an unconditional prohibition on reuse after that age. [RFC 9111 §§4.2.4 and 5.2.2.2](https://www.rfc-editor.org/rfc/rfc9111.html#section-4.2.4) distinguish those properties. |
| + |
| +**Correction:** Require `must-revalidate` alongside the bounded freshness lifetime, or use `no-store`. Override source-object directives that permit stale delivery. Generate expiry-dependent headers for the current capability on every response, including internal byte-cache hits. This governs HTTP cache reuse, not a recipient’s ability to retain downloaded bytes. |
| + |
| +### R2-6 — P2 — Stage 1: purge catch-up and authorization-state freshness need a serving gate |
| + |
| +**Problem and evidence:** [The purge design](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:327) says an offline edge catches up on reconnect. It does not say that catch-up precedes serving its persistent disk cache. [Key revocation takes effect on the next sync](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:277), with no maximum age for that state. |
| + |
| +This matters because [the ingress spec explicitly permits continued serving from last-converged state during a control-bucket outage](/home/paulca/github.com/carlosframework/platform-infrastructure/platform-design/superpowers/specs/2026-08-20-multi-region-ingress-design.md:123). Removing bucket permission does not stop a disconnected edge serving cached bytes. A removed region, disabled media route or revoked key can remain usable under stale local state. |
| + |
| +**Correction:** Define maximum stale-state windows and fail-closed behavior for media authority. Require a returning edge to reconcile route authority and its purge cursor before admitting persistent cache hits. Define recovery from missing log history, and report retirement against the relevant boxes, not merely one acknowledgement per region. Local purge fast paths must not count as distributed completion. |
| + |
| +### R2-7 — P2 — Stages 2 and 3: identifiers and fencing tokens do not themselves prevent late publication |
| + |
| +**Problem and evidence:** [Stage 2’s derivative identity](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:377) is said to prevent publication after purge. [Stage 3 grants direct bucket writes and then promises atomic publication carrying a fencing token](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:449). |
| + |
| +Neither names the component that rejects an obsolete writer. A transform can finish after its derivative was removed by a scan-triggered purge. An expired worker can complete an already-started write after another worker takes the lease. Its bucket permission remains valid. Including an ETag in a filename or a token in metadata does not make that write fail. |
| + |
| +S3 conditional writes compare the state of the addressed object; they do not automatically consult a separate job lease. See [AWS’s conditional-write contract](https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html). |
| + |
| +**Correction:** Name an enforced publication mechanism: immutable attempt-specific output plus a conditional commit record tied to the current job lease, source/purge generation and app incarnation, for example. The serving path must expose only committed output. Purge, infection, cancellation and deletion must invalidate that same publication authority. Apply the rule to synchronous image transforms as well as transcodes. |
| + |
| +### R2-8 — P2 — Stage 3: the schedule runner does not provide the claimed remote completion door |
| + |
| +**Problem and evidence:** [Roadmap lines 457–459](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:457) claim the runner already delivers completion across hosts. |
| + |
| +Existing once-timers are filed through [an instance-local control socket, bound to that host and its administrative token](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/schedule/control.go:28). The runner processes its local registry. A worker on another box has no described door for filing the promised once-timer on the destination. |
| + |
| +**Correction:** Add a durable completion outbox that the destination host converges into an idempotent local delivery, with destination selection, retries and deletion handling. Alternatively, make polling the initial completion contract and defer callbacks explicitly. Reusing local timer execution does not eliminate the cross-host handoff. |
| + |
| +### R2-9 — P2 — Stages 1 and 3: allowed regions, bucket location and the Ireland-only launch can disagree |
| + |
| +**Problem and evidence:** [Stage 1 defaults allowed regions to app-route placements](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:339). Stage 3 requires workers to read buckets only in their own region, first describes one worker per relevant region, then [launches only on the Ireland medium](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:436). |
| + |
| +Store location is a separate fact: [the object-storage spec configures it through `CARLOS_STORE_REGION`](/home/paulca/github.com/carlosframework/platform-infrastructure/platform-design/superpowers/specs/2026-08-10-object-storage-primitive-design.md:197). An app placed outside its bucket’s region can therefore receive a default list that excludes the only worker permitted to process its source. A non-Ireland bucket has no version-one worker at all. |
| + |
| +**Correction:** State launch eligibility and refuse unsupported jobs before enqueueing. Resolve processing region from both bucket location and the allowed list. Either launch regional workers or explicitly limit version one to eligible Ireland buckets. Also specify media DNS placement within the allowed set; a 421 response alone is not a routing plan. |
| + |
| +### R2-10 — P2 — Stage 1: `media-<app>` occupies another valid app’s hostname |
| + |
| +**Problem and evidence:** [The generated hostname](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:255) fits the wildcard, but `media-docs` is also an ordinary valid app name under [the existing app-name grammar](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/release/release.go:239). |
| + |
| +In account `abc`, the media host for `docs` and the normal platform host for app `media-docs` are both `media-docs.abc.oncarlos.com`. Certificate coverage does not resolve ownership. |
| + |
| +**Correction:** Define an atomic hostname claim and collision policy covering both media enable and ordinary app creation. Existing apps must not be displaced. Either reserve a namespace with a migration/refusal policy or choose another naming scheme with explicit certificate support. |
| + |
| +### R2-11 — P2 — §4 and §9: the dependency graph contradicts the implementation dependencies |
| + |
| +**Problem and evidence:** [The graph](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:228) puts an arrow from stage 4b into stage 3, making SFU work a prerequisite for on-demand media. The prose instead has SFU recording submit stage-3 jobs. |
| + |
| +Meanwhile, stage 3 depends on [the derivative-write policy “stage 2 adds”](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:449), although decision 11 says the single-rendition product follows stage 1. Section 9 still describes each gate as preceding “the next” stage. |
| + |
| +**Correction:** Make stage 3 independent of SFU work and make recording depend on stage 3. Extract derivative-write authority and publication into shared prerequisites available to whichever of stages 2 or 3 ships first. Express gates against those dependencies rather than a global next-stage sequence. |
| + |
| +### R2-12 — P2 — §8: the external-relay exception still omits artefact disclosure |
| + |
| +**Problem and evidence:** [The revised interim guidance for Meet](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:673) requires the README’s first paragraph but omits disclosure on every produced artefact. [Doctrine §8 requires both](/home/paulca/amadan.net/oficina/suite/docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md:934). |
| + |
| +Removing arbitrary team-selected endpoints closes the main original defect. It does not make the shortened exception complete. |
| + |
| +**Correction:** Carry the entire disclosure condition into the recommendation. Where no artefact is produced, say so; recordings and other produced artefacts must retain the required disclosure. |
| + |
| +### R2-13 — P2 — §1 and §8: the blanket claim that placement is invisible and unconstrainable is outdated |
| + |
| +**Problem and evidence:** [Roadmap lines 681–683](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:681) say no app-visible placement signal or constraint exists or is planned before extended CONNECT. |
| + |
| +The current member-facing interfaces already include: |
| + |
| +- [`carlos instances create --region`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/instances_cli.go:182). |
| +- [`carlos apps place`](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/apps.go:65). |
| +- [Instance API placement information naming serving boxes and regions](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/internal/console/instancesplacement.go:21). |
| + |
| +These do not establish a runtime “WebSocket transport supported” flag, but they contradict the broad absence claim. |
| + |
| +**Correction:** Distinguish member-controlled placement, observable placement and a runtime transport-capability contract. Keep the tunnel upgrade limitation and SSE fallback recommendation; identify the missing runtime capability precisely. |
| + |
| +### R2-14 — P3 — §2: the universal third-party-cookie claim is false |
| + |
| +**Problem and evidence:** [Roadmap lines 160–165](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/media-roadmap-spec/docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md:160) claim every current browser blocks cross-site cookies regardless of `SameSite=None`. Chrome retains user choice rather than universal blocking; Google’s [published update](https://privacysandbox.google.com/blog/update-on-plans-for-privacy-sandbox-technologies) explicitly describes that approach. |
| + |
| +**Correction:** Say third-party cookies cannot be relied on across supported browsers and privacy settings. The app-origin decision remains justified without the universal claim. |
| + |
| +**Verdict: request changes.** Revision 2 closes the original unsigned visibility bypass, private-cookie transport problem, HLS query propagation failure, and most isolation and feasibility defects. Remaining changes are still architectural: visibility transitions must retire the old source, orthogonal links need a coherent object model, publication needs an enforced fence, and the relay’s regional compromise claim conflicts with existing secret delivery. Resolve those alongside stale-edge behavior, backend coverage and job placement before treating the roadmap as ready for stage specifications. |
| \ No newline at end of file |