carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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 assetsoff and assetgen`.
  • [ ] 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", "publishedgen", "publishedat"} - 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 protected; 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 (CARLOSASSETCACHE == "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.