feat(domains): reconcile executes trash markers — all routes drop, nothing mints
A valid apps/<sqid>/<app>/deleted.json is the owner's explicit instruction to stop serving the app, not something this pass inferred — so it extends the destructive-move charter: every route of a trashed app goes, custom domains included, and minting sees the shrunken slice so nothing re-mints an apex or a canary host. A marker that fails validation changes nothing and logs why, every pass: an instruction we cannot fully believe must not destroy routes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+144
−3
internal/domains/reconcile.go+63 −3internal/domains/reconcile_test.go+81 −0
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go| index 6f1250be9..bc4133abd 100644 |
| --- a/internal/domains/reconcile.go |
| +++ b/internal/domains/reconcile.go |
| @@ -46,9 +46,13 @@ type Config struct { |
| // host must never take the pass down; only setup-level failures (listing the |
| // bucket or the registry) return an error. |
| // |
| -// The one destructive move — removing a route — happens ONLY for routes with |
| -// Platform set whose canary channel pointer is gone from the bucket. Custom |
| -// domains are owner property; reconciliation never touches them. |
| +// Removing a route happens on exactly two grounds. INFERRED: a route with |
| +// Platform set whose canary channel pointer is gone from the bucket — and |
| +// inference stops there, because custom domains are owner property. |
| +// INSTRUCTED: every route of an app carrying a valid trash marker, custom |
| +// domains included. The marker is not something this pass inferred; it is the |
| +// owner saying stop serving this app, and a custom domain still serving a |
| +// "deleted" app would be the worse betrayal (§1c). |
| func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg Config) error { |
| if cfg.AppsDomain == "" { |
| return nil // inert until configured |
| @@ -125,6 +129,36 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| known[sqid] = true |
| } |
| + // --- 1c. trashed apps ---------------------------------------------------- |
| + // A valid trash marker (apps/<sqid>/<app>/deleted.json) is the owner's |
| + // explicit instruction to stop serving the app. This extends the pass's |
| + // destructive-move charter deliberately: inference never destroys owner |
| + // property, but a marker is not inference — so ALL the app's routes go, |
| + // custom domains included. A custom domain that kept serving a "deleted" |
| + // app would be the worse betrayal. A marker that fails validation |
| + // (ReadAppTombstone) changes nothing: an instruction we cannot fully |
| + // believe must not destroy routes, so the app stays live and the log |
| + // says why, every pass, until someone fixes or removes the object. |
| + // |
| + // Read BEFORE the linkage below so a trashed app can be excluded from it: |
| + // nothing should be re-homing, minting for, or otherwise reasoning about |
| + // an app whose owner has already said stop. |
| + trashed := map[appKey]*release.AppTombstone{} |
| + for _, key := range keys { |
| + acct, app, prefix, ok := appKeyOwner(key, known) |
| + if !ok || key != prefix+"deleted.json" { |
| + continue |
| + } |
| + tomb, err := release.ReadAppTombstone(ctx, s, acct, app) |
| + if err != nil { |
| + log.Printf("domains: ignoring trash marker for %s/%s: %v", acct, app, err) |
| + continue |
| + } |
| + if tomb != nil { |
| + trashed[appKey{acct, app}] = tomb |
| + } |
| + } |
| + |
| // --- 1b. app→account linkage, from the KEY PATH -------------------------- |
| // apps/<sqid>/<app>/… says the app belongs to that account. A legacy |
| // unqualified apps/<app>/… key says nothing at all since the read-fallback |
| @@ -142,6 +176,9 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| if !ok { |
| continue |
| } |
| + if trashed[appKey{acct, app}] != nil { |
| + continue // a trashed app owns nothing here; its routes are going |
| + } |
| if !slices.Contains(owners[app], acct) { |
| owners[app] = append(owners[app], acct) |
| } |
| @@ -161,6 +198,26 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| routes[i].Account = acct |
| } |
| + // Trashed apps stop routing NOW: every route of a trashed (account, app) |
| + // is removed, and the shrunken slice is what minting sees, so nothing |
| + // below re-mints an apex or canary host for an app in the trash. |
| + if len(trashed) > 0 { |
| + keptLive := routes[:0] |
| + for _, rt := range routes { |
| + if rt.App != "" && trashed[appKey{release.RouteAccount(rt), rt.App}] != nil { |
| + if err := reg.Delete(rt.Host); err != nil { |
| + log.Printf("domains: remove route %s of trashed app %s: %v", rt.Host, rt.App, err) |
| + keptLive = append(keptLive, rt) // still there; try next pass |
| + continue |
| + } |
| + log.Printf("domains: removed route %s — app %s is in the trash", rt.Host, rt.App) |
| + continue |
| + } |
| + keptLive = append(keptLive, rt) |
| + } |
| + routes = keptLive |
| + } |
| + |
| // --- 2. platform-domain minting ------------------------------------------ |
| byHost := map[string]bool{} |
| // hasApex tracks the (ACCOUNT, app) pairs that already own a platform |
| @@ -217,6 +274,9 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| continue |
| } |
| k := appKey{acctOfKey, app} |
| + if trashed[k] != nil { |
| + continue // no canary host for an app in the trash |
| + } |
| if canaryChannels[k] == nil { |
| canaryChannels[k] = map[string]bool{} |
| } |
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go| index 54daecb30..e4cbe8dc8 100644 |
| --- a/internal/domains/reconcile_test.go |
| +++ b/internal/domains/reconcile_test.go |
| @@ -645,6 +645,87 @@ func TestReconcileInertWithoutAppsDomain(t *testing.T) { |
| } |
| } |
| +// routesFor returns every route in the registry whose App is app — the shape |
| +// the trash tests assert on, since "stops serving" means EVERY route, not one |
| +// named host. |
| +func routesFor(t *testing.T, reg *registry.Registry, app string) []registry.Route { |
| + t.Helper() |
| + routes, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var out []registry.Route |
| + for _, rt := range routes { |
| + if rt.App == app { |
| + out = append(out, rt) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// A trash marker is the owner saying "stop serving this app", so reconcile |
| +// removes ALL of its routes — the custom domain included, which is the one |
| +// destructive move inference alone would never be allowed. A custom domain |
| +// still serving a "deleted" app would be the worse betrayal. And nothing |
| +// re-mints: no apex, no canary host, on this pass or any later one. |
| +func TestReconcileTrashedAppDropsAllRoutesAndMintsNothing(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "derp.custom", Kind: "static", App: "derp", |
| + Status: "ready", Channel: "stable", Account: domains.Sqid(793481920157)}, |
| + registry.Route{Host: "derp." + domains.Sqid(793481920157) + ".oncarlos.test", Kind: "static", |
| + App: "derp", Status: "ready", Channel: "stable", Platform: true, |
| + Account: domains.Sqid(793481920157)}, |
| + registry.Route{Host: "keep.custom", Kind: "static", App: "keep", |
| + Status: "ready", Channel: "stable", Account: domains.Sqid(793481920157)}, |
| + ) |
| + sqid := writeAccountManifest(t, s, 793481920157, "Kass") |
| + putKey(t, s, "apps/"+sqid+"/derp/channels/canary/x.json") |
| + putKey(t, s, "apps/"+sqid+"/keep/channels/stable.json") |
| + if err := release.TrashApp(ctx, s, sqid, "derp", "paul@keymail.dev", time.Unix(1753900000, 0)); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + for pass := 1; pass <= 2; pass++ { |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatalf("pass %d: %v", pass, err) |
| + } |
| + if got := routesFor(t, reg, "derp"); len(got) != 0 { |
| + t.Errorf("pass %d: trashed app still routed: %+v", pass, got) |
| + } |
| + // The neighbour in the same account is untouched by every pass: its |
| + // custom route stands and its apex mints as it always would. |
| + mustGet(t, reg, "keep.custom") |
| + if apex := mustGet(t, reg, "keep."+sqid+".oncarlos.test"); !apex.Platform || apex.Account != sqid { |
| + t.Errorf("pass %d: neighbour apex = %+v", pass, apex) |
| + } |
| + } |
| +} |
| + |
| +// A marker we cannot fully believe must not destroy routes: ReadAppTombstone |
| +// errors, reconcile logs it and treats the app as live, every pass, until |
| +// someone fixes or removes the object. |
| +func TestReconcileMalformedTrashMarkerIsIgnoredLoudly(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "derp.custom", Kind: "static", App: "derp", |
| + Status: "ready", Channel: "stable", Account: domains.Sqid(793481920157)}, |
| + ) |
| + sqid := writeAccountManifest(t, s, 793481920157, "Kass") |
| + putKey(t, s, "apps/"+sqid+"/derp/channels/stable.json") |
| + // The body claims a different app than the path it sits at. |
| + if _, err := s.Put(ctx, release.AppTombstoneKey(sqid, "derp"), |
| + []byte(`{"schema":1,"account":"`+sqid+`","app":"elsewhere","deleted_at":"2026-08-04T09:00:00Z"}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + mustGet(t, reg, "derp.custom") // routes stay |
| + // ...and minting proceeds exactly as if no marker existed. |
| + if apex := mustGet(t, reg, "derp."+sqid+".oncarlos.test"); !apex.Platform || apex.Account != sqid { |
| + t.Errorf("apex = %+v — an unbelievable marker must change nothing", apex) |
| + } |
| +} |
| + |
| // A retired apps/<app>/account.json (the domains spec's linkage manifest, |
| // superseded by the key path) is just another object in the bucket now — it |
| // must not be read, and above all must not re-home an app. |