carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Media on CARLOS: a staged roadmap
2
3**Date:** 2026-09-07, revision 6
4**Status:** rulings taken. Paul ruled on all eleven decisions in §7 on
52026-09-07; each is marked **(ruling)**. Everything not so marked is a
6**(decision)** that a stage spec may overturn cheaply.
7
8Brainstormed with Claude from discussions #107, #112 and #113 and the
9requirements the Meet session sent on 2026-09-07. Reviewed four times
10by GPT-6 via Codex, independently of the author: revision 1's 27
11findings are in `CODEX-FINDINGS-MEDIA.md`, revision 2's 14 in
12`CODEX-FINDINGS-MEDIA-R2.md`, revision 3's 9 in
13`CODEX-FINDINGS-MEDIA-R3.md`, revision 4's 8 in
14`CODEX-FINDINGS-MEDIA-R4.md`, and revision 5's 2 in
15`CODEX-FINDINGS-MEDIA-R5.md`, all unedited. **Round 5 returned accept:
16no P1 or P2 remained, and its two wording corrections are in this
17revision.** Each place a finding changed the text is marked
18**(codex N)**, **(codex R2-N)**, **(codex R3-N)** or **(codex R4-N)**.
19§10 names the two parts declined.
20
21This is a roadmap, not an implementation spec. Each stage below gets its
22own design and plan when it is picked up. The roadmap exists so the first
23stage is built with the later ones in view, so nobody builds a stage out
24of order, and so the security properties are named before any URL shape
25is.
26
27## 0. The goal in one sentence
28
29Any app on CARLOS can serve, transform, stream and relay media at the
30performance people expect from a CDN or a media API, with nothing to
31install and no third-party service in the path, and every object can be
32public, secret or private.
33
34"No third party" is not taste. Suite ruling 6 forbids egress that is a
35property of the product, and a media CDN, an image API, a TURN service
36or a hosted SFU that every app dials by default is exactly that. The
37platform is the one recipient a team has already chosen, so media
38handled by the platform is not egress at all. That distinction is what
39makes this a platform feature rather than a per-app settings field, and
40the plan keeps that language throughout.
41
42## 1. Where things stand, measured on `main`
43
44Discussion #107 recorded most of this on 2026-08-31. Re-checked today,
45and corrected where the reviews found the citations wrong.
46
47- **Apps already have object storage**, bucket-per-app, credentials
48 delivered sealed through `config.json` (object-storage spec §5). Reads
49 and writes go app-to-S3 directly. The spec says at line 27 that a
50 byte-proxy in the data path is "permanently out of scope". Reads must
51 cross that line for a CDN to exist. Writes never will.
52- **Sealed config is decryptable on every host.** The deployment's
53 decryption key sits in `host.env` on every box (config-secrets spec
54 §4) and `internal/appconfig/materialize.go:30` renders secrets to env
55 at adopt time. A compromised host therefore already exposes every
56 app's store keys and secrets, not only its own residents'. Nothing in
57 this plan makes that better or worse, and no claim below may pretend
58 a per-region secret changes it. **(codex R2-2)**
59- **The store already scans.** `internal/objstore` has a ClamAV scanner,
60 a resumable sweep that tags every object with a verdict, a scan queue
61 of one object per item under the app's own prefix, and, when a store's
62 scan block has enforcement on, a `Deny` on `GetObject` for objects
63 tagged infected, written into the store user's policy
64 (`internal/objstore/aws.go:211`, `objstore.go:82`). There is no upload
65 hook by design; the sweep counts what it has not seen. Any new reader
66 of the bucket must carry the same deny whenever the store user does,
67 or it is a bypass. **(codex 4, 26, R4-8)**
68- **The edge cannot read a store bucket.** The region host role is scoped
69 to the deployment bucket and its own region's data bucket
70 (`infra/modules/region-host/main.tf:106`, residency argument in the
71 header at line 6). A new grant is most of the serving design.
72- **The shared response cache is unsafe for anything authorised.** The
73 key is host, method, path and query; a hit returns before any handler
74 runs (`internal/edge/edge.go:1300`). `kind:status` already opts out
75 and caches for itself because an unbounded keyspace in a
76 process-global LRU lets one tenant evict every other. Media has that
77 problem and an authorisation one.
78- **Purge is local to one box.** The purge listener is a unix socket on
79 the host it runs on, and the `Carlos-Purge` response header acts on
80 the edge that saw the response. Nothing distributes an invalidation
81 across regions today. **(codex 9)**
82- **An edge keeps serving from last-converged state through a control
83 bucket outage** (multi-region ingress spec, line 123). Any authority
84 media adds to the route record inherits that: a disconnected edge
85 will honour a revoked key until told otherwise. **(codex R2-6)**
86- **The tick path is local.** `activatorDeliverer` reaches an instance
87 through the activator and its unix socket on the same box, and refuses
88 routes without one (`internal/host/schedule_wiring.go:220`). Once-timers
89 are filed on an instance's control socket, bound to that host
90 (`internal/schedule/control.go:28`). Neither crosses a box.
91 **(codex R2-4, R2-8)**
92- **Range and conditional handling come from `http.ServeFile`** for
93 `kind:static`, and only there. A streaming S3 handler has to build its
94 own HTTP mapping, and S3 `GetObject` serves one range per request.
95 **(codex 7)**
96- **The edge is identity-free by design** (`internal/edge/edge.go:1`).
97 It never reads a cookie. Suite identity is a home-minted Ed25519
98 assertion, audience-bound, single-use, two minutes long, delivered by
99 auto-POST; it cannot be replayed per image. Suite doctrine ruling 3
100 says authorisation is never cached at any scope, and leak item 11
101 names a cacheable personalised response at the platform edge as a
102 cross-person leak.
103- **The doctrine's visibilities are rungs plus an orthogonal link.**
104 Ruling 15: an item is `private`, `granted` or `team`, and may
105 additionally carry a read-only link. `team` is not public. One item
106 can be reachable by its roster and by a link at the same time, so the
107 storage model must let both doors reach one object. **(codex 11, R2-3)**
108- **Doctrine §8 says the recipient of any egress is the app's choice,
109 never the team's**, disclosed in the README's first paragraph and on
110 every artefact produced. A team-pasted relay URL is not admissible.
111 **(codex 12, R2-12)**
112- **Members already control placement.** `carlos apps place` and
113 `carlos instances create --region` exist, and the instances API
114 reports which boxes and regions serve a host
115 (`internal/console/instancesplacement.go:21`). What does not exist is
116 a runtime signal an app can read about its host's transport
117 capabilities. **(codex R2-13)**
118- **Wildcard certificates cover exactly two labels** under the apps
119 domain (`internal/edge/wildcard.go:186`), and app names are
120 `^[a-z][a-z0-9-]{0,31}$` (`internal/release/release.go:239`), so a
121 one-label media name for any app of up to 26 characters is also a
122 valid app name, and an app name may end in a hyphen, which a DNS
123 label may not (`internal/provision/provision.go:223`). Hostnames in
124 the account namespace are also claimed by the instances door
125 (`internal/console/apiinstances.go:571`), which lets an app take any
126 unclaimed label, not only its own name. **(codex 24, R2-10, R4-4, R4-5)**
127- **The fleet is small ARM.** Four production regions on Graviton
128 `t4g.small`, one `t4g.medium`, burstable at a 20% per-vCPU baseline
129 with the same two vCPUs on both sizes; the medium adds memory, not
130 CPU. No GPU, no ffmpeg, no package installs at bootstrap. Remote hosts
131 are Raspberry Pis. **(codex 20)**
132- **The Go tree is pure and CI keeps it so** (`.amadan/ci.d/20-cross@5`
133 sets `CGO_ENABLED=0` deliberately). That rule exists so the CLI
134 cross-compiles for the Macs people run it on. It constrains the tree,
135 not the box, and §3 sets the rule for native helpers.
136- **There is no generic media job queue.** The scan queue is the one
137 bucket-backed queue in the tree and its header explains where jobs
138 must live so app deletion finds them. Stage 3 reuses that shape rather
139 than inventing a second one. **(codex 19)**
140- **UDP is a stated non-goal of the capabilities spec** (line 931). A
141 relay is therefore a platform service next to the edge, not an app
142 capability.
143- **A tunnel-backed host cannot carry `Connection: Upgrade`**
144 (remote-host-pi design, line 63).
145- **Real demand exists.** Docs ships an "insert image" menu item disabled.
146 Meet is being designed now and needs STUN for calls to connect across
147 most home and mobile networks. Woodstar runs its own media service as
148 a tenant app, the shape this plan makes unnecessary.
149- **Apps already hold immutable keys.** rastrillo's blob store is
150 content-addressed and presigns locally. A content-addressed key can be
151 cached forever, and a sealed (E2EE) blob is opaque bytes the platform
152 can serve but never transform.
153
154## 2. Public, secret, private
155
156Three words, three delivery doors, two storage namespaces. The doors are
157how bytes reach a browser, not a new authorisation model: the app still
158decides who may see what, from its own grants, and the doctrine's rungs
159map onto the doors as the last row of the table says.
160
161| Door | Who can fetch | What the edge checks | Bytes cached | Reads from |
162|---|---|---|---|---|
163| **public** `/p/` | anyone with the URL | nothing | by object key, long-lived, in every allowed region | `public/` only |
164| **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/` |
165| **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/` |
166| *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 | | | |
167
168**Two namespaces, not three.** The media prefix has `public/` and
169`protected/`. The public door reads `public/` and nothing else, so
170rewriting a URL from one door to another cannot reach a protected
171object: `/p/x` reads `public/x`, which does not exist for a protected
172key. The secret and private doors both read `protected/`, which is what
173lets one object be reachable by its roster through the app's check and
174by a link through a capability at the same time, as the doctrine's
175orthogonal link requires. Derivatives and renditions live under the same
176namespace as their source. **(codex 1, R2-3)**
177
178**Changing visibility is a lifecycle, not a copy.** Between the two
179namespaces it is: copy to the new namespace, delete the old key and its
180derivatives, append a purge covering them, fence any transform or job in
181flight against the old key (§4, stage 1, "publication fence"), and
182report completion only when every box that could hold a copy, in any
183region the app has ever been allowed, has either acknowledged the purge
184or let its serving lease expire (§4, stage 1, "purge"). Bytes a browser
185already holds cannot be withdrawn, and the docs say so. **(codex R3-1)** Within `protected/`, secret and private are not a move at all:
186the app mints a capability or stops minting them, and an existing
187capability dies at its expiry or when its key id is revoked. Stage 1's
188gate tests public-to-protected and link-to-roster transitions on cold
189and warm caches. **(codex R2-1)**
190
191**Public** is a plain URL on the media host. Content-addressed keys get
192immutable caching. Mutable keys get whatever the app says with
193`Cache-Control` on the object, defaulting to a short lifetime, and the
194app can purge.
195
196**Secret** is the doctrine's read-only link and what S3 presign, Mux and
197Cloudinary do: a bearer capability, signed by the app, naming an object
198or a prefix in `protected/`, expiring. Anyone holding it can fetch; the
199bytes are the same for every holder, so a popular file is cached once
200and revocation lags by the expiry. **The capability is a path segment,
201not a query string**, so a playlist's relative references to its
202segments resolve under the same capability and every child request is
203authorised without rewriting. **(codex 6)** What the signature covers is
204defined in stage 1, not left to the implementer. **(codex 5)**
205
206**Private** is identity-bound, and the only way to honour ruling 3 is to
207not decide at the edge. On every request the edge asks the app: "may
208this request read this key?" The app answers from its own grants and
209session, in a millisecond, and the edge serves from its byte cache or
210fetches the object. This is the shape of nginx `auth_request`, with
211these properties the reviews forced into the open:
212
213- **Private media is served on the app's own origin**, under a reserved
214 path the edge intercepts ahead of the shared-cache lookup and ahead of
215 any proxy branch, because that is the only place the app's session
216 cookie reaches. A separate media host never sees a host-only cookie, a
217 custom domain cannot set one for the apps domain, and cross-site
218 cookies cannot be relied on across supported browsers and privacy
219 settings. The cost is user content on the app's origin, and the
220 mitigations in stage 1 (nosniff, a CSP sandbox on every response,
221 attachment disposition for anything not image, audio, video, font or
222 PDF, and no inline SVG or HTML ever) are what make that acceptable.
223 **(codex 2, R2-14)**
224- **The check travels over the route's own backend transport**, never
225 through a cache: the activator and unix socket for an instance on the
226 same box, the route's TCP dial for a service route, the tunnel for a
227 remote host. A private request for an app whose instance lives on
228 another box reaches the home edge by the existing backhaul, as every
229 other request for that route does, and the home edge's media cache is
230 the one that serves it. Private bytes are therefore cached on the
231 app's home edge only; public and secret bytes are cached in every
232 allowed region. The check fails closed on timeout, redirect, or
233 anything but a 200, and runs before `HEAD`, before a 304, before a
234 range is answered and before a cache hit is admitted. **(codex 3, R2-4)**
235- **Private responses carry `Cache-Control: private, no-store`.** Secret
236 responses carry `Cache-Control: private, max-age=<remaining life>,
237 must-revalidate`, generated per request from the capability presented
238 and overriding any directive on the object, so no HTTP cache may serve
239 a stale copy past the capability's expiry. The edge's own byte cache is
240 internal and never a substitute for either. **(codex 3, R2-5)**
241
242The costs: the app's instance is in the hot path for private media, one
243small request per fetch, and a hibernating instance wakes for it; the
244app must expose the check endpoint, which rastrillo provides once for
245every app; the edge stays identity-free, forwarding a cookie it never
246reads. The edge never caches the app's answer, for any tenant, under
247any knob. Ruling 1 says never; a proposal to cache a positive answer
248comes back to §7 for a changed ruling, not to a stage spec as an option.
249**(ruling 1 in §7; codex R3-7)**
250
251Media publication is never evidence for anything else. A sibling app
252seeing an object under `public/` learns nothing about whether it may
253ingest the source item; custody keys on the app's grants, as the
254doctrine says. **(codex 11)**
255
256## 3. Native helpers and media services: the rule
257
258Images and video are the two places where pure Go is not fast enough on
259a `t4g.small`. The answer is not a cgo dependency. Two classes, with
260different rules. **(codex 16, 22; ruling 10)**
261
262**Codec helpers** transform bytes and nothing else.
263
264- The Go tree stays `CGO_ENABLED=0`. A helper is a separate program in
265 whatever language wins the benchmark: Rust, C, C++, Go with wazero,
266 or an existing tool such as `vips` or `ffmpeg`. Every candidate,
267 including a Go one, runs as its own process. Nothing decodes inside
268 the edge.
269- A helper is built and shipped as its own artifact, linux/arm64 only,
270 with an authenticated manifest naming its version, hash and the
271 libraries and codecs it carries. A box with the role that needs it
272 fetches it at bootstrap and at each promotion, and can roll back the
273 way it rolls back the platform binary. It never enters the CLI build.
274- A platform unit spawns it under a hardened systemd scope with no
275 network, a read-only filesystem apart from scratch, and memory,
276 CPU, wall-time, scratch-space and process limits. It talks over
277 stdin/stdout or a unix socket, and the Go side owns the protocol, so
278 `vips` today and a Rust helper tomorrow answer the same calls.
279
280**Media services** are network-facing long-running units: the relay in
281stage 4a, LiveKit in 4b, a live packager. They are platform units like
282the edge, with their own units, ports and hardening, and the no-network
283rule above does not apply to them.
284
285This is the answer to "could we build some dependencies in Rust or C for
286throughput": yes, as codec helpers, and the spikes in stages 2 and 3
287decide whether the first ones are ours or borrowed.
288
289## 4. The stages and how they depend on each other
290
291Six pieces of work. The graph replaces a single sequence; anything with
292no arrow into it can start now. Stage 1 carries two things later stages
293share, the derivative-write grant and the publication fence, so that
294whichever of 2 or 3 ships first finds them ready. **(codex 27, R2-11;
295ruling 11)**
296
297 stage 1 serving class ─┬─▶ stage 2 image transforms
298 ├─▶ stage 3 audio/video on demand ─▶ recording (in 4b)
299 └─▶ stage 5 app-facing helpers
300 stage 4a STUN + TURN ────▶ stage 4b LiveKit SFU, live streaming
301
302| Stage | Delivers | Needs from the platform | Size |
303|---|---|---|---|
304| 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 |
305| 2 | image transforms behind preset URLs | a codec helper, limits, derivative identity | one spec, one spike first |
306| 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 |
307| 4a | STUN and TURN | UDP ingress, a relay unit, per-app per-region credentials as config, limits | one spec, one infra change; **starts now** |
308| 4b | LiveKit as a media service, recording, live streaming | a `c7g.large`, LiveKit's server unit, a live serving extension | one spec |
309| 5 | uploads, rastrillo helpers, accounting, residency disclosure | small pieces spread across 1 to 4 | folded into each stage's plan |
310
311### Stage 1: `kind:media`, the serving class
312
313A new serving class, the fourth app-mintable route kind beside
314`instance`, `service` and `static` (the edge also dispatches the
315platform's own `status`), that serves an app's store bucket through the
316edge, cached, with the three doors of §2. This is piece A of #107 with
317its open questions answered. **(codex R4-8)**
318
319**What an app sees.** `carlos media enable` writes an additive `media`
320block into `store.json` (prefix, allowed regions, key ids, hostname)
321and mints `media-<app>.<sqid>.<apps domain>`, one label so the account
322wildcard already covers it. **(codex 24)** Because `media-<app>` can
323also be a valid app name, the hostname is a **media-host claim** of its
324own, owned by the source app and recorded in the account, not an app
325row. It is one entry in the account's hostname namespace, and every
326door that acquires a hostname there consults it: new app names,
327renames, and the instances door that lets an app take an unclaimed
328label. Enable is refused, with the error saying so, when `media-<x>`
329is already an app's name or an existing route or instance host, and
330those doors refuse a label a media claim holds; the acquisitions are
331serialised on the claim so two racing requests cannot both win. An app
332whose name ends in a hyphen cannot form a DNS label with `media-`
333prefixed and is refused enable before any claim or grant is written,
334with the error saying to rename. The generated label can be up to 38
335characters, inside a DNS label; the shared `release.ValidApp` grammar
336is not narrowed, because existing manifests and channel pointers parse
337through it. **(codex R2-10, R3-4, R4-4, R4-5)** The app writes objects
338under `<prefix>/public/` or
339`<prefix>/protected/` with the store credentials it already has, and
340forms URLs:
341
342 https://media-docs.abc.oncarlos.com/p/<key> public
343 https://media-docs.abc.oncarlos.com/s/<capability>/<key or rest> secret
344 https://docs.abc.oncarlos.com/_carlos/media/<key> private, on the app's origin
345
346The capability is one URL-safe base64 path segment encoding the signed
347claims and the signature. Everything after it is resolved against the
348scope the claims name, so `.../s/<cap>/videos/abc/index.m3u8` and the
349`720p/seg001.m4s` it references are both authorised by the same
350capability. The cache key strips the capability and keys on the object.
351
352**What a capability signs.** Domain separator (`carlos-media-v1`), the
353app's store identity, the key id, the namespace (`protected/`), the
354scope as either an exact key or a prefix that is matched only at a `/`
355boundary after percent-decoding and normalising the path with no `..`,
356the expiry, and in stage 2 the transform presets allowed. Maximum life
357is seven days **(ruling 3)**. Rotation is a second key id with an
358overlap; emergency revocation removes a key id from the route record.
359The capability never appears in edge logs, and every media response
360carries `Referrer-Policy: no-referrer`. **(codex 5)**
361
362**How the edge is granted read.** The provisioner gains a bucket-policy
363operation. At enable it attaches statements on that one bucket letting
364each allowed region's host role `GetObject` on both namespaces and
365`PutObject` and `DeleteObject` under `derived/` within them, **with the
366same `s3:ExistingObjectTag` deny the store user carries**, so an object
367the scanner has tagged infected is unreadable through media exactly as
368it is through the app's credentials. Enable and disable are a
369reconciled walk like the store grant, with partial completion, store
370revocation and route replacement handled, and disable also retires
371cached bytes. Adopted and non-AWS stores are refused in version one.
372**(codex 4, 25, R2-11; ruling 2)**
373
374**Signing keys.** An Ed25519 pair per app, minted at enable. The public
375half goes in the route record, which every edge already syncs. The
376private half reaches the app as `CARLOS_MEDIA_SIGNING_KEY` through the
377sealed `config.json` path that already carries store keys. The edge
378holds no secret. **(ruling 3)**
379
380**The handler.** `mediaHandlerFor` streams: one S3 `GetObject` per
381request with a single normalised range, the body copied through, never
382buffered. It defines its own HTTP mapping rather than borrowing
383`http.ServeFile`'s: single-range only, multi-range answered with the
384whole object, `If-Range` against the ETag, `If-None-Match` before
385bytes, `HEAD` answered from metadata, 416 on unsatisfiable. Every cache
386entry is bound to the object's ETag and published only after a complete
387fill, so a range from one version is never stitched to metadata from
388another. **(codex 7)** Response headers set on every media response,
389whatever the object carries: `X-Content-Type-Options: nosniff`,
390`Content-Security-Policy: sandbox`, `Referrer-Policy: no-referrer`, a
391`Content-Type` from the object's metadata with a small allow-list
392fallback, `Content-Disposition: attachment` for anything that is not an
393image, audio, video, font or PDF, the door's `Cache-Control` from §2,
394and a real CORS policy for the app's own hostnames with `Vary: Origin`,
395because `Cross-Origin-Resource-Policy` is not CORS and an HLS player in
396JavaScript needs the latter. **(codex 23)**
397
398**Its own cache.** `kind:media` is exempt from the shared LRU in both
399directions and keeps a disk cache under `/data/media-cache/` that stores
400objects as fixed-size blocks (1 MiB), so overlapping ranges share
401storage. Isolation is per box: a total disk budget with filesystem
402headroom, a fair share per route within it, eviction that takes first
403from whichever route is furthest over its share, an account-level cap
404across routes, and a bound on concurrent fills. The fleet-wide figure
405is accounting, not a limit any one box can enforce. **(codex 8)**
406
407**Purge, distributed, with a serving gate.** A purge is a record
408appended to a log in the platform bucket, sequence-numbered per app.
409Every edge tails the log and applies it; entries carry the generation
410they were filled under, so an in-flight fill cannot repopulate an entry
411a purge already covers. A returning edge reconciles its purge cursor
412and its route authority **before** admitting any hit from the
413persistent cache, and if the log history it needs is gone it drops its
414media cache rather than guess. **Serving media is a lease.** An edge
415may serve any media door, public included, only while its media
416authority (the route record's media block and its purge cursor) is
417younger than fifteen minutes; past that every media door on that edge
418fails closed, because a public object that was moved to `protected/`,
419a disabled route, a removed region or an infected verdict must not
420outlive an outage on a box that clients can still reach. Retirement of
421a key, a route or a region is complete when every box that could hold
422a copy has acknowledged it or its lease has expired, and the API and
423CLI report completion per box on exactly that condition; the local
424`Carlos-Purge` header and socket stay as fast paths and never count as
425completion. A scan verdict turning infected files a purge for the key
426and its derivatives. **(codex 4, 9, R2-6, R3-1)**
427
428**The publication fence.** Anything that writes derived output, a stage
4292 transform or a stage 3 job, writes to an attempt-specific key under
430`derived/_attempts/` and has no authority to publish it. **The console
431is the publisher.** A commit is a console door, `POST
432/a/<sqid>/apps/<app>/media/publish`, carrying the attempt, the lease
433and the source ETag; the console checks the lease is current in its
434registry, the source's purge generation and the app's incarnation match
435what it holds, and only then writes the per-source manifest itself.
436That is one serialiser with the state to refuse, which a conditional
437write against the manifest is not: an expired worker with no successor
438would find the manifest's ETag unchanged and its `If-Match` would
439succeed. The serving path reads derived output only through a committed
440manifest, **with no exception**: a synchronous transform's first
441requester waits for the publish door's answer and receives the bytes
442only if it was 200, because an attempt that stalled past its lease or
443across an infected verdict must not reach even the request that caused
444it. One console round trip on a first miss is the cost. Purge, an
445infected verdict, cancellation and app deletion each bump the
446generation in the registry, so a late commit is refused by the door.
447Attempt keys are swept. This lives in stage 1 because both later stages
448need it and neither may reinvent it. **(codex 17, R2-7, R3-2, R4-3)**
449
450**Residency, enforced.** The `media` block names the regions allowed to
451serve and cache the app's media. The bucket policy grants only those
452regions' host roles, the media host's DNS records exist only in those
453regions, and the handler on any other edge answers 421 with the right
454host as a backstop, so a client addressing a foreign edge directly
455cannot fill a cache there. Default at enable: the regions the app's
456routes are already placed in, shown back by `carlos media status` and
457on the media docs page. **(codex 10, R2-9; ruling 4)**
458
459**Store spec amendment.** Line 27 of the object-storage spec gains one
460sentence: reads may cross the data plane through `kind:media`; writes
461never do. Uploads stay app-to-S3 on the app's credentials.
462
463**What stage 1 must prove** (§9) is the attacks in §2 and this section
464failing, not only playback succeeding. **(codex 27)**
465
466**Not in stage 1:** transforms, transcoding, uploads through the edge,
467per-viewer analytics, signed cookies, bandwidth quotas, dashboards
468beyond a bytes-served counter, multipart upload helpers.
469
470### Stage 2: image transforms
471
472`/p/<key>?t=thumb`, `/s/<cap>/<key>?t=thumb` and the private form, where
473`thumb` is a preset the app declared. A transform is synchronous, pure
474CPU in a codec helper, and its result is written back to the app's
475bucket through the publication fence so the second region never redoes
476the first's work.
477
478**Abuse surface first.** A public transform URL is CPU amplification on
479the box that terminates TLS for every tenant. From the first commit,
480transform parameters are a **named preset** the app declared, or for
481secret objects the presets the capability names, or for private objects
482the presets the app's check endpoint approves. Free-form parameters are
483refused everywhere. **(codex 5)** On top of that: single-flight per
484derived identity, a per-box concurrency cap with per-tenant fair
485admission so one tenant cannot hold every slot, a queue that turns into
486503 after a short wait, a source pixel-count cap, output dimension caps,
487and a per-route derivative byte budget. **(codex 17)**
488
489**Derivative identity.** A derivative is named by the source key, the
490source ETag, the normalised preset, the resolved output format and the
491helper version, and committed through the fence. `f_auto` picks AVIF,
492WebP or JPEG from `Accept`, and the response says `Vary: Accept`.
493**(codex 17)**
494
495**The engine, decided by a spike.** Three candidates, all run as separate
496processes under the §3 rule. First, a Go helper embedding wazero-hosted
497`libwebp`, `libavif` and `jpegli` (the `gen2brain/*` packages), which
498keeps the build pure Go end to end. Second, `vips` as a fetched helper.
499Third, our own Rust helper using the `image` and `ravif` crates or
500linking libvips, persistent over a unix socket. The spike measures a
5014000×3000 JPEG to an 800-wide WebP and AVIF on a `t4g.small` that is
502also serving: thumbnail latency, peak memory, sustained throughput
503under eight concurrent requests, behaviour on malformed input, and the
504serving latency of the box while it runs. Under a second for WebP with
505serving latency flat is the bar, and the candidate that clears it with
506the least to operate wins. The bar is not a ceiling on the fleet: if
507nothing clears it on a `t4g.small`, the choices are a bigger edge box,
508a dedicated transform box the fleet shares, or telling apps what to
509expect, and the spike's numbers decide which. **(codex 16; ruling 5)**
510
511**What a transform does.** Resize, crop modes, format, quality, and
512always strips metadata. EXIF GPS in a public photo is a privacy leak the
513platform should not require every app to remember. Animated GIF and
514WebP are passed through untransformed in version one. Sealed blobs are
515refused with 415.
516
517**Derivatives** land in the tenant's bucket under
518`<namespace>/derived/<source key>/<identity>.<fmt>`, written by the
519region host role under stage 1's `derived/` statement, and count
520against the tenant's storage. Purging a source key removes its
521derivatives. **(ruling 6)**
522
523**Not in stage 2:** face detection, smart crop, watermarks, uploads,
524SVG rasterisation, anything needing a model.
525
526### Stage 3: audio and video on demand
527
528Stage 1 already plays a well-formed MP4 or MP3 progressively: `Range`
529passes through and the browser's `<video>` seeks. For a short clip or a
530voice note that is the whole feature and it ships in stage 1. Stage 3 is
531for what progressive cannot do, and it has two products the review
532insisted be kept apart. **(codex 21)**
533
534- **A single rendition, fast.** Probe the input; if it is H.264 and AAC
535 in MP4 with sane keyframe spacing, remux to CMAF and one HLS playlist
536 in seconds. If it is WebM from a browser recording, re-encode one
537 rendition at the source's size. This is what a phone upload in Docs
538 or a recorded call needs first, and it follows stage 1 directly.
539- **An adaptive ladder, slower.** Re-encode to three rungs (1080p, 720p,
540 360p, capped at the source's height), CMAF, one multivariant playlist.
541 Remuxing cannot make lower rungs; this always encodes. AV1 and DASH
542 are one flag away and off. This follows the worker measurements.
543
544Both products also write posters at three timestamps for video, and a
545waveform JSON and duration for audio. Everything is served by stage 1
546under one prefix-scoped capability or one private door, which is why
547the capability is a path segment.
548
549**The media worker.** A platform unit, `carlos-media.service`, on one box
550per region that has media-enabled buckets. Jobs live where the scan
551queue lives and for the same reason: under the app's own prefix,
552`apps/<acct>/<app>/media-jobs/<id>.json`, one object per job, so app
553deletion finds them and the name-claim gate sees them. **(codex 19)**
554
555- **Submission** is a console door, `POST
556 /a/<sqid>/apps/<app>/media/jobs`, reached from the app through a
557 rastrillo helper with the app's route-scoped credential, and from
558 `carlos media transcode <key>`. The door derives the source and
559 destination scope itself; the app names a key, never a path. Delivered
560 store credentials never gain the right to write job records.
561 **(codex 18)**
562- **Eligibility, decided at submission.** The processing region is the
563 bucket's region, and it must be in the app's allowed list and have a
564 worker. Version one runs one worker, in Ireland, so a job for a bucket
565 elsewhere is refused at the door with an error naming the reason, not
566 queued to wait for a worker that does not exist. **(codex R2-9)**
567- **Authority.** The worker reads the source through the media bucket
568 policy and writes renditions under `derived/` through stage 1's
569 statement, on buckets in its own region only.
570- **Leases and the fence.** A worker takes a lease with an expiry; its
571 output goes to attempt keys and is committed through the stage 1
572 publication fence, which refuses a commit from an expired lease, a
573 purged source or a deleted app. Bounded retries, cancellation, a
574 terminal failure state. **(codex 18, R2-7)**
575- **Completion** is a result record the app polls in version one.
576 Callbacks come later as a durable completion outbox that the
577 destination host converges into a local once-timer, because nothing
578 files a timer across boxes today. **(codex R2-8)**
579- **Limits from the first job**: admitted source size and duration,
580 concurrent jobs per box, memory and scratch caps on the ffmpeg helper,
581 and measurement of serving latency, I/O and CPU credit consumption
582 under sustained encoding before any promise about co-location is
583 made. **(codex 20)**
584
585**Which box.** A `t4g.medium` has the same two burstable vCPUs as a
586`t4g.small`; it is not a transcoding box. Version one runs the worker
587on the Ireland medium behind the limits above, and the honest
588expectation is that the first tenant wanting ladders moves it to a
589dedicated `c7g.large`. Encode throughput is the first sustained
590bottleneck; host memory and scratch pressure fail earlier if left
591unbounded, which is why the limits come first. **(codex 20; ruling 7)**
592
593**Not in stage 3:** caption generation, DRM, per-viewer watermarking,
594server-side ad insertion, live packaging. Caption files the app supplies
595are just objects.
596
597### Stage 4: real time
598
599Meet's requirements, in the order Meet needs them. Stage 4a depends on
600nothing above and starts now. **(codex 27; ruling 11)**
601
602**4a. STUN and TURN.** A STUN listener the platform runs is what lets a
603WebRTC app connect across most home and mobile networks without an
604egress exception; TURN is the same listener relaying, and it is what
605turns "one call in ten cannot connect" into "calls connect". Both are
606UDP. The capabilities spec's non-goal stands: UDP is not something an
607app binds. The platform runs `carlos-relay.service` on every region
608box, built on `pion/turn`, pure Go, in the same binary as the edge, a
609§3 media service.
610
611- **Ingress.** UDP 3478 and a relay allocation range (49152 to 65535)
612 opened in the region security group and configured as the allocator's
613 only range; TCP 3478 for networks that block UDP. The relay advertises
614 the box's public Elastic IP. **No TURN over TLS in version one**
615 (Paul, 2026-09-07, stage 4a spec ruling 4): 5349 needs certificate
616 issuance for a non-HTTP listener, which is its own small design, and
617 443 would need a second address on the box because 443 is the edge's.
618 The docs say a network that blocks 3478 is the remaining case that
619 fails. **(codex 15; amended by the stage 4a spec)**
620- **Credentials, scoped per app and per region.** Each region has a
621 relay master key. At `carlos relay enable` the instance receives,
622 sealed through `config.json`, one derived key per allowed region:
623 `HKDF(region master, account, app, incarnation)`, as
624 `CARLOS_RELAY_KEYS` alongside `CARLOS_RELAY_URLS`. The app mints TURN
625 REST credentials per call, `username =
626 expiry:account:app:incarnation`, `credential = HMAC(derived key,
627 username)`, with a four-hour maximum (Paul, 2026-09-07, stage 4a
628 spec ruling 1; the relay ends the allocation at expiry, so one hour
629 would end any longer call). The exact wire shape, a base64-wrapped
630 JSON `CARLOS_RELAY` with a platform-supplied username suffix and a
631 key id, is the stage 4a spec's §2. The relay holds a **relay
632 authority record**, `control/relay.json`, rebuilt by the console from
633 per-app relay records and read by every relay every minute: for each
634 enabled app, its regions and current incarnation. On a request it
635 parses the username, refuses it unless the app is enabled and the
636 incarnation matches the record, re-derives the key from its own
637 region master and the username's fields, and verifies. Verification
638 is local and per-app metering is real. Disable removes the app from
639 the record, which ends existing allocations and makes every
640 outstanding credential fail; re-enable draws a new random
641 incarnation, so nothing minted before a disable ever works again and
642 nothing is reused across deletion or name reuse. A relay whose authority
643 record is older than fifteen minutes refuses new allocations **and
644 terminates the ones it is carrying**, the same lease rule the edge
645 applies to media: a disable the relay never heard about must not
646 outlive the outage by the length of a credential. Rotation is a new
647 region master and a re-render of every enabled app's config.
648 **(codex 13, R2-2, R3-3, R4-6; ruling 8)**
649- **What that does and does not protect.** Per-region masters mean a
650 relay box holds no other region's master. They do not mean a
651 compromised host learns nothing about other regions: sealed config is
652 decryptable on every host (§1), and the console must hold every
653 region's master as sealed config to derive app keys, so **a
654 compromised host can read every region's master** and mint for any
655 app in any region, present and future, until the operator rotates
656 (Paul, 2026-09-07, stage 4a spec ruling 3). That is wider than the
657 derived-key exposure alone, and it is stated in those words in the
658 operator docs. Rotation of one region recovers that region. A design
659 where only a region's boxes ever hold its master, the relay deriving
660 and writing app keys itself, is filed as the tightening. **(codex
661 R2-2; amended by the stage 4a spec)**
662- **Policy and limits.** Peer permissions refuse loopback, link-local,
663 RFC 1918, the VPC's ranges and the box's private addresses; the box's
664 own Elastic IP is reachable, but only on ports holding a live relay
665 allocation, so two clients on one relay can reach each other and
666 nothing else on the box is exposed. Allocations,
667 allocation rate, bandwidth and lifetimes are bounded per app, per
668 account and per box. Established allocations end when their
669 credential expires or the app is disabled. These are operational
670 limits in 4a; commercial quotas wait for measurement. **(codex 14)**
671- **Placement.** The app's `CARLOS_RELAY` lists its regions in the
672 console's order, one list for the whole app, since placement names
673 are not regions and an app may have several placements (stage 4a
674 spec). The browser gathers candidates from all of them
675 and ICE nominates a working pair by its own procedures; that is not a
676 lowest-latency guarantee and the roadmap does not claim one.
677 Client-aware relay selection is a separate later decision.
678 **(codex 15)**
679- **Metering.** Bytes forwarded from the client and bytes forwarded to
680 the client are two meters, per app, written by the relay to its own
681 metrics objects; the inter-region meter the first draft named, and
682 the account usage line, move to stage 5 with the usage view (Paul,
683 2026-09-07, stage 4a spec ruling 5). The unit-economics spec gets the
684 definitions. **(codex 26; amended by the stage 4a spec)**
685- **Custody language.** A platform relay is not egress under ruling 6:
686 the recipient is the operator the team already trusts with its pages.
687 A third-party relay is egress, and under doctrine §8 which third
688 party is the app's decision, never the team's.
689
690**4b. LiveKit as a media service, recording, live streaming.** An SFU
691lifts the four-participant cap, changes the privacy story from
692peer-to-peer to server-in-the-path, and is where recording becomes
693possible. **Paul ruled to host LiveKit's open-source server** rather
694than build on `pion/webrtc`, as a §3 media service, `carlos-sfu.service`,
695fronted by the platform's own API. **(ruling 9)** LiveKit Cloud is not an
696option for suite apps: a third party in every call's media path is
697egress that is a property of the product. It is also not cheaper past
698about sixty room-hours a month, since both are dominated by bandwidth.
699
700- **Box.** The server is one Go binary on pion and needs no Redis as a
701 single node. A `c7g.large` in Ireland (2 vCPU, 4 GB, about sixty
702 dollars a month) carries a handful of concurrent eight-person video
703 rooms; a `c7g.xlarge` gives headroom for tens. CPU is spent per
704 forwarded track, and LiveKit's own figures put thousands of tracks on
705 sixteen vCPUs, so the box is not the limit at this scale.
706- **Cost is egress.** Each participant downloads everyone else's stream.
707 An eight-person video room at about 1 Mbps per stream is about 25 GB
708 an hour, roughly two dollars at AWS rates. That line goes in the
709 unit-economics amendment beside the relay meters.
710- **Ports and hostname.** LiveKit's clients speak WebSocket to the
711 server's `/rtc` endpoint on 7880, so the SFU has a platform hostname
712 of its own, `sfu.<region>.<platform domain>`, with TLS terminated by
713 the edge on that box and the upgrade proxied to LiveKit. The browser
714 reaches it directly, never through the app's route, so an app on a
715 tunnel-backed host still joins SFU rooms. Port 7880 is reachable only
716 from that proxy. TCP 7881 and UDP 50000 to 60000 are opened directly,
717 the same shape as the relay. **(codex R3-5)**
718- **Tenant boundary, enforced at admission.** The platform holds
719 LiveKit's API key and secret; no app ever does. Rooms are named
720 `<account>/<app>/<incarnation>/<room>` and only the platform mints
721 tokens: the app's backend calls `POST
722 /a/<sqid>/apps/<app>/media/sfu/token` with its route credential,
723 naming a room, a participant identity and grants, and the door
724 prefixes the room, caps the grants to that app's namespace, signs a
725 ten-minute token, and returns it for the app to hand to its browser.
726 **The proxy in front of 7880 is the admission gate**: on every join
727 and reconnect it verifies the token, checks the room's revocation
728 list, and only then opens the upstream connection, serialised against
729 removals so a rejoin cannot race a removal. LiveKit's own token
730 refresh for connected clients does not matter, because admission is
731 keyed on identity and room against platform state, not on a token
732 being unexpired. Removal is an app-to-platform door that records the
733 revocation, calls LiveKit's remove-participant, which is what ends the
734 active media session, and has the proxy close that participant's
735 signalling connection and refuse its next admission, none of it
736 waiting for anything the client does. Webhooks are for
737 observation and reconciliation, never enforcement. Management
738 operations are the door's, scoped to the app's rooms. **(codex R3-6,
739 R4-1, R4-2)**
740- **No third-party defaults.** Stock LiveKit hands participants Google's
741 and Twilio's STUN servers when none are configured and uses them for
742 its own external-IP discovery. The platform's LiveKit is configured
743 with the stage 4a relay as its only STUN and TURN, for the server's
744 discovery and the client ICE configuration alike, and the 4b gate
745 runs a call with all third-party network access blocked to prove no
746 fallback remains. This is why 4b follows 4a. **(codex R4-7)**
747- **Recording and ingest are separate LiveKit services** and are
748 deferred until asked for, with their prerequisites named now so the
749 launch box is not mistaken for them: LiveKit Egress needs Redis to
750 talk to the server, its own worker of at least four vCPUs and 4 GB,
751 and Chrome's sandbox requirements for composite recording; WHIP and
752 RTMP ingest is a third service with the same Redis dependency.
753 Recording writes per-track or composite files into the app's bucket
754 and submits a stage 3 single-rendition job, so a recorded call
755 becomes a playable asset with no new serving code. Recording depends
756 on stage 3, not the other way round. **(codex R2-11, R3-8)**
757- **Live streaming to many viewers** is a distinct long-running workload:
758 ingest feeds a live packager, and LL-HLS needs partial segments,
759 blocking playlist reloads and delivery directives that an object
760 reader does not supply, so it is a named serving extension with its
761 own latency contract, not a reuse of stage 1 unchanged. **(codex 22)**
762
763**Signalling.** In peer-to-peer mode, with or without the stage 4a relay,
764offers, answers and candidates ride the app's own HTTP, and §8 answers
765#113 about that. In SFU mode the LiveKit client SDK carries its own
766signalling over the WebSocket to the SFU hostname above; the app's HTTP
767is then for room coordination and token refresh only. **(codex R3-5)**
768
769### Stage 5: what makes it usable from an app
770
771Each of these is small and belongs in the plan of the stage that first
772needs it. None of them is in stage 1's gate. **(codex 27)**
773
774- **Direct browser uploads.** Apps already presign PUTs, but a browser
775 cannot PUT to the bucket until the bucket has a CORS rule, and S3 CORS
776 is bucket-wide with no prefix selector. `carlos media enable`
777 therefore owns the bucket's CORS configuration for the app's
778 hostnames, merging with nothing (a store with CORS already set is
779 refused until the operator clears it), exposing `ETag` and the
780 multipart headers, and removing it at disable. Multipart upload for
781 anything over 100 MB is a rastrillo helper. **(codex 23)**
782- **rastrillo helpers.** One package, `rastrillo/media`: a URL builder
783 for the three doors, a capability signer that reads
784 `CARLOS_MEDIA_SIGNING_KEY`, the private-check handler that answers the
785 edge's subrequest from the app's grants, a visibility-move helper
786 that drives the lifecycle in §2, a presigned-upload helper with
787 CORS-correct headers, a job filer, and a TURN credential minter.
788 Docs' disabled "insert image" is the first consumer.
789- **Accounting.** Bytes served and cached in stage 1 as one counter;
790 transform seconds, transcode minutes, the three relay meters and SFU
791 egress when their stages land; on the console's app page and in
792 `carlos media status`. Quotas come after measurement, not before.
793- **Scanning.** Nothing to hook: the sweep already tags what it finds,
794 and the media grant carries the deny. The one addition is the verdict
795 purge in stage 1.
796- **Residency disclosure.** One paragraph on the media docs page and in
797 `carlos media status` naming the allowed regions and, for private
798 media, the home edge.
799- **Docs page.** `docs/site/media.md`, and `docs/site/cli.md` headings for
800 every new verb, because the docsite gate fails otherwise.
801
802## 5. What apps might want that is deliberately not here
803
804Named so nobody wonders whether it was forgotten.
805
806- **Signed cookies** instead of path capabilities. Not needed while
807 capabilities are prefix-scoped; revisit if a tenant needs a whole site
808 of secret media.
809- **A media-session cookie on the media host** for private media. Cannot
810 be relied on across browsers and privacy settings on custom domains;
811 the app-origin door in §2 is the answer.
812- **Per-viewer watermarking or DRM.** Different product, different buyer.
813- **AI on media**: captions, transcription, moderation, smart crop. Each
814 is a model-provider egress under ruling 6 and a per-app opt-in there,
815 not a platform primitive.
816- **A platform upload door** through the edge. Writes never cross the
817 data plane; uploads stay app-to-S3.
818- **Peer-to-peer file transfer** over the relay. TURN would carry it, but
819 it is an app protocol, not a platform one.
820- **A GPU tier.** Nothing in stages 1 to 4 needs one on a fleet this size.
821- **LiveKit Cloud, or any hosted SFU.** Egress that is a property of the
822 product.
823
824## 6. What must be true before an app can build on this
825
826The review's last question was what an app hits in its first month. The
827answers are all inside stages 1 and 5, and are the reason stage 1's gate
828is a security gate, but they are worth listing plainly:
829
830- a way to put a private image on a page that works on a custom domain
831 in Safari (the app-origin door);
832- a way to hand a video player one URL and have every segment load (the
833 path capability);
834- a way to share a roster-visible video by link without moving it (the
835 shared `protected/` namespace);
836- a way to replace an object and not serve the old bytes (ETag-bound
837 cache, distributed purge, the publication fence);
838- a way to know which regions hold copies (the media block);
839- an upload that does not fail at the last byte of a phone video (CORS
840 ownership, multipart helper);
841- a helper package so none of the above is hand-rolled per app.
842
843## 7. Rulings
844
845Paul ruled on each of these on 2026-09-07. They are the roadmap's fixed
846points; a stage spec that wants to move one comes back here first.
847
8481. **(ruling)** Private means ask-the-app, per request, never cached,
849 served on the app's own origin under a reserved path.
8502. **(ruling)** Edge read is granted by bucket policy on the two media
851 namespaces, per allowed region host role, carrying the infected deny,
852 attached and removed by a reconciled walk.
8533. **(ruling)** Ed25519 per app for capabilities, public half in the
854 route record, private half as env through sealed `config.json`, key
855 id in the capability for rotation, seven-day maximum life.
8564. **(ruling)** Allowed regions are explicit in the media block and
857 enforced in the bucket policy, DNS and the handler; default is the
858 regions the app's routes are placed in.
8595. **(ruling)** The transform engine is chosen by a measured spike among
860 a Go wazero helper, `vips` and a Rust helper, all as separate
861 processes, benchmarked on a `t4g.small`, with a bigger or dedicated
862 box on the table if the numbers say so.
8636. **(ruling)** Derivatives and renditions land in the tenant's bucket
864 under `derived/` in the source's namespace, counted as the tenant's
865 storage, removed with their source.
8667. **(ruling)** The media worker starts on the Ireland `t4g.medium`
867 behind hard admission limits, with a dedicated `c7g.large` when
868 ladders are wanted.
8698. **(ruling)** Relay credentials are per-region HKDF-derived per app and
870 incarnation, verified locally; the exposure through sealed config is
871 stated, not hidden.
8729. **(ruling)** The SFU is LiveKit's open-source server hosted as a
873 platform media service on a `c7g.large`, recording and ingest
874 services deferred until asked for.
87510. **(ruling)** Native helpers are allowed as separate processes only,
876 with codec helpers and media services as distinct classes.
87711. **(ruling)** Stage 1 and stage 4a start now, independently. Stage 2
878 and the stage 3 single-rendition product follow stage 1. The ladder
879 follows the worker measurements; 4b follows 4a.
880
881## 8. Answers to #112 and #113
882
883**#112.** Direction A: the platform runs the relay. STUN and TURN are
884stage 4a and start now; LiveKit as a hosted media service is 4b. UDP
885ingress is something the platform will carry, as a platform unit on the
886region boxes, never as an app-bindable port. A platform relay is not
887egress; a third-party one is.
888
889One correction to Meet's interim plan: under doctrine §8 the recipient
890of an egress is the app's decision, never the team's, so a settings
891field where a team pastes any TURN URL is not admissible. What is
892admissible until 4a ships is either no relay, with the disclosed floor
893of calls that connect without one, or an app-named provider that a team
894can switch on, disclosed in the README's first paragraph **and on every
895artefact the app produces while it is on**, which for Meet means any
896recording or export. Meet should pick one of those two, not the
897free-text field. **(codex 12, R2-12)**
898
899**#113.** Confirmed as a standing constraint: no `Connection: Upgrade`
900over a tunnel-backed host. The placement picture is more nuanced than
901the question assumed: a member can place an app with `carlos apps place`
902and `carlos instances create --region`, and the instances API reports
903which boxes serve a host, so a team can keep Meet off tunnel-backed
904hosts by choice. What does not exist, and is not planned before extended
905CONNECT, is a runtime signal the app can read saying "this host carries
906upgrades", or a manifest field that refuses placement without it.
907**(codex R2-13)** So SSE plus POST remains the right default: it works
908everywhere, its volume is small, and a socket can be adopted later on
909hosts the team has placed deliberately, with SSE as the fallback Meet
910would need anyway.
911
912## 9. Acceptance gates
913
914Each stage has its own gate; what a gate unlocks is whatever §4's graph
915hangs off it, not "the next stage" in this list. The gates are attacks
916failing, then playback working. **(codex 27, R3-9)**
917
918- **Stage 1**, on flagship: `/p/` of a protected key is 404 cold and
919 warm; a capability with the expiry edited, the scope widened past a
920 `/` boundary, the namespace changed, or the key id revoked is refused;
921 a private fetch is refused the request after the app revokes the
922 grant, on a warm cache; a public-to-protected move leaves nothing
923 readable through `/p/` in any region once it reports complete; an
924 object tagged infected is unreadable through media and its cached
925 copy is gone within a purge round; a purge issued while a fill is in
926 flight leaves no stale entry; an edge reconnecting after an outage
927 serves nothing from disk until its cursor is current; a revoked key,
928 and a public object moved to `protected/`, both stop being served by
929 a disconnected edge within fifteen minutes; an edge outside the
930 allowed regions refuses to fill; a late commit from a worker whose
931 lease expired with no successor is refused by the publish door, and
932 the request that caused a stalled synchronous transform receives no
933 bytes; an app named `media-<x>` created before enable keeps working
934 and enable for `<x>` is refused with the right error; an instance
935 host `media-<x>` taken by another app before enable, and one
936 attempted after, are each refused in the right direction; an app
937 name ending in a hyphen is refused enable. Then: a public hit served from
938 a second region's cache, one object reached both by the app's check
939 and by a capability, an HLS playlist and its segments loading under
940 one capability, and a 2 GB MP4 seeking in a browser through a
941 `t4g.small` without the box paging.
942- **Stage 2**: the spike's numbers, then 9,999 distinct transform URLs
943 against a public object producing zero decodes, and one tenant's
944 burst leaving another's thumbnails under a second.
945- **Stage 3**: a 1080p H.264 phone recording of two minutes uploaded
946 through Docs and playing in Safari as a single HLS rendition within
947 thirty seconds, with serving latency on the same box flat; a WebM
948 browser recording of the same length playable within three minutes; a
949 job for a non-Ireland bucket refused at the door; the ladder measured,
950 not promised.
951- **Stage 4a**: a Meet call between two symmetric-NAT networks connecting
952 through the Ireland relay with a credential that expires in four
953 hours, and both browsers forced to relay connecting through it; a
954 credential minted for one app refused for another; a credential
955 minted before a disable refused after re-enable, and the allocation
956 it held ended; a relay with an authority record older than fifteen
957 minutes refusing a new allocation and ending one already carrying
958 traffic; a relay permission to a private
959 address refused; the two meters moving in the relay's metrics object;
960 a network that blocks 3478 failing the way the docs say it will. The
961 full gate is the stage 4a spec's §7.
962- **Stage 4b**: a token minted for one app refused for another app's
963 room; a participant removed by the app dropped within a second and
964 unable to rejoin with its old token or a LiveKit-refreshed one, with
965 the webhook delayed and then lost; a browser on an app served from a
966 tunnel-backed host joining an SFU room; a call completing with all
967 third-party network access blocked from the SFU box; an eight-person
968 room's egress appearing on the account's usage line.
969
970## 10. Findings not accepted
971
972- **Codex 12, the part asking for a doctrine amendment** to allow
973 team-selected external relays. Not requested; the roadmap instead
974 corrects Meet's interim plan to what §8 already allows, now including
975 artefact disclosure.
976- **Codex 15, TURN over TLS on 443.** Acknowledged as a real gap and
977 documented as such; a second address per box is deferred until a
978 tenant reports a 443-only network, because it changes the fleet's
979 networking for every box to serve a case nobody has hit yet.
980
981Every finding from revisions 2 to 5 was accepted.
982
983## 11. Amendments to existing specs
984
985- `2026-08-10-object-storage-primitive-design.md:27`: reads may cross the
986 data plane through `kind:media`; writes never do.
987- `2026-08-03-app-capabilities-design.md:931`: UDP remains a non-goal
988 for apps; the platform's own relay and SFU units are the exception and
989 are specified in stage 4.
990- `2026-08-11-unit-economics-design.md`: a media section, at stage 1
991 for served bytes, at 4a for the two relay meters (bytes forwarded in
992 each direction), at stage 5 for the inter-region meter and the usage
993 line, at 4b for SFU egress.
994- `infra/modules/region-host/main.tf:6` header: residency by
995 construction now has a named exception, the per-region media bucket
996 policy, and the exception is itself region-scoped.
997- The console's name-claim gate (`internal/console/server.go:2904`) and
998 the instances door (`internal/console/apiinstances.go:571`): new app
999 names, renames and instance hosts consult the media-host claim table.
1000 The `release.ValidApp` grammar is unchanged. **(codex R3-4, R4-4)**
1001