carlos / platform Public

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>
Paul Campbell 81662fb9ff3537b687efb25cea60e4f551bc7b0f parent 5bbf4ee
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).