spec: edge asset cache rev 1 (draft for builder check)
Summary
First draft of the edge asset cache design: apps declare build-asset path prefixes at ship; every edge serves them from a bounded disk cache, pull-through, with an app-wide purge generation carried in the edges file.
Agent notes
Questions for Paul (§13) are filled after the builder check. #368 filed for the shared LRU's 206 gap found during research.
1 file changed,
+439
−0
docs/superpowers/specs/2026-10-24-edge-asset-cache-design.md+439 −0
CI passed — run details
CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (2 s)
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
=== vet (19 s)
=== gofmt (1 s)
gofmt -l .: clean
=== rest (169 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.482s
ok github.com/carlosframework/platform/cmd/moveprobe 11.805s
ok github.com/carlosframework/platform/cmd/zddprobe 0.699s
ok github.com/carlosframework/platform/internal/activator 60.934s
ok github.com/carlosframework/platform/internal/agentsock 0.031s
ok github.com/carlosframework/platform/internal/alerting 0.007s
ok github.com/carlosframework/platform/internal/analytics 0.027s
ok github.com/carlosframework/platform/internal/appconfig 2.922s
ok github.com/carlosframework/platform/internal/apperr 0.148s
ok github.com/carlosframework/platform/internal/applog 0.227s
ok github.com/carlosframework/platform/internal/awsinv 0.004s
ok github.com/carlosframework/platform/internal/boxchoice 0.007s
ok github.com/carlosframework/platform/internal/boxid 1.045s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.015s
ok github.com/carlosframework/platform/internal/caps 0.016s
ok github.com/carlosframework/platform/internal/certwarm 0.004s
ok github.com/carlosframework/platform/internal/cgroup 0.747s
ok github.com/carlosframework/platform/internal/changelog 0.604s
ok github.com/carlosframework/platform/internal/claimactivation 1.164s
ok github.com/carlosframework/platform/internal/clamd 0.058s
? github.com/carlosframework/platform/internal/cliflag [no test files]
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 8.181s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.012s
ok github.com/carlosframework/platform/internal/dbquery 0.016s
ok github.com/carlosframework/platform/internal/dbreads 2.250s
? github.com/carlosframework/platform/internal/dbreads/dbreadstest [no test files]
ok github.com/carlosframework/platform/internal/dbverify 0.006s
ok github.com/carlosframework/platform/internal/dnsrec 0.004s
ok github.com/carlosframework/platform/internal/docsite 0.016s
ok github.com/carlosframework/platform/internal/domains 2.823s
ok github.com/carlosframework/platform/internal/doorkey 0.010s
ok github.com/carlosframework/platform/internal/economics 0.003s
ok github.com/carlosframework/platform/internal/edge 45.825s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.020s
ok github.com/carlosframework/platform/internal/fleetapi 0.171s
ok github.com/carlosframework/platform/internal/gate 1.709s
ok github.com/carlosframework/platform/internal/genselect 0.020s
ok github.com/carlosframework/platform/internal/geo 0.022s
ok github.com/carlosframework/platform/internal/geoip 0.041s
ok github.com/carlosframework/platform/internal/historyreadback 0.227s
ok github.com/carlosframework/platform/internal/host 139.622s
ok github.com/carlosframework/platform/internal/hostactors 5.951s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.005s
ok github.com/carlosframework/platform/internal/hostbox 2.959s
ok github.com/carlosframework/platform/internal/hostclaim 0.816s
ok github.com/carlosframework/platform/internal/hostenv 0.005s
ok github.com/carlosframework/platform/internal/hostevidence 0.006s
ok github.com/carlosframework/platform/internal/hostexec 5.210s
ok github.com/carlosframework/platform/internal/hostfence 0.037s
ok github.com/carlosframework/platform/internal/hostfiles 10.326s
ok github.com/carlosframework/platform/internal/hostfloor 0.016s
ok github.com/carlosframework/platform/internal/hostguard 1.070s
ok github.com/carlosframework/platform/internal/hostops 1.883s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.011s
ok github.com/carlosframework/platform/internal/hostquarantine 0.007s
ok github.com/carlosframework/platform/internal/hostrelocate 0.544s
ok github.com/carlosframework/platform/internal/hostresolve 0.186s
ok github.com/carlosframework/platform/internal/hostwrite 0.005s
ok github.com/carlosframework/platform/internal/infraview 0.005s
ok github.com/carlosframework/platform/internal/instancectl 5.237s
ok github.com/carlosframework/platform/internal/instanceexec 2.753s
ok github.com/carlosframework/platform/internal/instancefs 0.006s
ok github.com/carlosframework/platform/internal/instancekey 0.004s
ok github.com/carlosframework/platform/internal/instanceupdates 0.017s
ok github.com/carlosframework/platform/internal/layout 1.710s
ok github.com/carlosframework/platform/internal/lbhealth 0.004s
ok github.com/carlosframework/platform/internal/lease 10.110s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.547s
ok github.com/carlosframework/platform/internal/ledger 0.015s
ok github.com/carlosframework/platform/internal/mail 0.246s
ok github.com/carlosframework/platform/internal/maintenance 0.101s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.600s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.117s
ok github.com/carlosframework/platform/internal/metrics 0.006s
ok github.com/carlosframework/platform/internal/moves 22.225s
ok github.com/carlosframework/platform/internal/moves/archive 1.046s
ok github.com/carlosframework/platform/internal/objstore 0.278s
ok github.com/carlosframework/platform/internal/ops 0.224s
ok github.com/carlosframework/platform/internal/park 0.286s
ok github.com/carlosframework/platform/internal/plans 0.007s
ok github.com/carlosframework/platform/internal/privatefile 0.006s
ok github.com/carlosframework/platform/internal/provision 23.077s
ok github.com/carlosframework/platform/internal/publicrelease 0.012s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.479s
ok github.com/carlosframework/platform/internal/relay 7.250s
ok github.com/carlosframework/platform/internal/release 25.866s
ok github.com/carlosframework/platform/internal/rollout 3.776s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.060s
ok github.com/carlosframework/platform/internal/rootstate 0.028s
ok github.com/carlosframework/platform/internal/runs 0.582s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.006s
ok github.com/carlosframework/platform/internal/schedule 2.611s
ok github.com/carlosframework/platform/internal/seeds 0.016s
ok github.com/carlosframework/platform/internal/sim/engines 2.602s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.889s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.977s
ok github.com/carlosframework/platform/internal/status 0.079s
ok github.com/carlosframework/platform/internal/steering 0.007s
ok github.com/carlosframework/platform/internal/stopevidence 0.259s
ok github.com/carlosframework/platform/internal/svcrole 0.004s
ok github.com/carlosframework/platform/internal/telemetry 0.006s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.008s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.006s
ok github.com/carlosframework/platform/internal/version 0.004s
ok github.com/carlosframework/platform/internal/vet 1.710s
=== internal_console@0 (87 s)
ok github.com/carlosframework/platform/internal/console 85.246s
=== internal_console@1 (44 s)
ok github.com/carlosframework/platform/internal/console 41.765s
=== internal_console@2 (41 s)
ok github.com/carlosframework/platform/internal/console 39.163s
=== internal_console@3 (42 s)
ok github.com/carlosframework/platform/internal/console 39.861s
=== internal_console@4 (43 s)
ok github.com/carlosframework/platform/internal/console 41.381s
=== internal_console@5 (42 s)
ok github.com/carlosframework/platform/internal/console 39.842s
=== internal_console@6 (37 s)
ok github.com/carlosframework/platform/internal/console 35.592s
=== internal_console@7 (46 s)
ok github.com/carlosframework/platform/internal/console 43.776s
=== root@0 (110 s)
ok github.com/carlosframework/platform 107.932s
=== root@1 (84 s)
ok github.com/carlosframework/platform 82.090s
=== root@2 (92 s)
ok github.com/carlosframework/platform 89.905s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 50528418 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
minio: downloaded, digest verified
mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
mc: downloaded, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.022s
=== RUN TestT4StorageProbeMinio
2026/10/24 17:33:06 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E187919E83653C, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.00s)
PASS
ok github.com/carlosframework/platform/internal/host 0.021s
=== RUN TestS3StoreMinioContract
=== RUN TestS3StoreMinioContract/conditional_get_and_etag_listing
=== RUN TestS3StoreMinioContract/bump_through_every_mutation_path
--- PASS: TestS3StoreMinioContract (0.02s)
--- PASS: TestS3StoreMinioContract/conditional_get_and_etag_listing (0.01s)
--- PASS: TestS3StoreMinioContract/bump_through_every_mutation_path (0.01s)
PASS
ok github.com/carlosframework/platform/internal/release 0.029s
Bucket created successfully `local/dbf-deploy`.
Bucket created successfully `local/dbf-data-ie`.
Bucket created successfully `local/dbf-data-uk`.
local/dbf-deploy versioning is enabled
Created policy `dbf-console` successfully.
Created policy `dbf-ukbox` successfully.
Added user `dbfconsole` successfully.
Added user `dbfukbox` successfully.
Attached Policies: [dbf-console]
To User: dbfconsole
Attached Policies: [dbf-ukbox]
To User: dbfukbox
=== RUN TestDBForwardMinio
2026/10/24 17:33:08 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 17:33:08 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 17:33:08 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 17:33:08 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 17:33:08 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 17:33:08 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 17:33:08 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 17:33:08 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 17:33:08 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 17:33:08 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000003
2026/10/24 17:33:08 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000003 maxTXID=0000000000000003 size=454
2026/10/24 17:33:08 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000003 txid.db=0000000000000003
2026/10/24 17:33:08 console: CARLOS_SECRETS_PUBKEY is unset, so no region outside the home can be chosen automatically
2026/10/24 17:33:08 console: could not notify member@example.com of a CLI token issue: keymail: lookup member@example.com: keymail: GET /api/federation/lookup?addr=member%40example.com&from=console%40127.0.0.1%3A41073: could not determine target mailbox
agent.go:135: dbreads: dbr_z4ogtl3cvs7kqzb6xzakyxgbiq: planned 3 files (1544 bytes) and restored to 3 in 9ms
agent.go:135: dbreads: dbr_z4ogtl3cvs7kqzb6xzakyxgbiq: output 5 bytes in 1 confirmed chunks in 1ms
2026/10/24 17:33:09 console: db read dbr_z4ogtl3cvs7kqzb6xzakyxgbiq: relayed 1 chunks (102 bytes) in 1.004s, ended done
agent.go:135: dbreads: dbr_w4ltucg7hynuoijfqt23udug2y: planned 1 files (720 bytes) and restored to 1 in 7ms
agent.go:135: dbreads: dbr_w4ltucg7hynuoijfqt23udug2y: output 5 bytes in 1 confirmed chunks in 1ms
2026/10/24 17:33:10 console: db read dbr_w4ltucg7hynuoijfqt23udug2y: relayed 1 chunks (102 bytes) in 1.003s, ended done
agent.go:135: dbreads: dbr_vtchwh46mi3lbfscewfzhcoj3u: planned 3 files (1544 bytes) and held through 3 in 8ms
agent.go:135: dbreads: dbr_vtchwh46mi3lbfscewfzhcoj3u: output 1544 bytes in 1 confirmed chunks in 1ms
2026/10/24 17:33:11 console: db read dbr_vtchwh46mi3lbfscewfzhcoj3u: relayed 1 chunks (1641 bytes) in 1.003s, ended done
=== RUN TestDBForwardMinio/by_disconnect
=== NAME TestDBForwardMinio
agent.go:135: dbreads: dbr_u5ssjav3qynhipzwibgovrpz24: planned 3 files (1544 bytes) and restored to 3 in 10ms
2026/10/24 17:33:11 console: db read dbr_u5ssjav3qynhipzwibgovrpz24: relayed 0 chunks (0 bytes) in 17ms, ended client gone
agent.go:135: dbreads: dbr_u5ssjav3qynhipzwibgovrpz24: output 5 bytes in 1 confirmed chunks in 103ms
=== RUN TestDBForwardMinio/by_timeout
=== NAME TestDBForwardMinio
agent.go:135: dbreads: dbr_3ipizss4uar74itdaonq6gkjbu: planned 3 files (1544 bytes) and restored to 3 in 9ms
2026/10/24 17:33:31 console: db read dbr_3ipizss4uar74itdaonq6gkjbu: relayed 0 chunks (0 bytes) in 19.312s, ended no-result
agent.go:135: dbreads: dbr_3ipizss4uar74itdaonq6gkjbu: output 5 bytes in 1 confirmed chunks in 19.289s
--- PASS: TestDBForwardMinio (22.74s)
--- PASS: TestDBForwardMinio/by_disconnect (0.23s)
--- PASS: TestDBForwardMinio/by_timeout (19.42s)
PASS
ok github.com/carlosframework/platform/internal/console 22.761s
minio contract: PASS
diff --git a/docs/superpowers/specs/2026-10-24-edge-asset-cache-design.md b/docs/superpowers/specs/2026-10-24-edge-asset-cache-design.md| new file mode 100644 |
| index 000000000..ab460135c |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-10-24-edge-asset-cache-design.md |
| @@ -0,0 +1,439 @@ |
| +# Edge asset cache: versioned build assets served from every edge |
| + |
| +Branch `edge-asset-cache`. Status: rev 1, 2026-10-24, draft for review. |
| +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 time. |
| +- 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. |
| + |
| +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 `carlos instances update <host> |
| +--assets=off|on`. It reaches the route row through `RouteFor` and |
| +`convergeOwned` like `Steering` and `Gate`. Use: an instance whose traffic |
| +must always reach the app, or a quick off switch while debugging. |
| +(My call; see Q1.) |
| + |
| +### 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 hostname has no single owner, by the same rule that decides the |
| + existing `edgeHost.Account`/`App` fields (`edgesfile.go:73-80`): a |
| + hostname split between apps by path routing catches nothing; |
| +- 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": 3} |
| + |
| +- `paths` comes from the release manifest of the version the route is |
| + serving (the same version `t.Version` stamps after adopt), minus the |
| + §3.3 exclusions. Reading that manifest happens on the home box, which |
| + already holds it; no other box reads it. |
| +- `gen` is the app's asset generation (§6), copied from the instance |
| + record through the route row. |
| +- Aliases (custom domains) publish the same block as their primary. |
| +- Additive and `omitempty`: a file without it parses byte-identically, and |
| + an older reader ignores it. |
| + |
| +A change to either field 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. |
| + |
| +### 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)` 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.** An edge serves a caught path from disk only while its |
| +view of that host's entry is younger than one hour (the time since the |
| +home map last successfully read the winning file). Past that it stops |
| +admitting hits and fills for the host and forwards as today. 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 |
| +is my call; it is a constant, not a knob. |
| + |
| +## 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 |
| +(after the same cleaning `lookupUpstreamPath` applies) starts with a |
| +published prefix; the box is armed (§10). 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 |
| + |
| +One fill per key at a time (singleflight); concurrent requests for the |
| +same key wait for it. 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. |
| + |
| +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; |
| +- `Vary` absent, or exactly `Accept-Encoding`; anything else (`Origin`, |
| + `Cookie`, `*`, `Accept`) is refused; |
| +- no `Content-Encoding` other than identity (the fill asked for identity); |
| +- body at most 32 MiB, measured while streaming; a larger body is streamed |
| + to the client and discarded. |
| + |
| +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, cleaned path, raw query)`. 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, a gzip copy for compressible types |
| +(`text/*`, `application/javascript`, `application/json`, `image/svg+xml`, |
| +`application/wasm`) made once at fill, and the stored headers: |
| +`Content-Type`, `Cache-Control`, `ETag`, `Last-Modified`, |
| +`Access-Control-Allow-Origin` (only the value `*`; any other value is |
| +refused at admission because it would need `Vary: Origin`), |
| +`Content-Disposition`, `X-Content-Type-Options`. 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 `ETag`; |
| + 416 when unsatisfiable. `http.ServeContent` semantics over the file are |
| + acceptable. |
| +- gzip copy when `Accept-Encoding` allows it and no `Range` is present. |
| +- Added on every caught response, hit or not: `Carlos-Cache: hit`, `miss` |
| + or `bypass` (the last for anything forwarded uncached). A hit carries |
| + no `Carlos-Version`: an entry can outlive many deploys, so a version on |
| + it would be a false "verified" for the deploy watch (`edge.go:630-660` |
| + explains the header's freshness contract). |
| +- `Age` on hits. |
| + |
| +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`) bumps an integer, the app's asset |
| +generation, on every instance record of the app. It flows record -> |
| +`convergeOwned` -> route row -> edges file -> every edge's home map, and |
| +every key under the old generation stops matching at once. Old entries are |
| +garbage, reclaimed by eviction and by a sweep that deletes entries whose |
| +generation is below 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 the declared paths of the serving |
| +release, the opt-out, the generation, and for each box whether its last |
| +read of the home's edges file carried the current generation. "Every box |
| +current" is the honest completion signal; a disconnected box is covered by |
| +the one-hour freshness bound (§4.2), and the status says so. |
| + |
| +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. |
| +- Headroom: no fill starts while the filesystem has under 15% free, so the |
| + cache can never be what pushes a box past the 10% placement floor or the |
| + 90% alert. |
| +- 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 registry has loaded (for local |
| + routes), 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. (My call; see Q2.) |
| + |
| +## 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. Off by default; an unarmed box |
| + forwards exactly as today. Publication of `assets` in the edges file is |
| + always on once the binary ships; it changes nothing on a reader that is |
| + unarmed or old. |
| +- **Mixed fleet.** An old home box publishes nothing, so nothing is caught |
| + for its hosts. An old far edge ignores the field and backhauls. An armed |
| + far edge whose home is old forwards. 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. Arm one dev box far from a test |
| + app's home, prove the gate (§12), arm the rest of dev, then flagship one |
| + box at a time, a small box (US or AU) first to watch disk. |
| +- **CLI.** `carlos ship --assets`, `carlos assets purge`, `carlos assets |
| + status`, `carlos instances update --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 (`200 text/html`, `no-cache`) for an unknown hashed name |
| + is not stored; |
| +- a gated route, a pooled route, an opted-out instance and a hostname split |
| + between two apps publish nothing and catch nothing; |
| +- 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; an edge whose view is older than an hour forwards; |
| +- 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. |
| + |
| +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. Questions for Paul |
| + |
| +To be filled after the builder check. |
| + |
| +## 14. Review log |
| + |
| +- rev 1, 2026-10-24: first draft. |