spec: edge asset cache rev 4 (Astra round 1 folded in)
Summary
Accepts all eight round-1 findings: signed fill assertion and acknowledgement bound to owner and generation, authority time from the record view instead of write time, full end-to-end header replay, installed build kept in the paths union across sleep, age-corrected absolute expiry, gzip representation rules, and worst-case disk reservations.
Agent notes
Findings committed unedited as CODEX-FINDINGS-ASSET-CACHE-R1.md. Each citation was checked before acceptance.
2 files changed,
+225
−63
docs/superpowers/specs/2026-10-24-edge-asset-cache-design.md+182 −63docs/superpowers/specs/CODEX-FINDINGS-ASSET-CACHE-R1.md+43 −0
CI passed — run details
CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (0 s)
ok amadan.net/carlos/platform.git/launchctx 0.016s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
=== vet (1 s)
=== gofmt (0 s)
gofmt -l .: clean
=== rest (144 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.715s
ok github.com/carlosframework/platform/cmd/moveprobe 11.882s
ok github.com/carlosframework/platform/cmd/zddprobe 0.746s
ok github.com/carlosframework/platform/internal/activator 58.695s
ok github.com/carlosframework/platform/internal/agentsock 0.023s
ok github.com/carlosframework/platform/internal/alerting 0.005s
ok github.com/carlosframework/platform/internal/analytics 0.019s
ok github.com/carlosframework/platform/internal/appconfig 2.991s
ok github.com/carlosframework/platform/internal/apperr 0.172s
ok github.com/carlosframework/platform/internal/applog 0.314s
ok github.com/carlosframework/platform/internal/awsinv 0.009s
ok github.com/carlosframework/platform/internal/boxchoice 0.011s
ok github.com/carlosframework/platform/internal/boxid 0.901s
? 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.010s
ok github.com/carlosframework/platform/internal/caps 0.026s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.754s
ok github.com/carlosframework/platform/internal/changelog 0.621s
ok github.com/carlosframework/platform/internal/claimactivation 1.342s
ok github.com/carlosframework/platform/internal/clamd 0.067s
? 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.477s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.013s
ok github.com/carlosframework/platform/internal/dbquery 0.007s
ok github.com/carlosframework/platform/internal/dbreads 2.092s
? github.com/carlosframework/platform/internal/dbreads/dbreadstest [no test files]
ok github.com/carlosframework/platform/internal/dbverify 0.007s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.011s
ok github.com/carlosframework/platform/internal/domains 2.942s
ok github.com/carlosframework/platform/internal/doorkey 0.008s
ok github.com/carlosframework/platform/internal/economics 0.008s
ok github.com/carlosframework/platform/internal/edge 45.373s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.164s
ok github.com/carlosframework/platform/internal/gate 1.817s
ok github.com/carlosframework/platform/internal/genselect 0.031s
ok github.com/carlosframework/platform/internal/geo 0.021s
ok github.com/carlosframework/platform/internal/geoip 0.045s
ok github.com/carlosframework/platform/internal/historyreadback 0.253s
ok github.com/carlosframework/platform/internal/host 141.082s
ok github.com/carlosframework/platform/internal/hostactors 5.631s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.006s
ok github.com/carlosframework/platform/internal/hostbox 3.276s
ok github.com/carlosframework/platform/internal/hostclaim 0.735s
ok github.com/carlosframework/platform/internal/hostenv 0.004s
ok github.com/carlosframework/platform/internal/hostevidence 0.008s
ok github.com/carlosframework/platform/internal/hostexec 5.479s
ok github.com/carlosframework/platform/internal/hostfence 0.035s
ok github.com/carlosframework/platform/internal/hostfiles 10.219s
ok github.com/carlosframework/platform/internal/hostfloor 0.023s
ok github.com/carlosframework/platform/internal/hostguard 1.072s
ok github.com/carlosframework/platform/internal/hostops 2.196s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.003s
ok github.com/carlosframework/platform/internal/hostquarantine 0.004s
ok github.com/carlosframework/platform/internal/hostrelocate 0.625s
ok github.com/carlosframework/platform/internal/hostresolve 0.157s
ok github.com/carlosframework/platform/internal/hostwrite 0.007s
ok github.com/carlosframework/platform/internal/infraview 0.002s
ok github.com/carlosframework/platform/internal/instancectl 5.247s
ok github.com/carlosframework/platform/internal/instanceexec 2.790s
ok github.com/carlosframework/platform/internal/instancefs 0.006s
ok github.com/carlosframework/platform/internal/instancekey 0.003s
ok github.com/carlosframework/platform/internal/instanceupdates 0.007s
ok github.com/carlosframework/platform/internal/layout 1.830s
ok github.com/carlosframework/platform/internal/lbhealth 0.002s
ok github.com/carlosframework/platform/internal/lease 10.078s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.564s
ok github.com/carlosframework/platform/internal/ledger 0.019s
ok github.com/carlosframework/platform/internal/mail 0.254s
ok github.com/carlosframework/platform/internal/maintenance 0.094s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.573s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.121s
ok github.com/carlosframework/platform/internal/metrics 0.006s
ok github.com/carlosframework/platform/internal/moves 21.275s
ok github.com/carlosframework/platform/internal/moves/archive 0.967s
ok github.com/carlosframework/platform/internal/objstore 0.275s
ok github.com/carlosframework/platform/internal/ops 0.182s
ok github.com/carlosframework/platform/internal/park 0.248s
ok github.com/carlosframework/platform/internal/plans 0.006s
ok github.com/carlosframework/platform/internal/privatefile 0.006s
ok github.com/carlosframework/platform/internal/provision 14.225s
ok github.com/carlosframework/platform/internal/publicrelease 0.010s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.550s
ok github.com/carlosframework/platform/internal/relay 7.236s
ok github.com/carlosframework/platform/internal/release 25.034s
ok github.com/carlosframework/platform/internal/rollout 3.737s
ok github.com/carlosframework/platform/internal/rolloutwords 0.007s
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.565s
ok github.com/carlosframework/platform/internal/s3compat 0.006s
ok github.com/carlosframework/platform/internal/safefile 0.006s
ok github.com/carlosframework/platform/internal/schedule 2.701s
ok github.com/carlosframework/platform/internal/seeds 0.011s
ok github.com/carlosframework/platform/internal/sim/engines 2.732s
ok github.com/carlosframework/platform/internal/sim/hostlife 10.499s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.824s
ok github.com/carlosframework/platform/internal/status 0.083s
ok github.com/carlosframework/platform/internal/steering 0.011s
ok github.com/carlosframework/platform/internal/stopevidence 0.195s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.011s
ok github.com/carlosframework/platform/internal/termfence 0.005s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.007s
ok github.com/carlosframework/platform/internal/usage 0.005s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.643s
=== internal_console@0 (83 s)
ok github.com/carlosframework/platform/internal/console 80.392s
=== internal_console@1 (44 s)
ok github.com/carlosframework/platform/internal/console 41.402s
=== internal_console@2 (41 s)
ok github.com/carlosframework/platform/internal/console 37.868s
=== internal_console@3 (40 s)
ok github.com/carlosframework/platform/internal/console 37.682s
=== internal_console@4 (43 s)
ok github.com/carlosframework/platform/internal/console 40.396s
=== internal_console@5 (40 s)
ok github.com/carlosframework/platform/internal/console 38.047s
=== internal_console@6 (36 s)
ok github.com/carlosframework/platform/internal/console 34.722s
=== internal_console@7 (45 s)
ok github.com/carlosframework/platform/internal/console 43.055s
=== root@0 (109 s)
ok github.com/carlosframework/platform 107.578s
=== root@1 (84 s)
ok github.com/carlosframework/platform 82.527s
=== root@2 (91 s)
ok github.com/carlosframework/platform 89.156s
=== 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: cached, digest verified
mc: cached, 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.029s
=== RUN TestT4StorageProbeMinio
2026/10/24 18:04:22 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E1894692F22E00, 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.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.027s
=== RUN TestS3StoreMinioContract
=== RUN TestS3StoreMinioContract/conditional_get_and_etag_listing
=== RUN TestS3StoreMinioContract/bump_through_every_mutation_path
--- PASS: TestS3StoreMinioContract (0.03s)
--- PASS: TestS3StoreMinioContract/conditional_get_and_etag_listing (0.01s)
--- PASS: TestS3StoreMinioContract/bump_through_every_mutation_path (0.02s)
PASS
ok github.com/carlosframework/platform/internal/release 0.038s
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 18:04:25 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:04:25 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:04:25 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:04:25 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 18:04:25 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 18:04:25 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 18:04:25 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000003
2026/10/24 18:04:25 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000003 maxTXID=0000000000000003 size=454
2026/10/24 18:04:25 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000003 txid.db=0000000000000003
2026/10/24 18:04:25 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 18:04:25 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 18:04:25 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 18:04:25 console: CARLOS_SECRETS_PUBKEY is unset, so no region outside the home can be chosen automatically
2026/10/24 18:04:25 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%3A38515: could not determine target mailbox
agent.go:135: dbreads: dbr_feyskt7fw563jlqnc6xp6cjv7m: planned 3 files (1544 bytes) and restored to 3 in 14ms
agent.go:135: dbreads: dbr_feyskt7fw563jlqnc6xp6cjv7m: output 5 bytes in 1 confirmed chunks in 2ms
2026/10/24 18:04:26 console: db read dbr_feyskt7fw563jlqnc6xp6cjv7m: relayed 1 chunks (102 bytes) in 1.005s, ended done
agent.go:135: dbreads: dbr_2pyc5jaq2hfxz43vhnorucajku: planned 1 files (720 bytes) and restored to 1 in 10ms
agent.go:135: dbreads: dbr_2pyc5jaq2hfxz43vhnorucajku: output 5 bytes in 1 confirmed chunks in 2ms
2026/10/24 18:04:27 console: db read dbr_2pyc5jaq2hfxz43vhnorucajku: relayed 1 chunks (102 bytes) in 1.005s, ended done
agent.go:135: dbreads: dbr_k2mdcqsuioeczflnkq2c5edr4e: planned 3 files (1544 bytes) and held through 3 in 13ms
agent.go:135: dbreads: dbr_k2mdcqsuioeczflnkq2c5edr4e: output 1544 bytes in 1 confirmed chunks in 1ms
2026/10/24 18:04:28 console: db read dbr_k2mdcqsuioeczflnkq2c5edr4e: relayed 1 chunks (1641 bytes) in 1.005s, ended done
=== RUN TestDBForwardMinio/by_disconnect
=== NAME TestDBForwardMinio
agent.go:135: dbreads: dbr_iky3g5v5p5veyaprn7ygyteubi: planned 3 files (1544 bytes) and restored to 3 in 12ms
2026/10/24 18:04:28 console: db read dbr_iky3g5v5p5veyaprn7ygyteubi: relayed 0 chunks (0 bytes) in 35ms, ended client gone
agent.go:135: dbreads: dbr_iky3g5v5p5veyaprn7ygyteubi: output 5 bytes in 1 confirmed chunks in 104ms
=== RUN TestDBForwardMinio/by_timeout
=== NAME TestDBForwardMinio
agent.go:135: dbreads: dbr_6lbet7d6hvgn7bo5d3evv33rzm: planned 3 files (1544 bytes) and restored to 3 in 12ms
2026/10/24 18:04:48 console: db read dbr_6lbet7d6hvgn7bo5d3evv33rzm: relayed 0 chunks (0 bytes) in 19.144s, ended no-result
agent.go:135: dbreads: dbr_6lbet7d6hvgn7bo5d3evv33rzm: output 5 bytes in 1 confirmed chunks in 19.115s
--- PASS: TestDBForwardMinio (22.62s)
--- PASS: TestDBForwardMinio/by_disconnect (0.25s)
--- PASS: TestDBForwardMinio/by_timeout (19.26s)
PASS
ok github.com/carlosframework/platform/internal/console 22.640s
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| index 07dd0347b..c16a58503 100644 |
| --- a/docs/superpowers/specs/2026-10-24-edge-asset-cache-design.md |
| +++ b/docs/superpowers/specs/2026-10-24-edge-asset-cache-design.md |
| @@ -1,6 +1,6 @@ |
| # Edge asset cache: versioned build assets served from every edge |
| -Branch `edge-asset-cache`. Status: rev 3, 2026-10-24, builder check folded in; rulings R1 to R3 (§13). Awaiting Paul's approval, then Astra review. |
| +Branch `edge-asset-cache`. Status: rev 4, 2026-10-24, builder check folded in; rulings R1 to R3 (§13). Approved by Paul 2026-10-24; Astra review round 2 next. |
| Related: media roadmap (`2026-09-07-media-platform-roadmap-design.md`), |
| which this does not amend; #368 (the shared cache stores 206 responses). |
| @@ -200,24 +200,29 @@ The home box publishes no prefixes for a hostname when any of these hold: |
| When a box writes its edges file, each `edgeHost` it emits for a hostname |
| that catches gains: |
| - "assets": {"paths": ["/assets/"], "gen": "9f2c41d07a3be516"} |
| + "assets": {"paths": ["/assets/"], "gen": "9f2c41d07a3be516", "at": 1792828800} |
| -and the file itself gains `"assets_at": <unix seconds>` (§4.2). |
| +(`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 every build the route is |
| - serving, minus the §3.3 exclusions. For a route that is not |
| +- `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 declared paths of every generation that |
| - is currently live on the route. During an update both builds' assets |
| - are caught, and the old build's prefix drops out when its generation |
| - retires **(astra B5)**. Reading those manifests happens on the home box, |
| + publishes the **union** of the installed build's declared paths and those |
| + of every generation currently live on the route **(astra B5)**. 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. Reading those manifests happens on the home box, |
| which already holds them; no other box reads them. |
| - `gen` is the instance's asset generation (§6), copied from the instance |
| record through the route row. |
| @@ -227,7 +232,8 @@ and the file itself gains `"assets_at": <unix seconds>` (§4.2). |
| 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. |
| +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 |
| @@ -240,20 +246,35 @@ is read; the 30 s poll and the change-index gate are unchanged. |
| 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)**. So the publisher stamps `assets_at`, its own wall clock at |
| -write time, and rewrites its file whenever `assets_at` is older than 20 |
| -minutes while it publishes any `assets` block. An edge serves and fills a |
| -far host's caught paths only while that host's winning file has |
| -`assets_at` within the last hour by the reader's clock. Past that it stops |
| -admitting hits and fills for the host and forwards as today. Cost: about |
| -three extra PUTs per publishing box per hour and the `control/edges` |
| -bumps they cause, which every edge already reads. |
| - |
| -For a **local** route (§5.1) the bound is the box's own record view: the |
| -box catches for its own routes only while its last successful record |
| -convergence (the change-index record view's refresh, or the backstop walk) |
| -is within the last hour, so a home box cut off from the bucket stops |
| -serving entries a purge it cannot see may have retired. |
| +**(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 record |
| +view (`internal/provision/recordview.go`) gains a per-scope confirmed |
| +time: the time of the last change-index read (or backstop walk) that |
| +succeeded **and** left the app's scope `apps/<acct>/<app>` applied rather |
| +than pending, **and** after which the registry rows for that scope were |
| +converged. A refresh that returns nil while a scope stays pending |
| +(`recordview.go:164-175`: a failed scope read is logged and left pending, |
| +and the snapshot is published incomplete) does not advance that scope's |
| +time. Publishing never advances it. The publisher copies the scope's |
| +confirmed time into each host's `at`. |
| + |
| +- 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 |
| @@ -313,19 +334,50 @@ same key wait for it. Fills are bounded per box (16 concurrent) and per app |
| than queued. Fills from a far edge go through `backhaulAcquire` like any |
| hop. |
| -**A far edge's fill is marked.** 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 fill therefore |
| -carries `X-Carlos-Asset-Fill: 1` on the hop. The home honours it only on a |
| -verified hop (`acceptHop`) and only when armed; it then answers from its |
| -own asset cache, or fills it from the app, and never reads or writes the |
| -shared LRU for that request and never meters it (the far edge meters, |
| -§8). The marker is stripped from every non-hop request, like the other hop |
| -headers. Because only an armed home publishes (§4.1), a far edge sends |
| -marked fills only to armed homes, except in the seconds after a home is |
| -disarmed and before far edges read its next file; in that window the home |
| -treats a marked fill as an ordinary hop, which is today's behaviour. |
| +**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: |
| + `X-Carlos-Asset-Expires` is the entry's absolute expiry (§5.4) and |
| + `X-Carlos-Asset-Fill-Ack` is HMAC-SHA256 under the mesh key over |
| + `carlos-asset-fill-ack-v1`, the request's assertion and the expiry. |
| +- **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 stores a response only when it carries a valid |
| + acknowledgement of its own assertion, and meters only acknowledged fills |
| + (and its own hits). A response without one 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: |
| @@ -335,6 +387,13 @@ A response is stored only when all hold: |
| 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 request time plus the |
| + lifetime minus the corrected initial age (RFC 9111 §4.2.3, from the |
| + response's `Age` and the response delay). 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); |
| @@ -357,30 +416,47 @@ 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. |
| +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 `ETag`; |
| - 416 when unsatisfiable. `http.ServeContent` semantics over the file are |
| - acceptable. |
| -- gzip copy when `Accept-Encoding` allows it and no `Range` is present. |
| + 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 `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. |
| +- `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 |
| @@ -427,18 +503,30 @@ Expected latency: under a minute (edges-file rewrite within about 2 s, |
| 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. |
| +- **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 registry has loaded (for local |
| - routes), so an entry is never served against a generation or owner it |
| - has not checked. |
| + 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 |
| @@ -512,9 +600,28 @@ routes. Attacks first: |
| 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; |
| -- a marked fill is not metered and does not touch the home's shared LRU; |
| - an unmarked hop is unchanged; a client-sent `X-Carlos-Asset-Fill` is |
| - stripped; |
| +- 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; |
| +- 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 |
| @@ -522,9 +629,11 @@ routes. Attacks first: |
| - 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; a far edge whose winning file's `assets_at` is older than an hour |
| - forwards even though the file still reads successfully; a home whose |
| - record view is older than an hour stops catching for its own routes; |
| + 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; |
| @@ -562,3 +671,13 @@ On flagship, the same from a small box with the disk figures 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. |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-ASSET-CACHE-R1.md b/docs/superpowers/specs/CODEX-FINDINGS-ASSET-CACHE-R1.md| new file mode 100644 |
| index 000000000..db61a59af |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-ASSET-CACHE-R1.md |
| @@ -0,0 +1,43 @@ |
| +1. **P1 — An old edge can turn a client-supplied fill marker into an authenticated fill.** |
| + §5.3 trusts `X-Carlos-Asset-Fill: 1` whenever `acceptHop` succeeds. An old far edge forwards that unknown client header and adds legitimate hop authentication ([edge.go:3032](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/internal/edge/edge.go:3032)). The signature covers only hostname and timestamp, not the fill marker ([mesh.go:179](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/internal/edge/mesh.go:179)). Consequently, a client reaching an old edge can make an upgraded home suppress metering, while the old edge also records nothing. Stripping the marker on upgraded ingress edges does not close this mixed-fleet path. |
| + |
| + **Smallest fix:** Require a separate, authenticated fill assertion covering the request and fill identity, with a signature domain distinct from ordinary hop authentication. Add an old-far/new-home test carrying a forged marker and require ordinary billing. |
| + |
| +2. **P1 — A purge can populate the new generation with the old generation’s bytes.** |
| + The publisher reads the registry independently ([edgesfile.go:877](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/internal/host/edgesfile.go:877)); the serving edge updates through its separate watcher ([edge.go:2332](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/internal/edge/edge.go:2332)). Therefore: the registry reaches G2, the publisher advertises G2, a far edge reads G2, but the home still serves G1. The marker in [§5.3](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/docs/superpowers/specs/2026-10-24-edge-asset-cache-design.md:320) carries no expected generation. The home can return its G1 cache hit, which the far edge stores under G2 for the full lifetime. Subsequent convergence cannot invalidate that entry. The explicitly permitted disarmed-home fallback also admits responses from the shared LRU and restores double billing. |
| + |
| + **Smallest fix:** Bind fills to the expected account, app and asset generation. Require a home-generated acknowledgement of that identity and fill handling before far-edge admission. Mismatches and unarmed homes must not populate the asset cache. Test publication ahead of home reload, disarming, and ownership changes during fills. |
| + |
| +3. **P1 — The freshness fix still allows stale authority to be renewed indefinitely.** |
| + [§4.2](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/docs/superpowers/specs/2026-10-24-edge-asset-cache-design.md:243) renews `assets_at` at publication time, independently of successful instance convergence. The publisher builds from local registry rows ([edgesfile.go:420](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/internal/host/edgesfile.go:420)). If instance reads fail while edges-file writes continue, it can repeatedly advertise an obsolete generation with a fresh timestamp. Far-edge hits then survive beyond the promised hour. |
| + |
| + There is another trap in the proposed local bound: `RecordView.Refresh` can return nil after individual scope reads fail; completeness is reported separately ([recordview.go:164](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/internal/provision/recordview.go:164)). |
| + |
| + **Smallest fix:** Tie serving and publication expiry to successfully validated **and applied** authoritative state. Publication alone must never renew that expiry. Require the appropriate snapshot-completeness checks and fresh convergence after restart. Test failed instance reads with successful edges-file publication for longer than an hour. |
| + |
| +4. **P1 — Cached responses lose security headers.** |
| + The exhaustive stored-header list in [§5.4](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/docs/superpowers/specs/2026-10-24-edge-asset-cache-design.md:360) omits `Content-Security-Policy` and `Cross-Origin-Resource-Policy`. An otherwise admissible SVG can contain script that the origin deliberately disables through CSP. Opening the cached SVG would execute that script because the hit drops the policy. This changes security behavior between miss and hit. The existing cache preserves the response headers ([edge.go:1907](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/internal/edge/edge.go:1907)) and replays them ([edge.go:2121](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/internal/edge/edge.go:2121)). |
| + |
| + **Smallest fix:** Preserve security-relevant end-to-end response headers, or refuse admission when their semantics cannot be preserved. Add an SVG response with restrictive CSP and CORP to the miss/hit tests. |
| + |
| +5. **P2 — “Union of live generations” removes catching when an instance hibernates.** |
| + [§4.1](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/docs/superpowers/specs/2026-10-24-edge-asset-cache-design.md:211) derives declarations exclusively from currently live generations. Hibernation deletes the activator entry and republishes the generation inventory without that build ([activator.go:4393](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/internal/activator/activator.go:4393)); `ServingGen` then returns false ([genservice.go:68](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/internal/activator/genservice.go:68)). The union becomes empty, so warmed assets cease to be caught and the next request can wake the app. The existing cache explicitly retains `CacheGen` after sleep for this reason ([gensource.go:29](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/internal/edge/gensource.go:29)). |
| + |
| + **Smallest fix:** Define declaration state that survives sleep, tied to the instance’s owner and asset generation and updated appropriately on deployment. Test a generation-managed instance through warm → sleep → home/far cache hit, including restart. |
| + |
| +6. **P2 — A home-cache hit receives a new full lifetime at the far edge.** |
| + [§5.3](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/docs/superpowers/specs/2026-10-24-edge-asset-cache-design.md:334) derives lifetime solely from `s-maxage` or `max-age`; §5.4 retains neither `Date` nor upstream age. Yet marked fills can return home-cache hits. With `max-age=3600`, a home response already 3,599 seconds old can receive another 3,600 seconds at the far edge. Emitting `Age` on later hits does not correct admission or expiry. Reusing the existing insertion pattern would produce exactly this reset ([edge.go:1907](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/internal/edge/edge.go:1907)). |
| + |
| + **Smallest fix:** Specify age-aware admission and persist the original freshness deadline, accounting for incoming `Age`, `Date`, and transfer time. Test a nearly expired home entry filled through a far edge and require both copies to expire at the original deadline. |
| + |
| +7. **P2 — The gzip serving rules omit representation correctness.** |
| + [§§5.4–5.5](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/docs/superpowers/specs/2026-10-24-edge-asset-cache-design.md:360) retain one strong ETag while serving either identity or gzip, omit outgoing `Vary: Accept-Encoding`, and permit compression despite `Cache-Control: no-transform`. A downstream cache can consequently reuse gzip for a client that cannot decode it; the shared strong validator also makes byte-range validation unsafe. Existing compression code already handles these requirements: it respects `no-transform` ([compress.go:112](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/internal/edge/compress.go:112)), adds `Vary`, and weakens the gzip ETag ([compress.go:237](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/internal/edge/compress.go:237)). |
| + |
| + **Smallest fix:** Require equivalent behavior for precompressed entries: appropriate `Vary`, representation-specific or weakened validators, and no transformation when prohibited. Test identity/gzip conditional requests and `If-Range`. |
| + |
| +8. **P2 — Checking free space only when a fill starts does not preserve the disk floor.** |
| + [§7](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/docs/superpowers/specs/2026-10-24-edge-asset-cache-design.md:430) claims that refusing starts below 15% free prevents crossing 10%. On an 8 GiB filesystem at 15% free, sixteen simultaneous 32 MiB identity fills consume 512 MiB and leave only 8.75% free—even before gzip copies. Spread across four apps, these fills satisfy both concurrency limits and fit within the default total budget. The actual placement floor is 10% ([policy.go:20](/home/paulca/amadan.net/carlos/platform-edge-asset-cache/internal/boxchoice/policy.go:20)). |
| + |
| + **Smallest fix:** Reserve disk capacity atomically before admitting fills, accounting for concurrent temporary files, identity bodies, gzip copies and metadata. Bound writes against those reservations and release them on every exit. Test simultaneous maximum-size fills near the headroom threshold. |
| + |
| +reject |
| \ No newline at end of file |