carlos / platform Public

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>
Paul Campbell 2d60258f4193b903d191af15466d4fafca4e1be1 parent 09f474c
2 files changed, +144 −3
  • internal/domains/reconcile.go +63 −3
  • internal/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.