carlos / platform Public
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
edgesFilePasswriter over aDirStore, then parse the written object with the real home map (internal/edgerefreshHosts) orinternal/edgesfilereaders. 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. Nevermake test, never a wholeinternal/console,internal/hostor root package.go build ./...,go vet ./...,gofmt -l .and the cross-compiles run locally. Prefix go commands withGOFLAGS=-buildvcs=falseif 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 ./...andGOOS=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-reviewskill (Task 0). The record isdocs/superpowers/plans/2026-10-24-edge-asset-cache-copy.md, gated byapproved_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.goandapiinstances.goare inmovesLegacyFiles(approved_moves_copy_test.go:1492-1493); a newjsonErrorsentence there failsTestMovesLegacyExemptionsAreLegacy. Put them ininternal/console/apiship_assets.goandinternal/console/apiassets.go, the wayapiship_swap.godoes for swap. - No tenant names. Fixtures use
shop,shop.example.com, account sqidabc, regionsieanduk. - Go cache. Leave
GOCACHE,GOMODCACHEandGOTMPDIRat their defaults; run go with the sandbox off if~/.cache/go-buildis 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 withgit merge-base --is-ancestor origin/main HEAD && amadan branch merge carlos/platform edge-asset-cache-1 -squash -expect <tip> -message "...". Never a bareamadan 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/consoleor 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: anatolder than one hour serves nothing; the publisher rewrites when anathas advanced 20 minutes or more since the last write. Arming env:CARLOS_ASSET_CACHE=1.
Review Focus
- An app that already exists, with instances created before this plan. Its records have no
AssetGen. A person who deploys with--assetsexpects catching to start without a separate step. Rule: an emptyAssetGenpublishes 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. - A saved
.carlos/configfrom an older CLI. Noassetskey, or a destinations block that predates it. A person expects deploy to behave exactly as before. Test: Task 3,TestDeployWithoutSavedAssetsDeclaresNone. - 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,TestValidAssetPathsTablerows. - 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.
- 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": truetocensusKeyExact(:56); addSetRecordAssetGen(classrecord-writer, kindskey:SetRecordAssetGen,key:updateRecord) and the purge door andmintMissingAssetGens(classrecord-writer, kinds namingRecordsPrefixandSetRecordAssetGen) to a newedgeAssetCache1WriteCensusInventory()that extends whichever inventory function the test calls at the merge (dbRegionForward3WriteCensusInventoryat 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, rowsrev3=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.goedgesFileWriteClaimsstays a plain row of classauthority, taskF,J1, basisroot-route;kept=F;scope=protected-decision-only(theplan14-root-reads-region-ha-2a.tsv:26precedent; the loader requires an authority row's task to name a kept family,plan14_root_reads_test.go:507-515);internal/domains/aliases.goReconcileAliasesandderiveInstanceAliaseskeep their@legacyrows (classlegacy, re-fingerprinted byrootReadLegacyFingerprint). - New unitinternal/host/assetmanifests.goassetManifestCache.paths(readsrelease.GetManifestfor the same protected publication decision): a plain row of classauthority, taskF,J1, basisroot-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 changesrecord.go, astra P6-1),internal/edge,internal/edgesfile,internal/console. Task 11 re-pins every directory the branch's diff touches, checked withgit 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 []stringin 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 rowpending. No em dashes. - [ ] Step 2: Write
TestAssetCacheCopyInventoryandTestAssetCacheCopyBuiltRowsAreApprovedAndPresentinapproved_asset_cache_copy_test.go, reusingparseCopyTableandcheckMovesNoEmDashfromapproved_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 inassetCacheCopyBuiltisapprovedand its text appears verbatim in its file. Start withassetCacheCopyBuiltempty. - [ ] Step 3: Run
go test . -run 'TestAssetCacheCopy' -count=1. Expected: PASS. - [ ] Step 4: Run the
copy-reviewskill on the 22 rows. Apply Paul's edits verbatim and mark each rowapproved. Rows he defers staypending, 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 withAssets == nilmatches the golden fromgolden_test.go.TestValidateManifestRefusesBadAssetsandTestValidateManifestRefusesAssetsOnStatic(kindstaticwith a non-nilAssets). - [ ] Step 2: Run
go test ./internal/release/ -run 'TestValidAssetPaths|TestManifestWithoutAssets|TestValidateManifestRefuses' -count=1. Expected: FAIL (undefined). - [ ] Step 3: Implement
Assets,ValidAssetPathsand the field; callValidAssetPathsfromvalidateManifestafter the capabilities check, and refuseAssets != nilonkind == "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) withString/Set, repeatable;Setdoes not validate. -apiSession.shipAssets []string(sent as meta"assets": {"paths": [...]}when non-nil, the wayshipSwaptravels) -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)inapiship_assets.go
- [ ] Step 1: Write the failing tests. -
TestShipDirectModeEmbedsAssets(root):carlos ship --assets /assets/ --assets /img/in direct mode writes a manifest whoseAssets.Pathsis["/assets/","/img/"]. -TestShipRefusesBadAssetsBeforeUpload(root):--assets /exits non-zero with EAC-4 and uploads nothing. -TestAPISessionShipCarriesAssetsInMeta(root,newTestSession): the meta part hasassets.paths. -TestAPIShipCarriesAssetsIntoTheManifest,TestAPIShipRefusesBadAssets(a CLI that skipped the check; body with/),TestAPIShipRefusesAssetsOnStatic,TestAPIShipRefusesReassetingAReship(same version, different paths, 409 like capabilities),TestAPIShipEchoesTheStoredAssets(console,newConsoleFixture,postShip, ametaPartAssetshelper besidemetaPartSwap). - [ ] Step 2: Run
go test . -run 'TestShip.*Assets|TestAPISessionShipCarriesAssets' -count=1andgo test ./internal/console/ -run 'TestAPIShip.*Assets' -count=1. Expected: FAIL. - [ ] Step 3: Implement the flag (help EAC-1), the direct-mode manifest field,
shipAssetsand the meta field, the echo check; in the console, decode metaassets, callrefuseBadAssetsbefore 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 toassetCacheCopyBuilt. - [ ] 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)indeploy.go`.
- [ ] Step 1: Write the failing tests.
TestDeployAssetsResolution(table overdeployAssets): flag beats saved;--no-assetsgivesnileven 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 returnsniland notice EAC-9.TestConfigLoadsAssetsListandTestConfigRewritesPreserveAssets(awriteConfigKeysround trip keeps the list).TestDeployWithoutSavedAssetsDeclaresNone: a config written by today's CLI (noassetskey) yieldsnil.TestZeroArgDeployCarriesSavedAssets(subprocess,buildCarlos, the pattern ofTestZeroArgDeployRunsFromSavedDefaults): the console fixture receives metaassets.pathsequal to the saved list. - [ ] Step 2: Run
go test . -run 'TestDeployAssets|TestConfigLoadsAssets|TestConfigRewritesPreserveAssets|TestDeployWithoutSavedAssets|TestZeroArgDeployCarriesSavedAssets' -count=1. Expected: FAIL. - [ ] Step 3: Implement.
loadConfigdecodesassetsas a list (it is the first non-string field: trim each entry, drop empties);overlayDestinationcopies a destination's list when non-nil;offerSaveProjectDefaultsoffers to save the list when the run used--assets. Add EAC-2, EAC-9 and EAC-20 toassetCacheCopyBuilt. - [ ] 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 unlessAssetsis non-nil and false) -func NewAssetGen() (string, error)(16 lowercase hex fromcrypto/rand) -Route.AssetsOff bool,Route.AssetGen string;func (r *Registry) SetAssets(host string, off bool, gen string) error- Fingerprint hashesassetsoffandassetgen`.
- [ ] Step 1: Write the failing tests.
TestRecordWireShapeIsAdditivegains the two fields (absent marshals byte-identically).TestNewAssetGenShape(16 hex, two calls differ).TestUpsertRoundTripsAllColumnscovers the columns.TestSetAssetsRoundTrips.TestFingerprintSeesAssetColumns(unlike steering: changing either column changesFingerprint).TestRouteForCarriesAssetsandTestReconcileConvergesAssets(a record whoseAssetGenchanges converges the row throughconvergeOwned).TestShapeMismatchIgnoresAssets. - [ ] Step 2: Run
go test ./internal/provision/ -run 'TestRecordWireShape|TestNewAssetGen|TestRouteForCarriesAssets|TestReconcileConvergesAssets|TestShapeMismatchIgnoresAssets' -count=1andgo 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-891comment and theSetSteering/Steeringconvergence shape inconvergeOwned. - [ ] 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 bodyapiinstances.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(withSetRecordAssetGen)
Interfaces:
- Consumes:
NewAssetGen,Record.Assets,Record.AssetGen(Task 4). - Produces: -
func SetRecordAssetGen(ctx context.Context, s release.Store, acct, app, host, gen string) error(onupdateRecord,errRecordUnchangedwhen equal) - PATCH body fieldAssets *string \json:"assets"\`taking"on"or"off"(stored asRecord.Assets: nil for on, false for off) -POST /api/cli/apps/{account}/{app}/assets/purge, roleDeployandPublishandAdmin; 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. -
TestInstanceCreateMintsAssetGenand one per creator (seed, import, attach, replica, prepare): the new record has a 16-hexAssetGen. -TestAPIInstanceUpdateAssetsRoundTrip(on,off, and a bad value refused with EAC-15 and nothing written),TestAPIInstanceUpdateAuditNamesAssets. -TestPurgeDrawsAFreshGenPerInstance(three instances; everyAssetGenchanges and they differ from each other). -TestPurgeReportsPartialAndRetryCompletes(a store wrapper failing the second record's write: replycomplete:falsenaming it; a second call completes). -TestShipWithAssetsMintsMissingGenerations(records withoutAssetGenget 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 undercontrol/edges/with anassetsblock is joined by host; usesreadEdgesFiles). - Route census and invariant fixtures for both new routes;TestEveryCLIDoorNamesItsRolespasses. - [ ] Step 2: Run
go test ./internal/console/ -run 'TestInstanceCreateMintsAssetGen|TestAPIInstanceUpdateAssets|TestPurge|TestShipWithAssetsMints|TestReshipRetries|TestAssetsStatus|TestClaimConsoleRouteCensus|TestEveryCLIDoorNamesItsRoles' -count=1andgo test ./internal/provision/ -run 'TestSetRecordAssetGen' -count=1. Expected: FAIL. - [ ] Step 3: Implement. The purge walks
provision.RecordsPrefixlikeplaceAppTarget(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 toassetCacheCopyBuilt. - [ ] 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: -
convergeOwnedreturnserror: 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 (orerrors.Joinof 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,Reconcilefills 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,putReplacehelpers):TestReportCompleteOnACleanPass;TestReportOmitsAnAppWithAnUnreadableRecord(aviewCountingStore-style wrapper failing one Get; the other app is still complete);TestReportOmitsAProtectedRecordsApp(malformed body);TestReportOmitsEveryAppOnAPartialListing;TestReportEmptyWhenThePassFails;TestReportOmitsAnAppWhoseRecordWasSkipped(an unreadable trash marker with the record'sAssetGenchanged: the row keeps the old generation and the app is not complete);TestReportOmitsAnAppWhoseGateConvergenceFailed(asset columns already equal,SetGatefailing 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 failsSetAssetsinsideconvergeOwned: 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; theedgesfile.DeploymentRoutescall 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.ReconcileAliaseskeeps its signature and calls it with a nil report.ClaimCache.listWithHostsgains adropped intresult. The caller passes adeploymentRoutesread error through so the pass can see it:ReconcileAliasesReporttreats a nildeploymentRoutesas incomplete.
- [ ] Step 1: Write the failing tests.
TestAliasReportCompleteOnACleanPass, then one test per incomplete cause, each with the pass still returning nil: label count fails (extendTestInstanceCatchallKeepsNamesWhenTheListingFailsto assertComplete == 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,deploymentRoutesnil. Registry write failures are injected with SQLite triggers on the test registry's own database file: the test opens the file a second time throughdatabase/sql(the driverinternal/registryuses) and createsBEFORE INSERT,BEFORE UPDATEorBEFORE DELETEtriggers onroute_aliases,wildcard_domainsorroute_pathsthatSELECT 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:ListAliaseswould 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 aReconcileReport),:3615andinternal/host/changeindex.go:316(alias calls pass anAliasReport),:835(edgesFileLoop wiring)
Interfaces:
- Consumes:
ReconcileReport(Task 6),AliasReport(Task 7),Route.AssetsOff,Route.AssetGen(Task 4),release.GetManifest. - Produces: -
type assetAuthority structwithfunc (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 structwithfunc (c *assetManifestCache) paths(ctx context.Context, acct, app, version string) ([]string, bool)(bucket read throughrelease.GetManifest; keeps only the declared paths per key once read; false on a read failure, never cached) andfunc (c *assetManifestCache) prune(keep map[string]bool)(drops every key not inkeep; 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).Buildon boxes with generations, always false otherwise -func servingBuildsDigest(routes []registry.Route, sb servingBuild) string(hash of every local route's serving build):edgesFileLoopcomputes it every 2 s tick and runs a pass when it changes, beside theFingerprintcheck (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 }andtype LocalAssets interface { For(host string) (HostAssets, bool); Declared(host, build, path string) bool }(astra P1-1) -edgeHost.Assets *edgeAssets \json:"assets,omitempty"\`withtype 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 andservingBuild), withfunc (p assetPolicy) Publish(host string) (edgeAssets, bool)(the publication rule below) and two methods plan 3's edge consumes throughedge.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) andfunc (p *assetPolicy) Declared(host, build, path string) bool(true whenbuild's cached paths contain a prefix ofpath`; 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 theDirStorewithrelease.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 haspaths,genandat. -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(servingBuildfalse). -TestAliasPublishesItsPrimarysBlock;TestCatchallAliasNeverPublishes. -TestConsoleClassRouteDoesNotPublish(Review Focus 4). -TestRewritesWhenAtAdvancesTwentyMinutesandTestDoesNotRewriteForASmallerAdvance(putCountingStorecounts 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,deploymentRoutesnil), held failing for 61 minutes of fake time with every other input and the edges-file writes healthy:TestAuthorityTable/<input>asserts the publishedatstopped advancing, so it is over an hour old, and thatassetPolicy.Forreturns false for the home's own catch. PlusTestExactClaimReplacingADerivedAliasStopsItandTestPathRowAddedUnderAPrimaryStopsIt(a gated target), each checked throughPublishandFor. The edge's half (anatover an hour old forwards; a changed owner or generation serves no old entry) is plan 3's Tasks 6 and 8. -TestFailedRecordReadsStopAtFromAdvancing: aReconcileReportomitting the app for 61 minutes of fake time while edges-file writes succeed; the publishedatstays 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
reconcileSerializedWithand both alias call sites, record pass start times, hand the writer the authority, the manifest cache andservingBuild. - [ ] 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), addAssetsFor;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'sinternal/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 fileinternal/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.HostAssetswith the same wire shape asedgeAssets
- [ ] Step 1: Write the failing tests.
TestHomeMapParsesAssets(readsassets-host.golden.json);TestAssetsForFalseBeforeFirstRead;TestAssetsForRefusesAStaleAt(61 minutes);TestAssetsForKeepsAStaleParseStale(an unreadable file keeps its previous parse, and itsatstill ages out,homemap.go:459-471);TestAssetsForNeverMatchesAWildcard;TestAssetsForFollowsTheWinningEntry(two boxes claim a host; the dial winner's block is the one);TestOldFileWithoutAssetsParses. Ininternal/edgesfile:TestParseKeepsAssets. - [ ] Step 2: Run
go test ./internal/edge/ -run 'TestHomeMapParsesAssets|TestAssetsFor|TestOldFileWithoutAssets' -count=1andgo 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(caseassets),main.go:211(usage),cli.go:362-400(set-assetsinrunInstances),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 ofTestInstanceSetSteeringPatches);TestAssetsPurgePrintsPartial(EAC-13 names the instance and says to run it again) andTestAssetsPurgePrintsComplete(EAC-12);TestAssetsStatusPrintsTheBound(EAC-14);TestAssetsPurgeOlderConsole(404 maps to the "console predates" form the restart verb uses). The docsite gatesTestCLICommandsAreDocumentedandTestFencedCarlosCommandsAreRealcover the new heading. - [ ] Step 2: Run
go test . -run 'TestInstanceSetAssets|TestAssetsPurge|TestAssetsStatus' -count=1andgo test ./internal/docsite/ -count=1. Expected: FAIL. - [ ] Step 3: Implement on the
features_cli.gopattern (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 toassetCacheCopyBuilt. - [ ] 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 andgofmt -l .. Expected: clean. - [ ] Step 5: Add weights for any new top-level test over a second (time it with
-runand-v). Commit, push, wait for CI on the tip, then merge per Global Constraints.