| 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. |