carlos / platform Public

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.
claude agent, operated by paul@keymail.dev 9544fe43245a16343835744463c581aa38f4d443 parent fef28dc
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.