| new file mode 100644 |
| index 000000000..484b65ee8 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-10-24-edge-asset-cache-plan-1-declare-publish.md |
| @@ -0,0 +1,325 @@ |
| +# 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`. |
| + |
| +## Census impact |
| + |
| +- **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, re-fingerprinted as `@legacy` with the same class: `internal/host/edgesfile.go` `edgesFileWriteClaims` (authority F,J1; it now reads manifests through the injected cache), `internal/domains/aliases.go` `ReconcileAliases` and `deriveInstanceAliases` (legacy). |
| + - New units: `internal/host/assetmanifests.go` `assetManifestCache.paths` (reads `release.GetManifest`, class `authority`, task J1, the same class as other manifest readers). |
| + - Envelopes re-pinned: `.`, `internal/release`, `internal/provision`, `internal/registry`, `internal/domains`, `internal/host`, `internal/edge`, `internal/edgesfile`, `internal/console`. |
| + - 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): |
| + |
| +| 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` | |
| + |
| +## 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-19 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-19; 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 19 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 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 an error; 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 and EAC-9 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, after a manifest with `Assets` 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). |
| + - `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|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: |
| + - `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 route write for its in-scope records returned nil. `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`; `TestReportNilKeepsTodaysBehaviour`. |
| +- [ ] **Step 2:** Run `go test ./internal/provision/ -run 'TestReport' -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 a registry wrapper or a closed-handle trick already used in `aliases_test.go`; if none exists, add `aliasRegFailing` in the test file. |
| +- [ ] **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 |
| + - `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"`) |
| + |
| +Publication rule for a host, applied in `edgesFileWriteClaims` (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). |
| + - `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|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:** |
| +- Produces: |
| + - `type HostAssets struct { Account, App string; Paths []string; Gen string; At time.Time }` in `internal/edge` |
| + - `func (m *homeMap) AssetsFor(host string, now time.Time) (HostAssets, bool)`: exact hosts only (no one-label wildcard match), owner present, `now.Sub(At) <= time.Hour` |
| + - `edgesfile.Host.Assets *edgesfile.HostAssets` with the same wire shape as `edgeAssets` |
| + |
| +- [ ] **Step 1: Write the failing tests.** `TestHomeMapParsesAssets` (the JSON shape Task 8's writer emits, copied from a writer test's golden so both sides pin one shape); `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 -count=1`. Expected: FAIL naming exactly the units and envelopes in "Census impact". |
| +- [ ] **Step 3:** Write the overlay rows and the notes file; post the ruling request on #280. |
| +- [ ] **Step 4:** Run the census test. 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. |