domains: leftover legacy keys are not linkage — they mint nothing, claim nothing
appKeyOwner used to read apps/<app>/… as "ops, until this app is migrated". Now that the runtime cannot read those objects, bytes nobody serves say nothing about who owns an app, and acting on them is worse than useless. Two behaviours flip. A legacy canary pointer no longer mints a canary host that resolves to a build no box can install — and a host already minted from one is retired by the ordinary pointerless-canary rule. And a leftover copy under a migrated app no longer makes ops an owner of its objects, which is what dragged an unstamped route onto ops instead of the account the app was migrated into. Platform apexes still mint, because minting follows the operator's ROUTE — a real record of intent — not the bucket. Such an apex serves nothing until the app is migrated, which is the honest report.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+107
−33
internal/domains/reconcile.go+38 −29internal/domains/reconcile_test.go+69 −4
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go| index fe691bea7..7ab9e28ad 100644 |
| --- a/internal/domains/reconcile.go |
| +++ b/internal/domains/reconcile.go |
| @@ -126,12 +126,12 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| } |
| // --- 1b. app→account linkage, from the KEY PATH -------------------------- |
| - // apps/<sqid>/<app>/… says the app belongs to that account; a legacy |
| - // unqualified apps/<app>/… belongs to ops until it is migrated. The two |
| - // shapes are told apart by whether the first segment is a KNOWN account |
| - // sqid, which is why the account set had to be read first — app names and |
| - // account sqids live in different namespaces, so there is no ambiguity |
| - // once the set is known. |
| + // apps/<sqid>/<app>/… says the app belongs to that account. A legacy |
| + // unqualified apps/<app>/… key says nothing at all since the read-fallback |
| + // was retired — the runtime cannot read those objects, so they must not |
| + // mint a route or claim an app (appKeyOwner). The shapes are told apart by |
| + // whether the first segment is a KNOWN account sqid, which is why the |
| + // account set had to be read first. |
| // owners is app name -> the accounts that hold objects for it. A LIST, not |
| // one account: the same app name legitimately exists in several accounts, |
| // and collapsing them to one would re-home every tenant's app onto |
| @@ -348,42 +348,51 @@ func AccountManifestSqid(key string) (string, bool) { |
| } |
| // appKeyOwner reads the app→account linkage straight out of a bucket key: |
| -// apps/<sqid>/<app>/… belongs to that account, apps/<app>/… is a legacy |
| -// unqualified key and belongs to ops until it is migrated. known is the set |
| -// of materialized account sqids — the only thing that tells the two shapes |
| -// apart, which is why accounts are read first. |
| +// apps/<sqid>/<app>/… belongs to that account. known is the set of |
| +// materialized account sqids, which is what makes a key path unambiguous — |
| +// hence accounts being read first. |
| // |
| -// Keys with nothing after the app segment (apps/<app>) are not linkage: an |
| -// object sitting directly at the app prefix says nothing about ownership. |
| -// It also returns the app's key prefix, so callers can parse what comes after |
| -// it without re-deciding which of the two shapes they are looking at. |
| +// A LEGACY unqualified apps/<app>/… key is not linkage. It used to be (it |
| +// meant "ops, until this app is migrated"), but the read-fallback's |
| +// retirement on 2026-08-03 made those objects unreadable to the runtime, and |
| +// bytes nobody can serve say nothing about who owns an app. Acting on them |
| +// here would be worse than useless: reconciliation would mint routes in front |
| +// of builds no box can install, and a leftover copy under a migrated app |
| +// would claim it back for ops. Nothing removes legacy objects, so this stays |
| +// true for as long as they sit there. |
| +// |
| +// Keys with nothing after the app segment (apps/<sqid>/<app>) are not linkage |
| +// either: an object sitting directly at the app prefix says nothing about |
| +// ownership. It also returns the app's key prefix, so callers can parse what |
| +// comes after it without rebuilding it. |
| func appKeyOwner(key string, known map[string]bool) (acct, app, prefix string, ok bool) { |
| rest, ok := strings.CutPrefix(key, "apps/") |
| if !ok { |
| return "", "", "", false |
| } |
| - first, rest, ok := strings.Cut(rest, "/") |
| - if !ok || first == "" { |
| + acct, rest, ok = strings.Cut(rest, "/") |
| + if !ok || !known[acct] { // legacy apps/<app>/…, or an account we know nothing about |
| return "", "", "", false |
| } |
| app, _, hasThird := strings.Cut(rest, "/") |
| + if !hasThird || app == "" { |
| + return "", "", "", false |
| + } |
| // The one place the two namespaces can genuinely collide: an app whose |
| - // NAME happens to equal a materialized account sqid (an app called "bab" |
| - // on a legacy key). What follows disambiguates it — a qualified key's |
| - // third segment is the app name, a legacy key's second segment is always |
| - // one of the release plane's own words. |
| - if !known[first] || app == "channels" || app == "releases" { |
| - return OpsSqid, first, "apps/" + first + "/", true // apps/<app>/… |
| - } |
| - if !hasThird || app == "" { // apps/<sqid>/<app>/… |
| + // NAME happens to equal a materialized account sqid — a legacy key for an |
| + // app called "bab" is apps/bab/channels/…, which parses as ops's app |
| + // "channels". A qualified key's third segment is the app name; a legacy |
| + // key's second is always one of the release plane's own words, so those |
| + // two words are never an app here. |
| + if app == "channels" || app == "releases" { |
| return "", "", "", false |
| } |
| - return first, app, "apps/" + first + "/" + app + "/", true |
| + return acct, app, "apps/" + acct + "/" + app + "/", true |
| } |
| -// canaryPointer reports whether key is a canary channel pointer — qualified |
| -// (apps/<sqid>/<app>/channels/canary/<name>.json) or legacy |
| -// (apps/<app>/channels/canary/<name>.json) — and its app + canary name. |
| +// canaryPointer reports whether key is an account-qualified canary channel |
| +// pointer (apps/<sqid>/<app>/channels/canary/<name>.json), and its app + |
| +// canary name. A legacy unqualified pointer is not one: see appKeyOwner. |
| func canaryPointer(key string, known map[string]bool) (app, name string, ok bool) { |
| _, app, prefix, ok := appKeyOwner(key, known) |
| if !ok { |
| @@ -412,7 +421,7 @@ type appKey struct{ acct, app string } |
| // The rules, in order: |
| // - the row's own account already holds objects: it is right, keep it; |
| // - exactly one account holds them: adopt it (this is what makes a |
| -// migration land, and what puts a legacy app under ops); |
| +// migration land); |
| // - several do, and the row names none of them: leave it alone. Picking one |
| // would re-home somebody's app onto another tenant's bytes, which is far |
| // worse than a row that reconciliation declines to touch. |
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go| index 6f9475d7e..69b5022aa 100644 |
| --- a/internal/domains/reconcile_test.go |
| +++ b/internal/domains/reconcile_test.go |
| @@ -182,8 +182,10 @@ func TestReconcileRetiresTombstonedAccounts(t *testing.T) { |
| } |
| // The app→account linkage IS the bucket key path: an app under |
| -// apps/<sqid>/<app>/… belongs to that account; a legacy unqualified |
| -// apps/<app>/… belongs to ops until it is migrated. |
| +// apps/<sqid>/<app>/… belongs to that account. A legacy unqualified |
| +// apps/<app>/… key is not linkage at all any more — the runtime cannot read |
| +// it, so it says nothing about who owns what. oldapp here still lands under |
| +// ops, but from its ROUTE's default, not from the leftover object. |
| func TestReconcileDerivesAccountFromKeyPath(t *testing.T) { |
| s, reg := dirStore(t), regWith(t, |
| registry.Route{Host: "kass.training", Kind: "instance", App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}, |
| @@ -209,6 +211,69 @@ func TestReconcileDerivesAccountFromKeyPath(t *testing.T) { |
| } |
| } |
| +// A leftover legacy object must not claim an app for ops. This is the shape |
| +// every migrated app's bucket is in — the migration copies and deliberately |
| +// never deletes — and the route was added before any of it, so it carries no |
| +// account yet. |
| +// |
| +// It used to go the other way: the legacy leftover made OPS an owner of |
| +// kass's objects, an unstamped route reads as ops, ops was among the owners, |
| +// and the route got stamped ops — pointing adopt at a prefix holding nothing |
| +// but the copy nobody serves. With legacy keys ignored there is exactly one |
| +// owner, the account the app actually lives in. |
| +func TestReconcileIgnoresLeftoverLegacyKeys(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "kass.training", Kind: "static", App: "kass", |
| + Status: "ready", Channel: "stable"}) |
| + sqid := writeAccountManifest(t, s, 793481920157, "Kass") |
| + putKey(t, s, "apps/"+sqid+"/kass/channels/stable.json") |
| + putKey(t, s, "apps/kass/channels/stable.json") |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if src := mustGet(t, reg, "kass.training"); src.Account != sqid { |
| + t.Errorf("source route account = %q, want %s — a legacy leftover claimed the app for ops", |
| + src.Account, sqid) |
| + } |
| + mustGet(t, reg, "kass."+sqid+".oncarlos.test") // the apex mints under the real account |
| +} |
| + |
| +// A legacy canary pointer mints no canary host. The host would resolve a |
| +// channel the fleet cannot read, so it would serve a 404 under a URL the |
| +// console advertises as live. |
| +func TestReconcileMintsNothingForALegacyCanaryPointer(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "static", |
| + App: "kass", Status: "ready", Channel: "stable"}) |
| + putKey(t, s, "apps/kass/channels/canary/pricing.json") |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, err := reg.Get("pricing.kass.bab.oncarlos.test"); err != nil || rt != nil { |
| + t.Errorf("a legacy canary pointer minted %+v (%v) — nothing can serve it", rt, err) |
| + } |
| +} |
| + |
| +// A platform-minted canary host whose pointer only ever existed at the legacy |
| +// keys is retired like any other pointerless canary. It is the same removal |
| +// rule, reached because legacy keys stopped counting as live pointers: the |
| +// host resolved to a build the fleet cannot install, so keeping it alive |
| +// would advertise a URL that 404s. |
| +func TestReconcileRetiresCanaryHostLeftOnALegacyPointer(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "kass.training", Kind: "static", App: "kass", |
| + Status: "ready", Channel: "stable"}, |
| + registry.Route{Host: "pricing.kass.bab.oncarlos.test", Kind: "static", App: "kass", |
| + Status: "ready", Channel: "canary/pricing", Platform: true, Canary: true, Account: "bab"}, |
| + ) |
| + putKey(t, s, "apps/kass/channels/canary/pricing.json") |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, err := reg.Get("pricing.kass.bab.oncarlos.test"); err != nil || rt != nil { |
| + t.Errorf("canary host on a legacy-only pointer survived: %+v %v", rt, err) |
| + } |
| +} |
| + |
| // A malformed account manifest is one skipped line, never a failed pass, and |
| // never a guessed id. |
| func TestReconcileSkipsMalformedAccountManifest(t *testing.T) { |
| @@ -306,7 +371,7 @@ func TestReconcileMintsCanaryHost(t *testing.T) { |
| App: "kass", Status: "ready", Channel: "stable"}) |
| // The pointer's existence is what reconcile reads — content is not its |
| // business (the edge verifies signatures when serving). |
| - if _, err := s.Put(ctx, "apps/kass/channels/canary/pricing.json", []byte("{}"), ""); err != nil { |
| + if _, err := s.Put(ctx, "apps/bab/kass/channels/canary/pricing.json", []byte("{}"), ""); err != nil { |
| t.Fatal(err) |
| } |
| if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| @@ -336,7 +401,7 @@ func TestReconcileMintsCanaryHost(t *testing.T) { |
| func TestReconcileSkipsCanaryMintForInstanceApps(t *testing.T) { |
| s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}) |
| - if _, err := s.Put(ctx, "apps/kass/channels/canary/pricing.json", []byte("{}"), ""); err != nil { |
| + if _, err := s.Put(ctx, "apps/bab/kass/channels/canary/pricing.json", []byte("{}"), ""); err != nil { |
| t.Fatal(err) |
| } |
| if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |