# Edge asset cache, plan 2: the disk store Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** A self-contained package, `internal/assetcache`, that stores cached asset responses on a box's disk under a budget, with worst-case reservations, a free-space floor, per-box and per-app fill bounds, fair-share eviction, expiry and a sweep, and that nothing calls yet (plan 3 does).

**Architecture:** One `Store` per process over one directory. Each entry is a `<id>.meta` file (JSON) naming its versioned body files, `<id>.<v>.body` (identity) and optional `<id>.<v>.gz`, where `<id>` is the hex sha256 of the key and `<v>` a random version drawn per commit. A commit writes the new versioned bodies, then renames a temporary over `<id>.meta`, then deletes the previous version's bodies, so a crash at any point leaves either the old entry or the new one, never new bytes under old metadata (astra P1-9). An in-memory index is rebuilt from the `.meta` files at `Open`. A fill is a `Fill` holding a reservation; it writes temporaries in the same directory and renames them on `Commit`. Free space comes from an `FS` interface whose Linux implementation is `statfs`. The package knows nothing about HTTP semantics beyond storing headers it is given: admission, freshness rules and serving belong to plan 3. The design is spec §7, written so the media roadmap's stage 1 can reuse it with its own directory and budget (spec §11).

**Tech Stack:** Go 1.26 standard library (`os`, `compress/gzip`, `crypto/sha256`, `encoding/json`), `golang.org/x/sys/unix` for `Statfs` on Linux.

**Spec:** `docs/superpowers/specs/2026-10-24-edge-asset-cache-design.md` (rev 8). Read §5.3 ("Coalescing", "Every fill has a deadline"), §5.4 and §7 before starting.

**Branch:** `edge-asset-cache-2`, cut from amadan `main` after the spec branch has merged. Independent of plan 1.

## Global Constraints

Every task's requirements include this section.

- **Real files.** Every test runs on a real directory from `t.TempDir()`. A fake `FS` (free-space source) is allowed and is the only stand-in; tests using it say "not acceptance" in the doc comment. Task 2's `TestStatfsReportsTheTempDir` is the acceptance test for the real `FS` and runs on Linux only.
- **Targeted local runs; CI runs the full suite.** `go test ./internal/assetcache/ -count=1` is allowed in full (new, small package). `go build ./...`, `go vet ./...`, `gofmt -l .` run locally.
- **Cross-compile gate.** `internal/edge` is linked into the CLI, and plan 3 imports this package from it. `statfs_linux.go` carries `//go:build linux`; `statfs_other.go` carries `//go:build !linux` and returns `ErrUnsupported`. Before every push: `GOOS=darwin GOARCH=arm64 go build ./...` and `GOOS=windows GOARCH=amd64 go build ./...`.
- **No user-facing strings.** Every error in this package is an internal fragment, so no copy rows.
- **No tenant names.** Keys in tests use account `abc`, apps `shop` and `blog`, host `shop.example.com`.
- **Go cache.** Leave `GOCACHE`, `GOMODCACHE` and `GOTMPDIR` at their defaults.
- **Census overlay per branch, loaded LAST.** `plan14-root-reads-edge-asset-cache-2.tsv`, `rev3=PROPOSED-edge-asset-cache-2`, after whatever loads last at the merge (plan 1's overlay if it has merged), previous seal checked first.
- **Merge main first; amadan tasks; scratch under `t.TempDir()`, removed by name.** As in plan 1.
- **Spec numbers, verbatim.** Default budget: the smaller of 4 GiB and 10% of the filesystem's size (`CARLOS_ASSET_CACHE_MAX` overrides, read by plan 3). Free-space floor: no fill admitted unless free space minus outstanding reservations minus this one stays at or above 15% of the filesystem's size. Reservation: `Content-Length` if known, else 32 MiB, doubled for the gzip copy, plus 64 KiB of metadata. Body cap: 32 MiB. Fill bounds: 16 per box, 4 per app. Fair share: budget divided by the number of apps with entries, never more than 25% of the budget for one app.

## Review Focus

1. **A crash between writing `.body` and `.meta`.** A person expects a restart to leave no half entry and no leaked disk. Test: Task 1, `TestOpenRemovesOrphanBodiesAndTemporaries`.
2. **A `.meta` file from a newer binary** with fields this one does not know. A person expects it to load (unknown JSON keys ignored), not to be deleted. Test: Task 1, `TestOpenKeepsMetaWithUnknownFields`; a `.meta` that does not parse is deleted with its files.
3. **A filesystem that fills from outside the cache** (logs, a database) while fills are in flight. A person expects in-flight writes to stop at their reservation and new fills to be refused, never the cache pushing past the floor. Test: Task 3, `TestReserveRefusesWhenSomethingElseFillsTheDisk`.
4. **One app over its share when a second app arrives.** A person expects the second app's fill to evict the first app's oldest entries, not its own and not to be refused. Test: Task 5, `TestNewAppEvictsTheAppOverItsShare`.
5. **Clock moving backwards.** An entry stored "in the future" after a clock step. A person expects it to stay valid until its stored expiry and never to report a negative age. Test: Task 4, `TestAgeNeverNegative`.

## Plan review

Astra reviewed the three plans together over eight rounds (`docs/superpowers/specs/CODEX-FINDINGS-ASSET-CACHE-PLANS.md`, then `-PLANS-R2.md` to `-PLANS-R8.md`; brief `CODEX-REVIEW-BRIEF-ASSET-CACHE-PLANS.md`) and accepted them at `4cca5784`. Findings were 16, 6, 6, 3, 1, 1, 2, then none. Every finding was accepted after its citation was checked; none was declined. Findings that changed a task are marked **(astra Pn-m)** where they landed.

## Census impact

- New package `internal/assetcache`: its file-reading declarations (`Open`'s scan, `Entry.Open`) are new plain rows of class `legacy`, task `accepted-legacy`, basis `accepted-legacy;rev3=PROPOSED-edge-asset-cache-2;asset cache files under $CARLOS_DATA_DIR/asset-cache, written and read only by this package`, the form the loader requires (`plan14_root_reads_test.go:478-485`); there is no `cache` class (astra P1-3). Its envelope is pinned.
- Notes: `docs/superpowers/notes/census-proposal-edge-asset-cache-2.md`, ruling asked on #280.

## File structure

| File | Responsibility |
|---|---|
| `internal/assetcache/key.go` | `Key`, its id. |
| `internal/assetcache/meta.go` | `Meta`, its file encoding. |
| `internal/assetcache/store.go` | `Config`, `Open`, the index, `Get`, `Retain`, `Stats`. |
| `internal/assetcache/fill.go` | `Reserve`, `Fill`, `Commit`, `Abort`. |
| `internal/assetcache/evict.go` | Fair-share eviction. |
| `internal/assetcache/fs.go`, `statfs_linux.go`, `statfs_other.go` | Free space. |
| `internal/assetcache/*_test.go` | Tests. |

---

### Task 1: Keys, metadata and `Open`

**Files:**
- Create: `internal/assetcache/key.go`, `meta.go`, `store.go`, `fs.go` (the interface only), `store_test.go`

**Interfaces:**
- Produces:
  - `type Key struct { Account, App, Gen, Host, Path, RawQuery string }`; `func (k Key) ID() string` (hex sha256 over the six fields, each length-prefixed so no two keys collide); `func (k Key) AppScope() string` (`Account + "/" + App`)
  - `type Meta struct { Key Key; Version string; Header http.Header; Lifetime time.Duration; Expires time.Time; StoredAt time.Time; Size int64; GzipSize int64 }` (`GzipSize == 0` means no gzip copy; `Version` names the body files)
  - `type FS interface { Space(dir string) (total, free uint64, err error) }` in `fs.go` (declared here so this task builds; Task 2 adds the implementations) (astra P1-1)
  - `type Config struct { Dir string; Budget int64; FS FS; Now func() time.Time }`
  - `func Open(cfg Config) (*Store, error)`: creates `Dir` (0700), deletes every `*.tmp-*` file, deletes every `.body`/`.gz` file no parsed `.meta` names (orphans and superseded versions), deletes a `.meta` that does not parse together with its files, and indexes the rest.
  - `type Stats struct { Entries int; Bytes int64; Reserved int64; Apps int }`; `func (s *Store) Stats() Stats`

- [ ] **Step 1: Write the failing tests.** `TestKeyIDIsStableAndDistinct` (same key same id; moving one character between `Path` and `RawQuery` changes it); `TestOpenCreatesTheDirectory`; `TestOpenRemovesOrphanBodiesAndTemporaries`; `TestOpenKeepsMetaWithUnknownFields`; `TestOpenDropsUnparseableMeta`; `TestOpenIndexesExistingEntries` (write three entries' files by hand in the documented layout, `Open`, `Stats` counts them and their bytes); `TestOpenDeletesSupersededVersions` (a `.meta` naming version `b` beside bodies for `a` and `b`: `a` is removed).
- [ ] **Step 2:** Run `go test ./internal/assetcache/ -count=1`. Expected: FAIL (package missing).
- [ ] **Step 3:** Implement.
- [ ] **Step 4:** Run the same command. Expected: PASS.
- [ ] **Step 5:** Commit.

### Task 2: Free space

**Files:**
- Create: `internal/assetcache/statfs_linux.go`, `statfs_other.go`, `fs_test.go`, `fs_linux_test.go`

**Interfaces:**
- Consumes: `FS` (Task 1).
- Produces:
  - `var Statfs FS` (Linux: `unix.Statfs`, `free` from `Bavail * Bsize`; other systems: returns `ErrUnsupported`)
  - `var ErrUnsupported = errors.New("assetcache: free space unknown on this system")`
  - `func DefaultBudget(total uint64) int64` (the smaller of 4 GiB and `total/10`)

- [ ] **Step 1: Write the failing tests.** `TestDefaultBudget` (20 GiB gives 2 GiB, 60 GiB gives 4 GiB, 0 gives 0). `TestStatfsReportsTheTempDir` (Linux only; `total > 0`, `free <= total`).
- [ ] **Step 2:** Run `go test ./internal/assetcache/ -run 'TestDefaultBudget|TestStatfs' -count=1`. Expected: FAIL.
- [ ] **Step 3:** Implement.
- [ ] **Step 4:** Run the same command, then both cross-compiles. Expected: PASS, clean builds.
- [ ] **Step 5:** Commit.

### Task 3: Reservations, the floor and fill bounds

**Files:**
- Create: `internal/assetcache/fill.go`, `fill_test.go`

**Interfaces:**
- Consumes: `FS`, `Store` (Tasks 1 and 2).
- Produces:
  - `const MaxBody = 32 << 20`, `const metaReserve = 64 << 10`, `const maxFillsPerBox = 16`, `const maxFillsPerApp = 4`
  - `var ErrNoRoom`, `ErrBelowFloor`, `ErrBusy`, `ErrTooLarge` (sentinels)
  - `func (s *Store) Reserve(k Key, contentLength int64) (*Fill, error)`: `contentLength < 0` means unknown; reserves `2*n + metaReserve` where `n` is `contentLength` or `MaxBody`; `ErrTooLarge` when `contentLength > MaxBody`; `ErrBusy` over either fill bound; evicts (Task 5's `makeRoom`, a no-op until then) then `ErrNoRoom` if `used + reserved + r > Budget`; `ErrBelowFloor` if `free - reserved - r < total*15/100`; an `FS` error refuses with that error.
  - `type Fill struct` implementing `io.Writer` for the identity body; a write past the reservation's identity half or past `MaxBody` returns `ErrTooLarge` and marks the fill failed.
  - `func (f *Fill) Abort()`: deletes temporaries, releases the reservation and the fill slot; safe to call twice and after `Commit`.

- [ ] **Step 1: Write the failing tests** (a fake `FS` with settable total and free; "not acceptance").
  - `TestReserveWorstCaseWithUnknownLength` (`Stats().Reserved == 2*MaxBody + 64<<10`), `TestReserveUsesContentLength`.
  - `TestReserveRefusesBelowTheFloor`: total 8 GiB, free 15% plus one reservation fits once, the second concurrent reservation is `ErrBelowFloor`. This is spec §7's sixteen-fill case reduced to its rule.
  - `TestReserveRefusesWhenSomethingElseFillsTheDisk` (free drops between two calls).
  - `TestReserveBoundsFillsPerBoxAndPerApp` (17th fill and 5th per app are `ErrBusy`).
  - `TestWritePastTheReservationFails`, `TestAbortReleasesEverything` (reserved bytes and slots back to zero, no files left), `TestAbortTwiceIsSafe`.
- [ ] **Step 2:** Run `go test ./internal/assetcache/ -run 'TestReserve|TestWritePast|TestAbort' -count=1`. Expected: FAIL.
- [ ] **Step 3:** Implement. The index lock is held only to update counters, never across file I/O.
- [ ] **Step 4:** Run the same command. Expected: PASS.
- [ ] **Step 5:** Commit.

### Task 4: Commit, lookup and expiry

**Files:**
- Modify: `internal/assetcache/fill.go`, `store.go`
- Test: `internal/assetcache/entry_test.go`

**Interfaces:**
- Produces:
  - `const MaxMeta = metaReserve` (64 KiB)
  - `func (f *Fill) Commit(m Meta, gzipCopy bool) error`, in this order: (1) gzip the identity body into a temporary when `gzipCopy`, abandoning the copy (`GzipSize = 0`) if it would exceed the identity size or the reservation's gzip half; (2) draw a fresh `Version`; (3) set every meta field, `Version`, `Size`, `GzipSize` and `StoredAt = Now()`; (4) encode the **final** meta and return `ErrTooLarge` (behaving as `Abort`) when it exceeds `MaxMeta`, so no write passes the reservation (astra P1-8, P3-1); (5) fsync and rename the bodies to their versioned names; (6) write and fsync the meta temporary and rename it over `<id>.meta`; (7) delete the previous version's bodies (subject to leases); then count the meta's bytes in the store's used bytes and release the reservation. Any error behaves as `Abort`. A `Commit` for a key that already has an entry replaces it. A reader holding the old files keeps reading them (unlinked files stay readable on Linux).
  - `type Entry struct { Meta Meta }`; `func (e *Entry) Open(gzip bool) (*Reader, error)`; `type Reader struct` wrapping the `*os.File` (`Read`, `Seek`, `ReadAt`, `Stat`, so `http.ServeContent` takes it) whose `Close` releases a **lease** on the body version (astra P2-2). A version that is replaced, evicted, expired or swept while leased is unlinked at once but its bytes stay counted in the store's used bytes until its last lease closes, because an unlinked open file still holds its disk space. `Stats` gains `Retired int64` (bytes held only by open readers).
  - `func (s *Store) Get(k Key) (*Entry, bool)`: false and the entry deleted when `Now()` is at or past `Meta.Expires`; marks the entry recently used.
  - `func (m Meta) Age(now time.Time) time.Duration` (`Lifetime - Expires.Sub(now)`, never below zero)

- [ ] **Step 1: Write the failing tests.** `TestCommitThenGetRoundTrips` (header and body bytes equal); `TestCommitThenReopenThenRead` (commit with a gzip copy, close, `Open` a new `Store` on the same directory, read both identity and gzip bytes back: the persisted meta names the version it was committed with); `TestCommitWritesMetaLast` (a crash simulated by stopping before the meta rename leaves `Open` with no entry); `TestReplacementCrashKeepsTheOldEntry` (an existing entry, a replacement stopped after its bodies are renamed but before the meta: `Open` serves the old bytes with the old headers and removes the new bodies); `TestOversizedMetaIsRefused` (a 70 KiB header value and a 70 KiB query each give `ErrTooLarge`, nothing left on disk, reservation released); `TestEvictedBodyStaysCountedWhileAReaderHoldsIt` and `TestReplacedBodyStaysCountedWhileAReaderHoldsIt` (`Stats().Retired` is the old body's size until the reader closes, and a fill that would only fit by counting those bytes as free is refused); `TestGzipCopyIsAbandonedWhenLarger` (random bytes); `TestGzipCopyIsKept` (CSS text; reading `.gz` through `gzip.NewReader` gives the identity bytes); `TestGetExpires` (fake `Now` past `Expires`: false, files gone); `TestCommitReplacesAnEntry`; `TestAgeNeverNegative`.
- [ ] **Step 2:** Run `go test ./internal/assetcache/ -run 'TestCommit|TestCommitThenReopen|TestReplacementCrash|TestOversizedMeta|TestEvictedBody|TestReplacedBody|TestGzipCopy|TestGetExpires|TestAge' -count=1`. Expected: FAIL.
- [ ] **Step 3:** Implement.
- [ ] **Step 4:** Run the same command. Expected: PASS.
- [ ] **Step 5:** Commit.

### Task 5: Fair-share eviction and the sweep

**Files:**
- Create: `internal/assetcache/evict.go`, `evict_test.go`
- Modify: `fill.go` (`Reserve` calls `makeRoom`), `store.go`

**Interfaces:**
- Produces:
  - `func (s *Store) makeRoom(need int64)` (internal): while `used + reserved + need > Budget`, evict the least recently used entry of the app with the largest `bytes - share`, `share = min(Budget/apps, Budget/4)`; stops when nothing is left to evict.
  - `func (s *Store) Retain(keep func(Key) bool) int`: deletes every entry whose key `keep` rejects, returns how many. Plan 3 calls it with "this key's generation and owner are the host's current ones".

- [ ] **Step 1: Write the failing tests.** `TestEvictionTakesFromTheAppFurthestOverItsShare` (app `shop` holds 80% of the budget, `blog` 10%; a `blog` fill evicts `shop`'s oldest); `TestOneAppWalkingUniqueQueriesEvictsItself` (only `shop` has entries, it fills past the budget, `blog`'s single entry survives); `TestNewAppEvictsTheAppOverItsShare`; `TestShareNeverExceedsAQuarter` (one app alone is still evicted first once it passes 25%); `TestRetainDeletesRejectedKeys`.
- [ ] **Step 2:** Run `go test ./internal/assetcache/ -run 'TestEviction|TestOneApp|TestNewApp|TestShare|TestRetain' -count=1`. Expected: FAIL.
- [ ] **Step 3:** Implement; per-app LRU lists inside the index.
- [ ] **Step 4:** Run `go test ./internal/assetcache/ -count=1 -race`. Expected: PASS.
- [ ] **Step 5:** Commit.

### Task 6: Census, merge and CI

- [ ] **Step 1:** `git fetch origin && git merge origin/main`; re-read the last overlay.
- [ ] **Step 2:** Run `go test ./internal/provision/ -run TestPlan14RootReadCensus -count=1`. Expected: FAIL naming the new package's units and envelope.
- [ ] **Step 3:** Write `plan14-root-reads-edge-asset-cache-2.tsv` and the notes file; post on #280.
- [ ] **Step 4:** Census test PASS; cross-compiles and `gofmt -l .` clean.
- [ ] **Step 5:** Commit, push, CI green on the tip, merge per plan 1's Global Constraints with this branch's name.
