# Edge asset cache, plan 1: declare and publish, dark 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:** Let an app declare build-asset path prefixes at ship and deploy, give every instance an asset generation and an off switch, compute each host's authority time on its home box, and publish `assets` blocks in the edges file and parse them on every edge, with nothing served from a cache yet (plan 3 serves).

**Architecture:** The declaration is an additive `assets` block in the release manifest (`internal/release/assets.go`), written by `carlos ship`, `carlos deploy` and the console's ship door, each checking one grammar. Instance records gain `Assets *bool` and `AssetGen string`, carried to two new route columns. The box tracks two authority clocks in `internal/host/assetauthority.go`: a per-app records confirmed time fed by a new completeness report from `provision.Reconcile`, and a domain confirmed time fed by a new completeness report from `domains.ReconcileAliases`. The edges-file writer publishes `assets` for armed boxes only; the edge's home map parses it and answers `AssetsFor(host)` with the one-hour freshness rule. Purge, opt-out and status are console doors with CLI verbs.

**Tech Stack:** Go 1.26, SQLite registry (`internal/registry`), the release store (`release.Store`, `DirStore` in tests), `crypto/rand`.

**Spec:** `docs/superpowers/specs/2026-10-24-edge-asset-cache-design.md` (rev 7; rev 6 accepted by Astra in round 4, rev 7's corrections under round 5; rulings R1 to R3 in §13). Read §3, §4, §6, §9 and §10 before starting. Where this plan and the spec disagree, the spec wins and the plan is the bug.

**Branch:** `edge-asset-cache-1`, cut from amadan `main` after the spec branch `edge-asset-cache` has merged (the spec lands first, alone). Plan 2 (`2026-10-24-edge-asset-cache-plan-2-disk-store.md`) does not depend on this plan and can be built at the same time. Plan 3 needs both.

## Global Constraints

Every task's requirements include this section.

- **Production-wired acceptance.** Publication tests drive the real `edgesFilePass` writer over a `DirStore`, then parse the written object with the real home map (`internal/edge` `refreshHosts`) or `internal/edgesfile` readers. A stand-in is allowed in unit tests only, and every such test says "not acceptance" in its doc comment.
- **Targeted local runs; CI runs the full suite.** `go test ./internal/<pkg>/ -run 'TestName' -count=1`. Never `make test`, never a whole `internal/console`, `internal/host` or root package. `go build ./...`, `go vet ./...`, `gofmt -l .` and the cross-compiles run locally. Prefix go commands with `GOFLAGS=-buildvcs=false` if the sandbox refuses VCS stamping. A green CI run on the branch tip is the merge gate.
- **Cross-compile gate.** Before every push: `GOOS=darwin GOARCH=arm64 go build ./...` and `GOOS=windows GOARCH=amd64 go build ./...`. Nothing in this plan needs a Linux-only syscall.
- **Copy needs review before it ships, with no em dashes.** No user-facing string reaches code or docs until Paul approves it through the `copy-review` skill (Task 0). The record is `docs/superpowers/plans/2026-10-24-edge-asset-cache-copy.md`, gated by `approved_asset_cache_copy_test.go`. A full stop and a new sentence, continuing in lower case. Internal error fragments, log lines and diagnostics are not copy rows.
- **New refusal sentences stay out of the legacy console files.** `internal/console/apicli.go` and `apiinstances.go` are in `movesLegacyFiles` (`approved_moves_copy_test.go:1492-1493`); a new `jsonError` sentence there fails `TestMovesLegacyExemptionsAreLegacy`. Put them in `internal/console/apiship_assets.go` and `internal/console/apiassets.go`, the way `apiship_swap.go` does for swap.
- **No tenant names.** Fixtures use `shop`, `shop.example.com`, account sqid `abc`, regions `ie` and `uk`.
- **Go cache.** Leave `GOCACHE`, `GOMODCACHE` and `GOTMPDIR` at their defaults; run go with the sandbox off if `~/.cache/go-build` is read-only.
- **Census overlay per branch, loaded LAST.** `internal/provision/testdata/plan14-root-reads-edge-asset-cache-1.tsv`, `rev3=PROPOSED-edge-asset-cache-1`, loaded after whatever overlay main loads last at the merge (at the time of writing, `wake-latency-1`, `plan14_root_reads_test.go:1455`), with the previous seal checked first. Re-read the test after every merge of main.
- **Merge main first.** Before review and again before merging: `git fetch origin && git merge origin/main`, rebuild the census overlay, push, wait for CI on that tip. Merge with `git merge-base --is-ancestor origin/main HEAD && amadan branch merge carlos/platform edge-asset-cache-1 -squash -expect <tip> -message "..."`. Never a bare `amadan branch merge`.
- **Amadan tasks.** One amadan task per plan task on the branch, advanced as each closes.
- **Test weights.** A new top-level test in `internal/console` or the root package taking a second or more gets a row in `.amadan/test-weights.tsv`.
- **New host tests call `t.Parallel()`** unless they touch env, cwd, umask or package vars.
- **Wire shapes are additive.** Every new field is `omitempty`. A file, manifest or record written before this plan parses byte-identically.
- **Spec numbers, verbatim.** Prefix grammar: starts and ends with `/`, at most 128 bytes, `path.Clean`'d apart from the trailing slash, none of `%`, `?`, `#`, `*`, `..`; `/` alone refused; at most 8 entries; none a prefix of another. Generation: random 64-bit token, 16 lowercase hex. Freshness: an `at` older than one hour serves nothing; the publisher rewrites when an `at` has advanced 20 minutes or more since the last write. Arming env: `CARLOS_ASSET_CACHE=1`.

## Review Focus

1. **An app that already exists, with instances created before this plan.** Its records have no `AssetGen`. A person who deploys with `--assets` expects catching to start without a separate step. Rule: an empty `AssetGen` publishes nothing, and the ship door, on a release that declares assets, mints a generation for every record of the app that lacks one (spec §6: a token is drawn at create; this covers records created before the field existed). Test: Task 5, `TestShipWithAssetsMintsMissingGenerations`.
2. **A saved `.carlos/config` from an older CLI.** No `assets` key, or a destinations block that predates it. A person expects deploy to behave exactly as before. Test: Task 3, `TestDeployWithoutSavedAssetsDeclaresNone`.
3. **Two prefixes that differ only in case or a trailing segment** (`/Assets/` and `/assets/`, `/a/` and `/a/b/`). The grammar is byte-wise: case-distinct prefixes are both allowed, nested ones refused. Test: Task 1, `TestValidAssetPathsTable` rows.
4. **The console box itself.** It homes most apps and runs the console; its reconcile and alias passes run on the same cadence as any box. A person expects its hosts to publish like any other box's. Test: Task 8 runs the writer with a registry holding a console-class route beside an instance route and checks only the instance route publishes.
5. **A purge interrupted half way.** Three instances, the second write fails. A person expects to be told which instance did not change and that running the purge again finishes it. Test: Task 5, `TestPurgeReportsPartialAndRetryCompletes`.

## 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

- **Record-write census (`TestPlan9B8RecordWriteCensus`, `internal/provision/plan9_b8_census_test.go:360`):** this plan adds a typed record writer and changes record creators, which that census rejects until its inventory is reviewed (astra P4-1). Add `"setrecordassetgen": true` to `censusKeyExact` (`:56`); add `SetRecordAssetGen` (class `record-writer`, kinds `key:SetRecordAssetGen,key:updateRecord`) and the purge door and `mintMissingAssetGens` (class `record-writer`, kinds naming `RecordsPrefix` and `SetRecordAssetGen`) to a new `edgeAssetCache1WriteCensusInventory()` that extends whichever inventory function the test calls at the merge (`dbRegionForward3WriteCensusInventory` at the time of writing); re-digest every record creator Task 5 changes, each with a one-line review comment in the style of `:721`; point the test at the new function.
- **Plan 14 root-read census:** one overlay, `plan14-root-reads-edge-asset-cache-1.tsv`, rows `rev3=PROPOSED-edge-asset-cache-1`.
  - Changed frozen units keep their rows' form and are re-digested with class, task and basis unchanged: `internal/host/edgesfile.go` `edgesFileWriteClaims` stays a plain row of class `authority`, task `F,J1`, basis `root-route;kept=F;scope=protected-decision-only` (the `plan14-root-reads-region-ha-2a.tsv:26` precedent; the loader requires an authority row's task to name a kept family, `plan14_root_reads_test.go:507-515`); `internal/domains/aliases.go` `ReconcileAliases` and `deriveInstanceAliases` keep their `@legacy` rows (class `legacy`, re-fingerprinted by `rootReadLegacyFingerprint`).
  - New unit `internal/host/assetmanifests.go` `assetManifestCache.paths` (reads `release.GetManifest` for the same protected publication decision): a plain row of class `authority`, task `F,J1`, basis `root-route;kept=F;scope=protected-decision-only`, the writer's own disposition.
  - Envelopes re-pinned: `.`, `internal/release`, `internal/provision`, `internal/registry`, `internal/domains`, `internal/host`, `internal/hostops` (Task 5 changes `record.go`, astra P6-1), `internal/edge`, `internal/edgesfile`, `internal/console`. Task 11 re-pins every directory the branch's diff touches, checked with `git diff --stat origin/main -- '*.go' ':!*_test.go'` against this list.
  - Notes file: `docs/superpowers/notes/census-proposal-edge-asset-cache-1.md`, ruling asked on #280.

## Copy strings

Task 0 drafts every row as `pending` and runs the copy review. Rows (draft text is in the copy record, not here). The inventory is EAC-1 to EAC-22; plan 3 adds EAC-23 and EAC-24 (astra P1-16):

| id | where | what it says |
|---|---|---|
| EAC-1 | `cli.go`, `deploy.go` | `--assets` flag help |
| EAC-2 | `deploy.go` | `--no-assets` flag help |
| EAC-3 to EAC-7 | `internal/release/assets.go` | the five grammar refusals (slashes, `/` alone, forbidden characters, length or not clean, more than 8 or nested) |
| EAC-8 | `cli.go`, `deploy.go`, `internal/console/apiship_assets.go` | `--assets` refused for a static release |
| EAC-9 | `deploy.go` | a saved asset list ignored for a static deploy |
| EAC-10, EAC-11 | `instances_assets.go` | `set-assets` result, on and off |
| EAC-12, EAC-13 | `assets_cli.go` | purge complete; purge partly applied, naming what to run |
| EAC-14 | `assets_cli.go` | the status table's column heads and its bound sentence (spec §6) |
| EAC-15 | `internal/console/apiassets.go` | refusal for a set-assets body that is not `on` or `off` |
| EAC-16 to EAC-19 | `docs/site/cli.md` | `### carlos assets`, `### carlos instances set-assets`, and the `--assets` paragraphs under `carlos ship` and `carlos deploy` |
| EAC-20 | `deploy.go` | `--assets` and `--no-assets` given together |
| EAC-21 | `internal/console/apiship_assets.go` | a re-ship of a version with different asset paths refused |
| EAC-22 | `apimode.go` (`checkAssetsEcho`) | the console did not echo the asset paths: it predates them |

## File structure

| File | Responsibility |
|---|---|
| `internal/release/assets.go` (create) | `Assets` type, `ValidAssetPaths`, the grammar. |
| `internal/release/release.go` (modify) | `Manifest.Assets`; `validateManifest` calls the grammar. |
| `cli.go` (modify) | `carlos ship --assets`; direct-mode manifest; API-mode meta. |
| `apimode.go` (modify) | `apiSession.shipAssets` field, meta `assets`, echo check; `instanceSetAssets`; `assetsPurge`; `assetsStatus`. |
| `deploy.go`, `config.go` (modify) | `--assets`, `--no-assets`, the `assets` project default. |
| `internal/console/apiship_assets.go` (create) | Ship-door grammar check, refusal, echo, minting missing generations. |
| `internal/console/apicli.go` (modify) | `handleAPIShip` calls into `apiship_assets.go`; nothing else. |
| `internal/provision/provision.go` (modify) | `Record.Assets`, `Record.AssetGen`, `NewAssetGen`, `SetRecordAssetGen`. |
| `internal/console/apiinstances.go` and the record creators (modify) | Mint `AssetGen` at create; PATCH `assets`. |
| `internal/console/apiassets.go` (create) | Purge and status doors, set-assets refusal text. |
| `internal/registry/registry.go` (modify) | Route columns `assets_off`, `asset_gen`; `SetAssets`; Fingerprint. |
| `internal/provision/reconcile.go` (modify) | `RouteFor`, `convergeOwned`; `ReconcileConfig.Report`. |
| `internal/domains/aliases.go` (modify) | `ReconcileReport`; completeness. |
| `internal/host/assetauthority.go` (create) | The two clocks and `HostAt`. |
| `internal/host/assetmanifests.go` (create) | Manifest cache, `paths(acct, app, version)`. |
| `internal/host/edgesfile.go`, `agent.go`, `changeindex.go` (modify) | Publication, arming, feeding the clocks. |
| `internal/edgesfile/edgesfile.go`, `snapshot.go` (modify) | `Host.Assets` for console readers. |
| `internal/edge/homemap.go` (modify) | Parse `assets`; `AssetsFor`. |
| `assets_cli.go`, `instances_assets.go` (create), `main.go`, `cli.go` (modify) | `carlos assets purge|status`, `carlos instances set-assets`. |
| `approved_asset_cache_copy_test.go`, `docs/superpowers/plans/2026-10-24-edge-asset-cache-copy.md` (create) | Copy gate and record. |
| `docs/site/cli.md` (modify) | Docs. |

---

### Task 0: Copy record, gate and review

**Files:**
- Create: `docs/superpowers/plans/2026-10-24-edge-asset-cache-copy.md`, `approved_asset_cache_copy_test.go`

**Interfaces:**
- Produces: `assetCacheCopyBuilt []string` in the gate (ids a task has built), extended by each task that writes a row.

- [ ] **Step 1:** Write the copy record: a table `| id | file | text | status |` with rows EAC-1 to EAC-22 drafted from spec §3, §6 and the table above, every row `pending`. No em dashes.
- [ ] **Step 2:** Write `TestAssetCacheCopyInventory` and `TestAssetCacheCopyBuiltRowsAreApprovedAndPresent` in `approved_asset_cache_copy_test.go`, reusing `parseCopyTable` and `checkMovesNoEmDash` from `approved_moves_copy_test.go`: the id set is exactly EAC-1 to EAC-22 (plan 3 Task 0 extends it to EAC-24); every id in `assetCacheCopyBuilt` is `approved` and its text appears verbatim in its file. Start with `assetCacheCopyBuilt` empty.
- [ ] **Step 3:** Run `go test . -run 'TestAssetCacheCopy' -count=1`. Expected: PASS.
- [ ] **Step 4:** Run the `copy-review` skill on the 22 rows. Apply Paul's edits verbatim and mark each row `approved`. Rows he defers stay `pending`, and the task that builds them waits.
- [ ] **Step 5:** Commit.

### Task 1: The manifest's `assets` block and its grammar

**Files:**
- Create: `internal/release/assets.go`, `internal/release/assets_test.go`
- Modify: `internal/release/release.go:47-107` (Manifest), `:322-366` (validateManifest)

**Interfaces:**
- Produces:
  - `type Assets struct { Paths []string \`json:"paths"\` }`
  - `func ValidAssetPaths(paths []string) error` (nil for an empty list; the error text is EAC-3 to EAC-7)
  - `Manifest.Assets *Assets \`json:"assets,omitempty"\``

- [ ] **Step 1: Write the failing tests.** `TestValidAssetPathsTable`: accepts `/assets/`, `/_app/immutable/`, eight distinct siblings, `/Assets/` beside `/assets/`; refuses `/`, `assets/`, `/assets`, `/a//b/`, `/a/./b/`, `/a/../b/`, `/a%2f/`, `/a?/`, `/a#/`, `/a*/`, a 129-byte prefix, nine entries, `/a/` with `/a/b/`, and a duplicate. `TestManifestWithoutAssetsIsByteIdentical`: marshalling a manifest with `Assets == nil` matches the golden from `golden_test.go`. `TestValidateManifestRefusesBadAssets` and `TestValidateManifestRefusesAssetsOnStatic` (kind `static` with a non-nil `Assets`).
- [ ] **Step 2:** Run `go test ./internal/release/ -run 'TestValidAssetPaths|TestManifestWithoutAssets|TestValidateManifestRefuses' -count=1`. Expected: FAIL (undefined).
- [ ] **Step 3:** Implement `Assets`, `ValidAssetPaths` and the field; call `ValidAssetPaths` from `validateManifest` after the capabilities check, and refuse `Assets != nil` on `kind == "static"` with EAC-8.
- [ ] **Step 4:** Run the same command. Expected: PASS.
- [ ] **Step 5:** Commit.

### Task 2: `carlos ship --assets` and the console's ship door

**Files:**
- Modify: `cli.go:2158-2350` (runShip), `cli.go:2444` (runShipAPI unchanged signature), `apimode.go:56` (apiSession), `apimode.go:4447-4476` (ship meta), `internal/console/apicli.go:1900-1926`, `:2167-2178`, `:2216-2221`, `:2238`, `:2293`
- Create: `internal/console/apiship_assets.go`, `internal/console/apiship_assets_test.go`, `ship_assets_test.go`

**Interfaces:**
- Consumes: `release.Assets`, `release.ValidAssetPaths` (Task 1).
- Produces:
  - `type assetPathsFlag []string` (root package) with `String`/`Set`, repeatable; `Set` does not validate.
  - `apiSession.shipAssets []string` (sent as meta `"assets": {"paths": [...]}` when non-nil, the way `shipSwap` travels)
  - `func checkAssetsEcho(sent []string, echoed *release.Assets) error` (root package)
  - console: `func refuseBadAssets(w http.ResponseWriter, paths []string, kind string) bool`, `func shipAssetsMatch(stored, asked *release.Assets) bool`, `func addAssetsEcho(resp map[string]any, a *release.Assets)` in `apiship_assets.go`

- [ ] **Step 1: Write the failing tests.**
  - `TestShipDirectModeEmbedsAssets` (root): `carlos ship --assets /assets/ --assets /img/` in direct mode writes a manifest whose `Assets.Paths` is `["/assets/","/img/"]`.
  - `TestShipRefusesBadAssetsBeforeUpload` (root): `--assets /` exits non-zero with EAC-4 and uploads nothing.
  - `TestAPISessionShipCarriesAssetsInMeta` (root, `newTestSession`): the meta part has `assets.paths`.
  - `TestAPIShipCarriesAssetsIntoTheManifest`, `TestAPIShipRefusesBadAssets` (a CLI that skipped the check; body with `/`), `TestAPIShipRefusesAssetsOnStatic`, `TestAPIShipRefusesReassetingAReship` (same version, different paths, 409 like capabilities), `TestAPIShipEchoesTheStoredAssets` (console, `newConsoleFixture`, `postShip`, a `metaPartAssets` helper beside `metaPartSwap`).
- [ ] **Step 2:** Run `go test . -run 'TestShip.*Assets|TestAPISessionShipCarriesAssets' -count=1` and `go test ./internal/console/ -run 'TestAPIShip.*Assets' -count=1`. Expected: FAIL.
- [ ] **Step 3:** Implement the flag (help EAC-1), the direct-mode manifest field, `shipAssets` and the meta field, the echo check; in the console, decode meta `assets`, call `refuseBadAssets` before any write, put it in the manifest, compare it in the reship short-circuit, echo it. Add EAC-1 to EAC-8, EAC-21 and EAC-22 to `assetCacheCopyBuilt`.
- [ ] **Step 4:** Run both commands. Expected: PASS.
- [ ] **Step 5:** Commit.

### Task 3: `carlos deploy --assets`, `--no-assets` and the saved default

**Files:**
- Modify: `deploy.go:63-334`, `config.go:53-77` (cliConfig), `:249-299` (loadConfig, overlayDestination), `apimode.go:705` (offerSaveProjectDefaults)
- Test: `deploy_assets_test.go` (create), `config_test.go`

**Interfaces:**
- Consumes: `assetPathsFlag`, `apiSession.shipAssets` (Task 2).
- Produces: `cliConfig.Assets []string \`json:"assets,omitempty"\``; `func deployAssets(flagSet bool, flag []string, noAssets bool, saved []string, kind string) (paths []string, notice string, err error)` in `deploy.go`.

- [ ] **Step 1: Write the failing tests.** `TestDeployAssetsResolution` (table over `deployAssets`): flag beats saved; `--no-assets` gives `nil` even with a saved list; both flags together is EAC-20; saved list used when neither flag is set; static kind with a flag is EAC-8, static kind with only a saved list returns `nil` and notice EAC-9. `TestConfigLoadsAssetsList` and `TestConfigRewritesPreserveAssets` (a `writeConfigKeys` round trip keeps the list). `TestDeployWithoutSavedAssetsDeclaresNone`: a config written by today's CLI (no `assets` key) yields `nil`. `TestZeroArgDeployCarriesSavedAssets` (subprocess, `buildCarlos`, the pattern of `TestZeroArgDeployRunsFromSavedDefaults`): the console fixture receives meta `assets.paths` equal to the saved list.
- [ ] **Step 2:** Run `go test . -run 'TestDeployAssets|TestConfigLoadsAssets|TestConfigRewritesPreserveAssets|TestDeployWithoutSavedAssets|TestZeroArgDeployCarriesSavedAssets' -count=1`. Expected: FAIL.
- [ ] **Step 3:** Implement. `loadConfig` decodes `assets` as a list (it is the first non-string field: trim each entry, drop empties); `overlayDestination` copies a destination's list when non-nil; `offerSaveProjectDefaults` offers to save the list when the run used `--assets`. Add EAC-2, EAC-9 and EAC-20 to `assetCacheCopyBuilt`.
- [ ] **Step 4:** Run the same command. Expected: PASS.
- [ ] **Step 5:** Commit.

### Task 4: Record fields, route columns and convergence

**Files:**
- Modify: `internal/provision/provision.go:38-224` (Record), `internal/registry/registry.go` (Route, migrate `:571-616`, routeCols `:892`, routeColCount `:901`, scanRoute `:908`, routeArgs `:1261`, Upsert `:1170`, Fingerprint `:3055-3073`, `remote_dispatch.go`), `internal/provision/reconcile.go:436-451` (RouteFor), `:706-776` (convergeOwned)
- Test: `internal/provision/provision_test.go`, `internal/registry/registry_test.go`, `internal/provision/reconcile_test.go`

**Interfaces:**
- Produces:
  - `Record.Assets *bool \`json:"assets,omitempty"\``, `Record.AssetGen string \`json:"asset_gen,omitempty"\``, `func (r Record) AssetsOn() bool` (true unless `Assets` is non-nil and false)
  - `func NewAssetGen() (string, error)` (16 lowercase hex from `crypto/rand`)
  - `Route.AssetsOff bool`, `Route.AssetGen string`; `func (r *Registry) SetAssets(host string, off bool, gen string) error`
  - Fingerprint hashes `assets_off` and `asset_gen`.

- [ ] **Step 1: Write the failing tests.** `TestRecordWireShapeIsAdditive` gains the two fields (absent marshals byte-identically). `TestNewAssetGenShape` (16 hex, two calls differ). `TestUpsertRoundTripsAllColumns` covers the columns. `TestSetAssetsRoundTrips`. `TestFingerprintSeesAssetColumns` (unlike steering: changing either column changes `Fingerprint`). `TestRouteForCarriesAssets` and `TestReconcileConvergesAssets` (a record whose `AssetGen` changes converges the row through `convergeOwned`). `TestShapeMismatchIgnoresAssets`.
- [ ] **Step 2:** Run `go test ./internal/provision/ -run 'TestRecordWireShape|TestNewAssetGen|TestRouteForCarriesAssets|TestReconcileConvergesAssets|TestShapeMismatchIgnoresAssets' -count=1` and `go test ./internal/registry/ -run 'TestUpsertRoundTripsAllColumns|TestSetAssets|TestFingerprintSeesAssetColumns' -count=1`. Expected: FAIL.
- [ ] **Step 3:** Implement, following the six-edit column recipe in the `registry.go:885-891` comment and the `SetSteering`/`Steering` convergence shape in `convergeOwned`.
- [ ] **Step 4:** Run both commands. Expected: PASS.
- [ ] **Step 5:** Commit.

### Task 5: Minting, opt-out and purge doors

**Files:**
- Modify: the record creators: `internal/console/apiinstances.go:1421-1480` (create), `apiinstanceseed.go:341`, `apiinstanceimport.go:172`, `domainattach.go:250`, `internal/provision/replica.go:84`, `internal/hostops/record.go:159`; the PATCH body `apiinstances.go:1992-2010`, apply `:2049-2078`, audit `:2165-2185`; `internal/console/authcli.go` (routes); `internal/console/testdata/claim-routes.txt`; `claim_invariant_fixtures_test.go`; `apiship_assets.go` (mint on declare)
- Create: `internal/console/apiassets.go`, `internal/console/apiassets_test.go`, `internal/provision/assetgen.go` (with `SetRecordAssetGen`)

**Interfaces:**
- Consumes: `NewAssetGen`, `Record.Assets`, `Record.AssetGen` (Task 4).
- Produces:
  - `func SetRecordAssetGen(ctx context.Context, s release.Store, acct, app, host, gen string) error` (on `updateRecord`, `errRecordUnchanged` when equal)
  - PATCH body field `Assets *string \`json:"assets"\`` taking `"on"` or `"off"` (stored as `Record.Assets`: nil for on, false for off)
  - `POST /api/cli/apps/{account}/{app}/assets/purge`, role `Deploy` and `Publish` and `Admin`; reply `{"instances":[{"host":..., "gen":..., "ok":bool, "error":...}], "complete":bool}`
  - `GET /api/cli/apps/{account}/{app}/assets`, any app role; reply per instance `{"host", "paths", "assets", "gen", "published_gen", "published_at"}`
  - `func mintMissingAssetGens(ctx context.Context, s release.Store, acct, app string) error` (ship door): called before **both** successful ship responses whenever the manifest that will be served declares assets, the newly written one or, on the re-ship short-circuit (`apicli.go:2202`), the stored one, so a partly failed backfill is retried by shipping the same version again (astra P3-6); a failure is a 502 naming the instances not yet minted, after the manifest is stored

- [ ] **Step 1: Write the failing tests.**
  - `TestInstanceCreateMintsAssetGen` and one per creator (seed, import, attach, replica, prepare): the new record has a 16-hex `AssetGen`.
  - `TestAPIInstanceUpdateAssetsRoundTrip` (`on`, `off`, and a bad value refused with EAC-15 and nothing written), `TestAPIInstanceUpdateAuditNamesAssets`.
  - `TestPurgeDrawsAFreshGenPerInstance` (three instances; every `AssetGen` changes and they differ from each other).
  - `TestPurgeReportsPartialAndRetryCompletes` (a store wrapper failing the second record's write: reply `complete:false` naming it; a second call completes).
  - `TestShipWithAssetsMintsMissingGenerations` (records without `AssetGen` get one; records with one keep it).
  - `TestReshipRetriesAPartlyFailedBackfill` (the first ship's backfill fails for one record; shipping the same version again mints it).
  - `TestAssetsStatusJoinsPublishedGeneration` (an edges file under `control/edges/` with an `assets` block is joined by host; uses `readEdgesFiles`).
  - Route census and invariant fixtures for both new routes; `TestEveryCLIDoorNamesItsRoles` passes.
- [ ] **Step 2:** Run `go test ./internal/console/ -run 'TestInstanceCreateMintsAssetGen|TestAPIInstanceUpdateAssets|TestPurge|TestShipWithAssetsMints|TestReshipRetries|TestAssetsStatus|TestClaimConsoleRouteCensus|TestEveryCLIDoorNamesItsRoles' -count=1` and `go test ./internal/provision/ -run 'TestSetRecordAssetGen' -count=1`. Expected: FAIL.
- [ ] **Step 3:** Implement. The purge walks `provision.RecordsPrefix` like `placeAppTarget` (`fleets.go:578`) but continues past a failed write, reports each, and audits once with the list. Each door bumps the serial after writing. Add EAC-15 to `assetCacheCopyBuilt`.
- [ ] **Step 4:** Run both commands. Expected: PASS.
- [ ] **Step 5:** Commit.

### Task 6: Reconcile reports which apps it read completely and applied

**Files:**
- Modify: `internal/provision/reconcile.go:818-1000` (Reconcile, the record scan at `:836-900`, the owned and adoption branches)
- Test: `internal/provision/reconcile_report_test.go` (create)

**Interfaces:**
- Produces:
  - `convergeOwned` returns `error`: today it returns nothing and logs each setter failure itself (`reconcile.go:706`), so its callers cannot see them (astra P7-1). It keeps logging and keeps converging the remaining columns after a failure, and returns the first setter error (or `errors.Join` of them). Its three call sites (`:1329`, `:1463`, `:1478`) feed that into the app's completeness and otherwise ignore it, so the pass-level error behaviour is unchanged.
  - `type ReconcileReport struct { Complete map[string]bool }` (keys `"<acct>/<app>"`)
  - `ReconcileConfig.Report *ReconcileReport` (nil is today's behaviour; when set, `Reconcile` fills it before returning)

An app is complete when: the listing was not `partial`; no record body under the app failed to read; no record under it was `protect`ed; and **every** in-scope record was positively applied, meaning every convergence step the pass runs for it (`convergeOwned`, the gate convergence at `reconcile.go:1366-1374`, a mint or an adoption) ran and returned nil, including steps that found nothing to change. There is no equality shortcut on a subset of columns: a record whose gate convergence failed is not applied even if its asset columns already match (astra P2-R1-7). Retirements count too: when the pass decides to remove a retired route of the app and `claimRetire` fails (`reconcile.go:1597-1599` logs and continues), or the removal is withheld because the fresh bucket walk that must confirm it could not be read cleanly (`reconcile.go:1613-1617`, which logs and returns nil), every app with a pending retirement in that pass is incomplete (astra P4-R3-3), so a deleted hostname whose row survives cannot keep a renewed `at` through a sibling's healthy convergence (astra P3-3). A record the pass skipped for any other reason (a trash marker that could not be read sets `declineMint`, `reconcile.go:1137`, and the loop skips the record at `:1174` with no failing write; a held claim; a deferred adoption) makes its app incomplete (astra P1-7). `readFailed` from an unreadable account manifest makes every app of that account incomplete. A pass that returns an error leaves `Complete` empty.

- [ ] **Step 1: Write the failing tests** (`setup`, `acmeRecord`, `putReplace` helpers): `TestReportCompleteOnACleanPass`; `TestReportOmitsAnAppWithAnUnreadableRecord` (a `viewCountingStore`-style wrapper failing one Get; the other app is still complete); `TestReportOmitsAProtectedRecordsApp` (malformed body); `TestReportOmitsEveryAppOnAPartialListing`; `TestReportEmptyWhenThePassFails`; `TestReportOmitsAnAppWhoseRecordWasSkipped` (an unreadable trash marker with the record's `AssetGen` changed: the row keeps the old generation and the app is not complete); `TestReportOmitsAnAppWhoseGateConvergenceFailed` (asset columns already equal, `SetGate` failing through a SQLite trigger as in Task 7); `TestReportOmitsAnAppWhoseRetirementFailed` (two instances; one record deleted and its route's delete failing through a trigger; the sibling converges; the app is not complete); `TestReportOmitsAnAppWhoseRetirementCouldNotBeConfirmed` (the same, with the fresh walk failing instead); `TestConvergeOwnedReturnsASetterFailure` (a SQLite trigger fails `SetAssets` inside `convergeOwned`: it returns the error, still converges the other columns, the pass returns nil and the app is not complete); `TestReportNilKeepsTodaysBehaviour`.
- [ ] **Step 2:** Run `go test ./internal/provision/ -run 'TestReport|TestConvergeOwnedReturns' -count=1`. Expected: FAIL.
- [ ] **Step 3:** Implement by tracking per-app failure inside the existing scan and convergence loops; no new reads.
- [ ] **Step 4:** Run the same command. Expected: PASS.
- [ ] **Step 5:** Commit.

### Task 7: ReconcileAliases reports completeness

**Files:**
- Modify: `internal/domains/aliases.go:162-171`, `:204`, `:232`, `:310`, `:531`, `:541`, `:561`, `:568`, `:584`, `:590`; `internal/domains/claimcache.go:107-145`; the `edgesfile.DeploymentRoutes` call in the pass
- Test: `internal/domains/aliases_complete_test.go` (create), `instancecatchall_test.go:507`

**Interfaces:**
- Produces: `type AliasReport struct { Complete bool }`; `func ReconcileAliasesReport(ctx context.Context, s release.Store, reg *registry.Registry, now func() time.Time, deploymentRoutes map[string]bool, cache *ClaimCache, rep *AliasReport) error`. `ReconcileAliases` keeps its signature and calls it with a nil report. `ClaimCache.listWithHosts` gains a `dropped int` result. The caller passes a `deploymentRoutes` read error through so the pass can see it: `ReconcileAliasesReport` treats a nil `deploymentRoutes` as incomplete.

- [ ] **Step 1: Write the failing tests.** `TestAliasReportCompleteOnACleanPass`, then one test per incomplete cause, each with the pass still returning nil: label count fails (extend `TestInstanceCatchallKeepsNamesWhenTheListingFails` to assert `Complete == false`), alias upsert fails, catch-all upsert fails, alias delete fails, wildcard upsert or delete fails, path upsert fails, path delete fails, a claim body unreadable, `deploymentRoutes` nil. Registry write failures are injected with SQLite triggers on the test registry's own database file: the test opens the file a second time through `database/sql` (the driver `internal/registry` uses) and creates `BEFORE INSERT`, `BEFORE UPDATE` or `BEFORE DELETE` triggers on `route_aliases`, `wildcard_domains` or `route_paths` that `SELECT RAISE(FAIL, 'injected')` for one named row, so reads succeed and only the named write fails (astra P1-5). A closed handle is not usable: `ListAliases` would fail first (`aliases.go:245`) and the pass would return an error.
- [ ] **Step 2:** Run `go test ./internal/domains/ -run 'TestAliasReport|TestInstanceCatchallKeepsNamesWhenTheListingFails' -count=1`. Expected: FAIL.
- [ ] **Step 3:** Implement: every log-and-continue point also sets a local `incomplete`; the report is written once at the end.
- [ ] **Step 4:** Run the same command. Expected: PASS.
- [ ] **Step 5:** Commit.

### Task 8: The box's authority clocks and publication

**Files:**
- Create: `internal/host/assetauthority.go`, `internal/host/assetmanifests.go`, `internal/host/assetpublish_test.go`
- Modify: `internal/host/edgesfile.go:44-91` (edgeHost), `:420-668` (edgesFileWriteClaims and the dirty check), `:802-972` (edgesFileLoop, schedule); `internal/host/agent.go:1604-1650` (reconcileSerializedWith passes a `ReconcileReport`), `:3615` and `internal/host/changeindex.go:316` (alias calls pass an `AliasReport`), `:835` (edgesFileLoop wiring)

**Interfaces:**
- Consumes: `ReconcileReport` (Task 6), `AliasReport` (Task 7), `Route.AssetsOff`, `Route.AssetGen` (Task 4), `release.GetManifest`.
- Produces:
  - `type assetAuthority struct` with `func (a *assetAuthority) recordsPass(start time.Time, rep provision.ReconcileReport)`, `func (a *assetAuthority) aliasPass(start time.Time, rep domains.AliasReport)`, `func (a *assetAuthority) hostAt(acct, app string) (time.Time, bool)` (the older of the two; false until both have one)
  - `type assetManifestCache struct` with `func (c *assetManifestCache) paths(ctx context.Context, acct, app, version string) ([]string, bool)` (bucket read through `release.GetManifest`; keeps only the declared paths per key once read; false on a read failure, never cached) and `func (c *assetManifestCache) prune(keep map[string]bool)` (drops every key not in `keep`; the writer calls it at the end of each pass with the installed and serving builds of its local routes, spec §4.1, R5-1)
  - `type servingBuild func(host string) (build string, ok bool)` handed to the writer; `ServingGen(host).Build` on boxes with generations, always false otherwise
  - `func servingBuildsDigest(routes []registry.Route, sb servingBuild) string` (hash of every local route's serving build): `edgesFileLoop` computes it every 2 s tick and runs a pass when it changes, beside the `Fingerprint` check (`edgesfile.go:888`), so a generation switch republishes within seconds with the registry unchanged (astra P1-13)
  - `internal/edge/assettypes.go` (create, types only, so this task builds): `type HostAssets struct { Account, App string; Paths []string; Gen string; At time.Time }` and `type LocalAssets interface { For(host string) (HostAssets, bool); Declared(host, build, path string) bool }` (astra P1-1)
  - `edgeHost.Assets *edgeAssets \`json:"assets,omitempty"\`` with `type edgeAssets struct { Paths []string \`json:"paths"\`; Gen string \`json:"gen"\`; At int64 \`json:"at"\` }`
  - `func assetCacheArmed(getenv func(string) string) bool` (`CARLOS_ASSET_CACHE == "1"`)
  - `type assetPolicy struct` (holds the registry, the authority, the manifest cache and `servingBuild`), with `func (p *assetPolicy) Publish(host string) (edgeAssets, bool)` (the publication rule below) and two methods plan 3's edge consumes through `edge.LocalAssets`: `func (p *assetPolicy) For(host string) (edge.HostAssets, bool)` (the same rule **without** the pooled exclusion, spec §3.3: a pooled route still caches on the boxes that serve it) and `func (p *assetPolicy) Declared(host, build, path string) bool` (true when `build`'s cached paths contain a prefix of `path`; false when the build's manifest is not cached)

Publication rule for a host, applied in `edgesFileWriteClaims` through `assetPolicy.Publish` (spec §3.3, §4.1, §4.2): armed; route not `AssetsOff`; `AssetGen` non-empty; `Gate == ""`; `ImportGate == ""` for the row and the row it resolves to; `Pool == ""`; no `route_paths` row for the hostname; single owner by `servedOwners`; `hostAt` known; paths = union of `paths(Version)` and `paths(servingBuild(host))`, non-empty. Aliases (exact names, never `*.`) get their primary's block with the same `At`. The loop also rewrites when any published `At` would advance by 20 minutes or more over the last written one.

- [ ] **Step 1: Write the failing tests** (`edgesTestRegistry`, `seedRoute`, `ownedRoute`, `putCountingStore`, `publishHosts`; manifests put in the `DirStore` with `release.Ship`).
  - `TestAssetAuthorityHostAtIsTheOlderClock`, `TestAssetAuthorityIncompletePassDoesNotAdvance`, `TestAssetAuthorityUnknownUntilBothClocks`, `TestAssetAuthorityRecordsTheStartTime`.
  - `TestAssetManifestCacheReadsTheBucketOnce`, `TestAssetManifestCacheDoesNotCacheAFailure`, `TestAssetManifestCacheStaysBoundedAcrossDeploys` (one route cycled through 50 versions with passes between: at most two entries retained, the installed and serving builds' paths always available, and a rollback to a dropped version re-reads the bucket).
  - `TestPublishesAssetsForAnArmedHome`: the written object's host entry has `paths`, `gen` and `at`.
  - `TestUnarmedBoxPublishesNoAssets`.
  - One test per exclusion: opted out, no generation, gated, import-gated, pooled, a path row under the host, split owners, a manifest read failing, no authority yet.
  - `TestPublishesTheUnionOfInstalledAndServingBuilds` (installed declares `/a/`, serving declares `/b/`); `TestSleepingInstanceKeepsTheInstalledBuildsPaths` (`servingBuild` false).
  - `TestAliasPublishesItsPrimarysBlock`; `TestCatchallAliasNeverPublishes`.
  - `TestConsoleClassRouteDoesNotPublish` (Review Focus 4).
  - `TestRewritesWhenAtAdvancesTwentyMinutes` and `TestDoesNotRewriteForASmallerAdvance` (`putCountingStore` counts PUTs).
  - `TestServingBuildSwitchRepublishesWithAnUnchangedFingerprint`.
  - **The authority table** (spec §12; moved here from plan 3 so it runs where the real policy lives, with no import cycle, astra P2-3): for each input (record scope incomplete, claim listing failing, catch-all label count failing, instance alias upsert failing, alias upsert failing, catch-all upsert failing, alias delete failing, wildcard upsert or delete failing, path upsert failing, path delete failing, an unreadable claim body, `deploymentRoutes` nil), held failing for 61 minutes of fake time with every other input and the edges-file writes healthy: `TestAuthorityTable/<input>` asserts the published `at` stopped advancing, so it is over an hour old, and that `assetPolicy.For` returns false for the home's own catch. Plus `TestExactClaimReplacingADerivedAliasStopsIt` and `TestPathRowAddedUnderAPrimaryStopsIt` (a gated target), each checked through `Publish` and `For`. The edge's half (an `at` over an hour old forwards; a changed owner or generation serves no old entry) is plan 3's Tasks 6 and 8.
  - `TestFailedRecordReadsStopAtFromAdvancing`: a `ReconcileReport` omitting the app for 61 minutes of fake time while edges-file writes succeed; the published `at` stays at the last complete pass.
- [ ] **Step 2:** Run `go test ./internal/host/ -run 'TestAssetAuthority|TestAuthorityTable|TestExactClaimReplacing|TestPathRowAdded|TestServingBuildSwitch|TestAssetManifestCache|TestPublishes|TestUnarmedBoxPublishesNoAssets|TestSleepingInstance|TestAliasPublishes|TestCatchallAliasNever|TestConsoleClassRoute|TestRewritesWhenAt|TestDoesNotRewrite|TestFailedRecordReads' -count=1`. Expected: FAIL.
- [ ] **Step 3:** Implement; wire the clocks into `reconcileSerializedWith` and both alias call sites, record pass start times, hand the writer the authority, the manifest cache and `servingBuild`.
- [ ] **Step 4:** Run the same command. Expected: PASS.
- [ ] **Step 5:** Commit.

### Task 9: Every edge parses `assets`; console readers see it

**Files:**
- Modify: `internal/edge/homemap.go:177-220` (edgesHostView, homeEntry), `:706-834` (foldHomes copies the block from the winning entry), add `AssetsFor`; `internal/edgesfile/edgesfile.go:57` (Host), `snapshot.go:28` (PlacementView)
- Test: `internal/edge/homemap_assets_test.go` (create), `internal/edgesfile/edgesfile_test.go`

**Interfaces:**
- Consumes: `HostAssets`, `LocalAssets` (declared in Task 8's `internal/edge/assettypes.go`).
- Produces:
  - `func (m *homeMap) AssetsFor(host string, now time.Time) (HostAssets, bool)`: false until the home map's first successful hosts refresh after start (spec §7, "after a restart"); exact hosts only (no one-label wildcard match); owner present; `now.Sub(At) <= time.Hour`
  - a golden file `internal/edgesfile/testdata/assets-host.golden.json`: Task 8's writer test asserts its output entry equals it, and this task's parse test reads it, so writer and reader pin one shape
  - `edgesfile.Host.Assets *edgesfile.HostAssets` with the same wire shape as `edgeAssets`

- [ ] **Step 1: Write the failing tests.** `TestHomeMapParsesAssets` (reads `assets-host.golden.json`); `TestAssetsForFalseBeforeFirstRead`; `TestAssetsForRefusesAStaleAt` (61 minutes); `TestAssetsForKeepsAStaleParseStale` (an unreadable file keeps its previous parse, and its `at` still ages out, `homemap.go:459-471`); `TestAssetsForNeverMatchesAWildcard`; `TestAssetsForFollowsTheWinningEntry` (two boxes claim a host; the dial winner's block is the one); `TestOldFileWithoutAssetsParses`. In `internal/edgesfile`: `TestParseKeepsAssets`.
- [ ] **Step 2:** Run `go test ./internal/edge/ -run 'TestHomeMapParsesAssets|TestAssetsFor|TestOldFileWithoutAssets' -count=1` and `go test ./internal/edgesfile/ -run 'TestParseKeepsAssets' -count=1`. Expected: FAIL.
- [ ] **Step 3:** Implement.
- [ ] **Step 4:** Run both commands. Expected: PASS.
- [ ] **Step 5:** Commit.

### Task 10: CLI verbs and docs

**Files:**
- Create: `assets_cli.go`, `instances_assets.go`, `assets_cli_test.go`
- Modify: `main.go:43` (case `assets`), `main.go:211` (usage), `cli.go:362-400` (`set-assets` in `runInstances`), `apimode.go` (`instanceSetAssets`, `assetsPurge`, `assetsStatus`), `docs/site/cli.md`

**Interfaces:**
- Consumes: the doors from Task 5.
- Produces: `func runAssets(args []string)` (`purge`, `status`), `func runInstancesSetAssets(args []string)`; `func (s *apiSession) instanceSetAssets(ctx context.Context, a apiAcct, app, host string, on bool, messages ...string) (apiInstanceRecord, error)`, `assetsPurge(ctx, a, app string, messages ...string) (apiAssetsPurgeResult, error)`, `assetsStatus(ctx, a, app string) (apiAssetsStatus, error)`.

- [ ] **Step 1: Write the failing tests.** `TestInstanceSetAssetsPatches` (the shape of `TestInstanceSetSteeringPatches`); `TestAssetsPurgePrintsPartial` (EAC-13 names the instance and says to run it again) and `TestAssetsPurgePrintsComplete` (EAC-12); `TestAssetsStatusPrintsTheBound` (EAC-14); `TestAssetsPurgeOlderConsole` (404 maps to the "console predates" form the restart verb uses). The docsite gates `TestCLICommandsAreDocumented` and `TestFencedCarlosCommandsAreReal` cover the new heading.
- [ ] **Step 2:** Run `go test . -run 'TestInstanceSetAssets|TestAssetsPurge|TestAssetsStatus' -count=1` and `go test ./internal/docsite/ -count=1`. Expected: FAIL.
- [ ] **Step 3:** Implement on the `features_cli.go` pattern (mutation message flags, `consoleOption`, `resolvedAccount`, `resolvedApp`); write the docs rows EAC-16 to EAC-19; add EAC-10 to EAC-14 and EAC-16 to EAC-19 to `assetCacheCopyBuilt`.
- [ ] **Step 4:** Run both commands and `go test . -run 'TestAssetCacheCopy' -count=1`. Expected: PASS.
- [ ] **Step 5:** Commit.

### Task 11: Census, weights, merge and CI

**Files:**
- Create: `internal/provision/testdata/plan14-root-reads-edge-asset-cache-1.tsv`, `docs/superpowers/notes/census-proposal-edge-asset-cache-1.md`
- Modify: `internal/provision/plan14_root_reads_test.go` (load the overlay last, check the previous seal), `.amadan/test-weights.tsv`

- [ ] **Step 1:** `git fetch origin && git merge origin/main`. Re-read which overlay loads last.
- [ ] **Step 2:** Run `go test ./internal/provision/ -run 'TestPlan14RootReadCensus|TestPlan9B8RecordWriteCensus' -count=1`. Expected: FAIL naming exactly the units, sites and envelopes in "Census impact".
- [ ] **Step 3:** Write the overlay rows, the record-write inventory function and the notes file (which covers both censuses); post the ruling request on #280.
- [ ] **Step 4:** Run both census tests and `TestPlan9B8CensusRejectsNewSites`. Expected: PASS. Run the cross-compiles and `gofmt -l .`. Expected: clean.
- [ ] **Step 5:** Add weights for any new top-level test over a second (time it with `-run` and `-v`). Commit, push, wait for CI on the tip, then merge per Global Constraints.
