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