| 1 | # Edge asset cache: versioned build assets served from every edge |
| 2 | |
| 3 | Branch `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). |
| 4 | Related: media roadmap (`2026-09-07-media-platform-roadmap-design.md`), |
| 5 | which this does not amend; #368 (the shared cache stores 206 responses). |
| 6 | |
| 7 | ## 0. Intent |
| 8 | |
| 9 | An app's build produces images, JavaScript and stylesheets. Most of them |
| 10 | carry a content hash in the name (`app.3f9c2a.js`), so the bytes behind a |
| 11 | name never change. Paul wants an app to be able to say "these paths are |
| 12 | build assets" and have whichever edge a visitor reaches serve them from its |
| 13 | own disk, for as long as the app says they are good for, without asking the |
| 14 | app (Paul, 2026-10-24). Three reasons, all of them goals: |
| 15 | |
| 16 | 1. **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. |
| 19 | 2. **Wakes.** A hibernating instance is woken to serve a stylesheet. |
| 20 | 3. **Home load.** The home box serves every asset byte for every region. |
| 21 | |
| 22 | This is not stage 1 of the media roadmap. Stage 1 serves objects an app |
| 23 | writes into its store bucket at runtime and treats them as untrusted user |
| 24 | content (three doors, capabilities, bucket grants, distributed purge with a |
| 25 | serving lease). Build assets are public, come from the app's own route, and |
| 26 | are immutable by construction. They need almost none of that machinery, and |
| 27 | waiting for it would delay all three goals. The media roadmap's stage 1 is |
| 28 | untouched 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 | |
| 118 | The 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 | |
| 132 | Old binaries ignore the key (`encoding/json` drops unknown top-level keys |
| 133 | on the manifest read path, `release.go:95-100`). `kind:static` releases |
| 134 | refuse the flag; they are already everywhere. |
| 135 | |
| 136 | **`carlos deploy` declares the same way** (Paul, 2026-10-24). Deploy is |
| 137 | ship, 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 | |
| 157 | Why the release manifest and not the instance record: the paths an app |
| 158 | builds assets under change with its build tooling, and a rollback to a |
| 159 | build that used `/static/` must catch `/static/`, not the newer build's |
| 160 | prefix. (My call.) |
| 161 | |
| 162 | ### 3.2 Instance opt-out |
| 163 | |
| 164 | The instance record gains `Assets *bool` (`omitempty`; absent means |
| 165 | "follow the release", `false` means "catch nothing for this instance"). |
| 166 | Set through the existing PATCH door (`apiinstances.go`, the PATCH body |
| 167 | beside `gate` and `steering`) and a new verb, `carlos instances |
| 168 | set-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` |
| 171 | and `convergeOwned` like `Steering`. Use: an instance whose traffic |
| 172 | must always reach the app, or a quick off switch while debugging. |
| 173 | (R1.) |
| 174 | |
| 175 | ### 3.3 When a route catches nothing |
| 176 | |
| 177 | The 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 | |
| 202 | When a box writes its edges file, each `edgeHost` it emits for a hostname |
| 203 | that 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 | |
| 255 | A change to `paths` or `gen` must change the registry `Fingerprint` (or |
| 256 | the edges file's own dirty check) so the file is rewritten within seconds, |
| 257 | not at the five-minute refresh. So must an advance of any published `at` |
| 258 | by 20 minutes or more since the last write (§4.2). |
| 259 | |
| 260 | ### 4.2 Far edges read it |
| 261 | |
| 262 | The 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, |
| 264 | assets_at)` for hosts whose winning entry carries `assets`. No new object |
| 265 | is 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 |
| 268 | stopped publisher's last file stays readable forever, and every poll |
| 269 | would 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 |
| 272 | are failing still writes its file from its local registry rows |
| 273 | (`edgesfile.go:420`), so a write-time stamp would keep advertising an |
| 274 | obsolete generation as fresh **(astra R1-3)**. |
| 275 | |
| 276 | So `at` is the host's **authority time**: the last moment the home box |
| 277 | knew the records behind that host were current and applied. The box keeps |
| 278 | a per-app **records confirmed time**: the start time of the last |
| 279 | reconcile pass (`provision.Reconcile`, run by |
| 280 | `reconcileSerializedWith`, `internal/host/agent.go:1604`) that read the |
| 281 | app's records completely and applied them to the registry without error. |
| 282 | Reconcile today returns one error for the whole pass, so it must also |
| 283 | report which app scopes it read completely and applied. Where those reads |
| 284 | come 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 | |
| 295 | The pass's **start** time is the one recorded, because a record written |
| 296 | during 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* |
| 299 | input the catch decision for that host reads was known complete and |
| 300 | applied on the publishing box. Rounds 1 to 3 of review each found one |
| 301 | more input left out (records, then aliases, then catch-all aliases and |
| 302 | path 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 | |
| 305 | 1. **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. |
| 308 | 2. **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". |
| 328 | 3. **Release manifests**: immutable once read, so they add no bound. |
| 329 | |
| 330 | Every published host, primary or alias, gets `at` = the older of (1) for |
| 331 | its app's scope and (2) for the box. This applies to primary hostnames |
| 332 | as much as aliases, because proving a hostname has **no** path row is a |
| 333 | domain read too (R3-2). A box whose domain reads fail for an hour stops |
| 334 | catching every host it serves, on every edge. Any new input a future |
| 335 | change adds to the catch decision must bring its own completeness report |
| 336 | into this minimum, and the plan states this as a test (§12): a table of |
| 337 | inputs, each failed in turn for over an hour with every other input |
| 338 | healthy, 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 | |
| 349 | Cost: the file is rewritten when an `at` advances by 20 minutes or more, |
| 350 | about three extra PUTs per publishing box per hour and the |
| 351 | `control/edges` bumps they cause, which every edge already reads. |
| 352 | |
| 353 | Immutable assets would be safe to serve longer, but a purge (§6) or an |
| 354 | owner change that never arrived must not live forever on a disconnected |
| 355 | box. One hour and twenty minutes are my calls; they are constants, not |
| 356 | knobs. |
| 357 | |
| 358 | ## 5. Serving |
| 359 | |
| 360 | ### 5.1 Where the asset cache sits |
| 361 | |
| 362 | A new disk cache, separate from the shared LRU in both directions: caught |
| 363 | requests never read or fill the LRU, and nothing else reads or fills the |
| 364 | asset cache. |
| 365 | |
| 366 | In `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 | |
| 375 | A request is caught when all hold: method `GET` or `HEAD`; the request |
| 376 | path is **already canonical**, meaning `r.URL.RawPath` is empty (no |
| 377 | percent-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 |
| 379 | prefix; the box is armed (§10). A non-canonical path under a prefix |
| 380 | (`/assets//app.js`, `/assets/./app.js`, `/assets/%61pp.js`) is forwarded |
| 381 | as today and answered `Carlos-Cache: bypass`. Neither the key nor the |
| 382 | upstream request is ever normalised: `lookupUpstreamPath` matches cleaned |
| 383 | and raw first segments but forwards the original path |
| 384 | (`internal/edge/edge.go:1304-1323`), so a cleaned key could be filled with |
| 385 | the answer to a different URL **(astra B4, B7)**. Anything else under the |
| 386 | prefix (`POST`, an upgrade) is forwarded exactly as today, uncached and |
| 387 | with its headers intact. |
| 388 | |
| 389 | ### 5.2 What a caught request sends upstream |
| 390 | |
| 391 | Fills send an allow-listed request: method `GET`, the path and raw query, |
| 392 | `Host`, `User-Agent`, `Accept`, `Accept-Encoding: identity`, plus the hop |
| 393 | headers when it is a backhaul. **No `Cookie`, no `Authorization`, no other |
| 394 | client header.** That is the public contract a declared prefix makes, and |
| 395 | it is what makes the anti-goal "a logged-in response served to someone |
| 396 | else" structural rather than a hope about the app's headers. A `HEAD` |
| 397 | fills with `GET`. The docs page says it in one sentence: files under an |
| 398 | asset path are fetched without the visitor's cookies. |
| 399 | |
| 400 | Range, `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 | |
| 405 | Fills are bounded per box (16 concurrent) and per app (4); over either |
| 406 | bound the request is forwarded as today, uncached, rather than queued. |
| 407 | Fills 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)**. |
| 410 | Ordinary backhaul deliberately has no body deadline (`edge.go:2837`), so a |
| 411 | fill must bring its own: 60 seconds from start to the last body byte. A |
| 412 | fill that misses it is abandoned for caching; the initiating client keeps |
| 413 | receiving the bytes as an ordinary response would, but nothing is stored |
| 414 | and the reservation (§7) is released at the moment of abandonment, not |
| 415 | when the stream ends. At most 32 requests wait on one fill, and each waits |
| 416 | at most 10 seconds; a request over the cap or past its wait is forwarded |
| 417 | as 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 |
| 421 | requests for one key wait on a single fill, but what they wait for is the |
| 422 | entry, not the response. If the fill produces an entry, each waiter is |
| 423 | then answered from it as a hit, with its own conditional headers, `Range` |
| 424 | and encoding applied separately (§5.5). If it does not (refused at |
| 425 | admission, no acknowledgement, an error), only the requester that started |
| 426 | the fill gets that response, and every other waiter makes its own |
| 427 | request, forwarded as today and subject to the same fill bounds. A refused |
| 428 | response, which may carry `Set-Cookie` or vary on `Accept`, therefore |
| 429 | never reaches anyone but the client it was made for, and every client |
| 430 | delivery is a request somebody metered. |
| 431 | |
| 432 | **A far edge's fill is a signed request, answered with a signed |
| 433 | acknowledgement.** Ordinary backhaul is not enough: the home meters every |
| 434 | hop and consults its shared LRU (`edge.go:1678`, `:1768`), so a plain hop |
| 435 | would count the bytes twice and could refill a far edge from a shared-LRU |
| 436 | entry that predates a purge **(astra B6)**. A bare marker header is not |
| 437 | enough either: an old far edge forwards any client header and then signs |
| 438 | the 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 |
| 440 | metering through an old edge **(astra R1-1)**. And the fill must name the |
| 441 | generation it is filling for, or a home that has not yet reloaded a purge |
| 442 | could answer from its old generation and the far edge would store those |
| 443 | bytes 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 | |
| 488 | A 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 | |
| 529 | A refused response is still served to the requester, unmodified, so a |
| 530 | caught prefix that the app does not mark cacheable behaves exactly as an |
| 531 | uncaught one. That is what defuses the SPA fallback: `index.html` served |
| 532 | with `no-cache` for an unknown asset name is never stored. Non-200 is never |
| 533 | stored either, so a request racing a deploy that 404s does not poison the |
| 534 | name. |
| 535 | |
| 536 | ### 5.4 Cache key and entry |
| 537 | |
| 538 | Key: `(account, app, gen, request host, path, raw query)`, the path being |
| 539 | canonical by §5.1. Account |
| 540 | and app come from the published owner, so a hostname that changes hands |
| 541 | can never serve the previous owner's entries; `gen` makes a purge a key |
| 542 | change (§6). The release version is not in the key: an unchanged hashed |
| 543 | file stays warm across deploys, which is the point. |
| 544 | |
| 545 | An entry holds the identity body, its absolute expiry and lifetime, a |
| 546 | gzip copy when the response is compressible by the existing rule |
| 547 | (`compressible`, `internal/edge/compress.go:100-118`: not already encoded, |
| 548 | no `Content-Range`, no `Cache-Control: no-transform`, at least |
| 549 | `minCompressSize`, a compressible type), made once at fill, and **every |
| 550 | end-to-end response header** except: hop-by-hop headers (`Connection` and |
| 551 | those 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 |
| 554 | platform `X-Carlos-*` headers. So `Content-Security-Policy`, |
| 555 | `Cross-Origin-Resource-Policy` and anything else the app sets for safety |
| 556 | reach a hit exactly as they reached the miss, the way the shared cache |
| 557 | already 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`. |
| 560 | When the app sent no `ETag`, the edge sets a strong one from the body's |
| 561 | sha256, 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 | |
| 587 | An entry past its lifetime is not revalidated in v1; it is dropped and |
| 588 | refilled. (Revalidation with `If-None-Match` upstream is a later |
| 589 | optimisation.) |
| 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 |
| 595 | token, the asset generation, for every instance record of the app. A new |
| 596 | instance record draws its own token at create. A random token rather than |
| 597 | a counter means a hostname deleted and recreated can never land back on a |
| 598 | generation an edge still holds entries for, and needs no app-level state |
| 599 | that outlives its instances **(astra B1)**. Each record is written |
| 600 | separately; the door and the CLI report per instance which writes |
| 601 | succeeded and retry the rest, and a partly applied purge says so rather |
| 602 | than reporting done. The token flows record -> `convergeOwned` -> route |
| 603 | row -> edges file -> every edge's home map, and every key under the old |
| 604 | token stops matching at once. Old entries are garbage, reclaimed by |
| 605 | eviction and by a sweep that deletes entries whose token is not the host's |
| 606 | current one. |
| 607 | |
| 608 | `Carlos-Purge` from the app continues to act on the shared LRU only, on |
| 609 | the box that saw it. It does not touch the asset cache: a per-path purge |
| 610 | that reaches one edge of four is the trap the shared cache's five-minute |
| 611 | clamp exists to bound, and this cache has no clamp. Apps fix a mistake |
| 612 | with the generation. |
| 613 | |
| 614 | `carlos assets status <app>` prints, per instance: the declared paths of |
| 615 | the builds being served, the opt-out, the generation in the record, and |
| 616 | the 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 |
| 618 | when the published generation matches the record. Far edges do not report |
| 619 | back; the status states the bound plainly: every edge that can read the |
| 620 | bucket picks the new generation up within a minute, and one that cannot |
| 621 | stops serving the host's cached assets within an hour (§4.2). |
| 622 | |
| 623 | Expected latency: under a minute (edges-file rewrite within about 2 s, |
| 624 | 30 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 | |
| 660 | Every caught response, hit or fill, is counted as the app's request and |
| 661 | bytes out, attributed by the published account and app, by whichever edge |
| 662 | served it. Today the sending edge of a backhaul records nothing and the |
| 663 | home records the hop (`usagemeter.go:43-45`); for caught paths the serving |
| 664 | edge records instead, and a fill's backhaul is recorded once, not twice. |
| 665 | Asset bytes are not free bandwidth. (R2.) |
| 666 | |
| 667 | ## 9. Provider boundary |
| 668 | |
| 669 | All core. The design reads and writes nothing beyond the edges file, the |
| 670 | release manifest and the instance record, all through existing store |
| 671 | interfaces, and adds no cloud API call. The geographic win depends on |
| 672 | latency DNS, which is the existing Route 53 steering adapter; a self-hosted |
| 673 | deployment with one plain wildcard to one edge keeps G4, G6 and the home |
| 674 | box's disk cache, and loses only nearest-edge entry. No AWS SDK import in |
| 675 | any 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 | |
| 708 | Stage 1 needs a per-box disk cache with a total budget, filesystem |
| 709 | headroom, per-route fair share and bounded fills (roadmap §4, "Its own |
| 710 | cache"). Build §7 as a package the media handler can reuse with its own |
| 711 | budget and its own index, not as code inside `edge.go`. Stage 1's |
| 712 | distributed purge log and serving lease remain its own; nothing here |
| 713 | weakens them or is offered as a substitute. |
| 714 | |
| 715 | ## 12. Testing and the acceptance gate |
| 716 | |
| 717 | Tests go through production wiring (two edges, real edges files through a |
| 718 | `DirStore`, real home map fold, real backhaul with hop auth), not fixture |
| 719 | routes. 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 | |
| 799 | Then, 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 |
| 801 | for it, and a 5 MiB bundle is served gzip-compressed from the far edge. |
| 802 | On 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 | |