carlos / platform Public

docs: dated retirement note for the read-fallback (spec §7c, README)

Records the decision where the next person will look for it: §2 and §5 carry
a retirement stamp pointing at a new §7c, §6 says what the tests now pin,
and §7's rollout step 4 marks the gate passed. §7c itself is the record —
why now, what explicitly survives (migrate -from legacy, the console's
name-claim check), why the miss hint is unconditional rather than probed,
what reconciliation does with leftover keys, and the cost (a promote over a
legacy pointer no longer inherits its history).

The README swaps its "tries qualified, falls back" sentence for the shape an
operator actually needs: if you are upgrading from a pre-accounts binary,
your apps go quiet rather than serve something stale, and here is the one
command that fixes it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell eaa8e9af53146941afc359f74406fce91518631e parent 6c2faff
2 files changed, +104 −9
  • README.md +15 −8
  • docs/superpowers/specs/2026-08-02-multi-account-design.md +89 −1
diff --git a/README.md b/README.md
index 071fc93a4..fabd0c7b9 100644
--- a/README.md
+++ b/README.md
@@ -132,10 +132,9 @@ the multi-account migration.
**Bucket layout, and the migration.** App objects live under
`apps/<account-sqid>/<app>/…`, and that key path *is* the app→account linkage
-— there is no separate manifest. Existing deployments still have their objects
-at the legacy `apps/<app>/…` keys, so **every read path tries the qualified
-key first and falls back to the legacy one**; writes are always qualified.
-Moving an app is a verb:
+— there is no separate manifest. **Every read path resolves those qualified
+keys and nothing else** (the legacy read-fallback was retired on 2026-08-03,
+once both deployments were migrated). Moving an app is a verb:
carlos accounts migrate -app kass -to <sqid> -dry-run # prints the plan
carlos accounts migrate -app kass -to <sqid>
@@ -147,10 +146,18 @@ carlos binary deliberately never opens its database — so `console
migrate-app` completes the move; `carlos accounts migrate` prints that exact
command when it finishes. Both halves are idempotent.
-**It never deletes a legacy object.** Deleting them — and removing the
-fallback code — is a separate operator step, gated on verification: every
-channel pointer must resolve from the qualified keys with the fallback
-disabled, and one adopt pass must be a no-op, first.
+**Upgrading from a pre-accounts binary?** Your objects are all at the old
+account-less `apps/<app>/…` keys, which nothing reads any more — so the apps
+will go quiet rather than serve something stale, and every miss tells you so
+by name:
+
+ carlos accounts migrate -app <app> -from legacy -to bab # -to <sqid> for a tenant
+
+That `-from legacy` source is permanent. However long a deployment sits on an
+old build, this is the way off those keys.
+
+**It never deletes a legacy object.** Deleting them is a separate operator
+step, and an unhurried one now that nothing resolves through them.
## App config & secrets
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 24b4dade0..db56fb4f8 100644
--- a/docs/superpowers/specs/2026-08-02-multi-account-design.md
+++ b/docs/superpowers/specs/2026-08-02-multi-account-design.md
@@ -142,6 +142,11 @@ qualified key first and falls back to the legacy unqualified
`apps/<app>/…`. Writes always use the qualified key. The fallback is
deleted once both deployments are migrated and verified (§5).
+> **Retired 2026-08-03.** Both deployments are migrated and the fallback is
+> gone; the runtime reads account-qualified keys only. `accounts migrate
+> -from legacy` keeps its explicit access to the account-less space
+> forever. See §7c.
+
### Permissions collapse into membership
The console's `grants` table (address → app) is **replaced by
@@ -209,6 +214,14 @@ then delete the legacy objects.
pointer must resolve, and one adopt pass must be a no-op, from the
qualified keys with the fallback *disabled* in a dry run.
+> **Gate passed 2026-08-03** for every app with a route on either
+> deployment: console, platform-site and carlosframework migrated
+> `-from legacy -to bab`; kass and keymail earlier, to `bad`/`bae`; titogo
+> migrated on the Tito deployment and its pointer byte-verified under
+> `bab`. The fallback was retired the same day (§7c). Deleting the legacy
+> OBJECTS remains a separate operator step — no code path has ever removed
+> one, and none does now.
+
**Then, and only then**, `PLATFORM_APPS_DOMAIN=oncarlos.com` gets set on
the flagship — so every app mints exactly one platform domain, under its
real account, with a durable sqid. That ordering is the whole reason
@@ -223,7 +236,12 @@ domains shipped inert.
- **Sqid**: existing frozen goldens still pass (the encoder is unchanged;
only what feeds it changes).
- **Bucket keys**: table-driven over qualified/legacy paths, including
- the fallback and its removal.
+ the fallback and its removal. Post-§7c that means: a qualified read does
+ not answer from a legacy object; a miss names `accounts migrate -from
+ legacy` and stays `os.ErrNotExist`-wrapped; a legacy-only app is
+ invisible to every runtime read path — pinned by a tripwire store that
+ fails the test if anything so much as touches an unqualified prefix —
+ while `GetMigrationSource` still reads it.
- **Console**: membership gates every account-scoped route; non-member
gets a byte-identical 404; owner-only actions refuse members; the last
owner cannot be removed; invite tokens are single-use, expiring, and
@@ -240,6 +258,9 @@ domains shipped inert.
3. Create accounts; migrate kass and keymail; leave platform components
under `ops`.
4. Verification gate; delete legacy objects; remove the fallback.
+ *(Gate passed and fallback removed 2026-08-03 — §7c. Deleting the legacy
+ objects is still outstanding, and deliberately unhurried: they are inert
+ now that nothing reads them.)*
5. Enable `PLATFORM_APPS_DOMAIN` on the flagship.
6. Woodstar migrates as the first app *born* into its own account.
@@ -302,6 +323,73 @@ reaches every box, not just the console that performed it); anything that
**allocates** against it must still count it (`NextAccountID`). Backwards
in either direction resurrects a deleted account or reissues its identity.
+## 7c. The read-fallback is retired (2026-08-03)
+
+The migration seam described in §2 is gone. **Every runtime read path
+resolves account-qualified keys and nothing else**, and a legacy-only app
+is invisible to all of them: it does not resolve, does not list, does not
+adopt, and does not mint a route.
+
+**Why now.** The §5 gate passed for every app with a route on either
+deployment. Past that point the fallback had no job left, and each read it
+performed was another chance for a stale legacy object to shadow reality —
+the class of failure that nearly forced a rollback during the migration
+itself, when a legacy pointer left behind by a half-migrated app would have
+downgraded a live app on the next agent tick.
+
+**What explicitly survives, and must keep surviving.**
+
+- `carlos accounts migrate -from legacy` reads the account-less space
+ directly, forever. It is the upgrade path for any deployment coming from
+ a pre-accounts binary — a self-hosted box that sits on an old build for a
+ year still has all of its data at `apps/<app>/…`, and the day it upgrades
+ is the day it needs this verb. Its per-key precedence (qualified wins,
+ legacy-only keys still come across) outlived the fallback it was modelled
+ on, because the buckets it repairs are exactly the ones holding objects
+ under both prefixes.
+- The console's **app-name claim check** still refuses a name whose legacy
+ ops objects exist. Handing that name to a stranger would put their app in
+ the way of the migration those objects are still owed.
+
+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
+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.
+
+**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
+host (and one already minted from a legacy pointer 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 used to drag an
+unstamped route onto ops rather than the account the app now lives in.
+Platform apexes still mint, because minting follows the operator's ROUTE, a
+real record of intent; such an apex serves nothing until the app is
+migrated, which is the honest report of where its objects are.
+
+**The cost, stated.** A promote against an app still on legacy keys is now a
+FIRST promote at the qualified key: its legacy history does not come
+forward. Carrying history across the two key spaces is the migration verb's
+job, not a side effect of the next deploy — and it is why a miss names that
+verb. In exchange, promote and rollback hold a real etag for the object they
+write, so the CAS is honest rather than a create-only workaround.
+
+**Adopt skips, loudly.** A legacy-only app leaves adopt printing the store's
+error — which carries the hint — and moving on. It never fails the pass: one
+app's missing pointer must not stop every other app on the box from
+installing, which is already how a channel with nothing promoted behaves.
+
+**Deleting the legacy objects is still a separate operator step.** No code
+path has ever removed one and none does now. They are inert.
+
## 8. Future work (named, not built)
- **Roles beyond owner/member** — read-only members, deploy-but-not-invite.