carlos / platform Public

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>
Paul Campbell 01e1286ea130d5773d084d1f7c93a855997363f6 parent 4ee9962
7 files changed, +42 −38
  • accounts.go +19 −16
  • cli.go +2 −2
  • internal/appconfig/environments.go +3 −2
  • internal/console/server.go +7 −5
  • internal/console/view.go +9 −7
  • internal/edge/static.go +1 −3
  • internal/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