# Edge asset cache: versioned build assets served from every edge

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).
Related: media roadmap (`2026-09-07-media-platform-roadmap-design.md`),
which this does not amend; #368 (the shared cache stores 206 responses).

## 0. Intent

An app's build produces images, JavaScript and stylesheets. Most of them
carry a content hash in the name (`app.3f9c2a.js`), so the bytes behind a
name never change. Paul wants an app to be able to say "these paths are
build assets" and have whichever edge a visitor reaches serve them from its
own disk, for as long as the app says they are good for, without asking the
app (Paul, 2026-10-24). Three reasons, all of them goals:

1. **Distance.** A visitor far from the app's home region waits a
   cross-region round trip for every stylesheet and script on every
   uncached load.
2. **Wakes.** A hibernating instance is woken to serve a stylesheet.
3. **Home load.** The home box serves every asset byte for every region.

This is not stage 1 of the media roadmap. Stage 1 serves objects an app
writes into its store bucket at runtime and treats them as untrusted user
content (three doors, capabilities, bucket grants, distributed purge with a
serving lease). Build assets are public, come from the app's own route, and
are immutable by construction. They need almost none of that machinery, and
waiting for it would delay all three goals. The media roadmap's stage 1 is
untouched and still next in that line; §11 notes what it can reuse from here.
(My call, playback approved by Paul 2026-10-24.)

## 1. Where things stand on `main` (fef28dc5)

- **Visitors already reach their nearest edge on the flagship.** The
  `*.<apps domain>` wildcard and `edge.<apps domain>` (custom domain CNAME
  target) answer by latency across the four regions; the steering converger
  is armed there (flagship infra `docs/latency-routing-oncarlos.md:29-38`;
  the code comments in `internal/steering/steering.go:14` saying it is armed
  nowhere are stale). So the geographic half of this needs no DNS work.
- **A far edge forwards everything for a home-boxed app and caches
  nothing.** An instance route row exists only on its home box
  (`internal/provision/reconcile.go:401-414`). Any other edge misses
  `lookupUpstreamPath` and calls `e.backhaul` (`internal/edge/edge.go`, the
  lookup-miss branch), which runs before the meter, gzip and cache. The
  comment there says why the far edge must not cache: purge is honoured only
  by the home edge, so a far copy "could never be invalidated by the app
  that owns it".
- **The shared response cache is not built for assets.** It is one
  process-wide in-memory LRU, 4096 entries, 1 MiB per body
  (`edge.go:61-69`), lifetime clamped to five minutes
  (`DefaultCacheMaxTTL`, `edge.go:77`) because a lost purge must be a blip.
  It has no per-tenant share. Many bundles exceed 1 MiB and are never
  stored. It ignores `Range` (#368) and conditional requests.
- **Far edges already learn per-host facts cheaply.** Every box publishes
  `control/edges/<box>.json` (`internal/host/edgesfile.go:44-91`, struct
  `edgeHost`: role, platform, alias, replica, wildcard, account, app,
  stamp), rewritten within about 2 s of a registry change and read by every
  edge on a 30 s poll gated by the change index's `control/edges` scope
  (`internal/edge/homemap.go`). An additive field there reaches every edge
  with no new polling. The change index exists because per-app reads on
  every box cost about $3 per app per month (`2026-10-21-s3-change-index-design.md`
  §0); this design adds none.
- **Declarations that change with the code live in the release
  manifest.** `Capabilities` (`internal/release/release.go:95-107`) is the
  precedent: it rides the signed pointer, immutable manifest, sha256 chain,
  and a rollback rolls it back. `carlos ship --capabilities <file>` embeds it
  (`cli.go:2167`).
- **Disk is tight on small boxes.** US and AU flagship boxes are
  `t4g.small` with a 20 GiB root and no separate data volume. The box disk
  alert fires above 90% (`internal/metrics/alert.go`); placement wants 10%
  free (`internal/boxchoice/policy.go:16-22`). The only edge disk cache
  today, `/data/static`, is unbounded.
- **Static routes are already served everywhere** (`everywhereClass`,
  `reconcile.go:171-174`). They are out of scope here.

## 2. Goals, non-goals, anti-goals

**Goals**

- G1. An app declares path prefixes as build assets once, at ship or
  deploy time, and a saved project default keeps declaring them.
- G2. Any edge that receives a request under a declared prefix serves it
  from its own disk after the first fill, with no backhaul and no request
  to the app.
- G3. Lifetimes are the app's `Cache-Control`, up to a year, so hashed
  assets marked `immutable` stay cached until evicted or purged.
- G4. A hit never wakes a hibernating instance, on any edge including home.
- G5. A mistake is recoverable: one command drops an app's cached assets on
  every edge within about a minute.
- G6. Bounded disk on every box, with a fair share per app.
- G7. Hits and fills are counted as the app's usage like any other
  response.

**Non-goals**

- Serving the store bucket (media stage 1), transforms, video streaming as
  a target (ranges work, §5.5, but nothing is tuned for media).
- Caching HTML or anything personalised.
- Pushing assets to edges at deploy time. Pull-through only.
- Content negotiation beyond gzip (no `Vary: Accept` image formats, no
  brotli).
- A third-party CDN or CloudFront. The 2026-08-02 edge cache spec §9
  rejected that as the default and this keeps the ruling.

**Anti-goals** (failure even if everything else works)

- A logged-in response cached and served to someone else.
- A far edge serving one app's bytes under a hostname that now belongs to
  another app or account.
- A cache filling a 20 GiB box's disk.
- New per-app reads of the deployment bucket on every box.
- An SPA fallback (`200 index.html` for an unknown path) cached as a
  script forever.

## 3. The declaration

### 3.1 In the release manifest

The release manifest gains an additive `assets` block:

    "assets": {"paths": ["/assets/", "/_app/immutable/"]}

`carlos ship --assets /assets/ --assets /_app/immutable/` writes it
(repeatable flag). Grammar, refused at ship with an error naming the rule:

- each entry starts and ends with `/`, is at most 128 bytes, is already
  `path.Clean`'d (apart from the trailing slash), and contains no `%`, `?`,
  `#`, `*` or `..`;
- `/` alone is refused: it would catch the app's pages and strip their
  cookies (§5.2);
- at most 8 entries, no entry a prefix of another.

Old binaries ignore the key (`encoding/json` drops unknown top-level keys
on the manifest read path, `release.go:95-100`). `kind:static` releases
refuse the flag; they are already everywhere.

**`carlos deploy` declares the same way** (Paul, 2026-10-24). Deploy is
ship, promote and watch in one verb, and its ship half is `runShipAPI`
(`deploy.go:1-6`, `:334`), which already carries `--capabilities`. So:

- `carlos deploy --assets /assets/` takes the same repeatable flag with
  the same grammar and passes it to the ship half.
- The project-defaults block in `.carlos/config` (`config.go:58-65`:
  `app`, `kind`, `artifact`, `target`) gains `"assets": ["/assets/"]`, so
  a zero-arg `carlos deploy` keeps declaring on every release. A flag on
  the command line replaces the saved list for that run; `--no-assets`
  ships with none. Without either, a release declares nothing, and since
  paths come from the serving release (§4.1), deploying a build without
  them turns catching off for it. That is why the saved default matters.
- The console's ship door (API mode) puts the block in the manifest it
  writes and checks the same grammar itself. It does not trust the CLI.
- Deploy's watch probes the app's own URL for `X-Carlos-Version`. `/` can
  never be declared, so the watch never lands on a caught path and a hit
  can never answer it with a stale version (§5.5).
- `--kind static` refuses `--assets` and ignores a saved list, saying so
  once.

Why the release manifest and not the instance record: the paths an app
builds assets under change with its build tooling, and a rollback to a
build that used `/static/` must catch `/static/`, not the newer build's
prefix. (My call.)

### 3.2 Instance opt-out

The instance record gains `Assets *bool` (`omitempty`; absent means
"follow the release", `false` means "catch nothing for this instance").
Set through the existing PATCH door (`apiinstances.go`, the PATCH body
beside `gate` and `steering`) and a new verb, `carlos instances
set-assets <host> on|off`, beside `set-upstreams` and `set-channel`
(`carlos instances update` is already "install the newest build now",
`instances_updates.go:201`). It reaches the route row through `RouteFor`
and `convergeOwned` like `Steering`. Use: an instance whose traffic
must always reach the app, or a quick off switch while debugging.
(R1.)

### 3.3 When a route catches nothing

The home box publishes no prefixes for a hostname when any of these hold:

- the instance opt-out is `false`;
- the route has a gate (`Route.Gate`): a password-gated preview must not
  leak its assets to anyone who guesses a URL;
- the route, or the row the hostname resolves to, has an import gate
  (`ImportGate`, `internal/edge/importgate.go:24-35`), which refuses
  requests even when `Route.Gate` is empty; a warm far cache must not
  answer ahead of it **(astra B9)**;
- the hostname has any path-prefix row (`route_paths`) at all, even when
  every target belongs to the same app: different instances behind one
  hostname can serve different releases, gates and opt-outs, and one
  hostname-wide declaration cannot describe them. The hostname must also
  have a single owner by the rule that decides `edgeHost.Account`/`App`
  (`edgesfile.go:77-85`, fold at `:93-116`) **(astra B2)**;
- the route is pooled (`Route.Pool != ""`): pooled routes are a tenancy
  boundary only their pool serves, the same reason steering refuses them
  (`apiinstances.go:2112-2116`). A pooled route still uses the local disk
  cache on the boxes that serve it (§5.1), but publishes nothing for far
  edges.

## 4. Getting it to every edge

### 4.1 The home box publishes

When a box writes its edges file, each `edgeHost` it emits for a hostname
that catches gains:

    "assets": {"paths": ["/assets/"], "gen": "9f2c41d07a3be516", "at": 1792828800}

(`at` is the host's authority time, §4.2.)

- **Only an armed box publishes.** A box without `CARLOS_ASSET_CACHE=1`
  emits no `assets` block, so far edges catch a host only when its home
  is armed and will treat their fills as fills (§5.3). This makes the
  arming order home-first by construction **(astra B3)**.
- `paths` comes from the release manifests of the builds the route can
  answer with, minus the §3.3 exclusions. For a route that is not
  generation-managed that is the release `Route.Version` names. For a
  generation-managed route `Route.Version` names only what is installed,
  not what answers (`internal/edge/edge.go:842-845`; the served build comes
  from the generation, `internal/edge/genproxy.go:298-302`), so the home
  publishes the **union** of the installed build's declared paths and the
  current serving generation's (`ServingGen(host).Build`,
  `internal/activator/genservice.go:68`) **(astra B5)**. The activator
  keeps one current generation per host (`actEntry.gen`,
  `internal/activator/activator.go:469`); a generation being replaced only
  finishes requests already sent to it, so no third build can answer a new
  request. The
  installed build is in the union so that a sleeping instance, which has no
  live generation (`ServingGen` is false while asleep,
  `internal/edge/gensource.go:24-28`), keeps catching: the installed build
  is the one a wake would start, and its assets are what a page served
  before the sleep asks for **(astra R1-5)**. During an update both builds'
  assets are caught, and the old build's prefix drops out when its
  generation retires. The home reads each build's manifest from the
  bucket with `release.GetManifest` (`internal/release/release.go:710`)
  once per version and keeps only the declared paths, since a manifest is
  immutable. It keeps them only for builds a local route currently
  references, as its installed version or its serving build; each
  edges-file pass drops the rest, and a rollback to a dropped build reads
  its manifest again. Retained state is therefore bounded by twice the
  box's routes, whatever the deploy history **(astra R5-1)**.
  It does not use the staged copy under `/opt/carlos/versions`
  (`release.ReadStagedManifest`, `versions.go:261`), which an older binary
  may have re-marshalled without the `assets` key. A manifest that cannot
  be read publishes no paths for that build until it can. No other box
  reads manifests. The edges-file writer gets the serving build through a
  narrow function the agent hands it, since it has no activator handle
  today.
- `gen` is the instance's asset generation (§6), copied from the instance
  record through the route row.
- Aliases (custom domains, and the exact names instance catch-alls
  derive) publish the same block as their primary, with their `at`
  computed by the same invariant (§4.2).
- Additive and `omitempty`: a file without it parses byte-identically, and
  an older reader ignores it.

A change to `paths` or `gen` must change the registry `Fingerprint` (or
the edges file's own dirty check) so the file is rewritten within seconds,
not at the five-minute refresh. So must an advance of any published `at`
by 20 minutes or more since the last write (§4.2).

### 4.2 Far edges read it

The edge's home map (`homemap.go`) already folds every box's file into
`host -> home`. It now also keeps `host -> (account, app, paths, gen,
assets_at)` for hosts whose winning entry carries `assets`. No new object
is read; the 30 s poll and the change-index gate are unchanged.

**Freshness bound.** A successful read is not evidence of freshness: a
stopped publisher's last file stays readable forever, and every poll
would renew a read-age clock (`homemap.go:440-455`;
`internal/host/edgesfile.go` has no write timestamp today)
**(astra B8)**. Nor is a successful write: a publisher whose record reads
are failing still writes its file from its local registry rows
(`edgesfile.go:420`), so a write-time stamp would keep advertising an
obsolete generation as fresh **(astra R1-3)**.

So `at` is the host's **authority time**: the last moment the home box
knew the records behind that host were current and applied. The box keeps
a per-app **records confirmed time**: the start time of the last
reconcile pass (`provision.Reconcile`, run by
`reconcileSerializedWith`, `internal/host/agent.go:1604`) that read the
app's records completely and applied them to the registry without error.
Reconcile today returns one error for the whole pass, so it must also
report which app scopes it read completely and applied. Where those reads
come from depends on how the box is configured:

- with the change-index record view armed (`CARLOS_CHANGE_INDEX=read`,
  `internal/host/changeindex.go:57-78`), a scope counts as read
  completely only when it is applied and not pending in the snapshot the
  pass used. A refresh that returns nil while a scope stays pending
  (`internal/provision/recordview.go:164-175`) does not count;
- without it (`armedRecordView` nil, the bucket read directly), a scope
  counts only when its listing and every record body under it were read
  without error.

The pass's **start** time is the one recorded, because a record written
during the pass may not have been seen. Publishing never advances it.

**The invariant.** A host's `at` may only be a time at which *every*
input the catch decision for that host reads was known complete and
applied on the publishing box. Rounds 1 to 3 of review each found one
more input left out (records, then aliases, then catch-all aliases and
path rows), so the spec names the inputs instead of the cases
**(astra R2-1, R3-1, R3-2)**. The catch decision (§3.3, §4.1) reads:

1. **The app's records** (the instance record, its route row, gate,
   import gate, opt-out, generation): bounded by the app's records
   confirmed time above.
2. **Domain claims**, which decide whether a hostname has path rows,
   which aliases exist, and which aliases instance catch-alls derive
   (`internal/domains/aliases.go`, `ReconcileAliases`): bounded by the
   box's **domain confirmed time**, the completion time of the last
   `ReconcileAliases` pass that was *complete* (again its start time).
   Today the pass returns an error only when the claim listing fails.
   Everything else is logged and the pass returns nil: the label count for
   a catch-all (`aliases.go:162-171`), instance alias upsert (`:204`),
   alias upsert (`:310`), catch-all upsert (`:531`), alias delete
   (`:541`), wildcard upsert and delete (`:561`, `:568`), path upsert
   (`:584`) and path delete (`:590`). Two inputs fail silently too: claim
   bodies the claim cache could not read are dropped
   (`internal/domains/claimcache.go:137-145`), and a failed
   `edgesfile.DeploymentRoutes` read comes back nil and is treated as
   "allow". So `ReconcileAliases` must also report completeness, and
   **any** of those makes the pass incomplete. An incomplete pass does not
   advance the domain confirmed time. Existing callers keep today's error
   behaviour; `TestInstanceCatchallKeepsNamesWhenTheListingFails`
   (`instancecatchall_test.go:507`) still sees nil and now also sees
   "incomplete".
3. **Release manifests**: immutable once read, so they add no bound.

Every published host, primary or alias, gets `at` = the older of (1) for
its app's scope and (2) for the box. This applies to primary hostnames
as much as aliases, because proving a hostname has **no** path row is a
domain read too (R3-2). A box whose domain reads fail for an hour stops
catching every host it serves, on every edge. Any new input a future
change adds to the catch decision must bring its own completeness report
into this minimum, and the plan states this as a test (§12): a table of
inputs, each failed in turn for over an hour with every other input
healthy, must stop catching on home and far edges.

- A far edge serves and fills a host's caught paths only while that
  host's `at` is within the last hour by the reader's clock. Past that it
  stops admitting hits and fills and forwards as today.
- A box serves and fills its **own** routes' caught paths under the same
  rule, against its own confirmed time, so a home cut off from the bucket
  stops serving entries a purge it cannot see may have retired.
- After a restart the confirmed time starts empty, so nothing is caught
  until the first complete convergence.

Cost: the file is rewritten when an `at` advances by 20 minutes or more,
about three extra PUTs per publishing box per hour and the
`control/edges` bumps they cause, which every edge already reads.

Immutable assets would be safe to serve longer, but a purge (§6) or an
owner change that never arrived must not live forever on a disconnected
box. One hour and twenty minutes are my calls; they are constants, not
knobs.

## 5. Serving

### 5.1 Where the asset cache sits

A new disk cache, separate from the shared LRU in both directions: caught
requests never read or fill the LRU, and nothing else reads or fills the
asset cache.

In `ServeHTTP` the caught-path check runs:

- **on a box with a local route row** (the home box, a replica, a pool
  member): after `lookupUpstreamPath` and the gate, before the shared-cache
  lookup and before the activator. The catch is decided from the local
  route's own published state (§3.3, §4.1), not from the home map.
- **on a box without one**: in the lookup-miss branch, before `e.backhaul`,
  using the home map's entry (§4.2). A miss fills by backhaul (§5.3).

A request is caught when all hold: method `GET` or `HEAD`; the request
path is **already canonical**, meaning `r.URL.RawPath` is empty (no
percent-encoding the decoder had to preserve) and the path equals its own
`path.Clean` with any trailing slash restored; it starts with a published
prefix; the box is armed (§10). A non-canonical path under a prefix
(`/assets//app.js`, `/assets/./app.js`, `/assets/%61pp.js`) is forwarded
as today and answered `Carlos-Cache: bypass`. Neither the key nor the
upstream request is ever normalised: `lookupUpstreamPath` matches cleaned
and raw first segments but forwards the original path
(`internal/edge/edge.go:1304-1323`), so a cleaned key could be filled with
the answer to a different URL **(astra B4, B7)**. Anything else under the
prefix (`POST`, an upgrade) is forwarded exactly as today, uncached and
with its headers intact.

### 5.2 What a caught request sends upstream

Fills send an allow-listed request: method `GET`, the path and raw query,
`Host`, `User-Agent`, `Accept`, `Accept-Encoding: identity`, plus the hop
headers when it is a backhaul. **No `Cookie`, no `Authorization`, no other
client header.** That is the public contract a declared prefix makes, and
it is what makes the anti-goal "a logged-in response served to someone
else" structural rather than a hope about the app's headers. A `HEAD`
fills with `GET`. The docs page says it in one sentence: files under an
asset path are fetched without the visitor's cookies.

Range, `If-None-Match` and `If-Modified-Since` are answered by the edge
(§5.5) and never forwarded on a fill.

### 5.3 Fill, admission and coalescing

Fills are bounded per box (16 concurrent) and per app (4); over either
bound the request is forwarded as today, uncached, rather than queued.
Fills from a far edge go through `backhaulAcquire` like any hop.

**Every fill has a deadline, and so does every waiter** **(astra R3-3)**.
Ordinary backhaul deliberately has no body deadline (`edge.go:2837`), so a
fill must bring its own: 60 seconds from start to the last body byte. A
fill that misses it is abandoned for caching; the initiating client keeps
receiving the bytes as an ordinary response would, but nothing is stored
and the reservation (§7) is released at the moment of abandonment, not
when the stream ends. At most 32 requests wait on one fill, and each waits
at most 10 seconds; a request over the cap or past its wait is forwarded
as today, which puts it back under `backhaulAcquire`'s per-home limit
(`edge.go:2903`) instead of beside it.

**Coalescing shares only an admitted entry** **(astra R2-5)**. Concurrent
requests for one key wait on a single fill, but what they wait for is the
entry, not the response. If the fill produces an entry, each waiter is
then answered from it as a hit, with its own conditional headers, `Range`
and encoding applied separately (§5.5). If it does not (refused at
admission, no acknowledgement, an error), only the requester that started
the fill gets that response, and every other waiter makes its own
request, forwarded as today and subject to the same fill bounds. A refused
response, which may carry `Set-Cookie` or vary on `Accept`, therefore
never reaches anyone but the client it was made for, and every client
delivery is a request somebody metered.

**A far edge's fill is a signed request, answered with a signed
acknowledgement.** Ordinary backhaul is not enough: the home meters every
hop and consults its shared LRU (`edge.go:1678`, `:1768`), so a plain hop
would count the bytes twice and could refill a far edge from a shared-LRU
entry that predates a purge **(astra B6)**. A bare marker header is not
enough either: an old far edge forwards any client header and then signs
the hop, and the hop MAC covers only the host and a timestamp
(`internal/edge/mesh.go:178-190`), so a client could have a new home skip
metering through an old edge **(astra R1-1)**. And the fill must name the
generation it is filling for, or a home that has not yet reloaded a purge
could answer from its old generation and the far edge would store those
bytes under the new one **(astra R1-2)**.

- **Assertion.** The far edge sends `X-Carlos-Asset-Fill: <ts>:<mac>`,
  where `mac` is HMAC-SHA256 under the mesh key (the same key, current or
  next, that `signHop` uses) over the domain `carlos-asset-fill-v1`, the
  host, the path and raw query, the account, the app, the generation and
  `ts`, each newline-separated, with the hop's `±hopSkew` window. The
  domain separator means no hop signature is ever a valid assertion.
- **Home.** The home treats a request as a fill only when the hop is
  verified, the assertion verifies, the home is armed, and its own local
  route for the host has that account, app and generation **current** and
  catches that path under §4.2's freshness rule. It then answers from its
  own asset cache under that key, or fills it from the app (§5.2), never
  reads or writes its shared LRU for the request and never meters it. It
  first deletes any `X-Carlos-Asset-Fill-Ack` and
  `X-Carlos-Asset-Expires` the app's response carries, then sets its own
  on the response headers:
  - `X-Carlos-Asset-Expires` is the entry's absolute expiry (§5.4) when
    the response is storable by the header-time rules below, and `0` when
    it is not (no lifetime, `no-store`, non-200, a disallowed `Vary`, an
    undeclaring build, and so on);
  - `X-Carlos-Asset-Fill-Ack` is HMAC-SHA256 under the mesh key over
    `carlos-asset-fill-ack-v1`, the request's assertion and the
    `X-Carlos-Asset-Expires` value.

  The acknowledgement means "the home handled this as a fill and did not
  meter it", whatever the expiry says; storability is the expiry's job
  **(astra R2-3)**.
- **Anything else is an ordinary hop**: a missing or invalid assertion, an
  unarmed or old home, a generation or owner mismatch, a host past its
  freshness bound. The home meters it as today and sends no
  acknowledgement.
- **Far edge.** It meters every response that carries a valid
  acknowledgement of its own assertion, storable or not, and its own hits.
  It stores an acknowledged response only when `X-Carlos-Asset-Expires` is
  non-zero and its own admission checks (body size, reservation) pass. A
  response without a valid acknowledgement is served to the requester
  unmodified, answered `Carlos-Cache: bypass`, not stored and not metered
  at the far edge, because the home metered it. So every response is
  counted exactly once, a disarmed or lagging home can never populate a
  far cache, and the mixed-fleet window after a home is disarmed only
  costs hits.
- The assertion and acknowledgement headers are stripped from every
  inbound non-hop request and from every response a client receives.

A response is stored only when all hold:

- status `200` (not 206, not any other 2xx);
- no `Set-Cookie` header or trailer;
- `Cache-Control` present, not `private`, `no-store` or `no-cache`, with a
  positive `s-maxage` or `max-age`. Lifetime is `s-maxage`, else
  `max-age`, capped at 365 days. There is no five-minute clamp: a declared
  prefix plus an explicit lifetime is the app saying the bytes hold;
- the response is still fresh on arrival. On a fill from the app the edge
  computes the entry's absolute expiry as the **response time** plus the
  lifetime minus the corrected initial age (RFC 9111 §4.2.3: the corrected
  initial age already includes the response delay, so the reference point
  is when the response arrived, not when the request left)
  **(astra R2-4)**. On an acknowledged far fill
  the expiry is the home's `X-Carlos-Asset-Expires`, never recomputed, so
  a copy of a nearly expired home entry expires when the home's does
  **(astra R1-6)**. An already expired response is served and not stored;
- `Vary` absent, or exactly `Accept-Encoding`; anything else (`Origin`,
  `Cookie`, `*`, `Accept`) is refused;
- no `Content-Encoding` other than identity (the fill asked for identity);
- the `Content-Type` is present and is not HTML (`text/html`,
  `application/xhtml+xml`). A response with no `Content-Type` is not
  stored either: sniffing needs the body, and the home must decide
  storability when the headers arrive (§5.3, `X-Carlos-Asset-Expires`). Build assets are
  never pages, and an SPA fallback can carry the asset path's long
  `Cache-Control` as easily as `no-cache`, so the lifetime alone cannot be
  what refuses it **(astra R3-4)**;
- body at most 32 MiB, measured while streaming; a larger body is streamed
  to the client and discarded;
- **the build that answered declared the prefix** **(astra R2-2)**. The
  §4.1 union decides what is *caught* and which existing entries may be
  *served*, but during an update the proxy picks the serving generation on
  its own (`internal/edge/genproxy.go:255`), so an older build that never
  declared `/bundles/` could answer a request for it with a cacheable
  fallback, and with no version in the key that entry would outlive the
  update. On a generation-managed route the home knows which generation
  answered (`genproxy.go:298-302`, `noteServedGen`) and stores only when
  that generation's build declared the matching prefix; on any other route
  the answering build is `Route.Version`'s. A far fill inherits this
  through the home's `X-Carlos-Asset-Expires: 0`.

A refused response is still served to the requester, unmodified, so a
caught prefix that the app does not mark cacheable behaves exactly as an
uncaught one. That is what defuses the SPA fallback: `index.html` served
with `no-cache` for an unknown asset name is never stored. Non-200 is never
stored either, so a request racing a deploy that 404s does not poison the
name.

### 5.4 Cache key and entry

Key: `(account, app, gen, request host, path, raw query)`, the path being
canonical by §5.1. Account
and app come from the published owner, so a hostname that changes hands
can never serve the previous owner's entries; `gen` makes a purge a key
change (§6). The release version is not in the key: an unchanged hashed
file stays warm across deploys, which is the point.

An entry holds the identity body, its absolute expiry and lifetime, a
gzip copy when the response is compressible by the existing rule
(`compressible`, `internal/edge/compress.go:100-118`: not already encoded,
no `Content-Range`, no `Cache-Control: no-transform`, at least
`minCompressSize`, a compressible type), made once at fill, and **every
end-to-end response header** except: hop-by-hop headers (`Connection` and
those it names, `Keep-Alive`, `Transfer-Encoding`, `Upgrade`, `TE`,
`Trailer`, `Proxy-*`), `Date` (regenerated), `Age` (recomputed),
`Content-Length` (recomputed per representation), the purge headers and
platform `X-Carlos-*` headers. So `Content-Security-Policy`,
`Cross-Origin-Resource-Policy` and anything else the app sets for safety
reach a hit exactly as they reached the miss, the way the shared cache
already keeps and replays response headers (`edge.go:1907`, `:2121`)
**(astra R1-4)**. `Access-Control-Allow-Origin` with any value other than
`*` is still refused at admission, because it would need `Vary: Origin`.
When the app sent no `ETag`, the edge sets a strong one from the body's
sha256, identical on every edge.

### 5.5 Answering from an entry

- `If-None-Match` / `If-Modified-Since` answered with 304.
- One `Range` answered with 206 from the stored identity body; multiple
  ranges answered with the whole body; `If-Range` against the identity
  `ETag` (a weak validator never matches, so a client holding the gzip
  `ETag` gets the whole body); 416 when unsatisfiable.
  `http.ServeContent` semantics over the file are acceptable.
- gzip copy when the entry has one, `Accept-Encoding` allows it and no
  `Range` is present. The two representations are kept apart the way
  `compress.go:230-245` already does: every response from an entry that
  has a gzip copy carries `Vary: Accept-Encoding`, whichever encoding it
  is, and the gzip copy's `ETag` is the identity `ETag` weakened
  (`weakenETag`), so a downstream cache cannot hand gzip to a client that
  did not ask for it and a range is never validated against compressed
  bytes **(astra R1-7)**. Caught responses bypass the edge's gzip wrapper;
  the asset path writes the chosen representation itself.
- Added on every caught response, hit or not: `Carlos-Cache: hit`, `miss`
  or `bypass` (the last for anything forwarded uncached). A hit carries
  no `X-Carlos-Version`: an entry can outlive many deploys, so a version on
  it would be a false "verified" for the deploy watch (`edge.go:813-847`
  explains the header's freshness contract).
- `Age` on hits: the lifetime minus the time left to the entry's expiry.

An entry past its lifetime is not revalidated in v1; it is dropped and
refilled. (Revalidation with `If-None-Match` upstream is a later
optimisation.)

## 6. Purge: the asset generation

`carlos assets purge <app>` (and a console door,
`POST /a/<sqid>/apps/<app>/assets/purge`) draws a fresh random 64-bit
token, the asset generation, for every instance record of the app. A new
instance record draws its own token at create. A random token rather than
a counter means a hostname deleted and recreated can never land back on a
generation an edge still holds entries for, and needs no app-level state
that outlives its instances **(astra B1)**. Each record is written
separately; the door and the CLI report per instance which writes
succeeded and retry the rest, and a partly applied purge says so rather
than reporting done. The token flows record -> `convergeOwned` -> route
row -> edges file -> every edge's home map, and every key under the old
token stops matching at once. Old entries are garbage, reclaimed by
eviction and by a sweep that deletes entries whose token is not the host's
current one.

`Carlos-Purge` from the app continues to act on the shared LRU only, on
the box that saw it. It does not touch the asset cache: a per-path purge
that reaches one edge of four is the trap the shared cache's five-minute
clamp exists to bound, and this cache has no clamp. Apps fix a mistake
with the generation.

`carlos assets status <app>` prints, per instance: the declared paths of
the builds being served, the opt-out, the generation in the record, and
the generation and `assets_at` its home has published in its edges file
(the console reads those files already). A purge is complete at the home
when the published generation matches the record. Far edges do not report
back; the status states the bound plainly: every edge that can read the
bucket picks the new generation up within a minute, and one that cannot
stops serving the host's cached assets within an hour (§4.2).

Expected latency: under a minute (edges-file rewrite within about 2 s,
30 s read poll, plus record convergence on the home box).

## 7. Disk budget and fairness

- Location: `$CARLOS_DATA_DIR/asset-cache/`. Entries are files named by a
  hash of the key; an index is rebuilt by scanning at start.
- Budget: `CARLOS_ASSET_CACHE_MAX` bytes; default the smaller of 4 GiB and
  10% of the data filesystem's size. A 20 GiB box gets 2 GiB.
- **Reservations.** Checking free space when a fill starts is not enough:
  sixteen concurrent maximum-size fills on an 8 GiB filesystem at 15% free
  would take it below the 10% placement floor
  (`internal/boxchoice/policy.go:20`) **(astra R1-8)**. So before a fill
  starts it reserves its worst case: `Content-Length` if the response
  will declare one, else the 32 MiB cap, doubled for the gzip copy (which
  is abandoned if it would exceed the identity body), plus 64 KiB for
  metadata. A fill is admitted only when the cache's used bytes plus all
  outstanding reservations plus this one fit the budget (evicting first if
  they do not), and the filesystem's free space minus all outstanding
  reservations minus this one stays at or above 15% of its size. A write
  past the reservation aborts the fill. The reservation is released on
  every exit, success or failure. Temporary files live in the cache
  directory and are deleted at start.
- Fair share: each app with entries on the box has a share of the budget
  (budget divided by the number of such apps, but never more than 25% of
  the budget for one app). Eviction takes the least recently used entry of
  whichever app is furthest over its share, so one app walking unique query
  strings evicts itself first.
- **After a restart** the edge serves no entry until the home map has
  completed one read (for far hosts) or the box's record view has a
  confirmed time for the route's scope (for local routes, §4.2), so an
  entry is never served against a generation or owner it has not
  checked.
- Memory: the index only. The kernel page cache does the rest.

## 8. Usage

Every caught response, hit or fill, is counted as the app's request and
bytes out, attributed by the published account and app, by whichever edge
served it. Today the sending edge of a backhaul records nothing and the
home records the hop (`usagemeter.go:43-45`); for caught paths the serving
edge records instead, and a fill's backhaul is recorded once, not twice.
Asset bytes are not free bandwidth. (R2.)

## 9. Provider boundary

All core. The design reads and writes nothing beyond the edges file, the
release manifest and the instance record, all through existing store
interfaces, and adds no cloud API call. The geographic win depends on
latency DNS, which is the existing Route 53 steering adapter; a self-hosted
deployment with one plain wildcard to one edge keeps G4, G6 and the home
box's disk cache, and loses only nearest-edge entry. No AWS SDK import in
any touched package; a test runs the full fill/hit/purge path against
`DirStore` with no AWS configuration.

## 10. Rollout

- **Arming.** `CARLOS_ASSET_CACHE=1` in a box's `host.env` arms its asset
  cache: catching, filling, serving, and publishing `assets` for the
  routes it serves (§4.1). Off by default; an unarmed box publishes
  nothing and forwards exactly as today. The one exception is the window
  after a home is disarmed (§5.3), in which it treats marked fills as
  ordinary hops.
- **Mixed fleet.** An old or unarmed home publishes nothing, so nothing is
  caught for its hosts anywhere. An old far edge ignores the field and
  backhauls. Every combination is today's behaviour or the new one, never
  a half.
- **Order.** Release with the feature dormant; roll dev boxes, dev
  console, check, flagship boxes, flagship console. Then arm a test app's
  **home** dev box first (only an armed home publishes, §4.1), then one
  dev box in another region, and run the two-edge acceptance gate (§12)
  before arming the rest of dev **(astra R3-5)**. On flagship, arm the
  console box (which homes most apps) first, then one small box (US or
  AU) to watch disk, then the rest one at a time.
- **CLI.** `carlos ship --assets`, `carlos deploy --assets` and
  `--no-assets`, the `assets` project default, `carlos assets purge`,
  `carlos assets status`, `carlos instances set-assets` get
  `docs/site/cli.md`
  headings (docsite gate). Every user-facing string goes through copy
  review before it is written.
- **Census.** Touching `internal/provision` and `internal/host` likely
  needs a plan14 overlay and a #280 ruling; the plan names which units.

## 11. Relation to the media roadmap

Stage 1 needs a per-box disk cache with a total budget, filesystem
headroom, per-route fair share and bounded fills (roadmap §4, "Its own
cache"). Build §7 as a package the media handler can reuse with its own
budget and its own index, not as code inside `edge.go`. Stage 1's
distributed purge log and serving lease remain its own; nothing here
weakens them or is offered as a substitute.

## 12. Testing and the acceptance gate

Tests go through production wiring (two edges, real edges files through a
`DirStore`, real home map fold, real backhaul with hop auth), not fixture
routes. Attacks first:

- a fill never forwards `Cookie` or `Authorization`, asserted at the app;
- a response with `Set-Cookie`, `Vary: Cookie`, `Vary: Origin`,
  `Cache-Control: private`, `no-cache`, a 206, a 404, or a body over the cap
  is served to the requester and not stored;
- an SPA fallback for an unknown hashed name is not stored, both with
  `no-cache` and with `Cache-Control: public, max-age=31536000`;
- the authority table: each input of §4.2's invariant (record scope,
  claim listing, catch-all label count, alias upsert, path delete) failed
  alone for over an hour, with every other input and the edges-file writes
  healthy, stops catching on home and far edges. The cases include an
  exact claim replacing a derived catch-all alias, and a claim adding a
  path row to a gated target under a primary hostname;
- a fill whose app stalls its body after admissible headers is abandoned
  at 60 seconds with its reservation released, at most 32 requests wait on
  it, and the rest are forwarded under the backhaul limit;
- a gated route, an import-gated route, a pooled route, an opted-out
  instance, a hostname with any path-prefix row and a hostname split
  between two apps publish nothing and catch nothing;
- a non-canonical path under a prefix (`//`, `./`, percent-encoded) is
  forwarded and answered `bypass`, and never fills a canonical key;
- an acknowledged fill is metered once, at the far edge, and does not
  touch the home's shared LRU; an unacknowledged one is metered once, at
  the home, and not stored; a client-sent assertion or acknowledgement is
  stripped at a new edge, and one forwarded by an **old** far edge to a
  new home fails verification and is billed as an ordinary hop;
- a fill whose assertion names a generation the home has not reloaded yet,
  or an owner that has changed mid-fill, gets no acknowledgement and is not
  stored; the same for a disarmed home;
- an app response carrying its own `X-Carlos-Asset-Fill-Ack` or
  `X-Carlos-Asset-Expires` has them removed before the home signs;
- a nearly expired home entry filled through a far edge expires at the
  home's deadline on both boxes; a delayed origin whose response arrives
  after half its lifetime is stored and expires at response time plus
  lifetime minus age;
- an acknowledged fill whose response has no `Cache-Control`, `no-store`,
  a 404 or an oversized body is metered once at the far edge and stored
  nowhere;
- during an update where only the installed build declares `/bundles/`,
  a cacheable fallback answered by the current build is not stored on
  home or far edges; the same when only the retiring build declares it;
- concurrent requests for one key with different `Accept` headers, a
  refused response carrying `Set-Cookie`, and an unacknowledged fill each
  reach only their initiating client, and the other waiters are metered
  separately;
- an alias detached while the box's claim listing fails for over an hour,
  with record reads and edges-file writes succeeding, stops being served
  from cache on home and far edges;
- an SVG with a restrictive `Content-Security-Policy` and
  `Cross-Origin-Resource-Policy` carries both on a hit as on the miss;
- identity and gzip responses from one entry both carry
  `Vary: Accept-Encoding`, the gzip `ETag` is weak, `If-Range` with it
  returns the whole body, and `no-transform` gets no gzip copy;
- sixteen concurrent maximum-size fills near the headroom threshold never
  take the filesystem below 15% free, and every exit releases its
  reservation;
- a generation-managed instance goes warm, sleeps and restarts, and its
  assets are still hits on home and far edges without a wake;
- a generation-managed route mid-update catches both builds' prefixes and
  drops the old one when its generation retires;
- a deleted and recreated hostname draws a new generation and serves no
  old entry;
- a hostname moved to another app or account serves none of the old
  owner's entries, on a warm cache;
- a purge makes every edge miss within one home-map poll of the rewritten
  file; with the home's record reads failing for over an hour while its
  edges-file writes keep succeeding, `at` stops advancing and every edge,
  the home included, stops catching for the host; a far edge whose
  winning entry's `at` is older than an hour forwards even though the
  file still reads successfully;
- an edge restarted with a warm disk serves nothing until its first read;
- one app requesting unbounded unique query strings evicts its own entries
  before another app's, and no fill starts under 15% free disk;
- a hibernating route's caught hit does not wake it;
- `/` and the other bad prefixes are refused at ship, at deploy and by
  the console's ship door with a CLI that skipped the check;
- a zero-arg `carlos deploy` with a saved `assets` default declares it;
  `--assets` replaces it for the run; `--no-assets` declares none.

Then, on dev: an asset requested through the far region's edge is a
`miss` once and a `hit` after, the home box's access log shows one request
for it, and a 5 MiB bundle is served gzip-compressed from the far edge.
On flagship, the same from a small box with the disk figures recorded.

## 13. Rulings (Paul, 2026-10-24)

- **R1. Instance off switch only.** Paths come from the release; an
  instance can switch catching off and cannot declare paths (§3.2).
- **R2. Asset bytes count the same.** Every hit and fill is the app's
  request and bytes out, against plan caps like any other response (§8).
- **R3. It works with `carlos deploy`** as well as `carlos ship` (§3.1).

## 14. Review log

- rev 1, 2026-10-24: first draft.
- rev 2, 2026-10-24: Astra builder check
  (`CODEX-FINDINGS-ASSET-CACHE-BUILDER.md`), all nine items accepted and
  verified against the tree: B1 random per-record generation; B2 no
  path-prefix hostnames; B3 only an armed home publishes; B4 and B7
  canonical paths only, never normalised; B5 union of live generations'
  paths; B6 marked fills bypass the home's LRU and meter; B8 `assets_at`
  stamp and record-view bound; B9 import gate excluded. Purge status no
  longer claims a far-edge acknowledgement it has no channel for.
  Rulings R1 and R2 recorded.
- rev 3, 2026-10-24: `carlos deploy` declares too (Paul): `--assets`,
  `--no-assets`, the `.carlos/config` default, and the console ship door's
  own check (§3.1).
- rev 4, 2026-10-24: Astra review round 1
  (`CODEX-FINDINGS-ASSET-CACHE-R1.md`, reject: 4 P1, 4 P2). All eight
  accepted after checking the citations: R1-1 and R1-2 the fill becomes a
  domain-separated signed assertion naming owner and generation, answered
  by a signed acknowledgement, with single metering either way; R1-3 `at`
  is the record view's per-scope confirmed time, never the write time;
  R1-4 every end-to-end header is kept; R1-5 the installed build is in
  the paths union so a sleeping instance keeps catching; R1-6 absolute
  expiry, age-corrected, carried to far copies; R1-7 representation rules
  from `compress.go`; R1-8 worst-case disk reservations.
- rev 5, 2026-10-24: Astra review round 2
  (`CODEX-FINDINGS-ASSET-CACHE-R2.md`, reject: 1 P1, 4 P2; R1-3 and R1-6
  partly fixed). All five accepted after checking the citations: R2-1
  alias `at` also bounded by the last successful alias reconcile; R2-2
  only a build that declared the prefix may fill it; R2-3 the
  acknowledgement transfers billing and `X-Carlos-Asset-Expires: 0` says
  "not storable"; R2-4 expiry is measured from response time; R2-5
  coalescing shares only an admitted entry.
- rev 6, 2026-10-24: Astra review round 3
  (`CODEX-FINDINGS-ASSET-CACHE-R3.md`, reject: 2 P1, 2 P2, 1 P3; R2-1 and
  R1-3 partly fixed). All five accepted after checking the citations. The
  two P1s were the third round in a row to find one more input left out
  of the freshness bound, so §4.2 now states it as an invariant over every
  input the catch decision reads, requires `ReconcileAliases` to report
  completeness, and §12 tests it as a table (R3-1, R3-2). R3-3 fill and
  waiter deadlines; R3-4 HTML is never stored; R3-5 arm the home first.
- round 4, 2026-10-24: Astra **accept** at 287a59c4, no new findings
  (`CODEX-FINDINGS-ASSET-CACHE-R4.md`).
- rev 7, 2026-10-24: corrections found while writing the plan, against
  the code: the records confirmed time comes from reconcile's own
  per-app completeness, with or without the record view (which is armed
  only under `CARLOS_CHANGE_INDEX=read`); the paths union is installed
  build plus `ServingGen`'s build (the activator keeps one current
  generation); manifests are read from the bucket, not the staged copy;
  every `ReconcileAliases` log-and-continue point and silent input is
  listed; the opt-out verb is `carlos instances set-assets`; two citation
  fixes.
- rev 8, 2026-10-24: Astra round 5 (`CODEX-FINDINGS-ASSET-CACHE-R5.md`,
  reject on one P2): the manifest cache keeps only declared paths, only
  for builds a local route references, pruned every pass (R5-1).
- round 6, 2026-10-24: Astra **accept** at 91debc18 (`CODEX-FINDINGS-ASSET-CACHE-R6.md`).
- rev 9, 2026-10-24: a response with no `Content-Type` is not stored
  (narrows rev 6's "absent and sniffed as HTML", which the header-time
  storability decision could not implement). Reviewed with the plans.
- plan review round 8, 2026-10-24: Astra **accept** of plans 1 to 3 and spec rev 9 at 4cca5784.
