spec: multi-account — accounts as projects, console-minted durable ids, account-scoped app identity
Supersedes the domains spec §2 and resolves its durable-identity prerequisite: the console mints a random 40-bit account id into the bucket manifest and the registry only materializes it, so a registry rebuild can never re-home a platform domain. Owner+member roles, keymail-delivered invites, MRU account landing, read-fallback migration with a verification gate before any legacy object is deleted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+198
−0
docs/superpowers/specs/2026-08-02-multi-account-design.md+198 −0
diff --git a/docs/superpowers/specs/2026-08-02-multi-account-design.md b/docs/superpowers/specs/2026-08-02-multi-account-design.md| new file mode 100644 |
| index 000000000..35d935064 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-02-multi-account-design.md |
| @@ -0,0 +1,198 @@ |
| +# Multi-account — accounts as projects, app identity scoped to them |
| + |
| +Accounts are the platform's tenancy primitive: a project or team that owns |
| +apps and has members. Kass, Keymail and Woodstar each get their own account |
| +rather than sharing one flat list, and a self-hosted single-app deployment |
| +can give team A and team B separate accounts that deploy independently. |
| + |
| +**Supersedes §2 of `2026-08-02-custom-domains-design.md`** (accounts + |
| +manifests) and resolves the durable-identity prerequisite that spec records |
| +in §8 — which is what currently blocks enabling `PLATFORM_APPS_DOMAIN`. |
| + |
| +## 1. The model |
| + |
| +An **account** is `{id, sqid, name, created_at}`: |
| + |
| +- **`id`** — a random 40-bit integer minted at creation. Not sequential. |
| +- **`sqid`** — the frozen encoder (`internal/domains`) over that id; the |
| + account's public identity, appearing in every platform domain |
| + (`kass.<sqid>.oncarlos.com`) and every bucket key. |
| +- **`name`** — free text, **deliberately not unique**. Two accounts may |
| + both be called "Blog". Identity is the sqid, so there is nothing to |
| + squat; this is why vanity names were rejected for the domain scheme. |
| + |
| +**Membership** is `{account_id, address, role, added_at}` with exactly two |
| +roles: |
| + |
| +| Role | Can | |
| +|---|---| |
| +| **owner** | everything a member can, plus invite, remove members, rename, transfer ownership, delete the account | |
| +| **member** | deploy, promote, rollback, create/retire canaries, add custom domains, view everything in the account | |
| + |
| +Two roles, not a matrix: enough authority to make invites safe, without |
| +inventing a permissions model before there is a second user to test it |
| +against. Every account has at least one owner; the last owner cannot be |
| +removed or demoted (transfer first). |
| + |
| +### Durable identity — why the console mints the id |
| + |
| +Account identity must survive a registry rebuild and be identical on every |
| +box in a deployment. Deriving it from an AUTOINCREMENT registry row id (the |
| +domains spec's original approach) fails both: ids are assigned in |
| +bucket-listing order at first reconcile, so a rebuild — or a second box |
| +materializing accounts in a different order — silently re-homes every |
| +platform domain. |
| + |
| +Instead: **the console mints the random id at account creation and writes |
| +it into the bucket**, at `accounts/<sqid>/account.json`: |
| + |
| +```json |
| +{"id": 793481920157, "sqid": "kqmv", "name": "Kass", |
| + "created_at": "2026-08-02T09:00:00Z"} |
| +``` |
| + |
| +The registry only ever *materializes* what the bucket says — it never |
| +assigns. The bucket is the control plane; identity belongs there like |
| +everything else. Randomness also means sqids are **not enumerable**: you |
| +cannot walk `bab`, `bac`, `bad` to discover who else is on a deployment. |
| + |
| +`ops` keeps the fixed id `1` (sqid `bab`), created by migration on every |
| +deployment — so a console-less self-hoster is unaffected and every app |
| +always has an account. |
| + |
| +## 2. App identity |
| + |
| +App identity becomes account-scoped. App names are unique **within an |
| +account**, never globally. |
| + |
| +- **Bucket**: `apps/<account-sqid>/<app>/releases/…` and |
| + `apps/<account-sqid>/<app>/channels/…`. The app→account linkage is the |
| + key path itself — no separate linkage manifest (this replaces the domains |
| + spec's `apps/<app>/account.json`, which is retired by migration). |
| +- **Console**: apps table primary key becomes `(account_id, app)`. |
| +- **Registry**: routes already carry `account` (added by the domains work); |
| + it holds the account sqid, and `App` + `Account` together build bucket |
| + keys. |
| +- **CLI**: `-account` flag on every app verb, defaulting to `ops`. It |
| + accepts a sqid, or a name when exactly one account matches (ambiguous |
| + names require the sqid — friendly without being guessy). |
| + |
| +**Read-fallback, so there is no flag day**: every read path tries the |
| +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). |
| + |
| +### Permissions collapse into membership |
| + |
| +The console's `grants` table (address → app) is **replaced by |
| +memberships** (address → account). App access follows from account |
| +membership; there is no per-app grant any more. This is a simplification, |
| +not a feature loss: every existing grant maps to a membership in the |
| +account that owns the app. |
| + |
| +## 3. Console |
| + |
| +**Account switcher, top left** — lists the accounts you're a member of, |
| +plus "New account". Switching is one click and sets your current account. |
| + |
| +**Landing on the most recently used account**: `users` gains an additive |
| +`last_account` column, written on every switch and on any account-scoped |
| +page view. Signing in lands you there; a user with no history lands in |
| +their first membership by creation order; a user with no memberships at |
| +all gets a personal account minted on the spot (§1). |
| + |
| +**URLs are explicit and shareable**: `/a/<sqid>/apps/<app>` rather than |
| +account-by-session-only, so a link a teammate opens lands where you meant. |
| +`/` redirects to the MRU account. A signed-in non-member requesting an |
| +account URL gets the **same 404 as a nonexistent page** — account |
| +existence is not probeable, matching the operator-role gate. |
| + |
| +**Inviting a teammate by keymail address** reuses the machinery already |
| +built for sign-in confirmations (console slice 1): the owner enters a |
| +keymail address, the console creates a single-use invite token and |
| +delivers a **sealed link into that address's Keymail Apps inbox** — the |
| +platform never emails anyone; the invite arrives in the mailbox the |
| +identity itself is anchored to. Clicking it, signed in as that address, |
| +creates the membership. Invites expire after 7 days, are revocable, and |
| +pending ones are listed on the account page. An invite to an address that |
| +has never signed in works: the OAuth ceremony happens first, then the |
| +invite is accepted. |
| + |
| +## 4. Agent |
| + |
| +The reconcile pass (`internal/domains`) changes shape with the manifests: |
| + |
| +- Materialize accounts from `accounts/*/account.json` — take the `id` as |
| + given, never assign. A malformed manifest is logged and skipped. |
| +- App→account comes from the bucket key path, not a linkage manifest. |
| +- Everything else (platform-domain minting, canary hosts for static apps, |
| + DNS/TLS stamping) is unchanged. |
| + |
| +## 5. Migration |
| + |
| +One deliberate pass, not a flag day — the read-fallback (§2) means the code |
| +can land days before the data moves. |
| + |
| +**Mapping**: `kass` → its own account (owner: paul@keymail.dev), `keymail` |
| +→ its own account (same owner), `console` + `platform-site` + `status` → |
| +`ops` (they are platform components, not tenant apps), `titogo` → `ops` on |
| +the Tito deployment. |
| + |
| +**Mechanics**, per app: create the account (console or a |
| +`carlos accounts create` verb) → server-side copy the app's objects to the |
| +qualified prefix (`aws s3 cp --recursive`, bytes never leave the account) → |
| +update the console apps row and the registry routes' `account` → verify |
| +`carlos channels` and an adopt pass resolve against the new keys → only |
| +then delete the legacy objects. |
| + |
| +**Verification gate before deleting anything**: every app's current channel |
| +pointer must resolve, and one adopt pass must be a no-op, from the |
| +qualified keys with the fallback *disabled* in a dry run. |
| + |
| +**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 |
| +domains shipped inert. |
| + |
| +## 6. Testing and guard rails |
| + |
| +- **Registry**: accounts materialize from manifests with the given id (a |
| + test proves a rebuilt registry re-materializes *identical* sqids from |
| + the same bucket — the durability property this spec exists for); |
| + additive-only migrations; ops is always id 1. |
| +- **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. |
| +- **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 |
| + revocable; MRU lands correctly for the three cases (history, no |
| + history, no memberships). |
| +- **Migration**: a dry-run verb reports what would move and what would |
| + break, and refuses to delete legacy objects while any pointer still |
| + resolves only via the fallback. |
| + |
| +## 7. Rollout |
| + |
| +1. Code lands behind the read-fallback (no behavior change). |
| +2. Ship/adopt both deployments. |
| +3. Create accounts; migrate kass and keymail; leave platform components |
| + under `ops`. |
| +4. Verification gate; delete legacy objects; remove the fallback. |
| +5. Enable `PLATFORM_APPS_DOMAIN` on the flagship. |
| +6. Woodstar migrates as the first app *born* into its own account. |
| + |
| +## 8. Future work (named, not built) |
| + |
| +- **Roles beyond owner/member** — read-only members, deploy-but-not-invite. |
| + Revisit when a real team asks, not before. |
| +- **Per-account apps domain** — the bring-your-own |
| + `*.platform.company.com` from the domains spec §8 becomes an |
| + account-level setting once accounts exist. |
| +- **Account-level billing/quotas** — the natural home if the flagship ever |
| + charges; deliberately absent now. |
| +- **Transfer an app between accounts** — mechanically the migration |
| + procedure with one app; needs a UI and a domain-rehoming answer |
| + (platform domains would change, which is exactly what §1's durability |
| + work makes tractable). |