carlos / platform Public

assets: minting, opt-out, purge and status doors (edge asset cache plan 1, task 5)

Every instance record draws an asset generation at create: the create door
(once, so seed, import and the claim path keep it), the seed and import
doors, the first-route attach, CreateRecordReplica and PrepareInstance, all
through provision.EnsureAssetGen. provision.SetRecordAssetGen is the typed
writer on updateRecord (errRecordUnchanged when equal).

The PATCH door takes "assets": "on" | "off" (nil or false on the record),
refused otherwise with EAC-15 before the record is read; the audit line
names assets. New doors: POST .../assets/purge (publish, deploy, admin)
draws a fresh generation per record, continues past a failed write, reports
per instance with complete, audits once and bumps the serial; GET .../assets
(every app role) joins records, the channel head's declared folders and the
home's published assets block from the edges files.

The ship door backfills generations for records without one whenever the
served manifest declares assets, on both success paths after the manifest
is stored; a failure is a 502 naming the hosts, retried by re-shipping.
mintMissingAssetGens returns (minted, error) so the door bumps the serial
only when it wrote.

Route census, claim invariant fixture and probe, deploy doors and the
legacy-pin exemption updated; EAC-15 joins assetCacheCopyBuilt;
setrecordassetgen joins censusKeyExact (the record-write census inventory
is Task 11's).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 66f630dd016b10917d5971e1ee3159e9e7804d6e parent b436972
22 files changed, +1222 −3
  • .amadan/test-weights.tsv +1 −0
  • approved_asset_cache_copy_test.go +1 −0
  • internal/console/apiassets.go +258 −0
  • internal/console/apiassets_test.go +540 −0
  • internal/console/apicli.go +6 −0
  • internal/console/apiinstanceimport.go +7 −0
  • internal/console/apiinstances.go +26 −1
  • internal/console/apiinstanceseed.go +7 −0
  • internal/console/apiship_assets.go +98 −0
  • internal/console/authcli.go +7 −0
  • internal/console/claim_invariant_fixtures_test.go +12 −0
  • internal/console/claim_invariant_probes_read_test.go +1 −0
  • internal/console/deploydoors_test.go +4 −0
  • internal/console/domainattach.go +6 −0
  • internal/console/svcroutes_test.go +6 −0
  • internal/console/testdata/claim-routes.txt +2 −0
  • internal/hostops/record.go +6 −0
  • internal/hostops/record_assetgen_test.go +42 −0
  • internal/provision/assetgen.go +61 −0
  • internal/provision/assetgen_test.go +122 −0
  • internal/provision/plan9_b8_census_test.go +1 −1
  • internal/provision/replica.go +8 −1
diff --git a/.amadan/test-weights.tsv b/.amadan/test-weights.tsv
index 5c1e9d53f..c55695b78 100644
--- a/.amadan/test-weights.tsv
+++ b/.amadan/test-weights.tsv
@@ -23,6 +23,7 @@ github.com/carlosframework/platform/internal/console TestSystemStatusAndRollback
github.com/carlosframework/platform/internal/console TestTodaysFleetWithNoLayoutFieldKeepsRollbackAndUpdate 2.2
github.com/carlosframework/platform/internal/console TestPublishSurvivesDatabasesThatHangOrPanicTheParser 2.1
github.com/carlosframework/platform/internal/console TestDBReadStreamsAQueryEndToEnd 1.9
+github.com/carlosframework/platform/internal/console TestInstanceCreateMintsAssetGenForSeed 1.8
github.com/carlosframework/platform/internal/console TestDBReadSetsBothReadDeadlines 1.7
github.com/carlosframework/platform/internal/console TestSelectMoveBoxesComposesTheCreateDoorsInputs 1.7
github.com/carlosframework/platform/internal/console TestClaimPurgeStoreInventoryRefusesBeforeEffects 1.6
diff --git a/approved_asset_cache_copy_test.go b/approved_asset_cache_copy_test.go
index 9e93f0f85..943f39ec5 100644
--- a/approved_asset_cache_copy_test.go
+++ b/approved_asset_cache_copy_test.go
@@ -48,6 +48,7 @@ var assetCacheCopyBuilt = []string{
"EAC-1", "EAC-8@cli.go", "EAC-8@internal/console/apiship_assets.go",
"EAC-21", "EAC-22",
"EAC-2", "EAC-9", "EAC-20",
+ "EAC-15",
}
// assetCacheBuiltRows turns built entries into the rows to check: one per
diff --git a/internal/console/apiassets.go b/internal/console/apiassets.go
new file mode 100644
index 000000000..43f3368b5
--- /dev/null
+++ b/internal/console/apiassets.go
@@ -0,0 +1,258 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "log"
+ "net/http"
+ "os"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/edgesfile"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The edge asset cache's console doors (design §3.2, §6): the PATCH door's
+// on/off opt-out sentence, the purge door and the status door. The sentence
+// lives here, not in apiinstances.go: the moves copy gate pins the jsonError
+// text of that file. It is the approved copy row EAC-15
+// (docs/superpowers/plans/2026-10-24-edge-asset-cache-copy.md).
+const apiAssetsOnOff = "assets must be on or off."
+
+// assetsSwitch reads the PATCH body's assets value as the record's field:
+// "on" is nil (follow the release), "off" is false. Absent (v nil) is ok and
+// the caller leaves the record alone. Anything else answers EAC-15 and
+// reports ok false; the caller runs this before reading the record, so
+// nothing is written.
+func assetsSwitch(w http.ResponseWriter, v *string) (assets *bool, ok bool) {
+ if v == nil || *v == "on" {
+ return nil, true
+ }
+ if *v == "off" {
+ off := false
+ return &off, true
+ }
+ jsonError(w, http.StatusBadRequest, apiAssetsOnOff)
+ return nil, false
+}
+
+// appRecordHosts lists the hosts of an app's instance records, sorted, by
+// walking provision.RecordsPrefix the way placeAppTarget does: a key below
+// the record level, or one that is not <host>.json, is not a record.
+func appRecordHosts(ctx context.Context, s release.Store, acct, app string) ([]string, error) {
+ prefix := provision.RecordsPrefix(acct, app)
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return nil, err
+ }
+ var hosts []string
+ for _, key := range keys {
+ rest, ok := strings.CutPrefix(key, prefix)
+ if !ok || rest == "" || strings.Contains(rest, "/") {
+ continue
+ }
+ host, ok := strings.CutSuffix(rest, ".json")
+ if !ok || host == "" {
+ continue
+ }
+ hosts = append(hosts, host)
+ }
+ sort.Strings(hosts)
+ return hosts, nil
+}
+
+// assetPurgeResult is one instance's line in the purge reply. Gen is the new
+// generation when the write landed; Error is a diagnostic when it did not.
+type assetPurgeResult struct {
+ Host string `json:"host"`
+ Gen string `json:"gen,omitempty"`
+ OK bool `json:"ok"`
+ Error string `json:"error,omitempty"`
+}
+
+// assetPurgeWriteFailed is the per-instance diagnostic of a write that did
+// not land. The CLI's sentence (EAC-13) names the hosts; this says only what
+// went wrong.
+const assetPurgeWriteFailed = "could not update the instance record"
+
+// handleAPIAssetsPurge draws a fresh asset generation for every instance
+// record of the app (design §6), each its own random token, so every key an
+// edge holds under an old one stops matching at once. Each record is written
+// separately and the walk continues past a failed write: the reply names
+// each instance and whether its write landed, and complete is true only when
+// every one did, so a partly applied purge says so and running it again
+// finishes the job (every record draws again, which is still a purge). A
+// record retired between the listing and its write is not an instance any
+// more and is left out of the reply, placeAppTarget's rule.
+//
+// One audit row names the instances changed and any not changed; the serial
+// is bumped once, after the writes, when any landed.
+func (s *Server) handleAPIAssetsPurge(w http.ResponseWriter, r *http.Request) {
+ principal, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ hosts, err := appRecordHosts(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: api assets purge %s/%s: list instances: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not list instances")
+ return
+ }
+ results := make([]assetPurgeResult, 0, len(hosts))
+ var done, failed []string
+ for _, host := range hosts {
+ gen, err := provision.NewAssetGen()
+ if err == nil {
+ err = provision.SetRecordAssetGen(ctx, s.cfg.Releases, acct.Sqid, app, host, gen)
+ }
+ if errors.Is(err, os.ErrNotExist) {
+ log.Printf("console: api assets purge %s/%s: instance %s vanished mid-walk, skipping", acct.Sqid, app, host)
+ continue
+ }
+ if err != nil {
+ log.Printf("console: api assets purge %s/%s: instance %s: %v", acct.Sqid, app, host, err)
+ results = append(results, assetPurgeResult{Host: host, Error: assetPurgeWriteFailed})
+ failed = append(failed, host)
+ continue
+ }
+ results = append(results, assetPurgeResult{Host: host, Gen: gen, OK: true})
+ done = append(done, host)
+ }
+ if len(results) > 0 {
+ detail := "purged asset generations on " + listOrNone(done)
+ if len(failed) > 0 {
+ detail += "; not changed: " + strings.Join(failed, ", ")
+ }
+ s.audit(acct, app, principal, "assets-purge", detail+" (cli)")
+ }
+ if len(done) > 0 {
+ s.bumpSerial(ctx)
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"instances": results, "complete": len(failed) == 0})
+}
+
+func listOrNone(hosts []string) string {
+ if len(hosts) == 0 {
+ return "none"
+ }
+ return strings.Join(hosts, ", ")
+}
+
+// assetStatusView is one instance in the status reply (design §6): the
+// folders its channel's build declares, the opt-out, the generation in the
+// record, and the generation and at its home published in its edges file.
+// A purge is complete at the home when PublishedGen matches Gen.
+type assetStatusView struct {
+ Host string `json:"host"`
+ Paths []string `json:"paths"`
+ Assets string `json:"assets"`
+ Gen string `json:"gen"`
+ PublishedGen string `json:"published_gen,omitempty"`
+ PublishedAt string `json:"published_at,omitempty"`
+}
+
+// handleAPIAssetsStatus answers per instance what `carlos assets status`
+// prints. Reads only: the records (the instance list's capped read), each
+// channel head's manifest once, and the edges files through readEdgesFiles.
+// The folders are the channel head's declaration, what the release says
+// rather than what any box has picked up; a channel or manifest that cannot
+// be read leaves them empty, logged. The published block is the one a box
+// publishing the host as its home (not as an alias) wrote, the newest at
+// when more than one does.
+func (s *Server) handleAPIAssetsStatus(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ recs, truncated, skipped, err := provision.ListRecordsComplete(ctx, s.cfg.Releases, acct.Sqid, app, instanceListMax)
+ if err != nil {
+ log.Printf("console: api assets status %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not list instances")
+ return
+ }
+ if len(skipped) > 0 {
+ log.Printf("console: api assets status %s/%s: %d record(s) unreadable %v", acct.Sqid, app, len(skipped), skipped)
+ }
+ published := publishedAssets(readEdgesFiles(ctx, s.cfg.Releases))
+ declared := map[string][]string{}
+ out := make([]assetStatusView, 0, len(recs))
+ for _, rec := range recs {
+ paths, seen := declared[rec.Channel]
+ if !seen {
+ paths = s.channelAssetPaths(ctx, acct.Sqid, app, rec.Channel)
+ declared[rec.Channel] = paths
+ }
+ v := assetStatusView{Host: rec.Host, Paths: paths, Assets: "on", Gen: rec.AssetGen}
+ if !rec.AssetsOn() {
+ v.Assets = "off"
+ }
+ if p, ok := published[rec.Host]; ok {
+ v.PublishedGen = p.Gen
+ v.PublishedAt = time.Unix(p.At, 0).UTC().Format(time.RFC3339)
+ }
+ if v.Paths == nil {
+ v.Paths = []string{}
+ }
+ out = append(out, v)
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"instances": out, "truncated": truncated})
+}
+
+// channelAssetPaths is the asset folders the build at channel's head
+// declares, nil when it declares none or cannot be read.
+func (s *Server) channelAssetPaths(ctx context.Context, acct, app, channel string) []string {
+ if channel == "" {
+ return nil
+ }
+ ptr, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct, app, channel)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil || ptr == nil || ptr.Version == "" {
+ if err != nil {
+ log.Printf("console: api assets status %s/%s: channel %s: %v", acct, app, channel, err)
+ }
+ return nil
+ }
+ m, err := release.GetManifest(ctx, s.cfg.Releases, acct, app, ptr.Version)
+ if err != nil {
+ log.Printf("console: api assets status %s/%s: manifest %s: %v", acct, app, ptr.Version, err)
+ return nil
+ }
+ if m.Assets == nil {
+ return nil
+ }
+ return m.Assets.Paths
+}
+
+// publishedAssets folds the edges files into host -> the assets block its
+// home published: a host entry with the home role, not an alias, carrying a
+// block. Where two boxes publish one host as home, the newer at wins. A host
+// entry that does not decode is skipped, as readEdgesFiles skips a file.
+func publishedAssets(files []edgesFilePlacementView) map[string]edgesfile.HostAssets {
+ out := map[string]edgesfile.HostAssets{}
+ for _, f := range files {
+ for host, raw := range f.Hosts {
+ var h edgesfile.Host
+ if err := json.Unmarshal(raw, &h); err != nil {
+ continue
+ }
+ if h.Role != edgesfile.RoleHome || h.Alias || h.Assets == nil {
+ continue
+ }
+ if prev, ok := out[host]; ok && prev.At >= h.Assets.At {
+ continue
+ }
+ out[host] = *h.Assets
+ }
+ }
+ return out
+}
diff --git a/internal/console/apiassets_test.go b/internal/console/apiassets_test.go
new file mode 100644
index 000000000..08ad725c6
--- /dev/null
+++ b/internal/console/apiassets_test.go
@@ -0,0 +1,540 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "regexp"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The asset doors (apiassets.go): the asset generation drawn at every record
+// create, the PATCH door's on/off opt-out, the purge door, the status door,
+// and the ship door's backfill for records created before generations
+// existed (edge asset cache design §3.2, §6).
+
+// eac15Text is the approved copy row EAC-15.
+const eac15Text = "assets must be on or off."
+
+var assetGenRE = regexp.MustCompile(`^[0-9a-f]{16}$`)
+
+const assetsApp = "shop"
+
+// assetsFixture is the console with account abc (id 2), owned by the
+// fixture's signed-in person, holding app shop with instances enabled under
+// example.com, and that person's CLI token.
+func assetsFixture(t *testing.T, code string) (*consoleFixture, string) {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ liftShipBudget(fx)
+ now := time.Now()
+ if err := fx.st.CreateAccount(shipAssetsAccountID, shipAssetsAccount, "Shop", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(shipAssetsAccountID, assetsApp, "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := provision.PutManifest(context.Background(), fx.rel, shipAssetsAccount, assetsApp, provision.Manifest{
+ Schema: 1, App: assetsApp, Domain: "example.com", Channel: "stable", Regions: []string{"ie"},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ return fx, cliToken(t, fx, "paul@keymail.dev", code)
+}
+
+// putAssetRecord writes an instance record of abc/shop straight to the store,
+// the shape a record created before asset generations existed has when gen
+// is empty.
+func putAssetRecord(t *testing.T, fx *consoleFixture, host, gen string) {
+ t.Helper()
+ rec := provision.Record{
+ Schema: 1, Host: host, Slug: strings.SplitN(host, ".", 2)[0], App: assetsApp, Account: shipAssetsAccount,
+ Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC(), CreatedBy: "paul@keymail.dev", AssetGen: gen,
+ }
+ if err := provision.CreateRecord(context.Background(), fx.rel, rec); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func assetRecord(t *testing.T, fx *consoleFixture, host string) *provision.Record {
+ t.Helper()
+ rec, err := provision.GetRecord(context.Background(), fx.rel, shipAssetsAccount, assetsApp, host)
+ if err != nil {
+ t.Fatalf("record %s: %v", host, err)
+ }
+ return rec
+}
+
+func assetsURL(fx *consoleFixture) string {
+ return fx.ts.URL + "/api/cli/apps/" + shipAssetsAccount + "/" + assetsApp + "/assets"
+}
+
+// assetFailStore fails every Put to one key while failKey names it: the
+// half-applied purge and the half-applied backfill, without a broken bucket.
+type assetFailStore struct {
+ release.Store
+ mu sync.Mutex
+ failKey string
+}
+
+func (f *assetFailStore) fail(key string) {
+ f.mu.Lock()
+ f.failKey = key
+ f.mu.Unlock()
+}
+
+func (f *assetFailStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ f.mu.Lock()
+ fail := f.failKey != "" && key == f.failKey
+ f.mu.Unlock()
+ if fail {
+ return "", errors.New("assetFailStore: bucket unavailable")
+ }
+ return f.Store.Put(ctx, key, body, ifMatch)
+}
+
+func failRecords(fx *consoleFixture) *assetFailStore {
+ fs := &assetFailStore{Store: fx.rel}
+ fx.srv.cfg.Releases = fs
+ return fs
+}
+
+func assetEventDetails(t *testing.T, fx *consoleFixture, action string) []string {
+ t.Helper()
+ events, err := fx.st.Events(shipAssetsAccountID, assetsApp, 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out []string
+ for _, e := range events {
+ if e.Action == action {
+ out = append(out, e.Detail)
+ }
+ }
+ return out
+}
+
+// ---- minting at create ------------------------------------------------------
+
+// TestInstanceCreateMintsAssetGen: the create door draws a generation for
+// every record it writes, a different one per record.
+func TestInstanceCreateMintsAssetGen(t *testing.T) {
+ fx, token := assetsFixture(t, "code-assets-create")
+ u := fx.ts.URL + "/api/cli/apps/" + shipAssetsAccount + "/" + assetsApp + "/instances"
+ gens := map[string]bool{}
+ for _, host := range []string{"shop.example.com", "shop-2.example.com"} {
+ if status, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"`+host+`","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("create %s = %d %s", host, status, out)
+ }
+ gen := assetRecord(t, fx, host).AssetGen
+ if !assetGenRE.MatchString(gen) {
+ t.Fatalf("%s AssetGen = %q, want 16 lowercase hex", host, gen)
+ }
+ gens[gen] = true
+ }
+ if len(gens) != 2 {
+ t.Fatalf("two creates drew %d distinct generations", len(gens))
+ }
+}
+
+// TestInstanceCreateMintsAssetGenForSeed: a --from create writes its own
+// record (apiinstanceseed.go), which carries a generation too.
+func TestInstanceCreateMintsAssetGenForSeed(t *testing.T) {
+ f := newFromFixture(t, 3)
+ rec := f.copy(t, fromBody)
+ if !assetGenRE.MatchString(rec.AssetGen) {
+ t.Fatalf("seeded record AssetGen = %q, want 16 lowercase hex", rec.AssetGen)
+ }
+}
+
+// TestInstanceCreateMintsAssetGenForImport: an import's reserving record
+// (apiinstanceimport.go) carries a generation.
+func TestInstanceCreateMintsAssetGenForImport(t *testing.T) {
+ f := newImportFixture(t)
+ code, out := beginImport(t, f, importHost, importSHA, 4096)
+ rec, _ := f.requireBegun(t, code, out, importHost)
+ if !assetGenRE.MatchString(rec.AssetGen) {
+ t.Fatalf("import record AssetGen = %q, want 16 lowercase hex", rec.AssetGen)
+ }
+}
+
+// TestInstanceCreateMintsAssetGenForAttach: attaching a first domain writes
+// the app's primary record (domainattach.go), which carries a generation.
+func TestInstanceCreateMintsAssetGenForAttach(t *testing.T) {
+ fx, acct := attachFixture(t)
+ shipVersion(t, fx, assetsApp, "v1")
+ seedChannel(t, fx, assetsApp, "stable", "v1")
+ const host = "shop.example.com"
+ if _, ref := fx.srv.attachDomain(context.Background(), acct, assetsApp, host, "", "", "paul@keymail.dev"); ref != nil {
+ t.Fatalf("attach: refused %+v", ref)
+ }
+ rec, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, assetsApp, host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !assetGenRE.MatchString(rec.AssetGen) {
+ t.Fatalf("attached record AssetGen = %q, want 16 lowercase hex", rec.AssetGen)
+ }
+}
+
+// ---- the PATCH door's opt-out -----------------------------------------------
+
+// TestAPIInstanceUpdateAssetsRoundTrip: "off" stores false, "on" clears the
+// field back to following the release, and anything else is EAC-15 with
+// nothing written and nothing announced.
+func TestAPIInstanceUpdateAssetsRoundTrip(t *testing.T) {
+ fx, token := assetsFixture(t, "code-assets-patch")
+ const host = "shop.example.com"
+ putAssetRecord(t, fx, host, "0123456789abcdef")
+ pu := fx.ts.URL + "/api/cli/apps/" + shipAssetsAccount + "/" + assetsApp + "/instances/" + host
+ key := provision.RecordKey(shipAssetsAccount, assetsApp, host)
+
+ before := serial(t, fx)
+ status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"assets":"off"}`)
+ if status != http.StatusOK {
+ t.Fatalf("patch assets off = %d %s", status, out)
+ }
+ var echoed provision.Record
+ if err := json.Unmarshal([]byte(out), &echoed); err != nil {
+ t.Fatal(err)
+ }
+ if echoed.Assets == nil || *echoed.Assets {
+ t.Fatalf("echoed assets = %v, want false", echoed.Assets)
+ }
+ rec := assetRecord(t, fx, host)
+ if rec.Assets == nil || *rec.Assets || rec.AssetsOn() {
+ t.Fatalf("stored assets = %v, want false", rec.Assets)
+ }
+ if rec.AssetGen != "0123456789abcdef" {
+ t.Fatalf("the opt-out moved the generation: %q", rec.AssetGen)
+ }
+ if got := serial(t, fx); got != before+1 {
+ t.Fatalf("serial after off = %d, want one bump past %d", got, before)
+ }
+
+ status, _, out = rawReq(t, http.MethodPatch, pu, token, `{"assets":"on"}`)
+ if status != http.StatusOK {
+ t.Fatalf("patch assets on = %d %s", status, out)
+ }
+ if rec := assetRecord(t, fx, host); rec.Assets != nil || !rec.AssetsOn() {
+ t.Fatalf("stored assets after on = %v, want absent", rec.Assets)
+ }
+ raw, _, err := fx.rel.Get(context.Background(), key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(raw), `"assets"`) {
+ t.Fatalf("an on record still carries the key: %s", raw)
+ }
+
+ for _, bad := range []string{`"maybe"`, `""`, `"ON"`, `"true"`, `true`} {
+ before := serial(t, fx)
+ status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"assets":`+bad+`}`)
+ if bad == `true` {
+ // Not a string at all: the JSON decoder's own refusal.
+ if status != http.StatusBadRequest {
+ t.Fatalf("patch assets %s = %d %s, want 400", bad, status, out)
+ }
+ } else {
+ var body map[string]string
+ _ = json.Unmarshal([]byte(out), &body)
+ if status != http.StatusBadRequest || body["error"] != eac15Text {
+ t.Fatalf("patch assets %s = %d %s, want 400 %q", bad, status, out, eac15Text)
+ }
+ }
+ after, _, err := fx.rel.Get(context.Background(), key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(after) != string(raw) {
+ t.Fatalf("a refused patch %s wrote:\n%s\nwas\n%s", bad, after, raw)
+ }
+ if got := serial(t, fx); got != before {
+ t.Fatalf("a refused patch %s bumped the serial", bad)
+ }
+ }
+}
+
+// TestAPIInstanceUpdateAuditNamesAssets: the audit line names the field, the
+// way it names every field the request carried.
+func TestAPIInstanceUpdateAuditNamesAssets(t *testing.T) {
+ fx, token := assetsFixture(t, "code-assets-audit")
+ const host = "shop.example.com"
+ putAssetRecord(t, fx, host, "0123456789abcdef")
+ pu := fx.ts.URL + "/api/cli/apps/" + shipAssetsAccount + "/" + assetsApp + "/instances/" + host
+ if status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"assets":"off"}`); status != http.StatusOK {
+ t.Fatalf("patch = %d %s", status, out)
+ }
+ details := assetEventDetails(t, fx, "instance-update")
+ if len(details) == 0 || details[0] != "updated instance shop.example.com (assets) (cli)" {
+ t.Fatalf("instance-update details = %q, want the newest to name assets", details)
+ }
+ if status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"steering":"","assets":"on"}`); status != http.StatusOK {
+ t.Fatalf("patch = %d %s", status, out)
+ }
+ details = assetEventDetails(t, fx, "instance-update")
+ if details[0] != "updated instance shop.example.com (steering, assets) (cli)" {
+ t.Fatalf("instance-update detail = %q, want steering and assets", details[0])
+ }
+}
+
+// ---- the purge door ---------------------------------------------------------
+
+type purgeReply struct {
+ Instances []struct {
+ Host string `json:"host"`
+ Gen string `json:"gen"`
+ OK bool `json:"ok"`
+ Error string `json:"error"`
+ } `json:"instances"`
+ Complete bool `json:"complete"`
+}
+
+func postPurge(t *testing.T, fx *consoleFixture, token string) (int, purgeReply, string) {
+ t.Helper()
+ status, _, out := rawReq(t, http.MethodPost, assetsURL(fx)+"/purge", token, `{}`)
+ var r purgeReply
+ _ = json.Unmarshal([]byte(out), &r)
+ return status, r, out
+}
+
+var purgeHosts = []string{"shop.example.com", "shop-2.example.com", "shop-3.example.com"}
+
+// TestPurgeDrawsAFreshGenPerInstance: every record gets a new generation,
+// each its own, the reply names each one, the serial moves once and the
+// audit has one row naming them.
+func TestPurgeDrawsAFreshGenPerInstance(t *testing.T) {
+ fx, token := assetsFixture(t, "code-assets-purge")
+ old := map[string]string{}
+ for i, host := range purgeHosts {
+ gen := strings.Repeat(string(rune('a'+i)), 16)
+ putAssetRecord(t, fx, host, gen)
+ old[host] = gen
+ }
+ before := serial(t, fx)
+ status, r, out := postPurge(t, fx, token)
+ if status != http.StatusOK || !r.Complete || len(r.Instances) != 3 {
+ t.Fatalf("purge = %d %s, want 200, complete, three instances", status, out)
+ }
+ seen := map[string]bool{}
+ for _, in := range r.Instances {
+ rec := assetRecord(t, fx, in.Host)
+ if !in.OK || in.Error != "" || in.Gen != rec.AssetGen {
+ t.Fatalf("instance %+v, stored %q", in, rec.AssetGen)
+ }
+ if !assetGenRE.MatchString(in.Gen) || in.Gen == old[in.Host] {
+ t.Fatalf("%s generation %q, was %q: want a fresh one", in.Host, in.Gen, old[in.Host])
+ }
+ seen[in.Gen] = true
+ }
+ if len(seen) != 3 {
+ t.Fatalf("three instances share generations: %+v", r.Instances)
+ }
+ if got := serial(t, fx); got != before+1 {
+ t.Fatalf("serial = %d, want one bump past %d", got, before)
+ }
+ rows := assetEventDetails(t, fx, "assets-purge")
+ if len(rows) != 1 {
+ t.Fatalf("assets-purge rows = %q, want one", rows)
+ }
+ for _, host := range purgeHosts {
+ if !strings.Contains(rows[0], host) {
+ t.Errorf("audit %q does not name %s", rows[0], host)
+ }
+ }
+}
+
+// TestPurgeReportsPartialAndRetryCompletes: the second record's write fails.
+// The reply says the purge is not complete and names it, the others changed,
+// and a second purge finishes the job.
+func TestPurgeReportsPartialAndRetryCompletes(t *testing.T) {
+ fx, token := assetsFixture(t, "code-assets-purge-partial")
+ for i, host := range purgeHosts {
+ putAssetRecord(t, fx, host, strings.Repeat(string(rune('a'+i)), 16))
+ }
+ fs := failRecords(fx)
+ fs.fail(provision.RecordKey(shipAssetsAccount, assetsApp, purgeHosts[1]))
+ status, r, out := postPurge(t, fx, token)
+ if status != http.StatusOK || r.Complete || len(r.Instances) != 3 {
+ t.Fatalf("partial purge = %d %s, want 200, not complete, three instances", status, out)
+ }
+ for _, in := range r.Instances {
+ rec := assetRecord(t, fx, in.Host)
+ switch in.Host {
+ case purgeHosts[1]:
+ if in.OK || in.Error == "" || in.Gen != "" || rec.AssetGen != "bbbbbbbbbbbbbbbb" {
+ t.Fatalf("the failed instance: %+v, stored %q", in, rec.AssetGen)
+ }
+ default:
+ if !in.OK || in.Gen != rec.AssetGen || !assetGenRE.MatchString(in.Gen) {
+ t.Fatalf("a written instance: %+v, stored %q", in, rec.AssetGen)
+ }
+ }
+ }
+ rows := assetEventDetails(t, fx, "assets-purge")
+ if len(rows) != 1 || !strings.Contains(rows[0], purgeHosts[1]) {
+ t.Fatalf("assets-purge rows = %q, want one naming %s", rows, purgeHosts[1])
+ }
+
+ fs.fail("")
+ status, r, out = postPurge(t, fx, token)
+ if status != http.StatusOK || !r.Complete {
+ t.Fatalf("retried purge = %d %s, want complete", status, out)
+ }
+ if got := assetRecord(t, fx, purgeHosts[1]).AssetGen; got == "bbbbbbbbbbbbbbbb" || !assetGenRE.MatchString(got) {
+ t.Fatalf("after the retry %s holds %q", purgeHosts[1], got)
+ }
+}
+
+// ---- the ship door's backfill -----------------------------------------------
+
+// TestShipWithAssetsMintsMissingGenerations: records created before
+// generations existed get one when a release declaring assets ships; a record
+// with one keeps it; a release declaring none mints nothing.
+func TestShipWithAssetsMintsMissingGenerations(t *testing.T) {
+ fx, token := assetsFixture(t, "code-assets-mint")
+ putAssetRecord(t, fx, "shop.example.com", "")
+ putAssetRecord(t, fx, "shop-2.example.com", "")
+ putAssetRecord(t, fx, "shop-3.example.com", "0123456789abcdef")
+ u := fx.ts.URL + "/api/cli/apps/" + shipAssetsAccount + "/" + assetsApp + "/releases"
+
+ if code, out := postShip(t, u, token, []shipPart{metaPart(t, "v1", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}}); code != http.StatusCreated {
+ t.Fatalf("ship without assets = %d %v", code, out)
+ }
+ if got := assetRecord(t, fx, "shop.example.com").AssetGen; got != "" {
+ t.Fatalf("a release declaring no assets minted %q", got)
+ }
+
+ code, out := postShip(t, u, token, []shipPart{metaPartAssets(t, "v2", "binary", "/assets/"), {name: "linux-arm64", body: randomBytes(t, 64)}})
+ if code != http.StatusCreated {
+ t.Fatalf("ship with assets = %d %v", code, out)
+ }
+ a, b := assetRecord(t, fx, "shop.example.com").AssetGen, assetRecord(t, fx, "shop-2.example.com").AssetGen
+ if !assetGenRE.MatchString(a) || !assetGenRE.MatchString(b) || a == b {
+ t.Fatalf("minted generations %q and %q, want two distinct ones", a, b)
+ }
+ if got := assetRecord(t, fx, "shop-3.example.com").AssetGen; got != "0123456789abcdef" {
+ t.Fatalf("a record with a generation got %q", got)
+ }
+}
+
+// TestReshipRetriesAPartlyFailedBackfill: the first ship's backfill fails for
+// one record. The release is stored and the door answers 502 naming it;
+// shipping the same version again mints it.
+func TestReshipRetriesAPartlyFailedBackfill(t *testing.T) {
+ fx, token := assetsFixture(t, "code-assets-reship")
+ putAssetRecord(t, fx, "shop.example.com", "")
+ putAssetRecord(t, fx, "shop-2.example.com", "")
+ fs := failRecords(fx)
+ fs.fail(provision.RecordKey(shipAssetsAccount, assetsApp, "shop-2.example.com"))
+ u := fx.ts.URL + "/api/cli/apps/" + shipAssetsAccount + "/" + assetsApp + "/releases"
+ art := shipPart{name: "linux-arm64", body: randomBytes(t, 64)}
+
+ code, out := postShip(t, u, token, []shipPart{metaPartAssets(t, "v1", "binary", "/assets/"), art})
+ if code != http.StatusBadGateway {
+ t.Fatalf("ship with a failing backfill = %d %v, want 502", code, out)
+ }
+ if msg, _ := out["error"].(string); !strings.Contains(msg, "shop-2.example.com") || strings.Contains(msg, "shop.example.com,") {
+ t.Fatalf("502 = %v, want it to name shop-2.example.com only", out)
+ }
+ if _, err := release.GetManifest(context.Background(), fx.rel, shipAssetsAccount, assetsApp, "v1"); err != nil {
+ t.Fatalf("the release was not stored before the backfill: %v", err)
+ }
+ first := assetRecord(t, fx, "shop.example.com").AssetGen
+ if !assetGenRE.MatchString(first) || assetRecord(t, fx, "shop-2.example.com").AssetGen != "" {
+ t.Fatalf("after the failed backfill: %q and %q", first, assetRecord(t, fx, "shop-2.example.com").AssetGen)
+ }
+
+ fs.fail("")
+ code, out = postShip(t, u, token, []shipPart{metaPartAssets(t, "v1", "binary", "/assets/"), art})
+ if code != http.StatusOK {
+ t.Fatalf("re-ship = %d %v, want 200", code, out)
+ }
+ if got := assetRecord(t, fx, "shop-2.example.com").AssetGen; !assetGenRE.MatchString(got) {
+ t.Fatalf("the re-ship did not mint shop-2.example.com: %q", got)
+ }
+ if got := assetRecord(t, fx, "shop.example.com").AssetGen; got != first {
+ t.Fatalf("the re-ship replaced a minted generation: %q -> %q", first, got)
+ }
+}
+
+// ---- the status door --------------------------------------------------------
+
+// TestAssetsStatusJoinsPublishedGeneration: each instance's record
+// generation and opt-out, the folders its channel's build declares, and the
+// generation and at its home published in its edges file (readEdgesFiles).
+func TestAssetsStatusJoinsPublishedGeneration(t *testing.T) {
+ fx, token := assetsFixture(t, "code-assets-status")
+ putAssetRecord(t, fx, "shop.example.com", "0123456789abcdef")
+ ctx := context.Background()
+ off := false
+ if err := provision.CreateRecord(ctx, fx.rel, provision.Record{
+ Schema: 1, Host: "shop-2.example.com", Slug: "shop-2", App: assetsApp, Account: shipAssetsAccount,
+ Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC(), AssetGen: "fedcba9876543210", Assets: &off,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ u := fx.ts.URL + "/api/cli/apps/" + shipAssetsAccount + "/" + assetsApp + "/releases"
+ if code, out := postShip(t, u, token, []shipPart{metaPartAssets(t, "v1", "binary", "/assets/", "/img/"), {name: "linux-arm64", body: randomBytes(t, 64)}}); code != http.StatusCreated {
+ t.Fatalf("ship = %d %v", code, out)
+ }
+ if err := release.Promote(ctx, fx.rel, fx.priv, shipAssetsAccount, assetsApp, "stable", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ at := time.Date(2026, 10, 25, 9, 0, 0, 0, time.UTC)
+ edges := map[string]any{
+ "v": 1, "box": "box-a", "eip": "192.0.2.1", "region": "ie",
+ "hosts": map[string]any{
+ "shop.example.com": map[string]any{"role": "home", "assets": map[string]any{"paths": []string{"/assets/", "/img/"}, "gen": "0123456789abcdef", "at": at.Unix()}},
+ "shop-2.example.com": map[string]any{"role": "home"},
+ },
+ }
+ raw, err := json.Marshal(edges)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.rel.Put(ctx, "control/edges/box-a.json", raw, ""); err != nil {
+ t.Fatal(err)
+ }
+
+ status, _, out := rawReq(t, http.MethodGet, assetsURL(fx), token, "")
+ if status != http.StatusOK {
+ t.Fatalf("status = %d %s", status, out)
+ }
+ var reply struct {
+ Instances []struct {
+ Host string `json:"host"`
+ Paths []string `json:"paths"`
+ Assets string `json:"assets"`
+ Gen string `json:"gen"`
+ PublishedGen string `json:"published_gen"`
+ PublishedAt string `json:"published_at"`
+ } `json:"instances"`
+ }
+ if err := json.Unmarshal([]byte(out), &reply); err != nil {
+ t.Fatal(err)
+ }
+ if len(reply.Instances) != 2 {
+ t.Fatalf("status = %s, want two instances", out)
+ }
+ byHost := map[string]int{}
+ for i, in := range reply.Instances {
+ byHost[in.Host] = i
+ }
+ one := reply.Instances[byHost["shop.example.com"]]
+ if one.Assets != "on" || one.Gen != "0123456789abcdef" || one.PublishedGen != "0123456789abcdef" ||
+ one.PublishedAt != at.Format(time.RFC3339) || !samePathList(one.Paths, []string{"/assets/", "/img/"}) {
+ t.Fatalf("shop.example.com = %+v", one)
+ }
+ two := reply.Instances[byHost["shop-2.example.com"]]
+ if two.Assets != "off" || two.Gen != "fedcba9876543210" || two.PublishedGen != "" || two.PublishedAt != "" {
+ t.Fatalf("shop-2.example.com = %+v", two)
+ }
+}
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 80d29eb82..ef1233c33 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -2249,6 +2249,9 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
addSwapEcho(body, existing.Swap)
addAssetsEcho(body, existing.Assets)
addBuildProbe(body, probe)
+ if !s.mintShippedAssets(ctx, w, acct.Sqid, app, existing.Assets) {
+ return
+ }
writeJSON(w, http.StatusOK, body)
return
}
@@ -2305,6 +2308,9 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
addSwapEcho(body, m.Swap)
addAssetsEcho(body, m.Assets)
addBuildProbe(body, probe)
+ if !s.mintShippedAssets(ctx, w, acct.Sqid, app, m.Assets) {
+ return
+ }
writeJSON(w, http.StatusCreated, body)
}
diff --git a/internal/console/apiinstanceimport.go b/internal/console/apiinstanceimport.go
index d5716143a..1bc71d0b1 100644
--- a/internal/console/apiinstanceimport.go
+++ b/internal/console/apiinstanceimport.go
@@ -166,6 +166,13 @@ func (s *Server) beginImport(w http.ResponseWriter, r *http.Request, c seedCreat
rec := c.rec
rec.Seed = &provision.SeedSource{ID: id, Kind: seeds.KindImport, SHA256: imp.SHA256, Bytes: imp.Bytes,
State: seeds.StateAwaitingUpload, By: c.principal, At: time.Now().UTC(), Replaces: imp.Replaces, VerifyHash: hash}
+ // The create door drew the record's asset generation; this keeps it, and
+ // draws one only for a record that reached here without.
+ if err := provision.EnsureAssetGen(&rec); err != nil {
+ log.Printf("console: import %s: asset generation: %v", host, err)
+ jsonError(w, http.StatusBadGateway, importFailed)
+ return
+ }
// Not the request's cancellation: a client leaving now must not turn a
// write that landed into an error.
writeCtx, writeCancel := context.WithTimeout(context.WithoutCancel(ctx), instanceStoreTimeout)
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 110867b64..ab40380cb 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -1442,6 +1442,14 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
// everything else before this point.
rec.Role = probeRec.Role
rec.Replicas = probeRec.Replicas
+ // Every instance record draws its own asset generation at create (edge
+ // asset cache design §6). Drawn here, once, so the seed and import doors
+ // below and the claim path's buildClaim all write this same token.
+ if err := provision.EnsureAssetGen(&rec); err != nil {
+ log.Printf("console: api instances create %s/%s/%s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not create the instance — try again")
+ return
+ }
// A static platform host is HA by default. Static is the one serving
// class every platform box materializes unconditionally
// (provision.everywhereClass): no unit, no process, content read from
@@ -1999,11 +2007,16 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request)
// here, never stored or echoed (spec §5.2). The plaintext crosses
// the wire once, over TLS, the same as a sign-in.
Gate *string `json:"gate"`
+ // Assets is the instance's asset-cache opt-out (edge asset cache
+ // design §3.2): "on" follows the release, "off" catches nothing.
+ // Absent leaves the record alone. assetsSwitch (apiassets.go) reads
+ // it and holds the refusal for anything else.
+ Assets *string `json:"assets"`
}
if !readJSON(w, r, &req) {
return
}
- if req.Addrs == nil && req.Probe == nil && req.Steering == nil && req.Environment == nil && req.Gate == nil {
+ if req.Addrs == nil && req.Probe == nil && req.Steering == nil && req.Environment == nil && req.Gate == nil && req.Assets == nil {
jsonError(w, http.StatusBadRequest,
"nothing to update — send addrs, probe, steering, environment, gate, or a combination")
return
@@ -2016,6 +2029,12 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request)
return
}
}
+ // Before the record is read: a value that is neither on nor off is
+ // refused with nothing read or written.
+ assets, ok := assetsSwitch(w, req.Assets)
+ if !ok {
+ return
+ }
ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
defer cancel()
rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host)
@@ -2076,6 +2095,9 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request)
rec.Gate = h
}
}
+ if req.Assets != nil {
+ rec.Assets = assets
+ }
// The same door the create path and the box's reconcile pass check
// against, so a shape that could not have been created cannot be patched
// into existence either — and the caller gets ValidateShape's own
@@ -2181,6 +2203,9 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request)
if req.Gate != nil {
changed = append(changed, "gate")
}
+ if req.Assets != nil {
+ changed = append(changed, "assets")
+ }
s.audit(acct, app, principal, "instance-update",
"updated instance "+host+" ("+strings.Join(changed, ", ")+") (cli)")
// Strictly after the write landed, and exactly once: the record is what a
diff --git a/internal/console/apiinstanceseed.go b/internal/console/apiinstanceseed.go
index eaf271824..9b561960b 100644
--- a/internal/console/apiinstanceseed.go
+++ b/internal/console/apiinstanceseed.go
@@ -333,6 +333,13 @@ func (s *Server) beginFrom(w http.ResponseWriter, r *http.Request, c seedCreate,
rec := c.rec
rec.Seed = &provision.SeedSource{ID: id, Kind: seeds.KindReplica, From: source, State: seeds.StateReserving,
By: c.principal, At: time.Now().UTC()}
+ // The create door drew the record's asset generation; this keeps it, and
+ // draws one only for a record that reached here without.
+ if err := provision.EnsureAssetGen(&rec); err != nil {
+ log.Printf("console: from %s: reserve %s: %v", source, host, err)
+ jsonError(w, http.StatusBadGateway, failed)
+ return nil
+ }
// Not the request's cancellation: a client leaving now must not turn a
// write that landed into an error, which would answer 502 and start no
// copy while the reserving record held the host.
diff --git a/internal/console/apiship_assets.go b/internal/console/apiship_assets.go
index 22da9b1ec..aacd317f0 100644
--- a/internal/console/apiship_assets.go
+++ b/internal/console/apiship_assets.go
@@ -1,10 +1,16 @@
package console
import (
+ "context"
+ "errors"
"fmt"
+ "log"
"net/http"
+ "os"
"slices"
+ "strings"
+ "github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
)
@@ -74,3 +80,95 @@ func addAssetsEcho(resp map[string]any, a *release.Assets) {
resp["assets"] = a
}
}
+
+// assetGensNotMinted is mintMissingAssetGens' error when some records could
+// not be given a generation: the hosts, sorted.
+type assetGensNotMinted struct{ Hosts []string }
+
+func (e *assetGensNotMinted) Error() string {
+ return "could not draw asset generations for " + strings.Join(e.Hosts, ", ")
+}
+
+// mintMissingAssetGens gives every instance record of the app that has no
+// asset generation one of its own (design §6: a token is drawn at create;
+// this covers records created before the field existed, which publish
+// nothing until they have one). A record that has a generation keeps it. It
+// walks provision.RecordsPrefix and continues past a failed write; the error
+// names every record left without one (*assetGensNotMinted), or the listing's
+// failure. minted counts the writes that landed.
+//
+// The read and the write are two steps, so a purge landing between them is
+// overwritten by this fresh token, which is still a fresh token: nothing an
+// edge holds matches either. A record that does not decode is not an
+// instance any box can serve and is skipped, logged, rather than wedging
+// every ship of the app on it.
+func mintMissingAssetGens(ctx context.Context, s release.Store, acct, app string) (minted int, err error) {
+ hosts, err := appRecordHosts(ctx, s, acct, app)
+ if err != nil {
+ return 0, fmt.Errorf("list the instances of %s/%s: %w", acct, app, err)
+ }
+ var failed []string
+ for _, host := range hosts {
+ raw, _, err := s.Get(ctx, provision.RecordKey(acct, app, host))
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ if err == nil {
+ rec, derr := provision.DecodeRecord(raw)
+ if derr != nil {
+ log.Printf("console: asset generations %s/%s: record %s does not decode, skipping: %v", acct, app, host, derr)
+ continue
+ }
+ if rec.AssetGen != "" {
+ continue
+ }
+ var gen string
+ if gen, err = provision.NewAssetGen(); err == nil {
+ err = provision.SetRecordAssetGen(ctx, s, acct, app, host, gen)
+ }
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ }
+ if err != nil {
+ log.Printf("console: asset generations %s/%s: instance %s: %v", acct, app, host, err)
+ failed = append(failed, host)
+ continue
+ }
+ minted++
+ }
+ if len(failed) > 0 {
+ return minted, &assetGensNotMinted{Hosts: failed}
+ }
+ return minted, nil
+}
+
+// mintShippedAssets runs the backfill for a ship whose stored manifest, the
+// one that will be served, declares asset folders, and reports false after
+// answering 502 when it did not finish. It runs after the manifest is
+// stored, on both the new-version path and the re-ship short-circuit, so a
+// partly failed backfill is retried by shipping the same version again. The
+// serial is bumped when any record was given a generation, so a version
+// already being served picks the new tokens up in seconds.
+func (s *Server) mintShippedAssets(ctx context.Context, w http.ResponseWriter, acct, app string, a *release.Assets) bool {
+ if a == nil || len(a.Paths) == 0 {
+ return true
+ }
+ minted, err := mintMissingAssetGens(ctx, s.cfg.Releases, acct, app)
+ if minted > 0 {
+ s.bumpSerial(ctx)
+ }
+ if err == nil {
+ return true
+ }
+ log.Printf("console: api ship %s/%s: %v", acct, app, err)
+ body := map[string]any{"error": err.Error()}
+ var nm *assetGensNotMinted
+ if errors.As(err, &nm) {
+ body["unminted"] = nm.Hosts
+ } else {
+ body["error"] = "could not list instances"
+ }
+ writeJSON(w, http.StatusBadGateway, body)
+ return false
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 954e47d57..02bd49c53 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -700,6 +700,13 @@ func (s *Server) registerCLIRoutes() {
// keeps serving. Same {host} wildcard, same gate, same byte-identical
// 404 as its GET and DELETE siblings.
s.cliRoute("PATCH /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceUpdate)
+ // The edge asset cache's purge and status (design §6, apiassets.go).
+ // The purge draws a fresh asset generation for every instance of the
+ // app: the deploy loop's and a deploy pipeline's act, beside the ship
+ // that declared the folders. The status read is open to every app role:
+ // it shows the app's own records and what their homes published.
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/assets/purge", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIAssetsPurge)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/assets", svcrole.Allow(svcrole.Publish, svcrole.Deploy, svcrole.Instance, svcrole.Operate), s.handleAPIAssetsStatus)
// The residence-move doors (issue #331): requested by the tenant's
// ROUTER with the app-held instance token (titogo's residence design —
// never by an instance about itself... which the role system cannot yet
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go
index 20b1b8dab..baa9eb203 100644
--- a/internal/console/claim_invariant_fixtures_test.go
+++ b/internal/console/claim_invariant_fixtures_test.go
@@ -810,6 +810,7 @@ var claimFixtures = map[string]claimFixture{
"route GET /api/cli/apps": claimRead("lists the caller's apps"),
"route GET /api/cli/apps/{account}/list": claimRead("lists an account's apps with lifecycle times"),
"route GET /api/cli/apps/{account}/{app}/analytics": claimRead("reads analytics"),
+ "route GET /api/cli/apps/{account}/{app}/assets": claimRead("reads instance records, the channel heads' manifests and the edges files"),
"route GET /api/cli/apps/{account}/{app}/availability": claimRead("reads availability counts"),
"route GET /api/cli/apps/{account}/{app}/channels": claimRead("reads channel pointers"),
"route GET /api/cli/apps/{account}/{app}/config": claimRead("reads app config metadata (never secret values)"),
@@ -945,6 +946,17 @@ var claimFixtures = map[string]claimFixture{
do: claimAPI(http.MethodPatch, "/api/cli/apps/bab/titogo/instances/ha.go.tito.io", `{"addrs":["127.0.0.1:7002","127.0.0.1:7001"]}`),
}},
+ // The asset purge (edge asset cache design §6): a fresh generation on
+ // every instance record of the app.
+ "route POST /api/cli/apps/{account}/{app}/assets/purge": {door: &claimDoor{
+ apps: claimRefs("bab/titogo"),
+ setup: func(t *testing.T, cx *claimCase) {
+ cx.instanceWorld(t)
+ cx.instance(t, `{"host":"assets.go.tito.io","region":"ie"}`)
+ },
+ do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/assets/purge", `{}`),
+ }},
+
// ---- POST: browser, account level ----
"route POST /a/{sqid}/analytics/setting": claimNonApp("the account-level analytics default (telemetry account record); each app's own setting is its own door"),
"route POST /a/{sqid}/apps/new": {door: &claimDoor{
diff --git a/internal/console/claim_invariant_probes_read_test.go b/internal/console/claim_invariant_probes_read_test.go
index 5c814388d..b5a48c06b 100644
--- a/internal/console/claim_invariant_probes_read_test.go
+++ b/internal/console/claim_invariant_probes_read_test.go
@@ -160,6 +160,7 @@ var claimReadProbes = map[string]claimProbe{
"route GET /api/cli/apps": claimAPIRead(nil, "/api/cli/apps", `"sqid":"bab"`, `"kass"`),
"route GET /api/cli/apps/{account}/list": claimAPIRead(nil, "/api/cli/apps/bab/list", `"kass"`),
"route GET /api/cli/apps/{account}/{app}/analytics": claimAPIRead(nil, "/api/cli/apps/bab/titogo/analytics", `"pv"`),
+ "route GET /api/cli/apps/{account}/{app}/assets": claimAPIRead(claimWorldInstance, "/api/cli/apps/bab/titogo/assets", `"host":"acme.go.tito.io"`),
"route GET /api/cli/apps/{account}/{app}/availability": claimAPIRead(nil, "/api/cli/apps/bab/titogo/availability", `"groups"`),
"route GET /api/cli/apps/{account}/{app}/channels": claimAPIRead(nil, "/api/cli/apps/bab/kass/channels", `"stable"`),
"route GET /api/cli/apps/{account}/{app}/config": claimAPIRead(nil, "/api/cli/apps/bab/titogo/config", `"bundle"`),
diff --git a/internal/console/deploydoors_test.go b/internal/console/deploydoors_test.go
index 56da84e68..63bf008dc 100644
--- a/internal/console/deploydoors_test.go
+++ b/internal/console/deploydoors_test.go
@@ -26,6 +26,10 @@ var deployDoors = []string{
"GET /api/cli/apps/{account}/{app}/pending",
"GET /api/cli/apps/{account}/{app}/instances", // R6: carlos deploy
"GET /api/cli/apps/{account}/{app}/regions", // R6: carlos deploy --region
+ // Edge asset cache §6: a pipeline that ships may purge what it shipped
+ // and read where the purge has reached.
+ "POST /api/cli/apps/{account}/{app}/assets/purge",
+ "GET /api/cli/apps/{account}/{app}/assets",
}
func TestDeployReachesExactlyItsDoors(t *testing.T) {
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go
index 34da425d3..148e5e9a1 100644
--- a/internal/console/domainattach.go
+++ b/internal/console/domainattach.go
@@ -247,6 +247,12 @@ func (s *Server) attachDomainAs(ctx context.Context, acct *Account, app, host, k
Channel: channel, Kind: "static",
CreatedAt: time.Now().UTC(), CreatedBy: principal,
}
+ // The primary record draws its asset generation at create, like every
+ // instance record (edge asset cache design §6).
+ if err := provision.EnsureAssetGen(&rec); err != nil {
+ log.Printf("console: attach %s/%s %s: %v", acct.Sqid, app, host, err)
+ return attachResult{}, &doorRefusal{http.StatusBadGateway, "could not create the instance — try again"}
+ }
if err := provision.CreateRecord(ctx, s.cfg.Releases, rec); err != nil {
// A record already at this key is NOT a failure to undo. The key is
// account+app scoped, so a create that lost the race lost it to this
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index 92d8e2fdb..afb148733 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -570,6 +570,12 @@ var exemptFromLegacyPin = map[string]bool{
"POST /api/cli/apps/{account}/{app}/instances/{host}/import/stopped": true,
"POST /api/cli/apps/{account}/{app}/instances/{host}/import/commit": true,
"POST /api/cli/apps/{account}/{app}/instances/{host}/import/abort": true,
+ // The asset status read (edge asset cache design §6) is new access for
+ // every app role: it did not exist before service principals, and it
+ // discloses only the app's own instance records and what their homes
+ // published, which the instance list and GET .../instances/{host} the
+ // role already reaches carry most of.
+ "GET /api/cli/apps/{account}/{app}/assets": true,
}
// TestInstanceRoleMatchesTodaysAppTokenReach is the compatibility pin of spec
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt
index 92a03ad37..2a17dd6e7 100644
--- a/internal/console/testdata/claim-routes.txt
+++ b/internal/console/testdata/claim-routes.txt
@@ -41,6 +41,7 @@ route GET /api/cli/accounts/{account}/plan
route GET /api/cli/apps
route GET /api/cli/apps/{account}/list
route GET /api/cli/apps/{account}/{app}/analytics
+route GET /api/cli/apps/{account}/{app}/assets
route GET /api/cli/apps/{account}/{app}/availability
route GET /api/cli/apps/{account}/{app}/channels
route GET /api/cli/apps/{account}/{app}/config
@@ -165,6 +166,7 @@ route POST /api/cli/accounts/{account}/store/approve
route POST /api/cli/accounts/{account}/store/decline
route POST /api/cli/apps/{account}/new
route POST /api/cli/apps/{account}/new-temporary
+route POST /api/cli/apps/{account}/{app}/assets/purge
route POST /api/cli/apps/{account}/{app}/build-probe
route POST /api/cli/apps/{account}/{app}/channels/resign
route POST /api/cli/apps/{account}/{app}/config/sync
diff --git a/internal/hostops/record.go b/internal/hostops/record.go
index c3c5685b3..5aed993db 100644
--- a/internal/hostops/record.go
+++ b/internal/hostops/record.go
@@ -161,6 +161,12 @@ func PrepareInstance(r provision.Record, now time.Time, region string) (Record,
return Record{}, ErrIdentity
}
r.CreatedAt = now.UTC()
+ // The claim path's create writes this body, so the record draws its
+ // asset generation here unless its creator already did (edge asset
+ // cache design §6).
+ if err := provision.EnsureAssetGen(&r); err != nil {
+ return Record{}, err
+ }
body, err := json.Marshal(r)
if err != nil {
return Record{}, err
diff --git a/internal/hostops/record_assetgen_test.go b/internal/hostops/record_assetgen_test.go
new file mode 100644
index 000000000..063c003aa
--- /dev/null
+++ b/internal/hostops/record_assetgen_test.go
@@ -0,0 +1,42 @@
+package hostops
+
+import (
+ "encoding/json"
+ "regexp"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// TestPrepareInstanceMintsAssetGen: the claim path's create writes the body
+// PrepareInstance returns, so a record prepared without an asset generation
+// gets one there, and one that carries a generation keeps it.
+func TestPrepareInstanceMintsAssetGen(t *testing.T) {
+ now := time.Date(2026, 10, 25, 9, 0, 0, 0, time.UTC)
+ base := provision.Record{Schema: 1, Account: "abc", App: "shop", Host: "shop.example.com", Slug: "shop", Channel: "stable", Region: "ie"}
+ decode := func(r Record) provision.Record {
+ t.Helper()
+ var rec provision.Record
+ if err := json.Unmarshal(r.Body, &rec); err != nil {
+ t.Fatal(err)
+ }
+ return rec
+ }
+ r, err := PrepareInstance(base, now, "ie")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := decode(r).AssetGen; !regexp.MustCompile(`^[0-9a-f]{16}$`).MatchString(got) {
+ t.Fatalf("prepared AssetGen = %q, want 16 lowercase hex", got)
+ }
+ kept := base
+ kept.AssetGen = "0123456789abcdef"
+ r, err = PrepareInstance(kept, now, "ie")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := decode(r).AssetGen; got != "0123456789abcdef" {
+ t.Fatalf("prepared AssetGen = %q, want the record's own", got)
+ }
+}
diff --git a/internal/provision/assetgen.go b/internal/provision/assetgen.go
new file mode 100644
index 000000000..ce8977b64
--- /dev/null
+++ b/internal/provision/assetgen.go
@@ -0,0 +1,61 @@
+package provision
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ValidAssetGen reports whether gen has the asset generation's shape: 16
+// lowercase hex, what NewAssetGen draws.
+func ValidAssetGen(gen string) bool {
+ if len(gen) != 16 {
+ return false
+ }
+ for i := 0; i < len(gen); i++ {
+ c := gen[i]
+ if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
+ return false
+ }
+ }
+ return true
+}
+
+// EnsureAssetGen draws an asset generation for a record about to be created
+// that carries none (edge asset cache design §6: a new instance record draws
+// its own token at create). A record that already carries one keeps it, so a
+// creator that minted earlier on its own path is not overruled.
+func EnsureAssetGen(rec *Record) error {
+ if rec.AssetGen != "" {
+ return nil
+ }
+ gen, err := NewAssetGen()
+ if err != nil {
+ return err
+ }
+ rec.AssetGen = gen
+ return nil
+}
+
+// SetRecordAssetGen replaces an existing instance record's asset generation
+// (the purge, and the ship door's backfill for records created before the
+// field existed). The narrow read-CAS-put-one-retry of SetRecordRegion,
+// through updateRecord: every other field, the seed and the maintenance
+// projection included, is carried as read. The same generation again writes
+// nothing (errRecordUnchanged). Not-found is returned verbatim
+// (os.ErrNotExist-wrapped) so a walk over a stale listing can tell a retired
+// record from a failed write.
+func SetRecordAssetGen(ctx context.Context, s release.Store, acct, app, host, gen string) error {
+ if !ValidAssetGen(gen) {
+ return fmt.Errorf("provision: asset generation %q is not 16 lowercase hex", gen)
+ }
+ _, err := updateRecord(ctx, s, acct, app, host, func(rec *Record) error {
+ if rec.AssetGen == gen {
+ return errRecordUnchanged
+ }
+ rec.AssetGen = gen
+ return nil
+ })
+ return err
+}
diff --git a/internal/provision/assetgen_test.go b/internal/provision/assetgen_test.go
new file mode 100644
index 000000000..168e2e9ff
--- /dev/null
+++ b/internal/provision/assetgen_test.go
@@ -0,0 +1,122 @@
+package provision
+
+import (
+ "context"
+ "errors"
+ "os"
+ "regexp"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+var assetGenShape = regexp.MustCompile(`^[0-9a-f]{16}$`)
+
+func assetGenTestRecord(t *testing.T, s release.Store, host string) Record {
+ t.Helper()
+ rec := Record{
+ Schema: 1, Host: host, Slug: "shop", App: "shop", Account: "abc",
+ Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC(), CreatedBy: "paul@keymail.dev",
+ }
+ if err := CreateRecord(context.Background(), s, rec); err != nil {
+ t.Fatal(err)
+ }
+ return rec
+}
+
+// TestSetRecordAssetGen: the purge's writer replaces the record's generation
+// through the one CAS scaffold; the same
+// generation again writes nothing; a gone record is os.ErrNotExist; a value
+// that is not a generation is refused before any read.
+func TestSetRecordAssetGen(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ rec := assetGenTestRecord(t, s, "shop.example.com")
+ key := RecordKey(rec.Account, rec.App, rec.Host)
+ etag := func() string {
+ t.Helper()
+ _, tag, err := s.Get(ctx, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return tag
+ }
+ before := etag()
+ gen, err := NewAssetGen()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := SetRecordAssetGen(ctx, s, rec.Account, rec.App, rec.Host, gen); err != nil {
+ t.Fatalf("SetRecordAssetGen: %v", err)
+ }
+ got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.AssetGen != gen {
+ t.Fatalf("AssetGen = %q, want %q", got.AssetGen, gen)
+ }
+ written := etag()
+ if written == before {
+ t.Fatal("the record's etag did not move: nothing was written")
+ }
+ if got.Region != rec.Region || got.Channel != rec.Channel || !got.CreatedAt.Equal(rec.CreatedAt) {
+ t.Fatalf("the write moved other fields: %+v", got)
+ }
+
+ // The same generation again: the record already says it.
+ if err := SetRecordAssetGen(ctx, s, rec.Account, rec.App, rec.Host, gen); err != nil {
+ t.Fatalf("SetRecordAssetGen, same value: %v", err)
+ }
+ if etag() != written {
+ t.Fatal("an unchanged generation wrote")
+ }
+
+ if err := SetRecordAssetGen(ctx, s, rec.Account, rec.App, "gone.example.com", gen); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("SetRecordAssetGen on a missing record = %v, want os.ErrNotExist", err)
+ }
+ for _, bad := range []string{"", "ABCDEF0123456789", "0123", "0123456789abcdefg"} {
+ if err := SetRecordAssetGen(ctx, s, rec.Account, rec.App, rec.Host, bad); err == nil {
+ t.Errorf("SetRecordAssetGen(%q) = nil, want a refusal", bad)
+ }
+ }
+ if final, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host); err != nil || final.AssetGen != gen {
+ t.Fatalf("a refused value wrote: %+v, %v", final, err)
+ }
+}
+
+// TestEnsureAssetGen: a record without a generation is given one; a record
+// that carries one keeps it.
+func TestEnsureAssetGen(t *testing.T) {
+ var rec Record
+ if err := EnsureAssetGen(&rec); err != nil || !assetGenShape.MatchString(rec.AssetGen) {
+ t.Fatalf("EnsureAssetGen on an empty record: %q, %v", rec.AssetGen, err)
+ }
+ kept := Record{AssetGen: "0123456789abcdef"}
+ if err := EnsureAssetGen(&kept); err != nil || kept.AssetGen != "0123456789abcdef" {
+ t.Fatalf("EnsureAssetGen replaced a generation: %q, %v", kept.AssetGen, err)
+ }
+}
+
+// TestCreateRecordReplicaMintsAssetGen: a replica record is an instance
+// record like any other, so it draws its own generation at create.
+func TestCreateRecordReplicaMintsAssetGen(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ home := assetGenTestRecord(t, s, "shop.example.com")
+ rep, err := Replica(home, "shop-uk.example.com", "uk")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := CreateRecordReplica(ctx, s, rep); err != nil {
+ t.Fatal(err)
+ }
+ got, err := GetRecord(ctx, s, home.Account, home.App, "shop-uk.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !assetGenShape.MatchString(got.AssetGen) {
+ t.Fatalf("replica AssetGen = %q, want 16 lowercase hex", got.AssetGen)
+ }
+}
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 525edfb2c..446e2296c 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -53,7 +53,7 @@ var censusMethods = map[string]bool{
// typed-record-writer touchpoint. Keep these names aligned with the APIs above.
var (
censusKeySuffixes = []string{"recordkey", "recordsprefix", "sidecarsprefix", "sidecarkey", "sidecarmanifestkey", "writerecord", "sequencedrecord"}
- censusKeyExact = map[string]bool{"createrecord": true, "updaterecord": true, "setrecordregion": true, "setrecordmove": true, "setrecordrun": true, "setrecordseed": true, "createsidecarrecord": true, "claimrawupdaterecord": true}
+ censusKeyExact = map[string]bool{"createrecord": true, "updaterecord": true, "setrecordregion": true, "setrecordmove": true, "setrecordrun": true, "setrecordseed": true, "setrecordassetgen": true, "createsidecarrecord": true, "claimrawupdaterecord": true}
censusLitWords = []string{"instances", "sidecars"}
)
diff --git a/internal/provision/replica.go b/internal/provision/replica.go
index e2283bf86..84842347b 100644
--- a/internal/provision/replica.go
+++ b/internal/provision/replica.go
@@ -81,7 +81,14 @@ type ReplicaRecord struct {
// the proof ValidateShape's replica rails have already run, and this writer
// re-checks nothing.
func CreateRecordReplica(ctx context.Context, s release.Store, rec ReplicaRecord) error {
- return CreateRecord(ctx, s, rec.Record)
+ // A replica is an instance record like any other, so it draws its own
+ // asset generation at create (edge asset cache design §6). The
+ // generation is not one of the replica rails ValidateShape checked.
+ r := rec.Record
+ if err := EnsureAssetGen(&r); err != nil {
+ return err
+ }
+ return CreateRecord(ctx, s, r)
}
// ListRecordReplicas returns an app's records that ARE replicas — the