Introduce Org Structure: Move Wallet Ownership from User to Org #3111
ygrishajev
started this conversation in
Design Review
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Context
Console’s API is built around a flat single-tenant-per-user model. There is no shared workspace primitive — each user owns their own managed wallet, payment methods, deployment settings, alerts, and API keys. As we look at multi-seat customers (teams, agencies, anyone deploying for a client), this is a hard ceiling: two people cannot share a balance, manage the same set of deployments, or split roles.
The objection raised against introducing orgs has been async wallet interaction — multiple users contending on a single shared wallet’s sequence number. This RFC argues the right unblock isn’t to defer orgs; it’s to shift wallet ownership from user to org and let the org expose either a single wallet (like today, just owned higher up) or a small pool of HD-derived wallets when concurrency matters. The pool option is essentially free given the current key-derivation setup (more on that below).
Self-custody is out of scope here. Per the team direction, Console is moving to managed-only, so this design assumes managed wallets only.
Current state (what’s actually in
apps/api)User table —
userSettingapps/api/src/user/model-schemas/user/user.schema.tsWallet —
user_wallets(strict 1:1 with user)apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.tsHow wallets are created — HD derivation by serial id
apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.tsEvery wallet’s on-chain address is derived from the master mnemonic at HD index =
user_wallets.id. There is one master seed; every “new wallet” is just a new row at the next serial id. This is the property that makes a wallet pool trivial — additional wallets cost a row insert, not new key material.Everything else that hangs off the user
All scoped by
user_id:wallet_settings(auto-reload)billing/model-schemas/wallet-setting/wallet-setting.schema.tspayment_methodsbilling/model-schemas/payment-method/payment-method.schema.tsdeployment_settings(auto top-up, dseq)deployment/model-schemas/deployment-setting/deployment-setting.schema.ts— unique on(dseq, user_id)stripe_transactionsbilling/model-schemas/stripe-transaction/stripe-transaction.schema.tsAuthz today — CASL rules keyed on
user.idapps/api/src/auth/services/ability/ability.service.tsThis is where the org change has the most surface area: every
userId: "${user.id}"becomesorgId: "${user.activeOrgId}"(or a per-action equivalent), and the role grid expands from the current flatREGULAR_USER/REGULAR_PAYING_USERto a small set of org roles.Problem
.unique()onuser_wallets.user_idmakes it a strict 1:1. There is no place to put “the org’s wallet,” and no second user can sign for the same wallet, even if business needs it.stripeCustomerIdlives onuserSetting. A team that wants one invoice for ten engineers has nowhere to put a shared Stripe customer.deployment_settingsis unique on(dseq, user_id). Two users in the same team cannot manage each other’s deployments.userId: "${user.id}". There is noorgId, no role beyond user/paying-user/super-user.Proposed model
Introduce a thin org layer. Users join orgs through memberships. Wallet ownership shifts to org. Existing personal accounts become personal orgs (an org with one member, the owner) — no migration trauma.
Entity model
erDiagram ORG ||--o{ MEMBERSHIP : "has" USER ||--o{ MEMBERSHIP : "joins" ORG ||--|{ ORG_WALLET : "owns 1..N" ORG ||--o{ DEPLOYMENT_SETTING : "scopes" ORG ||--o{ PAYMENT_METHOD : "scopes" ORG_WALLET ||--o{ DEPLOYMENT_SETTING : "1..1 active per dseq" ORG { uuid id string name string slug string stripeCustomerId boolean isPersonal timestamp createdAt } MEMBERSHIP { uuid id uuid orgId uuid userId string role "owner|admin|deployer|viewer" timestamp createdAt } USER { uuid id string userId "Auth0 sub" string email } ORG_WALLET { serial id "= HD addressIndex" uuid orgId string address numeric deploymentAllowance numeric feeAllowance boolean isTrialing } DEPLOYMENT_SETTING { uuid id uuid orgId integer walletId string dseq boolean autoTopUpEnabled }Concurrency: single wallet vs pool
The pool is not a separate architecture — it’s just “1 row” vs “N rows” in the same
org_walletstable.user_walletstable is renamedorg_wallets,user_idbecomesorg_id, and the.unique()on the FK stays. Current behavior is preserved 1:1; orgs are introduced; the only contention surface is two users trying to broadcast at once — same as today’s “two tabs” case, which already exists.org_id. On deploy, atomically lease astatus = freerow (SELECT ... FOR UPDATE SKIP LOCKEDor equivalent), bind it to the deployment, release on close. Pool grows lazily — when all rows are leased, insert a new one. Address is HD-derived from the new serial id at insert time.This is the same code path either way. The pool is not a new system; it’s the same table without a uniqueness constraint and with a
lease/releasestep in the deploy service.Deploy flow (with optional pool lease)
sequenceDiagram participant Client participant API as Console API participant AuthZ as CASL ability participant Pool as org_wallets participant Chain Client->>API: POST /v1/deployments { orgId, sdl } API->>AuthZ: can("create", "Deployment", { orgId })? AuthZ-->>API: ✓ (role ∈ owner|admin|deployer) API->>Pool: lease(orgId) -- single row today, FOR UPDATE SKIP LOCKED if pooled Pool-->>API: wallet { id, address } API->>Chain: signAndBroadcastWithDerivedWallet(wallet.id, msgs) Chain-->>API: dseq API->>API: insert deployment_setting { orgId, walletId, dseq } API-->>Client: { deploymentId, dseq, walletId }signAndBroadcastWithDerivedWallet(derivationIndex, ...)already exists intx-manager.service.ts— the pool design uses the existing signer untouched.What changes
Schema
orgs,memberships(org_id, user_id, role).user_wallets→org_wallets;user_id→org_id. Drop.unique()only when Phase 2 lands.payment_methods.user_id→org_id; movestripeCustomerIdfromuserSettingtoorgs.deployment_settings.user_id→org_id; addwallet_idFK; unique becomes(dseq, org_id).wallet_settings.user_id→org_id. Same for alerts, notification channels, api keys.Authz
AbilityServicerules switch the condition key fromuserIdtoorgId, and the role grid expands:The auth interceptor needs to resolve the active org for the request (header, JWT claim, or path param) and pass
activeOrgintogetAbilityFor. API keys get scoped to an org at creation time.Wallet/billing services
WalletInitializerService.initialize(orgId)instead ofinitialize(userId). The HD index is still the row’s serial id — no key-management change.ManagedSignerService/tx-managerare unchanged. They already takederivationIndex.Migration (managed users only — self-custody is out of scope)
For each existing
userSettingrow:isPersonal = true,name = email,slug = userId).role = owner.user_wallets→org_walletsbyorg_id; same row, same serial id, same address — no on-chain change, no re-derivation.payment_methods,deployment_settings,wallet_settings, etc. byorg_id.stripeCustomerIdfromuserSettingto the personal org.Existing users see no behavioral change. The org is invisible until they invite someone.
Why this is the right shape
org_wallets.id(= olduser_wallets.id). Existing addresses keep their funds and their escrow accounts.wallet-initializeris exactly what a pool needs.FOR UPDATE SKIP LOCKEDlease, not a separate system.userId→orgId. Touching one file (ability.service.ts) plus the auth interceptor plus repositoryaccessibleBycalls.Open questions for the sync
x-org-id) vs JWT claim vs path-prefixed routes (/v1/orgs/:slug/...)? My preference: JWT claim refreshed on org switch; falls back to personal org if absent.orgIdparameter per request? My preference: keys are bound to an org at creation.owner / admin / deployer / viewerthe right four, or do we want to start with three (owner / member / viewer) and split later?Beta Was this translation helpful? Give feedback.
All reactions