carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Edge asset cache: versioned build assets served from every edge
2
3Branch `edge-asset-cache`. Status: rev 9, 2026-10-24, builder check folded in; rulings R1 to R3 (§13). Approved by Paul 2026-10-24; Astra accepted rev 6 in round 4; rev 8 accepted in round 6; rev 9 narrows one admission rule, accepted with the plans (plan review round 8).
4Related: media roadmap (`2026-09-07-media-platform-roadmap-design.md`),
5which this does not amend; #368 (the shared cache stores 206 responses).
6
7## 0. Intent
8
9An app's build produces images, JavaScript and stylesheets. Most of them
10carry a content hash in the name (`app.3f9c2a.js`), so the bytes behind a
11name never change. Paul wants an app to be able to say "these paths are
12build assets" and have whichever edge a visitor reaches serve them from its
13own disk, for as long as the app says they are good for, without asking the
14app (Paul, 2026-10-24). Three reasons, all of them goals:
15
161. **Distance.** A visitor far from the app's home region waits a
17 cross-region round trip for every stylesheet and script on every
18 uncached load.
192. **Wakes.** A hibernating instance is woken to serve a stylesheet.
203. **Home load.** The home box serves every asset byte for every region.
21
22This is not stage 1 of the media roadmap. Stage 1 serves objects an app
23writes into its store bucket at runtime and treats them as untrusted user
24content (three doors, capabilities, bucket grants, distributed purge with a
25serving lease). Build assets are public, come from the app's own route, and
26are immutable by construction. They need almost none of that machinery, and
27waiting for it would delay all three goals. The media roadmap's stage 1 is
28untouched and still next in that line; §11 notes what it can reuse from here.
29(My call, playback approved by Paul 2026-10-24.)
30
31## 1. Where things stand on `main` (fef28dc5)
32
33- **Visitors already reach their nearest edge on the flagship.** The
34 `*.<apps domain>` wildcard and `edge.<apps domain>` (custom domain CNAME
35 target) answer by latency across the four regions; the steering converger
36 is armed there (flagship infra `docs/latency-routing-oncarlos.md:29-38`;
37 the code comments in `internal/steering/steering.go:14` saying it is armed
38 nowhere are stale). So the geographic half of this needs no DNS work.
39- **A far edge forwards everything for a home-boxed app and caches
40 nothing.** An instance route row exists only on its home box
41 (`internal/provision/reconcile.go:401-414`). Any other edge misses
42 `lookupUpstreamPath` and calls `e.backhaul` (`internal/edge/edge.go`, the
43 lookup-miss branch), which runs before the meter, gzip and cache. The
44 comment there says why the far edge must not cache: purge is honoured only
45 by the home edge, so a far copy "could never be invalidated by the app
46 that owns it".
47- **The shared response cache is not built for assets.** It is one
48 process-wide in-memory LRU, 4096 entries, 1 MiB per body
49 (`edge.go:61-69`), lifetime clamped to five minutes
50 (`DefaultCacheMaxTTL`, `edge.go:77`) because a lost purge must be a blip.
51 It has no per-tenant share. Many bundles exceed 1 MiB and are never
52 stored. It ignores `Range` (#368) and conditional requests.
53- **Far edges already learn per-host facts cheaply.** Every box publishes
54 `control/edges/<box>.json` (`internal/host/edgesfile.go:44-91`, struct
55 `edgeHost`: role, platform, alias, replica, wildcard, account, app,
56 stamp), rewritten within about 2 s of a registry change and read by every
57 edge on a 30 s poll gated by the change index's `control/edges` scope
58 (`internal/edge/homemap.go`). An additive field there reaches every edge
59 with no new polling. The change index exists because per-app reads on
60 every box cost about $3 per app per month (`2026-10-21-s3-change-index-design.md`
61 §0); this design adds none.
62- **Declarations that change with the code live in the release
63 manifest.** `Capabilities` (`internal/release/release.go:95-107`) is the
64 precedent: it rides the signed pointer, immutable manifest, sha256 chain,
65 and a rollback rolls it back. `carlos ship --capabilities <file>` embeds it
66 (`cli.go:2167`).
67- **Disk is tight on small boxes.** US and AU flagship boxes are
68 `t4g.small` with a 20 GiB root and no separate data volume. The box disk
69 alert fires above 90% (`internal/metrics/alert.go`); placement wants 10%
70 free (`internal/boxchoice/policy.go:16-22`). The only edge disk cache
71 today, `/data/static`, is unbounded.
72- **Static routes are already served everywhere** (`everywhereClass`,
73 `reconcile.go:171-174`). They are out of scope here.
74
75## 2. Goals, non-goals, anti-goals
76
77**Goals**
78
79- G1. An app declares path prefixes as build assets once, at ship or
80 deploy time, and a saved project default keeps declaring them.
81- G2. Any edge that receives a request under a declared prefix serves it
82 from its own disk after the first fill, with no backhaul and no request
83 to the app.
84- G3. Lifetimes are the app's `Cache-Control`, up to a year, so hashed
85 assets marked `immutable` stay cached until evicted or purged.
86- G4. A hit never wakes a hibernating instance, on any edge including home.
87- G5. A mistake is recoverable: one command drops an app's cached assets on
88 every edge within about a minute.
89- G6. Bounded disk on every box, with a fair share per app.
90- G7. Hits and fills are counted as the app's usage like any other
91 response.
92
93**Non-goals**
94
95- Serving the store bucket (media stage 1), transforms, video streaming as
96 a target (ranges work, §5.5, but nothing is tuned for media).
97- Caching HTML or anything personalised.
98- Pushing assets to edges at deploy time. Pull-through only.
99- Content negotiation beyond gzip (no `Vary: Accept` image formats, no
100 brotli).
101- A third-party CDN or CloudFront. The 2026-08-02 edge cache spec §9
102 rejected that as the default and this keeps the ruling.
103
104**Anti-goals** (failure even if everything else works)
105
106- A logged-in response cached and served to someone else.
107- A far edge serving one app's bytes under a hostname that now belongs to
108 another app or account.
109- A cache filling a 20 GiB box's disk.
110- New per-app reads of the deployment bucket on every box.
111- An SPA fallback (`200 index.html` for an unknown path) cached as a
112 script forever.
113
114## 3. The declaration
115
116### 3.1 In the release manifest
117
118The release manifest gains an additive `assets` block:
119
120 "assets": {"paths": ["/assets/", "/_app/immutable/"]}
121
122`carlos ship --assets /assets/ --assets /_app/immutable/` writes it
123(repeatable flag). Grammar, refused at ship with an error naming the rule:
124
125- each entry starts and ends with `/`, is at most 128 bytes, is already
126 `path.Clean`'d (apart from the trailing slash), and contains no `%`, `?`,
127 `#`, `*` or `..`;
128- `/` alone is refused: it would catch the app's pages and strip their
129 cookies (§5.2);
130- at most 8 entries, no entry a prefix of another.
131
132Old binaries ignore the key (`encoding/json` drops unknown top-level keys
133on the manifest read path, `release.go:95-100`). `kind:static` releases
134refuse the flag; they are already everywhere.
135
136**`carlos deploy` declares the same way** (Paul, 2026-10-24). Deploy is
137ship, promote and watch in one verb, and its ship half is `runShipAPI`
138(`deploy.go:1-6`, `:334`), which already carries `--capabilities`. So:
139
140- `carlos deploy --assets /assets/` takes the same repeatable flag with
141 the same grammar and passes it to the ship half.
142- The project-defaults block in `.carlos/config` (`config.go:58-65`:
143 `app`, `kind`, `artifact`, `target`) gains `"assets": ["/assets/"]`, so
144 a zero-arg `carlos deploy` keeps declaring on every release. A flag on
145 the command line replaces the saved list for that run; `--no-assets`
146 ships with none. Without either, a release declares nothing, and since
147 paths come from the serving release (§4.1), deploying a build without
148 them turns catching off for it. That is why the saved default matters.
149- The console's ship door (API mode) puts the block in the manifest it
150 writes and checks the same grammar itself. It does not trust the CLI.
151- Deploy's watch probes the app's own URL for `X-Carlos-Version`. `/` can
152 never be declared, so the watch never lands on a caught path and a hit
153 can never answer it with a stale version (§5.5).
154- `--kind static` refuses `--assets` and ignores a saved list, saying so
155 once.
156
157Why the release manifest and not the instance record: the paths an app
158builds assets under change with its build tooling, and a rollback to a
159build that used `/static/` must catch `/static/`, not the newer build's
160prefix. (My call.)
161
162### 3.2 Instance opt-out
163
164The instance record gains `Assets *bool` (`omitempty`; absent means
165"follow the release", `false` means "catch nothing for this instance").
166Set through the existing PATCH door (`apiinstances.go`, the PATCH body
167beside `gate` and `steering`) and a new verb, `carlos instances
168set-assets <host> on|off`, beside `set-upstreams` and `set-channel`
169(`carlos instances update` is already "install the newest build now",
170`instances_updates.go:201`). It reaches the route row through `RouteFor`
171and `convergeOwned` like `Steering`. Use: an instance whose traffic
172must always reach the app, or a quick off switch while debugging.
173(R1.)
174
175### 3.3 When a route catches nothing
176
177The home box publishes no prefixes for a hostname when any of these hold:
178
179- the instance opt-out is `false`;
180- the route has a gate (`Route.Gate`): a password-gated preview must not
181 leak its assets to anyone who guesses a URL;
182- the route, or the row the hostname resolves to, has an import gate
183 (`ImportGate`, `internal/edge/importgate.go:24-35`), which refuses
184 requests even when `Route.Gate` is empty; a warm far cache must not
185 answer ahead of it **(astra B9)**;
186- the hostname has any path-prefix row (`route_paths`) at all, even when
187 every target belongs to the same app: different instances behind one
188 hostname can serve different releases, gates and opt-outs, and one
189 hostname-wide declaration cannot describe them. The hostname must also
190 have a single owner by the rule that decides `edgeHost.Account`/`App`
191 (`edgesfile.go:77-85`, fold at `:93-116`) **(astra B2)**;
192- the route is pooled (`Route.Pool != ""`): pooled routes are a tenancy
193 boundary only their pool serves, the same reason steering refuses them
194 (`apiinstances.go:2112-2116`). A pooled route still uses the local disk
195 cache on the boxes that serve it (§5.1), but publishes nothing for far
196 edges.
197
198## 4. Getting it to every edge
199
200### 4.1 The home box publishes
201
202When a box writes its edges file, each `edgeHost` it emits for a hostname
203that catches gains:
204
205 "assets": {"paths": ["/assets/"], "gen": "9f2c41d07a3be516", "at": 1792828800}
206
207(`at` is the host's authority time, §4.2.)
208
209- **Only an armed box publishes.** A box without `CARLOS_ASSET_CACHE=1`
210 emits no `assets` block, so far edges catch a host only when its home
211 is armed and will treat their fills as fills (§5.3). This makes the
212 arming order home-first by construction **(astra B3)**.
213- `paths` comes from the release manifests of the builds the route can
214 answer with, minus the §3.3 exclusions. For a route that is not
215 generation-managed that is the release `Route.Version` names. For a
216 generation-managed route `Route.Version` names only what is installed,
217 not what answers (`internal/edge/edge.go:842-845`; the served build comes
218 from the generation, `internal/edge/genproxy.go:298-302`), so the home
219 publishes the **union** of the installed build's declared paths and the
220 current serving generation's (`ServingGen(host).Build`,
221 `internal/activator/genservice.go:68`) **(astra B5)**. The activator
222 keeps one current generation per host (`actEntry.gen`,
223 `internal/activator/activator.go:469`); a generation being replaced only
224 finishes requests already sent to it, so no third build can answer a new
225 request. The
226 installed build is in the union so that a sleeping instance, which has no
227 live generation (`ServingGen` is false while asleep,
228 `internal/edge/gensource.go:24-28`), keeps catching: the installed build
229 is the one a wake would start, and its assets are what a page served
230 before the sleep asks for **(astra R1-5)**. During an update both builds'
231 assets are caught, and the old build's prefix drops out when its
232 generation retires. The home reads each build's manifest from the
233 bucket with `release.GetManifest` (`internal/release/release.go:710`)
234 once per version and keeps only the declared paths, since a manifest is
235 immutable. It keeps them only for builds a local route currently
236 references, as its installed version or its serving build; each
237 edges-file pass drops the rest, and a rollback to a dropped build reads
238 its manifest again. Retained state is therefore bounded by twice the
239 box's routes, whatever the deploy history **(astra R5-1)**.
240 It does not use the staged copy under `/opt/carlos/versions`
241 (`release.ReadStagedManifest`, `versions.go:261`), which an older binary
242 may have re-marshalled without the `assets` key. A manifest that cannot
243 be read publishes no paths for that build until it can. No other box
244 reads manifests. The edges-file writer gets the serving build through a
245 narrow function the agent hands it, since it has no activator handle
246 today.
247- `gen` is the instance's asset generation (§6), copied from the instance
248 record through the route row.
249- Aliases (custom domains, and the exact names instance catch-alls
250 derive) publish the same block as their primary, with their `at`
251 computed by the same invariant (§4.2).
252- Additive and `omitempty`: a file without it parses byte-identically, and
253 an older reader ignores it.
254
255A change to `paths` or `gen` must change the registry `Fingerprint` (or
256the edges file's own dirty check) so the file is rewritten within seconds,
257not at the five-minute refresh. So must an advance of any published `at`
258by 20 minutes or more since the last write (§4.2).
259
260### 4.2 Far edges read it
261
262The edge's home map (`homemap.go`) already folds every box's file into
263`host -> home`. It now also keeps `host -> (account, app, paths, gen,
264assets_at)` for hosts whose winning entry carries `assets`. No new object
265is read; the 30 s poll and the change-index gate are unchanged.
266
267**Freshness bound.** A successful read is not evidence of freshness: a
268stopped publisher's last file stays readable forever, and every poll
269would renew a read-age clock (`homemap.go:440-455`;
270`internal/host/edgesfile.go` has no write timestamp today)
271**(astra B8)**. Nor is a successful write: a publisher whose record reads
272are failing still writes its file from its local registry rows
273(`edgesfile.go:420`), so a write-time stamp would keep advertising an
274obsolete generation as fresh **(astra R1-3)**.
275
276So `at` is the host's **authority time**: the last moment the home box
277knew the records behind that host were current and applied. The box keeps
278a per-app **records confirmed time**: the start time of the last
279reconcile pass (`provision.Reconcile`, run by
280`reconcileSerializedWith`, `internal/host/agent.go:1604`) that read the
281app's records completely and applied them to the registry without error.
282Reconcile today returns one error for the whole pass, so it must also
283report which app scopes it read completely and applied. Where those reads
284come from depends on how the box is configured:
285
286- with the change-index record view armed (`CARLOS_CHANGE_INDEX=read`,
287 `internal/host/changeindex.go:57-78`), a scope counts as read
288 completely only when it is applied and not pending in the snapshot the
289 pass used. A refresh that returns nil while a scope stays pending
290 (`internal/provision/recordview.go:164-175`) does not count;
291- without it (`armedRecordView` nil, the bucket read directly), a scope
292 counts only when its listing and every record body under it were read
293 without error.
294
295The pass's **start** time is the one recorded, because a record written
296during the pass may not have been seen. Publishing never advances it.
297
298**The invariant.** A host's `at` may only be a time at which *every*
299input the catch decision for that host reads was known complete and
300applied on the publishing box. Rounds 1 to 3 of review each found one
301more input left out (records, then aliases, then catch-all aliases and
302path rows), so the spec names the inputs instead of the cases
303**(astra R2-1, R3-1, R3-2)**. The catch decision (§3.3, §4.1) reads:
304
3051. **The app's records** (the instance record, its route row, gate,
306 import gate, opt-out, generation): bounded by the app's records
307 confirmed time above.
3082. **Domain claims**, which decide whether a hostname has path rows,
309 which aliases exist, and which aliases instance catch-alls derive
310 (`internal/domains/aliases.go`, `ReconcileAliases`): bounded by the
311 box's **domain confirmed time**, the completion time of the last
312 `ReconcileAliases` pass that was *complete* (again its start time).
313 Today the pass returns an error only when the claim listing fails.
314 Everything else is logged and the pass returns nil: the label count for
315 a catch-all (`aliases.go:162-171`), instance alias upsert (`:204`),
316 alias upsert (`:310`), catch-all upsert (`:531`), alias delete
317 (`:541`), wildcard upsert and delete (`:561`, `:568`), path upsert
318 (`:584`) and path delete (`:590`). Two inputs fail silently too: claim
319 bodies the claim cache could not read are dropped
320 (`internal/domains/claimcache.go:137-145`), and a failed
321 `edgesfile.DeploymentRoutes` read comes back nil and is treated as
322 "allow". So `ReconcileAliases` must also report completeness, and
323 **any** of those makes the pass incomplete. An incomplete pass does not
324 advance the domain confirmed time. Existing callers keep today's error
325 behaviour; `TestInstanceCatchallKeepsNamesWhenTheListingFails`
326 (`instancecatchall_test.go:507`) still sees nil and now also sees
327 "incomplete".
3283. **Release manifests**: immutable once read, so they add no bound.
329
330Every published host, primary or alias, gets `at` = the older of (1) for
331its app's scope and (2) for the box. This applies to primary hostnames
332as much as aliases, because proving a hostname has **no** path row is a
333domain read too (R3-2). A box whose domain reads fail for an hour stops
334catching every host it serves, on every edge. Any new input a future
335change adds to the catch decision must bring its own completeness report
336into this minimum, and the plan states this as a test (§12): a table of
337inputs, each failed in turn for over an hour with every other input
338healthy, must stop catching on home and far edges.
339
340- A far edge serves and fills a host's caught paths only while that
341 host's `at` is within the last hour by the reader's clock. Past that it
342 stops admitting hits and fills and forwards as today.
343- A box serves and fills its **own** routes' caught paths under the same
344 rule, against its own confirmed time, so a home cut off from the bucket
345 stops serving entries a purge it cannot see may have retired.
346- After a restart the confirmed time starts empty, so nothing is caught
347 until the first complete convergence.
348
349Cost: the file is rewritten when an `at` advances by 20 minutes or more,
350about three extra PUTs per publishing box per hour and the
351`control/edges` bumps they cause, which every edge already reads.
352
353Immutable assets would be safe to serve longer, but a purge (§6) or an
354owner change that never arrived must not live forever on a disconnected
355box. One hour and twenty minutes are my calls; they are constants, not
356knobs.
357
358## 5. Serving
359
360### 5.1 Where the asset cache sits
361
362A new disk cache, separate from the shared LRU in both directions: caught
363requests never read or fill the LRU, and nothing else reads or fills the
364asset cache.
365
366In `ServeHTTP` the caught-path check runs:
367
368- **on a box with a local route row** (the home box, a replica, a pool
369 member): after `lookupUpstreamPath` and the gate, before the shared-cache
370 lookup and before the activator. The catch is decided from the local
371 route's own published state (§3.3, §4.1), not from the home map.
372- **on a box without one**: in the lookup-miss branch, before `e.backhaul`,
373 using the home map's entry (§4.2). A miss fills by backhaul (§5.3).
374
375A request is caught when all hold: method `GET` or `HEAD`; the request
376path is **already canonical**, meaning `r.URL.RawPath` is empty (no
377percent-encoding the decoder had to preserve) and the path equals its own
378`path.Clean` with any trailing slash restored; it starts with a published
379prefix; the box is armed (§10). A non-canonical path under a prefix
380(`/assets//app.js`, `/assets/./app.js`, `/assets/%61pp.js`) is forwarded
381as today and answered `Carlos-Cache: bypass`. Neither the key nor the
382upstream request is ever normalised: `lookupUpstreamPath` matches cleaned
383and raw first segments but forwards the original path
384(`internal/edge/edge.go:1304-1323`), so a cleaned key could be filled with
385the answer to a different URL **(astra B4, B7)**. Anything else under the
386prefix (`POST`, an upgrade) is forwarded exactly as today, uncached and
387with its headers intact.
388
389### 5.2 What a caught request sends upstream
390
391Fills send an allow-listed request: method `GET`, the path and raw query,
392`Host`, `User-Agent`, `Accept`, `Accept-Encoding: identity`, plus the hop
393headers when it is a backhaul. **No `Cookie`, no `Authorization`, no other
394client header.** That is the public contract a declared prefix makes, and
395it is what makes the anti-goal "a logged-in response served to someone
396else" structural rather than a hope about the app's headers. A `HEAD`
397fills with `GET`. The docs page says it in one sentence: files under an
398asset path are fetched without the visitor's cookies.
399
400Range, `If-None-Match` and `If-Modified-Since` are answered by the edge
401(§5.5) and never forwarded on a fill.
402
403### 5.3 Fill, admission and coalescing
404
405Fills are bounded per box (16 concurrent) and per app (4); over either
406bound the request is forwarded as today, uncached, rather than queued.
407Fills from a far edge go through `backhaulAcquire` like any hop.
408
409**Every fill has a deadline, and so does every waiter** **(astra R3-3)**.
410Ordinary backhaul deliberately has no body deadline (`edge.go:2837`), so a
411fill must bring its own: 60 seconds from start to the last body byte. A
412fill that misses it is abandoned for caching; the initiating client keeps
413receiving the bytes as an ordinary response would, but nothing is stored
414and the reservation (§7) is released at the moment of abandonment, not
415when the stream ends. At most 32 requests wait on one fill, and each waits
416at most 10 seconds; a request over the cap or past its wait is forwarded
417as today, which puts it back under `backhaulAcquire`'s per-home limit
418(`edge.go:2903`) instead of beside it.
419
420**Coalescing shares only an admitted entry** **(astra R2-5)**. Concurrent
421requests for one key wait on a single fill, but what they wait for is the
422entry, not the response. If the fill produces an entry, each waiter is
423then answered from it as a hit, with its own conditional headers, `Range`
424and encoding applied separately (§5.5). If it does not (refused at
425admission, no acknowledgement, an error), only the requester that started
426the fill gets that response, and every other waiter makes its own
427request, forwarded as today and subject to the same fill bounds. A refused
428response, which may carry `Set-Cookie` or vary on `Accept`, therefore
429never reaches anyone but the client it was made for, and every client
430delivery is a request somebody metered.
431
432**A far edge's fill is a signed request, answered with a signed
433acknowledgement.** Ordinary backhaul is not enough: the home meters every
434hop and consults its shared LRU (`edge.go:1678`, `:1768`), so a plain hop
435would count the bytes twice and could refill a far edge from a shared-LRU
436entry that predates a purge **(astra B6)**. A bare marker header is not
437enough either: an old far edge forwards any client header and then signs
438the hop, and the hop MAC covers only the host and a timestamp
439(`internal/edge/mesh.go:178-190`), so a client could have a new home skip
440metering through an old edge **(astra R1-1)**. And the fill must name the
441generation it is filling for, or a home that has not yet reloaded a purge
442could answer from its old generation and the far edge would store those
443bytes under the new one **(astra R1-2)**.
444
445- **Assertion.** The far edge sends `X-Carlos-Asset-Fill: <ts>:<mac>`,
446 where `mac` is HMAC-SHA256 under the mesh key (the same key, current or
447 next, that `signHop` uses) over the domain `carlos-asset-fill-v1`, the
448 host, the path and raw query, the account, the app, the generation and
449 `ts`, each newline-separated, with the hop's `±hopSkew` window. The
450 domain separator means no hop signature is ever a valid assertion.
451- **Home.** The home treats a request as a fill only when the hop is
452 verified, the assertion verifies, the home is armed, and its own local
453 route for the host has that account, app and generation **current** and
454 catches that path under §4.2's freshness rule. It then answers from its
455 own asset cache under that key, or fills it from the app (§5.2), never
456 reads or writes its shared LRU for the request and never meters it. It
457 first deletes any `X-Carlos-Asset-Fill-Ack` and
458 `X-Carlos-Asset-Expires` the app's response carries, then sets its own
459 on the response headers:
460 - `X-Carlos-Asset-Expires` is the entry's absolute expiry (§5.4) when
461 the response is storable by the header-time rules below, and `0` when
462 it is not (no lifetime, `no-store`, non-200, a disallowed `Vary`, an
463 undeclaring build, and so on);
464 - `X-Carlos-Asset-Fill-Ack` is HMAC-SHA256 under the mesh key over
465 `carlos-asset-fill-ack-v1`, the request's assertion and the
466 `X-Carlos-Asset-Expires` value.
467
468 The acknowledgement means "the home handled this as a fill and did not
469 meter it", whatever the expiry says; storability is the expiry's job
470 **(astra R2-3)**.
471- **Anything else is an ordinary hop**: a missing or invalid assertion, an
472 unarmed or old home, a generation or owner mismatch, a host past its
473 freshness bound. The home meters it as today and sends no
474 acknowledgement.
475- **Far edge.** It meters every response that carries a valid
476 acknowledgement of its own assertion, storable or not, and its own hits.
477 It stores an acknowledged response only when `X-Carlos-Asset-Expires` is
478 non-zero and its own admission checks (body size, reservation) pass. A
479 response without a valid acknowledgement is served to the requester
480 unmodified, answered `Carlos-Cache: bypass`, not stored and not metered
481 at the far edge, because the home metered it. So every response is
482 counted exactly once, a disarmed or lagging home can never populate a
483 far cache, and the mixed-fleet window after a home is disarmed only
484 costs hits.
485- The assertion and acknowledgement headers are stripped from every
486 inbound non-hop request and from every response a client receives.
487
488A response is stored only when all hold:
489
490- status `200` (not 206, not any other 2xx);
491- no `Set-Cookie` header or trailer;
492- `Cache-Control` present, not `private`, `no-store` or `no-cache`, with a
493 positive `s-maxage` or `max-age`. Lifetime is `s-maxage`, else
494 `max-age`, capped at 365 days. There is no five-minute clamp: a declared
495 prefix plus an explicit lifetime is the app saying the bytes hold;
496- the response is still fresh on arrival. On a fill from the app the edge
497 computes the entry's absolute expiry as the **response time** plus the
498 lifetime minus the corrected initial age (RFC 9111 §4.2.3: the corrected
499 initial age already includes the response delay, so the reference point
500 is when the response arrived, not when the request left)
501 **(astra R2-4)**. On an acknowledged far fill
502 the expiry is the home's `X-Carlos-Asset-Expires`, never recomputed, so
503 a copy of a nearly expired home entry expires when the home's does
504 **(astra R1-6)**. An already expired response is served and not stored;
505- `Vary` absent, or exactly `Accept-Encoding`; anything else (`Origin`,
506 `Cookie`, `*`, `Accept`) is refused;
507- no `Content-Encoding` other than identity (the fill asked for identity);
508- the `Content-Type` is present and is not HTML (`text/html`,
509 `application/xhtml+xml`). A response with no `Content-Type` is not
510 stored either: sniffing needs the body, and the home must decide
511 storability when the headers arrive (§5.3, `X-Carlos-Asset-Expires`). Build assets are
512 never pages, and an SPA fallback can carry the asset path's long
513 `Cache-Control` as easily as `no-cache`, so the lifetime alone cannot be
514 what refuses it **(astra R3-4)**;
515- body at most 32 MiB, measured while streaming; a larger body is streamed
516 to the client and discarded;
517- **the build that answered declared the prefix** **(astra R2-2)**. The
518 §4.1 union decides what is *caught* and which existing entries may be
519 *served*, but during an update the proxy picks the serving generation on
520 its own (`internal/edge/genproxy.go:255`), so an older build that never
521 declared `/bundles/` could answer a request for it with a cacheable
522 fallback, and with no version in the key that entry would outlive the
523 update. On a generation-managed route the home knows which generation
524 answered (`genproxy.go:298-302`, `noteServedGen`) and stores only when
525 that generation's build declared the matching prefix; on any other route
526 the answering build is `Route.Version`'s. A far fill inherits this
527 through the home's `X-Carlos-Asset-Expires: 0`.
528
529A refused response is still served to the requester, unmodified, so a
530caught prefix that the app does not mark cacheable behaves exactly as an
531uncaught one. That is what defuses the SPA fallback: `index.html` served
532with `no-cache` for an unknown asset name is never stored. Non-200 is never
533stored either, so a request racing a deploy that 404s does not poison the
534name.
535
536### 5.4 Cache key and entry
537
538Key: `(account, app, gen, request host, path, raw query)`, the path being
539canonical by §5.1. Account
540and app come from the published owner, so a hostname that changes hands
541can never serve the previous owner's entries; `gen` makes a purge a key
542change (§6). The release version is not in the key: an unchanged hashed
543file stays warm across deploys, which is the point.
544
545An entry holds the identity body, its absolute expiry and lifetime, a
546gzip copy when the response is compressible by the existing rule
547(`compressible`, `internal/edge/compress.go:100-118`: not already encoded,
548no `Content-Range`, no `Cache-Control: no-transform`, at least
549`minCompressSize`, a compressible type), made once at fill, and **every
550end-to-end response header** except: hop-by-hop headers (`Connection` and
551those it names, `Keep-Alive`, `Transfer-Encoding`, `Upgrade`, `TE`,
552`Trailer`, `Proxy-*`), `Date` (regenerated), `Age` (recomputed),
553`Content-Length` (recomputed per representation), the purge headers and
554platform `X-Carlos-*` headers. So `Content-Security-Policy`,
555`Cross-Origin-Resource-Policy` and anything else the app sets for safety
556reach a hit exactly as they reached the miss, the way the shared cache
557already keeps and replays response headers (`edge.go:1907`, `:2121`)
558**(astra R1-4)**. `Access-Control-Allow-Origin` with any value other than
559`*` is still refused at admission, because it would need `Vary: Origin`.
560When the app sent no `ETag`, the edge sets a strong one from the body's
561sha256, identical on every edge.
562
563### 5.5 Answering from an entry
564
565- `If-None-Match` / `If-Modified-Since` answered with 304.
566- One `Range` answered with 206 from the stored identity body; multiple
567 ranges answered with the whole body; `If-Range` against the identity
568 `ETag` (a weak validator never matches, so a client holding the gzip
569 `ETag` gets the whole body); 416 when unsatisfiable.
570 `http.ServeContent` semantics over the file are acceptable.
571- gzip copy when the entry has one, `Accept-Encoding` allows it and no
572 `Range` is present. The two representations are kept apart the way
573 `compress.go:230-245` already does: every response from an entry that
574 has a gzip copy carries `Vary: Accept-Encoding`, whichever encoding it
575 is, and the gzip copy's `ETag` is the identity `ETag` weakened
576 (`weakenETag`), so a downstream cache cannot hand gzip to a client that
577 did not ask for it and a range is never validated against compressed
578 bytes **(astra R1-7)**. Caught responses bypass the edge's gzip wrapper;
579 the asset path writes the chosen representation itself.
580- Added on every caught response, hit or not: `Carlos-Cache: hit`, `miss`
581 or `bypass` (the last for anything forwarded uncached). A hit carries
582 no `X-Carlos-Version`: an entry can outlive many deploys, so a version on
583 it would be a false "verified" for the deploy watch (`edge.go:813-847`
584 explains the header's freshness contract).
585- `Age` on hits: the lifetime minus the time left to the entry's expiry.
586
587An entry past its lifetime is not revalidated in v1; it is dropped and
588refilled. (Revalidation with `If-None-Match` upstream is a later
589optimisation.)
590
591## 6. Purge: the asset generation
592
593`carlos assets purge <app>` (and a console door,
594`POST /a/<sqid>/apps/<app>/assets/purge`) draws a fresh random 64-bit
595token, the asset generation, for every instance record of the app. A new
596instance record draws its own token at create. A random token rather than
597a counter means a hostname deleted and recreated can never land back on a
598generation an edge still holds entries for, and needs no app-level state
599that outlives its instances **(astra B1)**. Each record is written
600separately; the door and the CLI report per instance which writes
601succeeded and retry the rest, and a partly applied purge says so rather
602than reporting done. The token flows record -> `convergeOwned` -> route
603row -> edges file -> every edge's home map, and every key under the old
604token stops matching at once. Old entries are garbage, reclaimed by
605eviction and by a sweep that deletes entries whose token is not the host's
606current one.
607
608`Carlos-Purge` from the app continues to act on the shared LRU only, on
609the box that saw it. It does not touch the asset cache: a per-path purge
610that reaches one edge of four is the trap the shared cache's five-minute
611clamp exists to bound, and this cache has no clamp. Apps fix a mistake
612with the generation.
613
614`carlos assets status <app>` prints, per instance: the declared paths of
615the builds being served, the opt-out, the generation in the record, and
616the generation and `assets_at` its home has published in its edges file
617(the console reads those files already). A purge is complete at the home
618when the published generation matches the record. Far edges do not report
619back; the status states the bound plainly: every edge that can read the
620bucket picks the new generation up within a minute, and one that cannot
621stops serving the host's cached assets within an hour (§4.2).
622
623Expected latency: under a minute (edges-file rewrite within about 2 s,
62430 s read poll, plus record convergence on the home box).
625
626## 7. Disk budget and fairness
627
628- Location: `$CARLOS_DATA_DIR/asset-cache/`. Entries are files named by a
629 hash of the key; an index is rebuilt by scanning at start.
630- Budget: `CARLOS_ASSET_CACHE_MAX` bytes; default the smaller of 4 GiB and
631 10% of the data filesystem's size. A 20 GiB box gets 2 GiB.
632- **Reservations.** Checking free space when a fill starts is not enough:
633 sixteen concurrent maximum-size fills on an 8 GiB filesystem at 15% free
634 would take it below the 10% placement floor
635 (`internal/boxchoice/policy.go:20`) **(astra R1-8)**. So before a fill
636 starts it reserves its worst case: `Content-Length` if the response
637 will declare one, else the 32 MiB cap, doubled for the gzip copy (which
638 is abandoned if it would exceed the identity body), plus 64 KiB for
639 metadata. A fill is admitted only when the cache's used bytes plus all
640 outstanding reservations plus this one fit the budget (evicting first if
641 they do not), and the filesystem's free space minus all outstanding
642 reservations minus this one stays at or above 15% of its size. A write
643 past the reservation aborts the fill. The reservation is released on
644 every exit, success or failure. Temporary files live in the cache
645 directory and are deleted at start.
646- Fair share: each app with entries on the box has a share of the budget
647 (budget divided by the number of such apps, but never more than 25% of
648 the budget for one app). Eviction takes the least recently used entry of
649 whichever app is furthest over its share, so one app walking unique query
650 strings evicts itself first.
651- **After a restart** the edge serves no entry until the home map has
652 completed one read (for far hosts) or the box's record view has a
653 confirmed time for the route's scope (for local routes, §4.2), so an
654 entry is never served against a generation or owner it has not
655 checked.
656- Memory: the index only. The kernel page cache does the rest.
657
658## 8. Usage
659
660Every caught response, hit or fill, is counted as the app's request and
661bytes out, attributed by the published account and app, by whichever edge
662served it. Today the sending edge of a backhaul records nothing and the
663home records the hop (`usagemeter.go:43-45`); for caught paths the serving
664edge records instead, and a fill's backhaul is recorded once, not twice.
665Asset bytes are not free bandwidth. (R2.)
666
667## 9. Provider boundary
668
669All core. The design reads and writes nothing beyond the edges file, the
670release manifest and the instance record, all through existing store
671interfaces, and adds no cloud API call. The geographic win depends on
672latency DNS, which is the existing Route 53 steering adapter; a self-hosted
673deployment with one plain wildcard to one edge keeps G4, G6 and the home
674box's disk cache, and loses only nearest-edge entry. No AWS SDK import in
675any touched package; a test runs the full fill/hit/purge path against
676`DirStore` with no AWS configuration.
677
678## 10. Rollout
679
680- **Arming.** `CARLOS_ASSET_CACHE=1` in a box's `host.env` arms its asset
681 cache: catching, filling, serving, and publishing `assets` for the
682 routes it serves (§4.1). Off by default; an unarmed box publishes
683 nothing and forwards exactly as today. The one exception is the window
684 after a home is disarmed (§5.3), in which it treats marked fills as
685 ordinary hops.
686- **Mixed fleet.** An old or unarmed home publishes nothing, so nothing is
687 caught for its hosts anywhere. An old far edge ignores the field and
688 backhauls. Every combination is today's behaviour or the new one, never
689 a half.
690- **Order.** Release with the feature dormant; roll dev boxes, dev
691 console, check, flagship boxes, flagship console. Then arm a test app's
692 **home** dev box first (only an armed home publishes, §4.1), then one
693 dev box in another region, and run the two-edge acceptance gate (§12)
694 before arming the rest of dev **(astra R3-5)**. On flagship, arm the
695 console box (which homes most apps) first, then one small box (US or
696 AU) to watch disk, then the rest one at a time.
697- **CLI.** `carlos ship --assets`, `carlos deploy --assets` and
698 `--no-assets`, the `assets` project default, `carlos assets purge`,
699 `carlos assets status`, `carlos instances set-assets` get
700 `docs/site/cli.md`
701 headings (docsite gate). Every user-facing string goes through copy
702 review before it is written.
703- **Census.** Touching `internal/provision` and `internal/host` likely
704 needs a plan14 overlay and a #280 ruling; the plan names which units.
705
706## 11. Relation to the media roadmap
707
708Stage 1 needs a per-box disk cache with a total budget, filesystem
709headroom, per-route fair share and bounded fills (roadmap §4, "Its own
710cache"). Build §7 as a package the media handler can reuse with its own
711budget and its own index, not as code inside `edge.go`. Stage 1's
712distributed purge log and serving lease remain its own; nothing here
713weakens them or is offered as a substitute.
714
715## 12. Testing and the acceptance gate
716
717Tests go through production wiring (two edges, real edges files through a
718`DirStore`, real home map fold, real backhaul with hop auth), not fixture
719routes. Attacks first:
720
721- a fill never forwards `Cookie` or `Authorization`, asserted at the app;
722- a response with `Set-Cookie`, `Vary: Cookie`, `Vary: Origin`,
723 `Cache-Control: private`, `no-cache`, a 206, a 404, or a body over the cap
724 is served to the requester and not stored;
725- an SPA fallback for an unknown hashed name is not stored, both with
726 `no-cache` and with `Cache-Control: public, max-age=31536000`;
727- the authority table: each input of §4.2's invariant (record scope,
728 claim listing, catch-all label count, alias upsert, path delete) failed
729 alone for over an hour, with every other input and the edges-file writes
730 healthy, stops catching on home and far edges. The cases include an
731 exact claim replacing a derived catch-all alias, and a claim adding a
732 path row to a gated target under a primary hostname;
733- a fill whose app stalls its body after admissible headers is abandoned
734 at 60 seconds with its reservation released, at most 32 requests wait on
735 it, and the rest are forwarded under the backhaul limit;
736- a gated route, an import-gated route, a pooled route, an opted-out
737 instance, a hostname with any path-prefix row and a hostname split
738 between two apps publish nothing and catch nothing;
739- a non-canonical path under a prefix (`//`, `./`, percent-encoded) is
740 forwarded and answered `bypass`, and never fills a canonical key;
741- an acknowledged fill is metered once, at the far edge, and does not
742 touch the home's shared LRU; an unacknowledged one is metered once, at
743 the home, and not stored; a client-sent assertion or acknowledgement is
744 stripped at a new edge, and one forwarded by an **old** far edge to a
745 new home fails verification and is billed as an ordinary hop;
746- a fill whose assertion names a generation the home has not reloaded yet,
747 or an owner that has changed mid-fill, gets no acknowledgement and is not
748 stored; the same for a disarmed home;
749- an app response carrying its own `X-Carlos-Asset-Fill-Ack` or
750 `X-Carlos-Asset-Expires` has them removed before the home signs;
751- a nearly expired home entry filled through a far edge expires at the
752 home's deadline on both boxes; a delayed origin whose response arrives
753 after half its lifetime is stored and expires at response time plus
754 lifetime minus age;
755- an acknowledged fill whose response has no `Cache-Control`, `no-store`,
756 a 404 or an oversized body is metered once at the far edge and stored
757 nowhere;
758- during an update where only the installed build declares `/bundles/`,
759 a cacheable fallback answered by the current build is not stored on
760 home or far edges; the same when only the retiring build declares it;
761- concurrent requests for one key with different `Accept` headers, a
762 refused response carrying `Set-Cookie`, and an unacknowledged fill each
763 reach only their initiating client, and the other waiters are metered
764 separately;
765- an alias detached while the box's claim listing fails for over an hour,
766 with record reads and edges-file writes succeeding, stops being served
767 from cache on home and far edges;
768- an SVG with a restrictive `Content-Security-Policy` and
769 `Cross-Origin-Resource-Policy` carries both on a hit as on the miss;
770- identity and gzip responses from one entry both carry
771 `Vary: Accept-Encoding`, the gzip `ETag` is weak, `If-Range` with it
772 returns the whole body, and `no-transform` gets no gzip copy;
773- sixteen concurrent maximum-size fills near the headroom threshold never
774 take the filesystem below 15% free, and every exit releases its
775 reservation;
776- a generation-managed instance goes warm, sleeps and restarts, and its
777 assets are still hits on home and far edges without a wake;
778- a generation-managed route mid-update catches both builds' prefixes and
779 drops the old one when its generation retires;
780- a deleted and recreated hostname draws a new generation and serves no
781 old entry;
782- a hostname moved to another app or account serves none of the old
783 owner's entries, on a warm cache;
784- a purge makes every edge miss within one home-map poll of the rewritten
785 file; with the home's record reads failing for over an hour while its
786 edges-file writes keep succeeding, `at` stops advancing and every edge,
787 the home included, stops catching for the host; a far edge whose
788 winning entry's `at` is older than an hour forwards even though the
789 file still reads successfully;
790- an edge restarted with a warm disk serves nothing until its first read;
791- one app requesting unbounded unique query strings evicts its own entries
792 before another app's, and no fill starts under 15% free disk;
793- a hibernating route's caught hit does not wake it;
794- `/` and the other bad prefixes are refused at ship, at deploy and by
795 the console's ship door with a CLI that skipped the check;
796- a zero-arg `carlos deploy` with a saved `assets` default declares it;
797 `--assets` replaces it for the run; `--no-assets` declares none.
798
799Then, on dev: an asset requested through the far region's edge is a
800`miss` once and a `hit` after, the home box's access log shows one request
801for it, and a 5 MiB bundle is served gzip-compressed from the far edge.
802On flagship, the same from a small box with the disk figures recorded.
803
804## 13. Rulings (Paul, 2026-10-24)
805
806- **R1. Instance off switch only.** Paths come from the release; an
807 instance can switch catching off and cannot declare paths (§3.2).
808- **R2. Asset bytes count the same.** Every hit and fill is the app's
809 request and bytes out, against plan caps like any other response (§8).
810- **R3. It works with `carlos deploy`** as well as `carlos ship` (§3.1).
811
812## 14. Review log
813
814- rev 1, 2026-10-24: first draft.
815- rev 2, 2026-10-24: Astra builder check
816 (`CODEX-FINDINGS-ASSET-CACHE-BUILDER.md`), all nine items accepted and
817 verified against the tree: B1 random per-record generation; B2 no
818 path-prefix hostnames; B3 only an armed home publishes; B4 and B7
819 canonical paths only, never normalised; B5 union of live generations'
820 paths; B6 marked fills bypass the home's LRU and meter; B8 `assets_at`
821 stamp and record-view bound; B9 import gate excluded. Purge status no
822 longer claims a far-edge acknowledgement it has no channel for.
823 Rulings R1 and R2 recorded.
824- rev 3, 2026-10-24: `carlos deploy` declares too (Paul): `--assets`,
825 `--no-assets`, the `.carlos/config` default, and the console ship door's
826 own check (§3.1).
827- rev 4, 2026-10-24: Astra review round 1
828 (`CODEX-FINDINGS-ASSET-CACHE-R1.md`, reject: 4 P1, 4 P2). All eight
829 accepted after checking the citations: R1-1 and R1-2 the fill becomes a
830 domain-separated signed assertion naming owner and generation, answered
831 by a signed acknowledgement, with single metering either way; R1-3 `at`
832 is the record view's per-scope confirmed time, never the write time;
833 R1-4 every end-to-end header is kept; R1-5 the installed build is in
834 the paths union so a sleeping instance keeps catching; R1-6 absolute
835 expiry, age-corrected, carried to far copies; R1-7 representation rules
836 from `compress.go`; R1-8 worst-case disk reservations.
837- rev 5, 2026-10-24: Astra review round 2
838 (`CODEX-FINDINGS-ASSET-CACHE-R2.md`, reject: 1 P1, 4 P2; R1-3 and R1-6
839 partly fixed). All five accepted after checking the citations: R2-1
840 alias `at` also bounded by the last successful alias reconcile; R2-2
841 only a build that declared the prefix may fill it; R2-3 the
842 acknowledgement transfers billing and `X-Carlos-Asset-Expires: 0` says
843 "not storable"; R2-4 expiry is measured from response time; R2-5
844 coalescing shares only an admitted entry.
845- rev 6, 2026-10-24: Astra review round 3
846 (`CODEX-FINDINGS-ASSET-CACHE-R3.md`, reject: 2 P1, 2 P2, 1 P3; R2-1 and
847 R1-3 partly fixed). All five accepted after checking the citations. The
848 two P1s were the third round in a row to find one more input left out
849 of the freshness bound, so §4.2 now states it as an invariant over every
850 input the catch decision reads, requires `ReconcileAliases` to report
851 completeness, and §12 tests it as a table (R3-1, R3-2). R3-3 fill and
852 waiter deadlines; R3-4 HTML is never stored; R3-5 arm the home first.
853- round 4, 2026-10-24: Astra **accept** at 287a59c4, no new findings
854 (`CODEX-FINDINGS-ASSET-CACHE-R4.md`).
855- rev 7, 2026-10-24: corrections found while writing the plan, against
856 the code: the records confirmed time comes from reconcile's own
857 per-app completeness, with or without the record view (which is armed
858 only under `CARLOS_CHANGE_INDEX=read`); the paths union is installed
859 build plus `ServingGen`'s build (the activator keeps one current
860 generation); manifests are read from the bucket, not the staged copy;
861 every `ReconcileAliases` log-and-continue point and silent input is
862 listed; the opt-out verb is `carlos instances set-assets`; two citation
863 fixes.
864- rev 8, 2026-10-24: Astra round 5 (`CODEX-FINDINGS-ASSET-CACHE-R5.md`,
865 reject on one P2): the manifest cache keeps only declared paths, only
866 for builds a local route references, pruned every pass (R5-1).
867- round 6, 2026-10-24: Astra **accept** at 91debc18 (`CODEX-FINDINGS-ASSET-CACHE-R6.md`).
868- rev 9, 2026-10-24: a response with no `Content-Type` is not stored
869 (narrows rev 6's "absent and sniffed as HTML", which the header-time
870 storability decision could not implement). Reviewed with the plans.
871- plan review round 8, 2026-10-24: Astra **accept** of plans 1 to 3 and spec rev 9 at 4cca5784.
872