console/cli/edge/vet: qualified-only reads, and the two legacy readers that stay
The consumer list is everything that PARSES the shape, so every caller of the retired helpers moved: the console's channel and release listings, the edge's static extract, `carlos channels`, and vet's artifact check all read account-qualified keys and list account-qualified prefixes now. An app whose objects are still unqualified lists as empty in the console — exactly as the fleet sees it, which is the disagreement the fallback used to create. Two legacy readers survive because both are EXPLICIT rather than a resolution path. `carlos accounts migrate` keeps its per-key precedence over the account-less space (GetMigrationSource): it is the upgrade path for any deployment arriving from a pre-accounts binary and must work forever. And the console's app-name claim still checks the legacy ops prefix — a name whose legacy objects exist is not free, since handing it out would put a stranger's app in the way of the migration those objects are still owed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+42
−38
accounts.go+19 −16cli.go+2 −2internal/appconfig/environments.go+3 −2internal/console/server.go+7 −5internal/console/view.go+9 −7internal/edge/static.go+1 −3internal/vet/vet.go+1 −3
diff --git a/accounts.go b/accounts.go| index e86d7da2c..3a4679a2e 100644 |
| --- a/accounts.go |
| +++ b/accounts.go |
| @@ -403,18 +403,20 @@ func putOverwrite(ctx context.Context, s release.Store, key string, body []byte) |
| // most dangerous decision the verb makes. |
| // |
| // Once this binary is adopted, every ship and promote writes to the |
| -// account-qualified keys and every read resolves qualified-first — so an app |
| -// that has not been migrated yet ends up with objects at BOTH prefixes, and |
| -// its LEGACY pointer goes stale the first time anyone deploys. Copying the |
| -// legacy prefix at that point would move a stale pointer into the new |
| -// account, lose every post-adopt release, and let the next agent tick |
| -// silently downgrade the app to a pre-adopt build. |
| +// account-qualified keys — so an app that was half-migrated when the |
| +// accounts build landed has objects at BOTH prefixes, and its LEGACY pointer |
| +// went stale the first time anyone deployed. Copying the legacy prefix at |
| +// that point would move a stale pointer into the new account, lose every |
| +// post-adopt release, and let the next agent tick silently downgrade the app |
| +// to a pre-adopt build. |
| // |
| -// So the default source is not a prefix but the same PER-KEY PRECEDENCE the |
| -// runtime uses (release.GetWithFallback): for each relative key, the |
| -// ops-qualified object wins, and legacy-only objects still come across so no |
| -// rollback target is lost. `-from` overrides it with a single explicit |
| -// prefix, for moving between two non-ops accounts. |
| +// So the default source is not a prefix but a PER-KEY PRECEDENCE: for each |
| +// relative key, the ops-qualified object wins, and legacy-only objects still |
| +// come across so no rollback target is lost. (That precedence outlived the |
| +// read-fallback it was modelled on — the runtime resolves qualified keys only |
| +// now, but the buckets this verb repairs are exactly the ones with objects |
| +// under both.) `-from` overrides it with a single explicit prefix, for moving |
| +// between two non-ops accounts. |
| // |
| // Returns the copies to make and the account the app is being moved FROM (for |
| // the post-copy verification). |
| @@ -487,7 +489,7 @@ func migrationPlan(ctx context.Context, store release.Store, app, from, to, dst |
| } |
| // Qualified first, then legacy for anything it did not cover — the |
| - // per-key precedence GetWithFallback applies on every read. |
| + // per-key precedence described above. |
| seen := map[string]bool{} |
| var out []copyOp |
| for _, k := range opsKeys { |
| @@ -532,16 +534,17 @@ func verifyMigratedPointers(ctx context.Context, store release.Store, app, srcAc |
| if err != nil { |
| log.Fatalf("accounts migrate: verify %s: %v", key, err) |
| } |
| - // What a reader on the OLD account resolves right now, by the same |
| - // precedence the runtime uses. A legacy source has no qualified key at |
| - // all, so both halves of the fallback are the legacy one — passing the |
| + // What the OLD account still holds, by this verb's own per-key |
| + // precedence — the migration's explicit read of the legacy space, |
| + // which outlives the retired runtime fallback. A legacy source has no |
| + // qualified key at all, so both halves are the legacy one: passing the |
| // sentinel to ChannelKey would build apps/legacy/… and quietly depend |
| // on that never existing. |
| srcKey := release.ChannelKey(srcAcct, app, channel) |
| if srcAcct == release.LegacySource { |
| srcKey = release.LegacyChannelKey(app, channel) |
| } |
| - want, _, _, err := release.GetWithFallback(ctx, store, |
| + want, _, _, err := release.GetMigrationSource(ctx, store, |
| srcKey, release.LegacyChannelKey(app, channel)) |
| if err != nil { |
| log.Fatalf("accounts migrate: verify: re-read %s/%s at the source: %v", app, channel, err) |
diff --git a/cli.go b/cli.go| index 91be37344..b5adf5c8f 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -863,8 +863,8 @@ func runChannels(args []string) { |
| } |
| acct := acctOf() |
| s := openStore() |
| - keys, prefix, _, err := release.ListWithFallback(context.Background(), s, |
| - release.ChannelsPrefix(acct, *app), release.LegacyChannelsPrefix(*app)) |
| + prefix := release.ChannelsPrefix(acct, *app) |
| + keys, err := s.List(context.Background(), prefix) |
| if err != nil { |
| log.Fatal(err) |
| } |
diff --git a/internal/appconfig/environments.go b/internal/appconfig/environments.go| index 3fdcb21fa..552d19bca 100644 |
| --- a/internal/appconfig/environments.go |
| +++ b/internal/appconfig/environments.go |
| @@ -30,8 +30,9 @@ func EnvironmentKey(acct, app, name string) string { |
| // EnvironmentsPrefix is the list prefix behind "what named environments does |
| // this app have" — every apps/<acct>/<app>/environments/<name>.json key |
| -// shares it. Unlike release.ChannelsPrefix there is no legacy fallback: |
| -// environments are new, so there is nothing pre-migration to fall back to. |
| +// shares it. Environments were born account-scoped and never had a legacy |
| +// unqualified shape — which, since the read-fallback's retirement, is now |
| +// true of every read path rather than a distinction worth drawing. |
| func EnvironmentsPrefix(acct, app string) string { |
| return release.AppPrefix(acct, app) + "environments/" |
| } |
diff --git a/internal/console/server.go b/internal/console/server.go| index af9dde602..53fb6adf1 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -1122,11 +1122,13 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) { |
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| defer cancel() |
| // The account's own prefix, plus — for ops only — the legacy unqualified |
| - // one an unmigrated app's objects are still at. Missing the legacy prefix |
| - // would hand out a name whose releases already exist, which is precisely |
| - // the "orphan bucket objects" case this check was added for. For a |
| - // non-ops account the legacy prefix belongs to somebody else's app and |
| - // must NOT block the name. |
| + // one an unmigrated app's objects are still at. This one legacy read |
| + // OUTLIVES the retired read-fallback, and deliberately: it is explicit |
| + // rather than a resolution path, and a name whose legacy objects still |
| + // exist is not free. Handing it out would put a stranger's app in the way |
| + // of the `accounts migrate -from legacy` that is still owed to those |
| + // objects. For a non-ops account the legacy prefix belongs to somebody |
| + // else's app and must NOT block the name. |
| prefixes := []string{release.AppPrefix(acct.Sqid, name)} |
| if acct.ID == OpsAccountID { |
| prefixes = append(prefixes, release.LegacyAppPrefix(name)) |
diff --git a/internal/console/view.go b/internal/console/view.go| index 9b61c1ca2..096959ac1 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -273,12 +273,14 @@ func assembleCanaries(channels []ChannelView, routes []RouteView) []CanaryView { |
| return out |
| } |
| -// assembleChannels lists the app's channel pointers — account-qualified, |
| -// falling back to the legacy unqualified prefix for an app that hasn't been |
| -// migrated yet — and resolves each one. |
| +// assembleChannels lists the app's account-qualified channel pointers and |
| +// resolves each one. An app whose objects are still at the legacy unqualified |
| +// keys lists as empty here, exactly as the fleet sees it — the console showing |
| +// channels the boxes cannot follow is the disagreement the retired |
| +// read-fallback used to create. |
| func assembleChannels(ctx context.Context, s release.Store, pub ed25519.PublicKey, acct, app string, now time.Time) []ChannelView { |
| - keys, prefix, _, err := release.ListWithFallback(ctx, s, |
| - release.ChannelsPrefix(acct, app), release.LegacyChannelsPrefix(app)) |
| + prefix := release.ChannelsPrefix(acct, app) |
| + keys, err := s.List(ctx, prefix) |
| if err != nil { |
| return []ChannelView{{Name: "(all)", Err: "listing channels: " + err.Error()}} |
| } |
| @@ -354,8 +356,8 @@ const versionsCap = 20 |
| // capped at versionsCap. Unreadable manifests degrade to a row error. |
| func Versions(s release.Store, acct, app string) ([]VersionView, error) { |
| ctx := context.Background() |
| - keys, prefix, _, err := release.ListWithFallback(ctx, s, |
| - release.ReleasesPrefix(acct, app), release.LegacyReleasesPrefix(app)) |
| + prefix := release.ReleasesPrefix(acct, app) |
| + keys, err := s.List(ctx, prefix) |
| if err != nil { |
| return nil, err |
| } |
diff --git a/internal/edge/static.go b/internal/edge/static.go| index 585f90791..43b134cfe 100644 |
| --- a/internal/edge/static.go |
| +++ b/internal/edge/static.go |
| @@ -229,9 +229,7 @@ func (s *staticSite) ensureExtracted(ctx context.Context, version string) error |
| if art == nil { |
| return fmt.Errorf("release %s/%s has no %s artifact", s.app, version, siteArtifactFile) |
| } |
| - data, _, _, err := release.GetWithFallback(ctx, s.store, |
| - release.ArtifactKey(s.acct, s.app, version, art.File), |
| - release.LegacyArtifactKey(s.app, version, art.File)) |
| + data, _, err := release.GetQualified(ctx, s.store, release.ArtifactKey(s.acct, s.app, version, art.File)) |
| if err != nil { |
| return err |
| } |
diff --git a/internal/vet/vet.go b/internal/vet/vet.go| index 50b635fc5..5a929f2e8 100644 |
| --- a/internal/vet/vet.go |
| +++ b/internal/vet/vet.go |
| @@ -64,9 +64,7 @@ func Check(ctx context.Context, s release.Store, pub ed25519.PublicKey, acct, ap |
| platform := runtime.GOOS + "-" + runtime.GOARCH |
| for _, k := range keys { |
| a := m.Artifacts[k] |
| - b, _, _, err := release.GetWithFallback(ctx, s, |
| - release.ArtifactKey(acct, app, version, a.File), |
| - release.LegacyArtifactKey(app, version, a.File)) |
| + b, _, err := release.GetQualified(ctx, s, release.ArtifactKey(acct, app, version, a.File)) |
| if err != nil { |
| problems = append(problems, fmt.Sprintf("artifact %s (%s): %v", k, a.File, err)) |
| continue |