carlos / platform Public

fix(domains): trashed apps with unstamped routes still drop — stamp first, then remove

Excluding trashed apps from the app→account linkage left a route with an
empty account column reading as ops, so the removal lookup missed a marker
written under the tenant's sqid: the app went on serving from its custom
domain, and even minted itself an ops apex. The spec says ALL of them.

Trashed apps are stamped like any other now — one write on a row that is
about to be deleted, and the stamp is exactly what makes the removal match.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a00485afb1d05ab5f64cb5d4c82b3578e43e94c1 parent 2d60258
2 files changed, +33 −6
  • internal/domains/reconcile.go +10 −6
  • internal/domains/reconcile_test.go +23 −0
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go
index bc4133abd..cda5ff5d9 100644
--- a/internal/domains/reconcile.go
+++ b/internal/domains/reconcile.go
@@ -140,9 +140,9 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// 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.
+ // Collected before the linkage below purely so the set is complete when
+ // the removal pass (right after 1b's stamping) reads it. The linkage
+ // itself does NOT skip trashed apps — see the stamping loop for why.
trashed := map[appKey]*release.AppTombstone{}
for _, key := range keys {
acct, app, prefix, ok := appKeyOwner(key, known)
@@ -176,13 +176,17 @@ 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)
}
}
+ // Trashed apps are stamped here like any other, deliberately: the stamp is
+ // what makes the removal lookup below match. A route with an empty account
+ // column resolves to ops until 1b writes the account the BUCKET asserts,
+ // so skipping trashed apps here would leave such a row reading "ops" while
+ // its marker sits under the tenant's sqid — and the app would go on
+ // serving from its custom domain forever. Stamping a row that is about to
+ // be deleted costs one write and buys "ALL of them".
for i := range routes {
if routes[i].App == "" {
continue // an app-less route (status page, bare proxy) has no account
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go
index e4cbe8dc8..21cb9c3b6 100644
--- a/internal/domains/reconcile_test.go
+++ b/internal/domains/reconcile_test.go
@@ -701,6 +701,29 @@ func TestReconcileTrashedAppDropsAllRoutesAndMintsNothing(t *testing.T) {
}
}
+// "ALL of them" includes a route that never got its account column stamped.
+// Such a row reads as ops until step 1b stamps it, so the removal lookup would
+// miss a marker written under the tenant's account — the app would keep
+// serving from its custom domain forever. The stamp has to land FIRST, from
+// the account the bucket actually asserts, and then the removal matches.
+func TestReconcileTrashedAppWithUnstampedRouteStillDrops(t *testing.T) {
+ s, reg := dirStore(t), regWith(t,
+ registry.Route{Host: "derp.custom", Kind: "static", App: "derp",
+ Status: "ready", Channel: "stable"}, // no Account column at all
+ )
+ sqid := writeAccountManifest(t, s, 793481920157, "Kass")
+ putKey(t, s, "apps/"+sqid+"/derp/channels/stable.json")
+ if err := release.TrashApp(ctx, s, sqid, "derp", "paul@keymail.dev", time.Unix(1753900000, 0)); err != nil {
+ t.Fatal(err)
+ }
+ if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil {
+ t.Fatal(err)
+ }
+ if got := routesFor(t, reg, "derp"); len(got) != 0 {
+ t.Errorf("unstamped route of a trashed app survived: %+v", got)
+ }
+}
+
// 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.