carlos / platform Public

review round: tripwire the artifact paths, name the migration on promote

Three findings from the adversarial review.

The tripwire only guards paths a test routes THROUGH it, and neither artifact
download was: a fallback reintroduced in adopt.ensureStaged or in
edge.ensureExtracted left the whole suite green — the two reads that put
bytes on a box and in front of traffic. Both now have a pass of their own,
each with the half-migrated fixture that makes the read observable (pointer
and manifest qualified, artifact legacy-only), and both were mutation-checked
to fail when a fallback comes back. The edge gets its own tripwire type
because a tripwire is only worth its coverage.

`carlos promote` said nothing useful on a deployment that never migrated: the
ladder answered first with "this build is on no rung yet" — true, since
nothing qualified means no rung, and entirely beside the point. Promote's own
read cannot say it, because an absent pointer is the legitimate first
promotion of a fresh channel; so PromoteOnLadder refuses an app with NOTHING
under its qualified prefix and names the verb. Nothing qualified at all, not
zero qualified releases: an app that has shipped since accounts landed is not
unmigrated however new the channel is. Neither test reads a legacy key.
Promote's pointer read moves to GetQualified for uniform plumbing, hint
discarded at that level.

The "ONE read helper" claim was false — listings and Promote's RMW bypass it
— and it was the argument the retirement's durability rested on. Comment and
spec §7c now name both exceptions and why each is safe, and state the
narrower property that does hold: no runtime read builds a legacy key,
enforced at the Store by the tripwires.

Also pinned, not endorsed: a Platform+Canary route stamped with one account
while its app's pointer lives under another is removed on the next pass. The
shape predates this branch (the retirement only changed which pointers
count), and a live pre-flight found it on neither fleet — tito's reconcile is
inert, the flagship holds zero such rows. Pinned so it is known rather than
discovered by a deleted host, with the fix located in the stamping rule.

And a line recording that appKeyOwner's tightening closes a latent bug: a
qualified key whose account had not yet materialized used to come back with
the account's own sqid read as an app NAME.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell cd19dc9dcedee42a914c0778ea79ec10d2609631 parent 12e65a0
7 files changed, +335 −11
  • docs/superpowers/specs/2026-08-02-multi-account-design.md +28 −5
  • internal/domains/reconcile.go +7 −0
  • internal/domains/reconcile_test.go +44 −0
  • internal/edge/static_test.go +76 −0
  • internal/release/qualified.go +23 −5
  • internal/release/qualified_test.go +130 −0
  • internal/release/release.go +27 −1
diff --git a/docs/superpowers/specs/2026-08-02-multi-account-design.md b/docs/superpowers/specs/2026-08-02-multi-account-design.md
index db56fb4f8..641ca0416 100644
--- a/docs/superpowers/specs/2026-08-02-multi-account-design.md
+++ b/docs/superpowers/specs/2026-08-02-multi-account-design.md
@@ -353,17 +353,40 @@ downgraded a live app on the next agent tick.
Neither is a resolution path: nothing falls back, both are named.
-**A miss is actionable, in one place.** `release.GetQualified` is the only
-read helper the runtime uses, so the retired fallback has exactly one place
-it could grow back — and its not-found carries one sentence naming
-`carlos accounts migrate -app <app> -from legacy -to <acct>`. It stays
-`os.ErrNotExist`-wrapped so every "has this app ever shipped here" check
+**A miss is actionable, in one place.** Every runtime read of an OBJECT —
+manifest, channel pointer, artifact — goes through `release.GetQualified`, so
+the sentence a miss needs is written once: its not-found names
+`carlos accounts migrate -app <app> -from legacy -to <acct>` and stays
+`os.ErrNotExist`-wrapped, so every "has this app ever shipped here" check
keeps working. The hint is **unconditional rather than probed**: confirming
it would mean reading a legacy key on the error path, and "the runtime never
reads a legacy key" is a property worth holding outright rather than with an
exception. The wording carries the condition instead, so it stays honest for
an app that simply has no such channel. Non-app keys get no hint at all.
+Two kinds of read do not pass through it, and the claim is stated with them
+rather than around them. **Listings** (`VersionRung`, `carlos channels`, the
+console's channel and release views) call `List` on a qualified prefix; there
+is nothing to fall back to, since a prefix either holds objects or the app has
+none here, and both are answers. **`Promote`'s read-modify-write** calls
+`GetQualified` but discards the hint, because an absent pointer is the
+legitimate first promotion of a fresh channel.
+
+What holds without qualification is the narrower, checkable property: no
+runtime read anywhere builds a legacy key. The **tripwire tests** enforce it
+at the `Store`, not at any one helper — and because a tripwire only guards the
+paths a test routes through it, the two artifact downloads
+(`adopt.ensureStaged`, `edge.ensureExtracted`) each get their own pass. Both
+were mutation-checked: reintroducing a fallback at either site fails its test.
+
+**`carlos promote` says it too.** `PromoteOnLadder` refuses an app with
+nothing at all under its qualified prefix, naming the migration. Without that
+the ladder answers first — "this build is on no rung yet", true and beside the
+point. The condition is *nothing qualified at all*, not *no qualified
+releases*: an app that has shipped since accounts landed is not unmigrated
+however new the channel is. Both readings are computed from the qualified
+prefix; neither touches a legacy key.
+
**Leftover legacy objects mint nothing and claim nothing.** Reconciliation
used to read `apps/<app>/…` as "ops, until migrated"; it no longer treats
those keys as app→account linkage. A legacy canary pointer mints no canary
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go
index 7ab9e28ad..6f1250be9 100644
--- a/internal/domains/reconcile.go
+++ b/internal/domains/reconcile.go
@@ -365,6 +365,13 @@ func AccountManifestSqid(key string) (string, bool) {
// 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.
+//
+// Requiring a KNOWN sqid also closes a latent bug the old legacy branch hid:
+// a genuinely qualified key whose account had not materialized yet fell
+// through to it and came back as (ops, "<sqid>") — the account's own sqid
+// read as an app NAME, on any box whose account manifests reconcile had not
+// reached. That minted apps out of account ids. Now it returns nothing and
+// the next pass, with the account materialized, reads the key correctly.
func appKeyOwner(key string, known map[string]bool) (acct, app, prefix string, ok bool) {
rest, ok := strings.CutPrefix(key, "apps/")
if !ok {
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go
index 69b5022aa..54daecb30 100644
--- a/internal/domains/reconcile_test.go
+++ b/internal/domains/reconcile_test.go
@@ -253,6 +253,50 @@ func TestReconcileMintsNothingForALegacyCanaryPointer(t *testing.T) {
}
}
+// PINNED, NOT ENDORSED. A platform-minted canary host stamped with one
+// account while its app's real canary pointer lives under another is removed
+// on the next pass. The stamp is what decides, and a single stray object
+// under the stamped account is enough to hold it there: resolveRouteAccount's
+// first rule is "the row's own account already holds objects: it is right,
+// keep it", so step 1b never re-stamps the row onto the account the pointer
+// is actually under, and the retirement loop then finds no pointer for the
+// (account, app) pair the row names.
+//
+// This shape predates the fallback's retirement — the retirement only changed
+// which pointers count — and a live pre-flight on 2026-08-03 found it does not
+// occur on either fleet: tito's reconcile is inert (no apps domain configured)
+// and the flagship registry holds zero Platform+Canary rows. It is pinned here
+// so the behaviour is known rather than discovered by a deleted host.
+//
+// If it ever does bite, the fix belongs in the STAMPING rule (a row whose
+// stamped account holds no pointer for its own channel is not "right"), never
+// in the retirement loop — softening the loop would strand every genuinely
+// retired canary host forever.
+func TestReconcileRemovesACanaryHostStampedWithTheWrongAccount(t *testing.T) {
+ s, reg := dirStore(t), regWith(t,
+ registry.Route{Host: "blog.kass.test", Kind: "static", App: "blog",
+ Status: "ready", Channel: "stable", Account: "bab"},
+ registry.Route{Host: "pricing.blog.bab.oncarlos.test", Kind: "static", App: "blog",
+ Status: "ready", Channel: "canary/pricing", Platform: true, Canary: true, Account: "bab"},
+ )
+ sqid := writeAccountManifest(t, s, 793481920157, "Kass")
+ // One stray object under ops pins both rows to bab...
+ putKey(t, s, "apps/bab/blog/releases/v1/manifest.json")
+ // ...while the canary pointer the fleet actually follows is the tenant's.
+ putKey(t, s, "apps/"+sqid+"/blog/channels/canary/pricing.json")
+ if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil {
+ t.Fatal(err)
+ }
+ if rt, err := reg.Get("pricing.blog.bab.oncarlos.test"); err != nil || rt != nil {
+ t.Errorf("canary route survived: %+v %v — if this now passes, the stamping rule changed; "+
+ "re-read the comment above before deleting this test", rt, err)
+ }
+ // The row that pinned it keeps its stamp, which is the mechanism at work.
+ if src := mustGet(t, reg, "blog.kass.test"); src.Account != "bab" {
+ t.Errorf("source route account = %q, want bab", src.Account)
+ }
+}
+
// 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
diff --git a/internal/edge/static_test.go b/internal/edge/static_test.go
index 9f391bc20..3bd4188ce 100644
--- a/internal/edge/static_test.go
+++ b/internal/edge/static_test.go
@@ -9,6 +9,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "strings"
"testing"
"github.com/carlosframework/platform/internal/registry"
@@ -233,3 +234,78 @@ func TestEdgeServesStaticRouteWhenEnabled(t *testing.T) {
t.Fatalf("post-enable GET = %d %q, want 200 %q", code, body, "via the edge")
}
}
+
+// legacyTripwire fails the test if the edge asks the store for anything under
+// one app's LEGACY unqualified prefix. The release package has its own; the
+// edge needs one of its own too, because a tripwire only guards the paths a
+// test actually routes through it — and the static extract is where a
+// fallback would put unmigrated bytes in front of real traffic.
+type legacyTripwire struct {
+ release.Store
+ t *testing.T
+ app string
+}
+
+func (l *legacyTripwire) trip(what, key string) {
+ l.t.Helper()
+ if strings.HasPrefix(key, release.LegacyAppPrefix(l.app)) {
+ l.t.Errorf("the edge %s a legacy key: %s — the read-fallback is retired", what, key)
+ }
+}
+
+func (l *legacyTripwire) Get(ctx context.Context, key string) ([]byte, string, error) {
+ l.trip("read", key)
+ return l.Store.Get(ctx, key)
+}
+
+func (l *legacyTripwire) List(ctx context.Context, prefix string) ([]string, error) {
+ l.trip("listed", prefix)
+ return l.Store.List(ctx, prefix)
+}
+
+// The static extract never falls back to a legacy artifact. The fixture is
+// the half-migrated shape that makes the read observable: pointer and
+// manifest qualified (so the handler resolves and gets as far as wanting the
+// tar), the tar itself only at the legacy key. A fallback reintroduced in
+// ensureExtracted would serve those bytes; qualified-only serves an error.
+//
+// Without this, a fallback in ensureExtracted passes the whole suite.
+func TestStaticExtractNeverReadsALegacyArtifact(t *testing.T) {
+ ctx := context.Background()
+ dir := release.NewDirStore(filepath.Join(t.TempDir(), "store"))
+ priv, pub := testKeys(t)
+
+ scratch := release.NewDirStore(filepath.Join(t.TempDir(), "scratch"))
+ shipSite(t, scratch, "site", "v1", map[string]string{"index.html": "legacy bytes"})
+ if err := release.Promote(ctx, scratch, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ keys, err := scratch.List(ctx, release.AppPrefix(release.OpsAccount, "site"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, k := range keys {
+ body, _, err := scratch.Get(ctx, k)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dst := k
+ if strings.HasSuffix(k, siteArtifactFile) {
+ dst = release.LegacyAppPrefix("site") + strings.TrimPrefix(k, release.AppPrefix(release.OpsAccount, "site"))
+ }
+ if _, err := dir.Put(ctx, dst, body, ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ s := &legacyTripwire{Store: dir, t: t, app: "site"}
+ rt := registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}
+ h := newStaticHandler(rt, s, pub, filepath.Join(t.TempDir(), "cache"))
+ h.(*staticSite).recheck = 0
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ if code, body := getPath(t, srv.URL, "site.example.com", "/"); code == 200 {
+ t.Errorf("GET / = %d %q — the edge served an artifact that only exists at the legacy key", code, body)
+ }
+}
diff --git a/internal/release/qualified.go b/internal/release/qualified.go
index 0e16d5e8d..10e226653 100644
--- a/internal/release/qualified.go
+++ b/internal/release/qualified.go
@@ -59,11 +59,29 @@ var acctRe = regexp.MustCompile(`^[a-z]{3,16}$`)
// key.
func ValidAccount(a string) bool { return acctRe.MatchString(a) }
-// GetQualified reads an account-qualified key — the ONE read helper the
-// runtime uses, so the retired fallback has exactly one place it could ever
-// grow back. Not-found stays os.ErrNotExist-wrapped (every caller's "has this
-// app ever shipped here" check keeps working) and gains the migration hint
-// below.
+// GetQualified reads an account-qualified key. Not-found stays
+// os.ErrNotExist-wrapped (every caller's "has this app ever shipped here"
+// check keeps working) and gains the migration hint below.
+//
+// It is where every runtime read of an OBJECT goes — manifests, channel
+// pointers, artifacts — so the sentence a miss needs is written once. Two
+// kinds of read do not pass through it, and neither is a place the fallback
+// could grow back:
+//
+// - LISTINGS (VersionRung, `carlos channels`, the console's channel and
+// release views) call s.List on a prefix built by ChannelsPrefix or
+// ReleasesPrefix. There is nothing to fall back to: a prefix either has
+// objects under it or the app has none here, and both are answers.
+// - Promote's read-modify-write of a channel pointer calls GetQualified but
+// DISCARDS the hint, because an absent pointer is the legitimate first
+// promotion of a fresh channel. PromoteOnLadder supplies the hint from
+// the whole app's prefix instead, where the distinction is computable.
+//
+// What is true without qualification: no read anywhere in the runtime builds
+// a legacy key. The tripwire tests are what keep that honest, and they guard
+// the store rather than any one helper — the artifact downloads in
+// adopt.ensureStaged and edge.ensureExtracted each get their own pass,
+// because a tripwire only guards the paths a test routes through it.
//
// A store error that is not "not found" is returned untouched and never
// annotated: an unreachable bucket is not an unmigrated app, and pointing an
diff --git a/internal/release/qualified_test.go b/internal/release/qualified_test.go
index e1169f592..b3cff27b9 100644
--- a/internal/release/qualified_test.go
+++ b/internal/release/qualified_test.go
@@ -202,6 +202,67 @@ func TestLegacyOnlyAppIsInvisibleToTheRuntime(t *testing.T) {
}
}
+// A tripwire only guards the paths a test routes through it, so the ARTIFACT
+// download — the read that puts bytes on a box — gets its own pass. Its
+// fixture is the half-migrated shape that makes the read observable: pointer
+// and manifest qualified (so the pass gets as far as staging), the artifact
+// itself only at the legacy key. A fallback reintroduced in ensureStaged
+// would find those bytes and stage them; qualified-only cannot, so the host
+// is left alone and the pass survives.
+//
+// Without this, a fallback in ensureStaged passes the whole suite.
+func TestAdoptNeverDownloadsALegacyArtifact(t *testing.T) {
+ dir := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt, reg := t.TempDir(), testRegistry(t)
+
+ // Built in a scratch store, then placed key by key: the qualified pointer
+ // and manifest go down as they are, the artifact only in its legacy shape.
+ scratch := NewDirStore(t.TempDir())
+ shipRelease(t, scratch, OpsAccount, "half", "v1", adoptPlatform, []byte("build one"))
+ if err := Promote(ctx, scratch, priv, OpsAccount, "half", "canary/x", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ art := ""
+ keys, err := scratch.List(ctx, AppPrefix(OpsAccount, "half"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, k := range keys {
+ body, _, err := scratch.Get(ctx, k)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dst := k
+ if strings.Contains(k, "/releases/") && !strings.HasSuffix(k, "manifest.json") {
+ art = k
+ dst = LegacyAppPrefix("half") + strings.TrimPrefix(k, AppPrefix(OpsAccount, "half"))
+ }
+ if _, err := dir.Put(ctx, dst, body, ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if art == "" {
+ t.Fatal("fixture: no artifact key found")
+ }
+ if _, _, err := dir.Get(ctx, art); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("fixture: the qualified artifact exists (%v) — the test would prove nothing", err)
+ }
+
+ s := &legacyTripwire{Store: dir, t: t, app: "half"}
+ addRoute(t, reg, "half.example.com", "half", "canary/x", false)
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil {
+ t.Fatalf("one app's missing artifact must not fail the pass: %v", err)
+ }
+ if _, err := os.Stat(VersionPath(opt, OpsAccount, "v1", "half")); err == nil {
+ t.Error("adopt staged a build whose artifact only exists at the legacy key")
+ }
+ if v, _ := LiveVersion(opt, "half.example.com"); v != "" {
+ t.Errorf("host repointed to %q on a build that was never staged", v)
+ }
+}
+
// The other half of the bargain: the migration verb's access to legacy keys
// is EXPLICIT, and outlives the fallback. It is the upgrade path for any
// deployment arriving from a pre-accounts binary, so it works forever.
@@ -228,6 +289,75 @@ func TestGetMigrationSourceStillReadsLegacy(t *testing.T) {
}
}
+// Promoting an app on a deployment that never migrated must say SO. Left to
+// the ladder, the answer is "stable is entered from beta only, and this build
+// is on no rung yet" — true (nothing qualified means no rung) and entirely
+// beside the point. The operator's actual problem is that this deployment's
+// objects are all at the account-less keys.
+//
+// The test drives it through the tripwire: the distinction is computed from
+// the qualified prefix alone, never by peeking at legacy.
+func TestPromoteOnLadderNamesTheMigrationForAnUnmigratedApp(t *testing.T) {
+ dir := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, _ := testKeys(t)
+ s := &legacyTripwire{Store: dir, t: t, app: "old"}
+
+ // A wholly legacy app, built in a scratch store and copied down.
+ scratch := NewDirStore(t.TempDir())
+ shipRelease(t, scratch, OpsAccount, "old", "v1", adoptPlatform, []byte("build one"))
+ if err := Promote(ctx, scratch, priv, OpsAccount, "old", "stable", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ keys, err := scratch.List(ctx, AppPrefix(OpsAccount, "old"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, k := range keys {
+ body, _, err := scratch.Get(ctx, k)
+ if err != nil {
+ t.Fatal(err)
+ }
+ legacy := LegacyAppPrefix("old") + strings.TrimPrefix(k, AppPrefix(OpsAccount, "old"))
+ if _, err := dir.Put(ctx, legacy, body, ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ _, err = PromoteOnLadder(ctx, s, priv, OpsAccount, "old", "stable", "v1", PromoteOptions{})
+ if err == nil {
+ t.Fatal("promoting an unmigrated app succeeded")
+ }
+ if !strings.Contains(err.Error(), "carlos accounts migrate -app old -from legacy -to bab") {
+ t.Errorf("the refusal does not name the fix:\n%v", err)
+ }
+ if strings.Contains(err.Error(), "no rung yet") {
+ t.Errorf("the ladder answered instead of the migration:\n%v", err)
+ }
+}
+
+// The control for the test above, and the reason the condition is "nothing
+// qualified AT ALL" rather than "no qualified releases": an app that HAS
+// shipped under this account is not unmigrated, however new the target
+// channel is. It gets the ladder's answer, with no migration advice.
+func TestPromoteOnLadderKeepsTheLadderRefusalForAShippedApp(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, _ := testKeys(t)
+
+ shipRelease(t, s, OpsAccount, "fresh", "v1", adoptPlatform, []byte("build one"))
+ _, err := PromoteOnLadder(ctx, s, priv, OpsAccount, "fresh", "stable", "v1", PromoteOptions{})
+ if err == nil {
+ t.Fatal("a fresh build entered stable directly")
+ }
+ if !strings.Contains(err.Error(), "no rung yet") {
+ t.Errorf("want the ladder's refusal, got:\n%v", err)
+ }
+ if strings.Contains(err.Error(), "accounts migrate") {
+ t.Errorf("a shipped app was told to migrate:\n%v", err)
+ }
+}
+
// A store error that is not "not found" is never retried against legacy:
// masking a broken bucket as a fallback miss would make it look like an
// unmigrated app, and the migration would then "verify" an app it never read.
diff --git a/internal/release/release.go b/internal/release/release.go
index 668883e81..42067a922 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -499,7 +499,13 @@ func Promote(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, c
// FIRST promote here and its history does not come forward — carrying
// history across the two key spaces is `accounts migrate`'s job, not a
// side effect of the next deploy.
- cur, etag, err := s.Get(ctx, key)
+ //
+ // GetQualified for uniform plumbing, though the hint it attaches is
+ // DISCARDED below: an absent pointer is the legitimate first promotion of
+ // a fresh channel, and an app that has never been promoted here is not
+ // thereby unmigrated. PromoteOnLadder makes that distinction, where the
+ // whole app's prefix is in view.
+ cur, etag, err := GetQualified(ctx, s, key)
var old *ChannelPointer
switch {
case err == nil:
@@ -604,6 +610,26 @@ func PromoteOnLadder(ctx context.Context, s Store, priv ed25519.PrivateKey, acct
return res, fmt.Errorf("promote: read %s: %w", ChannelKey(acct, app, channel), err)
}
+ // An app with NOTHING under its account-qualified prefix cannot be
+ // promoted — Promote needs a shipped manifest there — so refuse now, while
+ // the reason is still nameable. Left to run on, the ladder answers first
+ // with "this build is on no rung yet", which is true (nothing qualified
+ // means no rung) and entirely beside the point on a deployment that
+ // upgraded from a pre-accounts binary and never migrated. Promote's own
+ // read cannot say it either: an absent pointer is the legitimate first
+ // promotion of a fresh channel, so the miss has to stay silent there.
+ //
+ // "Nothing qualified AT ALL" rather than "no qualified releases": an app
+ // that has shipped since accounts landed is not unmigrated, however new
+ // this channel is, and must not be told to migrate. Both readings are
+ // computed from the qualified prefix — no legacy key is touched.
+ if objs, err := s.List(ctx, AppPrefix(acct, app)); err != nil {
+ return res, fmt.Errorf("promote: list %s: %w", AppPrefix(acct, app), err)
+ } else if len(objs) == 0 {
+ return res, fmt.Errorf("promote: nothing has ever been shipped to %s under account %s%s",
+ app, acct, migrationHint(AppPrefix(acct, app)))
+ }
+
from, err := VersionRung(ctx, s, pub, acct, app, version)
if err != nil {
return res, fmt.Errorf("promote: %w", err)