Multi-portfolio engineering design
1. Summary and product behavior
Allow one Clerk account to organize its financial data into Main and additional named portfolios. Everyone using the shared login has full access to every portfolio. Main is the portfolio taggedmain. Every owner has exactly one; it cannot be deleted, but it can be renamed. This document refers to it as Main.
Preserve existing Main accounts, provider credentials, and connections. Every connection in every portfolio uses the owner’s existing provider identity, as today. Portfolios exist only in Silvia; Plaid and SnapTrade never see them.
Phase 1 decisions
Names are trimmed, required, and limited to the existing 100-character limit. Notes are plain text, limited to 1,000 characters. Every portfolio has a type.
main belongs to Main alone: it is set when Main is created, cannot be removed, and cannot be chosen for another portfolio. An additional portfolio’s type can change to any other person or entity type. The person/entity group is derived from the type, not stored, so the frontend can pick a group first and then a type, and the two cannot disagree. Display labels can be broader than the stored value: spouse is shown as “Spouse or partner” and parent as “Parent or in-law”, so unmarried partners and in-laws fit without a new type. personal covers a portfolio of the owner’s own assets kept apart from Main. charitable covers donor-advised funds as well as private foundations. The entity types separate S and C corporations, and revocable and irrevocable trusts, because each pair is taxed differently and Silvia reasons over the type; sole_proprietorship lets an owner separate a business that has no LLC from their personal accounts. Names are unique per owner, compared case-insensitively after trimming, so Silvia can resolve a portfolio by name without asking which one was meant. “All portfolios” is reserved. Main starts out named “Main”, and no other portfolio may take that name, even after Main is renamed. IDs remain authoritative everywhere else. A confirmed deletion frees the name along with the plan slot.
Default selection is All portfolios, which preserves today’s experience. The three filtered pages share one selection: setting it on the dashboard carries over to transactions and the portfolio page. Persist it in localStorage, keyed by Clerk user ID, not as a shared account preference, so spouses using different devices do not change each other’s view. It survives new tabs and restarts on that device, and tabs on the same device share it. Adding data from All or a multi-portfolio selection requires choosing a destination; a selection of exactly one portfolio preselects it. That holds on every page, including pages that don’t show the selector, because the selection is the owner’s persisted choice. The picker always shows the destination, so a preselection made on another page is visible before anything is added.
All is not the same as a set that happens to contain every portfolio: All includes portfolios created later, and a fixed set does not. The page-level selector shows All as its own option, and checking every portfolio individually selects All, so a portfolio created later appears in that view. This is deliberate for the selector, which is a view preference. A goal’s saved scope keeps exactly what was saved.
When the portfolio list loads, the client drops deleted or unknown portfolios from a persisted selection before issuing any request. If nothing is left, the selection falls back to All.
Plan limits
Multiple portfolios is a Pro and Max feature. Main counts toward the limit.
The plan shown as Max is
premium internally (TIER_NAMES in lib/token-limit-client.ts). Key the limit table by Tier, never by display name; 'max' is not a tier.
Only live portfolios (deleted_at IS NULL) count. Confirming a deletion sets deleted_at and frees the slot immediately, even while cleanup is still running.
The limit gates creation. An owner left with more portfolios than their plan allows consolidates them. Consolidation never deletes financial data: accounts and assets move into the portfolios that remain, and the emptied portfolios are removed.
Free users with only Main see no portfolio selector; portfolio management shows the upgrade path instead.
Downgrades
Every tier drop takes one path, whether it comes from the in-app downgrade (/api/stripe/change-tier), the Stripe portal, or a failed payment. Before an in-app downgrade, and before the in-app control opens the portal, a warning says what happens to portfolios over the new limit. No choice is collected up front, so nothing needs to be stored with a scheduled downgrade or cancelled with it.
Over the limit. When the Stripe webhook reports a tier change that has taken effect and leaves the owner over the limit:
- Creating a portfolio is blocked. Everything else keeps working: linking, imports, uploads, goals, and moves between existing portfolios. The limit counts portfolios, not accounts, so additions do not take the owner further over it, and consolidation moves them later.
- Portfolio management prompts the owner to choose which portfolios to keep (Main always stays) and which kept portfolio receives the rest.
- The webhook schedules a
consolidateoperation for a fixed grace period later (scheduled_for). If the owner has not chosen by then, every additional portfolio collapses into Main, the one choice that needs no input. An upgrade before the deadline cancels the operation. unpaidandpausedare recoverable, so they block creation but schedule nothing. Cancellation, when it comes, is a tier change like any other.
deleted_at on each emptied portfolio without running content deletion, frees its name and plan slot, and records an audit event per portfolio removed.
2. Current architecture and proposed data model
Repository inspection found existingportfolios, portfolio_members, asset_ownership, portfolio assignment columns, and portfolio snapshots. These are foundations, not a complete implementation:
- Plaid Link sends the Clerk ID as
client_user_id, and SnapTrade registers one user per Clerk ID (snaptrade_usershasUNIQUE (clerk_id)). Both stay as they are. - Silvia’s duplicate-connection guards for Plaid are keyed on the owner and institution, so a second login at an institution the owner already has is blocked in every portfolio.
- The SnapTrade connection root is
snaptrade_institutions, keyed bysnaptrade_connection_id. It has noportfolio_id; onlysnaptrade_accountsdoes. accounts,snaptrade_accounts, andmanual_accountseach carryis_hidden, which excludes that one account from totals and snapshots. Hiding is the only way to take one connected account out of the numbers today: the only removal path is/api/disconnect-bank, which removes the whole Item.accountsstores Plaid’spersistent_account_id, which stays the same when Plaid issues an existing account a newaccount_id.- The brokerage cash-sweep dedup (
docs/BROKERAGE-CASH-SWEEP-DEDUP.md) compares sibling accounts on one Item: a depository account is suppressed when an investment account on the same Item already contains its cash. snapshots_by_accounthas no rows from 2025-10-28 through 2026-06-26 because inserts failed.snapshots_by_itemanddaily_snapshots_summarycover that range.- A Coinbase connection is a
coinbase_accountsrow (OAuth tokens, pluswallet_id, which is Coinbase’s own portfolio ID). Coinbase has one account per currency, and the sync upserts each one intocrypto_holdingsonsource_holding_id(Coinbase’s account ID), so every currency keeps one stable row.coinbase_holdingsmirrors those rows, keyed by the sameaccount_id. - A
walletsrow is one address on one chain. On every sync,app/api/crypto/process-wallet-holdings.tsmarks all of that wallet’scrypto_holdingsrows inactive and inserts a new row per token, so a token’s row, and itsuuid, changes every run.snapshots_by_crypto_holdingkeys history by thatuuid, so one token’s history is spread across many rows. The sync has each token’s contract or mint address (token_address) in memory but stores only the symbol, incrypto_holdings.token, so two tokens with the same symbol on one wallet and chain, such as a spam token named “USDC”, are indistinguishable once written. - Financial hooks and caches predominantly scope by Clerk ID.
portfolio_daily_snapshotsis date-partitioned and written by the daily snapshot cron, which filters on explicitportfolio_idvalues and therefore omits legacy unassigned (Main) data. The Main backfill below removes unassigned data altogether.- Financial SQL views do not consistently expose portfolio assignment.
- Most portfolio foreign keys use
ON DELETE SET NULL;asset_ownership,portfolio_links,portfolio_members, andportfolio_daily_snapshotsuseON DELETE CASCADE. - Nothing enforces one default portfolio per owner;
is_defaulthas no unique index. - RLS on
portfolioslets clients insert, update, and delete their own rows directly. - The
portfoliosSELECT policy already admits acceptedportfolio_members, and clients can insert member rows. asset_ownership(per-portfolio percentages) andportfolio_links(portfolio-owns-portfolio percentages) exist, but no application code reads them.vault_documentsandvault_folderscarryportfolio_id. Every document belongs to a folder (folder_uuidis required). Phase 1 leaves both columns unwritten (below).
portfolios rows (checked 2026-09-23), about 4,750 financial rows pointing at them, and one asset_ownership and one portfolio_links row. All of it belongs to a single internal test user from an earlier attempt at this feature that no UI ever surfaced (confirmed 2026-09-24). No real user’s data is involved, so the cleanup doesn’t need to preserve or migrate any of it. Delete the rows first, while the foreign keys are still SET NULL and CASCADE: the references clear, those assets land in the test user’s Main with everything else, and the migration starts from an empty table with no existing assignments to preserve.
Separate ownership, assignment, and provider identity
This separation is the central architectural decision. Assignment never reaches provider identity, so moves and deletions change only Silvia’s data.
In the diagram,
NEW table on a table’s first column marks one of the four tables phase 1 adds (PORTFOLIO_OWNER, LINK_INTENT, PORTFOLIO_OPERATION, PORTFOLIO_AUDIT_EVENT); all of its columns are new. NEW column marks a column added to an existing table, and DROPPED a column phase 1 removes. Everything else exists today. The goal scope columns store a PortfolioScope (section 4): scope_kind is all or portfolios, and scope_portfolio_ids holds the IDs only for portfolios. Both are null on a goal that tracks an account.
Extend portfolios with notes and deleted_at, and repurpose the existing portfolio_type column to hold the portfolio’s type. A portfolio is live while deleted_at is null and deleted once it is set; deletion and consolidation set it the moment they are confirmed, and it is never cleared. Whether cleanup has finished is the status of the portfolio’s operation (portfolio_operations), not a second state on the portfolio, so the two cannot disagree. A check constraint, NOT (is_default AND deleted_at IS NOT NULL), keeps Main from being deleted. No application code has ever read or written portfolio_type, and the legacy cleanup (section 2) empties the table first, so no existing value needs mapping and the column can be NOT NULL from the start. It stays text with a check constraint rather than a Postgres enum type, so the value set can change by swapping one constraint; the TypeScript unions carry the strict typing. The value set is main plus the person and entity types in section 1, and a check constraint ties portfolio_type = 'main' to is_default, so the type and the existing flag cannot disagree. The same migration replaces the baseline column comment, which lists the old values (business, family_member, personal, other) and would steer model-generated SQL toward values that mean something else now, and drops the partial index idx_portfolios_type: its IS NOT NULL predicate is meaningless on a NOT NULL column, and nothing looks portfolios up by type.
Ownership shares live in a new table, portfolio_owners (portfolio_id, owner_clerk_id, percentage), not in a column on portfolios. They cover the joint-asset allocation named in section 1, and a portfolio may later be shared between Silvia users. A column holds one share per portfolio, so sharing would need a second migration; a table holds any number of owners and does not.
- Shape. The primary key is (
portfolio_id,owner_clerk_id), with an index onowner_clerk_idfor “portfolios this user holds a share in”.portfolio_idreferencesportfolioswithON DELETE RESTRICTfrom the start, as every portfolio FK will (below).owner_clerk_idreferencesall_userswithON DELETE CASCADE, matchingportfolios.clerk_id. - Precision.
percentageisnumeric(7,4),NOT NULL DEFAULT 100,CHECK (percentage > 0 AND percentage <= 100): four decimal places, so 100.0000 fits and a third is 33.3333. That is finer than the two decimal places ofasset_ownership.ownership_percentageand the asset dialogs’0.01step, but the range is the same. Postgres rounds a fifth decimal rather than rejecting it (33.33333 is stored as 33.3333), so phase 2’s write path rejects extra precision itself if it wants an error. An owner with no share has no row, so 0 is not a value. - Phase 1 contents. An
AFTER INSERTtrigger onportfolioswrites one row, the portfolio’sclerk_idat 100, in the same transaction, so every portfolio has its owner row however it was created (ensure_main_portfolio, the create RPC, a script).portfolios.clerk_idcannot change (below), so the row cannot drift from it. Phase 1 adds no other rows and nothing reads the table, so this is a priority call, not a security boundary. The table exists now, filled, so phase 2 starts with no migration and no backfill. - Not access. A row records an economic share and grants nothing. Authorization stays on
portfolios.clerk_idin phase 1 (below). Whether phase 2’s sharing derives access from this table or fromportfolio_membersis phase 2’s call; either way the membership paths are closed for phase 1. - Phase 2’s invariant. Shares on one portfolio sum to at most 100, not exactly 100, because a co-owner who isn’t a Silvia user has no row: an owner holding half of a joint portfolio with a spouse who has no account is one row at 50. A sum is a cross-row rule that a check constraint cannot express, so phase 2’s write path enforces it under a per-portfolio lock. Phase 1’s single row at 100 satisfies it.
- RLS. Enabled, with a SELECT policy for rows whose
owner_clerk_idis the caller and no client write policies. Writes are service-role only from the start, so the table needs no guard in the portfolio lockdown.
portfolio_id columns where available and add missing assignment fields on financial roots:
- Plaid accounts (
accounts). - SnapTrade accounts (
snaptrade_accounts). - Manual accounts and standalone assets.
- Crypto holdings (
crypto_holdings), whether from a wallet, from Coinbase, or entered manually.
A wallet token needs a key instead of a row because the wallet sync inserts a new row for it on every run (section 2, above). Every row with the same token key has the same
portfolio_id, active or not. The inactive rows matter because snapshots_by_crypto_holding still points at them for past days. coinbase_holdings has no column and resolves through the crypto_holdings row with the same Coinbase account ID. Changing the wallet sync to update rows in place would make the token key unnecessary, but it touches a hot writer on a 3.8M-row table, so phase 1 doesn’t require it.
The key uses the token’s contract or mint address, not its symbol, because distinct tokens can share a symbol and must move separately. Add a token_address column to crypto_holdings, and have the wallet sync write the address it already has; the symbol stays in token as display data. A chain’s native coin (ETH, SOL, BTC) has no contract, so its key uses the reserved value native. Rows written before the column existed have no address and cannot be told apart by anything but symbol. A move of a token carries those legacy rows along only when exactly one current token on that wallet and chain has the symbol. Otherwise the legacy rows stay put, and the moved token’s history from before the column shipped stays in its old portfolio.
A connection (items, snaptrade_institutions, coinbase_accounts, wallets) therefore has no assignment of its own, and stores no default for accounts discovered on it later. Where an account lands depends on when it arrives:
- A connection’s first accounts take the destination the owner chose when linking. Each flow writes that destination onto the accounts it creates (section 3). Plaid’s
/api/connect-bankinserts them in the same request. Coinbase links through SnapTrade, so it takes the SnapTrade path; no client starts the direct Coinbase sign-in any more, so its callback carries no destination. SnapTrade’s accounts can arrive by webhook first, so they take the destination from the link intent. A wallet’s first tokens come from thefetch-wallet-holdingscall the app makes right afteradd-wallet, which passes the destination along. - A later Plaid account arrives only when the owner completes update mode.
NEW_ACCOUNTS_AVAILABLEjust flags the Item, andcreate-link-token-relinkenables account selection. So the owner is present, and the update-mode flow asks which portfolio each new account goes to (section 3). - A later SnapTrade account, wallet token, or Coinbase currency arrives in the background, with nobody to ask. It follows the sibling rule. If every account on the connection is in a deleted portfolio, the connection is being removed, and the insert is rejected. Otherwise, if the connection’s accounts in live portfolios all share one portfolio, the new account goes there. Otherwise, it goes to Main. A split connection’s background arrivals therefore go to Main, where the owner can move them.
portfolio_id on items, coinbase_accounts, and wallets: after the legacy cleanup it holds nothing, the only reader was the per-portfolio snapshot loop (already removed), and leaving it in place would invite model-generated SQL to filter connections by portfolio and miss split ones.
Transactions live in three tables: transactions (Plaid), snaptrade_transactions, and manual_transactions, which holds manual-account transactions imported from CSV or entered by hand. manual_transactions has no portfolio_id and resolves through manual_account_uuid. Transaction readers query these tables separately today, so scope must be applied to each of them, not only to Plaid’s.
Out of scope for phase 1: the Vault. Vault folders and documents stay owner-level. They are not financial roots: no trigger fills their portfolio_id, the Main backfill skips them, they stay out of NOT NULL, and listing, search, upload, deletion, consolidation, and Silvia’s document tools ignore portfolios. The existing portfolio_id columns on vault_folders and vault_documents stay in place, nullable and unwritten, until phase 2. The legacy cleanup already nulled the old values (above), and their foreign keys still move to RESTRICT with every other portfolio FK. What phase 2 needs is listed in section 6.
The ERD names the new supporting records: SnapTrade link intents (section 3), deletion and move operations, and audit events. Cache keys carry a per-owner assignment revision, which is not stored anywhere: it is the ID of the owner’s newest audit event, or 0 if there is none. Every change to assignment already records an audit event in the same transaction (moves, consolidation, deletion, and the Coinbase owner reset below), so the revision advances with the change and there is no second counter to keep in step. Clients read it with the portfolio list, so a move invalidates every scoped query in one step. An audited action that changes no assignment also advances it, which costs only a refetch.
- Ordered IDs.
portfolio_audit_events.idis abigintidentity, and the revision ismax(id)for the owner, read through an index on(clerk_id, id). - Commit order matches ID order. Postgres assigns an identity at insert, not at commit, so two concurrent moves can commit out of ID order. The later ID becomes visible first, a client caches under it, and when the earlier move commits the maximum does not change, so that cache never goes stale. Every audit insert therefore goes through one function,
record_portfolio_audit_event, which first takes a transaction-scoped advisory lock keyed by owner. Callers write the event as their last step, after taking their row locks. - No stepping back. Deleting an owner’s newest event would return the revision to a value caches have already used. Any future pruning keeps each owner’s newest event.
Database-level protections
Enforce the invariants in the database, so a client call or a forgotten service path cannot bypass them:- Foreign keys. Change portfolio foreign keys from
SET NULLandCASCADEtoRESTRICT. Portfolios are tombstoned, never hard-deleted, so these actions should never fire; if one does, it must fail loudly rather than turn data into Main or drop snapshots. - One Main per owner. Add a partial unique index on
portfolios (clerk_id) WHERE is_default.ensure_main_portfoliorelies on it to stay idempotent under concurrent calls. - Every root has a portfolio. After the backfill,
portfolio_idisNOT NULLon every financial root.BEFORE INSERTtriggers fill missing values:- A manual account, a manual crypto holding, or a standalone asset falls back to the owner’s Main ID.
- A Plaid or SnapTrade account, or a Coinbase holding, inserted without a
portfolio_idfollows the sibling rule (above). The exception is a Plaid account whosepersistent_account_idmatches another account on the same Item. That is Plaid reissuing an existing account under a newaccount_id, so it keeps the predecessor’s portfolio. - A Coinbase holding’s connection is the active
coinbase_accountsrow with the same owner andwallet_id.wallet_idhas no unique constraint today, and several owners have more than one row. So a partial unique index oncoinbase_accounts (clerk_id, wallet_id) WHERE is_activemakes the parent deterministic, after any stale duplicates are deactivated. A holding with no active parent lands in Main. Reconnecting does not create a second row: the OAuth sign-in callback (app/api/auth/[...nextauth]/route.ts) looks up the existing row bywallet_idand reactivates it, inserting only when none exists. So a reconnect keeps its holdings and their portfolios, and the index never sees two active rows. That includes a holding in a deleted portfolio, which stays there: the callback restores nothing, and no client starts it any more (section 3, “Coinbase comes back through SnapTrade”). The SnapTrade Coinbase migration inapp/api/snaptrade/process-connectiononly deactivates rows. Any future path that replaces a row must deactivate the predecessor first. - A wallet holding takes the portfolio already held by its token key, and only a token never seen on that wallet follows the sibling rule. This is what keeps a moved token in place across the sync’s deactivate-and-insert. The lookup needs an index on the token key.
- Each of these triggers reads its connection row with
FOR SHARE, the same row a move locksFOR UPDATE(section 3). So an insert that races a move waits for it and then reads the committed assignment, instead of reading sibling portfolios or a token key that the move is about to change. - Child rows (Plaid and SnapTrade holdings) never get the Main default.
- Unique names. Add a partial unique index on
portfolios (clerk_id, lower(btrim(name))) WHERE deleted_at IS NULL, so concurrent creates and renames cannot produce a duplicate. - Server-only portfolio writes. Drop the client INSERT and DELETE policies on
portfolios, and stop clients from changingclerk_id,is_default, ordeleted_at, or from setting or clearing themaintype. Plan-limit enforcement and the deletion workflow both depend on this.portfolio_ownersneeds no guard here: it never had client write policies (above). - No assignment copies. Plaid and SnapTrade accounts and crypto holdings are roots, not children, so no connection-level value is copied onto them after insert. The Vault’s folder-to-document copy is phase 2 (section 6).
- Assignment changes only through a move. A
BEFORE UPDATEtrigger on every root rejects a change toportfolio_idunless it comes from the move, consolidation, or deletion functions, which set a transaction-local flag. Sync workers upsertaccounts,snaptrade_accounts, and Coinbasecrypto_holdingsconstantly; this stops an upsert that carries a stale or nullportfolio_idfrom quietly moving an account. A move of a wallet token updates every row with its token key in the same statement. - Coinbase owner change resets assignment. The Coinbase OAuth sign-in callback (
app/api/auth/[...nextauth]/route.ts) finds thecoinbase_accountsrow bywallet_idalone and writes the signing-in user’sclerk_id. So when a second Silvia user connects a Coinbase account another user already connected, the row changes owner, and the holdings sync then rewritesclerk_idon each of itscrypto_holdingsrows. Phase 1 keeps that takeover behavior. Left alone, the rows would keep the first owner’s portfolios, and the next holdings upsert would fail the same-owner check and stop the new owner’s sync. Instead, aBEFORE UPDATEtrigger on Coinbasecrypto_holdingsresets assignment wheneverclerk_idchanges:portfolio_idbecomes the new owner’s Main, in the same statement. The connection row carries no assignment, so it needs no reset. This is the one change toportfolio_idoutside the move, consolidation, and deletion functions. It records an audit event, with IDs only, for each owner, taking the two owner locks inclerk_idorder. That advances both owners’ assignment revisions, so neither owner’s cached views keep the holdings. - Live portfolios only. The fill, copy, and move paths reject any
portfolio_idwhose portfolio hasdeleted_atset. A late webhook that discovers a new account on a connection being removed fails its insert under the sibling rule, since every account on that connection is in a deleted portfolio, even while cleanup is still running. The worker treats that error as “connection being removed” and acknowledges the event instead of retrying. A connection that survives a deletion still has accounts in a live portfolio, so its inserts succeed. Moves out of a deleted portfolio are rejected too, except two restores (section 3): unhiding a hidden, detached Plaid or SnapTrade account, which moves it to a live portfolio and clearsis_hiddenin one statement, and adding a wallet again, which moves its tokens in deleted portfolios to the add’s destination. Clearingis_hiddenany other way on an account in a deleted portfolio is rejected, so today’s unhide call cannot leave a visible account on a tombstone. - Dormant tables. Phase 1 does not read
asset_ownership,portfolio_links, orportfolio_owners. The first two cascade from the legacy cleanup, so they start empty;portfolio_ownersholds only each portfolio’s owner row. Per-assetownership_percentageon properties and manual accounts is unrelated and keeps its current meaning: it is the owner’s share of one asset, whileportfolio_owners.percentageis a user’s share of a whole portfolio.
Main portfolio
Every owner has exactly one Main portfolio, taggedmain, and every financial root carries an explicit portfolio_id. Null never means Main. With an explicit ID, every reader stays correct with a plain portfolio_id = :id filter, including Silvia’s model-generated SQL, and a NOT NULL column cannot quietly turn data into Main.
Existing users get Main through a one-time backfill. It changes no provider identity and re-registers nothing. Today’s readers ignore portfolio_id, so the backfill is invisible to them and can ship before any portfolio UI:
- Delete the legacy rows (section 2, above), add the one-Main-per-owner index, then create a Main row (name “Main”, type
main,is_default = true) for every existing owner. - Add a
BEFORE INSERTtrigger to every top-level financial root that fills a missingportfolio_idwith the owner’s Main ID throughensure_main_portfolio(clerk_id), which creates Main withON CONFLICT DO NOTHINGif it is somehow missing. Existing writers need no change for the migration; new code passes its destination explicitly. - Backfill null
portfolio_idvalues to Main in batches, on the roots listed above. Existing aggregate goals get the All scope (section 4). - Add
CHECK (portfolio_id IS NOT NULL) NOT VALID, runVALIDATE, thenSET NOT NULL, so Postgres skips a full table scan while holding the lock. - Create Main at sign-up, from the Clerk
user.createdwebhook.
clerk_id is null cannot be mapped to a Main portfolio, and RLS’s clerk_id = jwt.sub check can never match a null column, so nobody can read them before they are deleted. Roots whose owner has no all_users row are a different case: RLS gates only on clerk_id, not on all_users, so in general a live Clerk session for that ID could still read the row up to the moment it is deleted. That gap doesn’t apply here: the team checked these clerk_ids against Clerk directly and confirmed the accounts themselves are gone, most likely orphaned by a bad deletion process rather than still-live users, so no session can ever exist to mint a matching JWT. portfolios.clerk_id references all_users regardless, so no Main could be created for these owners either way, and the team decided to delete rather than reconcile these rows, confirmed against production on 2026-09-24. Plaid accounts that have no item are deleted with them: the backfill could give one its owner’s Main, but with no Item there is no access token, so it can never sync or be disconnected. Some readers still count those accounts today, so their owners’ totals drop. A check after the delete stops the migration if any remain. This does not cover a null-clerk_id snaptrade_accounts row under a live connection: the delete only removes SnapTrade accounts under an orphaned connection, so that row survives and needs its own fix before the backfill (see the PR plan’s SnapTrade row).
Provider identity
Every portfolio uses the owner’s existing provider identity: the Clerk ID as Plaid’sclient_user_id, and the owner’s single SnapTrade user. No new identifiers are stored, snaptrade_users keeps UNIQUE (clerk_id), and existing lookups by clerk_id stay correct.
Neither provider ties a login to the user identifier, so one identity can hold several logins at the same institution:
- Plaid documents
client_user_idas the app’s own user ID, used to search logs in the Dashboard. An Item is one institution login, and Plaid calls several Items at one institution a legitimate use case. Its Chase and PNC invalidation rule applies to a second Item made with the same credentials. Plaid Link API, Duplicate Items, OAuth guide - SnapTrade de-duplicates a connection only when the same brokerage credentials connect again; a connection is one brokerage login. SnapTrade connections
3. Linking, moving, and deleting
Linking and ongoing synchronization
The existing linking flows stay as they are. Phase 1 adds a destination portfolio to each, and writes it asportfolio_id on every account the link creates (section 2). The connection itself stores no portfolio. In the diagrams, steps marked NEW are the only changes; every other step is today’s code.
The destination covers the whole link, because provider flows return every selected account together. After linking, the success step lists the new accounts, each with a portfolio picker preset to the destination. Changing one is an ordinary account move, not a separate linking path. This is where the Chase example happens: link once into Main, then set the joint account to “Joint” before closing the dialog.
The same step appears after any user-driven flow that adds accounts to an existing connection, not only after a first link. Plaid’s NEW_ACCOUNTS_AVAILABLE webhook only sets has_new_accounts_available on the Item; the account is inserted when the user completes update mode, in /api/relink. The route inserts them under the sibling rule, returns their IDs, and the app lists them with the picker preset to where they landed. If the Chase login later exposes a savings account, the owner decides then whether it belongs in Main or “Joint”. Closing the dialog without choosing leaves the account where the rule put it, visible and movable.
- Destination is fixed at the start. Changing the selector mid-flow does not change it. For Plaid, the client keeps it next to
plaid_link_token, which already survives the OAuth redirect, and sends it to/api/connect-bank. No server-side record is needed: only that call creates an Item, and the server validates the destination there. - SnapTrade needs a server-side link intent (NEW), because completion is not the only path that creates the connection.
handleNewAccountAvailablein the webhook and the missing-root recovery inprocess-snaptrade-holdings-syncboth insertsnaptrade_institutions, and completion is triggered by the browser, so a closed tab skips it. Correlation differs by path:- Each intent is bound to exactly one SnapTrade authorization (
snaptrade_link_intents.snaptrade_connection_id, unique, set once). An account’s destination comes only from the intent bound to its own authorization, never from whichever intent happens to be pending. - Client completion binds exactly. The intent ID rides in the redirect URL the app already supplies (
customRedirect), and SnapTrade adds theauthorizationId.process-connectionbinds the intent to that authorization and assigns the accounts that link created to the intent’s destination, moving them there if a webhook created them first. If a webhook had bound the intent to a different authorization, completion’s binding wins, and that other authorization’s accounts move to Main. The success-step picker opens only after this, so no per-account choice has been made yet that it could overwrite. - Webhooks and sync recovery know only SnapTrade’s own IDs, including
brokerageAuthorizationId. An owner can therefore have one pending SnapTrade intent at a time; starting a new one cancels the previous one. These paths use the intent bound to the event’s authorization if there is one. Otherwise they bind the pending, unbound intent only to an authorization Silvia first saw after the intent was created: one whosesnaptrade_institutionsrow this event is creating, or whose row’screated_atis later than the intent’s. Both timestamps come from the database clock, so no ordering against SnapTrade’screatedDateis involved. That covers the closed-tab case, where completion never runs. The check matters because a connection can exist with no local accounts:process-connectionwritessnaptrade_institutionsbefore it checks for accounts, and returns 404 when SnapTrade reports none. So an old, empty authorization is never mistaken for the one a newer link is creating. Nothing is ambiguous under this test: an authorization Silvia already knew before the intent existed belongs to an earlier link. An account on an authorization that matches no intent follows the sibling rule (section 2), which puts an empty or new connection’s account in Main. - No pending intent (expired or cancelled) lands the connection and its accounts in Main. Holding them unassigned would hide a working connection and add a lifecycle state; in Main they are visible in All and can be moved, and a late client completion carrying its intent ID still reassigns them.
- Each intent is bound to exactly one SnapTrade authorization (
- Destination deleted mid-flow. Completion into a portfolio that has since been deleted is rejected. Plaid rejects before the token exchange; a SnapTrade connection already created is removed with
removeBrokerageAuthorization. - Ongoing sync is unchanged. Webhooks resolve by
item_idandsnaptrade_user_idas today, and workers never writeportfolio_id. An account discovered later lands under the sibling rule (section 2); after update mode the owner then confirms or changes it (above). The Plaid balance jobs (app/api/fetch-balances,app/api/cron/process-stale-balance) upsert every account Plaid returns onaccount_id, withoutclerk_idorname, so an account Plaid returned before the owner added it would become a bare row. Account selection should prevent that, but nothing in the code does. So both jobs must skip anyaccount_idthey have no stored row for, and any bare rows already in production are counted and fixed before the Main backfill, which cannot give an account with a nullclerk_ida Main (PR plan, production findings and DB-9). A Plaid account reissued under a newaccount_idkeeps its predecessor’s portfolio throughpersistent_account_id(section 2).
clerk_id lookups.
The registration route’s stale-credential recovery deletes and re-registers the owner’s SnapTrade user, which drops every SnapTrade connection. That is existing behavior, but it now reaches every portfolio at once; connections re-linked afterwards choose a destination like any new connection.
Duplicate-connection guards
Silvia blocks duplicate Plaid connections in two places, both keyed on the owner and institution:- The client guard in
PlaidLinkButtoncloses Plaid Link as soon as the user picks an institution the owner already has. - The server guard in
/api/connect-bankrejects the token exchange when any incoming account matches a stored account at that institution by name and mask.
Moving accounts and assets
A move takes one or more accounts, crypto holdings, or standalone assets and a single destination. For a connected account or holding, the preview lists the one being moved and the others on its connection, each with its current portfolio, and offers to add them to the move. Nothing moves unless it is selected. Moving a whole connection is just a move that selects everything on it. For crypto this means one Coinbase currency account, or one token in a wallet: moving the wallet’s USDC to “LLC” leaves its ETH in Main. The move transaction:- Validates common ownership and live source/destination portfolios. The exceptions are the two restores, whose source is a deleted portfolio: unhiding an account that a portfolio deletion detached, and adding a wallet again (section 3, “Portfolio deletion”).
- Locks each affected connection row
FOR UPDATE, then re-reads that connection’s accounts under the lock to confirm the selected accounts are still on it and live. - Updates each selected account’s
portfolio_id. An account discovered after the preview was not selected, so it stays where it landed. - Records an audit event, which advances the assignment revision.
- Invalidates source, destination, aggregate, and Silvia caches.
portfolio_id (enforced in section 2), so updates to existing accounts don’t need to wait. But the insert triggers that assign a newly discovered account or a fresh wallet row read the connection row FOR SHARE (section 2). Two races are closed that way:
- A new account arrives during a move. The sibling rule reads the portfolios of the connection’s other accounts. Without the shared lock, a SnapTrade account arriving while the owner moves both of a connection’s accounts to “Joint” could read their old portfolio and land in Main. With it, the insert waits and reads the committed portfolios. An insert that locked first makes the move wait, and lands under what it read.
- A wallet sync runs during a token move. The sync inserts a fresh row for every token on every run. Without the shared lock, the trigger could copy the token key’s old portfolio onto the fresh row, splitting the token between two portfolios. The move locks the wallet row, so the fresh row reads the committed portfolio.
Connections that span portfolios
Connection-level state has no single portfolio once accounts are split, so each surface uses a rule. Wallets and Coinbase connections follow the same rules, with holdings in place of accounts:- Where a connection appears. In any scope that includes at least one of its live accounts. It lists only the accounts in scope and names the others (“1 more account in Main”).
- Health. A reconnect prompt, sync error, or
has_new_accounts_availablenotice shows in every scope where the connection appears, because fixing it affects every account on it. - Reconnect and refresh act on the whole connection, as today, and keep each account’s assignment.
- Disconnect removes the whole connection, as today. When it has accounts in portfolios outside the current scope, the confirmation names them and their portfolios.
- Hiding stays per account. A hidden account keeps its portfolio. Hidden-account management lives on
/accounts, which has no selector, so it lists every hidden account, with any a portfolio deletion detached in their own group (section 3). Crypto holdings have no hide flag today, and phase 1 doesn’t add one. - Cash-sweep dedup is decided over the whole Item, never over the accounts in scope (section 4). If a Wells Fargo “BROKERAGE CASH” account and its brokerage account are in different portfolios, the sweep account still reads zero in its own portfolio, because its cash is already counted in the brokerage account’s portfolio. The move preview says so when it separates such a pair.
History follows current assignment
Compute history at read time: sum the per-root history of every root whose currentportfolio_id is in the scope. Nothing is precomputed per portfolio or per selection (a Max owner has 1,023 possible selections), so a move needs no rebuild, and the chart and the current balance can never disagree. All is the same sum over every root, so every selection, All included, equals the sum of its portfolios.
Per-root history comes from the per-root snapshot tables (snapshots_by_account, snaptrade_account_daily_snapshots, snapshots_by_crypto_holding, snapshots_by_manual_account, snapshots_by_property, snapshots_by_vehicle, and the others). Physical assets and private investments have no snapshot table; their history comes from their value logs (physical_assets_history_logs, private_investments_history_logs), carried forward day by day. Commercial properties have rows in snapshots_by_property since 2026-08-18; days before a property’s first row come from commercial_properties_history_logs the same way. Do not rewrite historical prices or infer missing history from today’s balance; a range the retained data cannot support shows as unavailable.
Connected history is per account, not per connection, because a connection’s accounts can sit in different portfolios. The per-account derived columns (net_worth, cash_on_hand, total_assets) already have the cash-sweep dedup applied over the whole Item, so any subset of accounts sums consistently with All. Crypto history is per holding: a snapshots_by_crypto_holding row counts toward the portfolio of the row its crypto_holding_uuid points at. For a wallet token that is an old, inactive row, which carries the same portfolio as the rest of its token key (section 2). So a moved token’s whole history moves with it.
snapshots_by_account has no rows from 2025-10-28 through 2026-06-26 (section 2). For those days, a Plaid Item’s snapshots_by_item row stands in for its accounts, but only in a scope that includes every live account on the Item. That covers All, and every Item that is not split. A scope that includes only some of an Item’s accounts shows that range as unavailable for the Item, because the item-level row cannot be divided between accounts. Every selection still equals the sum of its portfolios on every day where each of those portfolios has history.
All moves off daily_snapshots_summary onto this computation. Before rollout, reconcile the two for every existing owner; an unexplained difference blocks rollout, so existing users’ history does not change. portfolio_daily_snapshots records each day’s assignment rather than current contents and omits Main, so phase 1 stops writing and reading it. The sum runs in the database. Profiling on production found it fast enough without a cache once zero-value crypto rows are skipped (PR plan, SCOPE-6).
Charts may include dates before the destination portfolio was created, because they show the history of its current contents. The move confirmation says so.
Portfolio deletion
Deletion is a retryable server operation, not a direct cascading database delete. It is also the only path: with the client DELETE policy dropped and portfolio foreign keys set toRESTRICT (section 2), a direct delete fails instead of turning the portfolio’s contents into Main.
- Show a preview of every financial root in the portfolio (section 2), and of goals whose whole scope is being deleted. The preview also says that Vault folders and documents are not part of any portfolio and stay where they are: deleting a portfolio never deletes a document. For each connection that stays connected, the preview says that its accounts here keep syncing, and offers to disconnect it instead. For Plaid, it also says the accounts can be unticked once the deletion is confirmed (below).
- Require destructive confirmation, including the owner’s choice for each connection that would stay.
- Set
deleted_at, record an audit event, and start the deletion operation. From then on the portfolio is excluded from financial views, its name and plan slot are free, and linking, moves, edits, and sync writes into it are rejected (enforced in the database, section 2). - Run each existing account/asset deletion behavior through shared server services.
- Track provider disconnections and local cleanup independently; retry unfinished work.
- Mark the operation complete after all required work completes. The portfolio row stays as a tombstone.
- Disconnect when every account on the connection is in this portfolio or already in a deleted one. Hidden accounts count, so a connection with a hidden account in Main is not disconnected. It also applies when the owner chose, in the preview, to disconnect a connection that would otherwise stay. This is today’s whole-connection removal.
- Detach when the connection still has an account in another live portfolio. The connection stays connected for those accounts. What happens to the accounts in this portfolio depends on the kind of account (below). The preview names each connection that stays and the accounts keeping it alive: “Chase stays connected for Chase individual checking in Main.” The owner can turn a detach into a disconnect (below).
is_hidden, the account drops out of totals and snapshots, it keeps syncing, and “Manage hidden accounts” on /accounts unhides it. Detaching does the same:
- Deletion sets
is_hiddenon each detached account and leaves it assigned to the deleted portfolio, so its history stays out of every live portfolio’s chart. The provider still returns the account, so sync keeps updating the row, as it does for any hidden account. - “Manage hidden accounts” lists detached accounts under “From deleted portfolios”, whatever the selection. Unhiding one asks for a live destination portfolio, preset to the portfolio of its connection’s other accounts. This is a move out of a deleted portfolio, one of the two restores the move functions allow (section 2). The account’s history comes with it, as with any move.
- The server duplicate guard keeps matching detached accounts, as it matches hidden accounts today (
app/api/connect-bank/route.tsdoes not filter onis_hidden). Otherwise a second login could link the same joint account and it would count twice once restored. The rejection message points to “Manage hidden accounts”. - Hiding does not stop the institution sharing the account. Only unticking it in Plaid’s account selection (update mode) does, and the deletion flow offers that (below).
- The statement. For example: “Chase stays connected for Chase individual checking in Main. Spouse checking will be hidden, not deleted. Chase keeps sharing it, and Silvia keeps receiving and storing its balances and transactions, out of every view.” It names every detached account on the connection. It is not a warning shown only at a threshold; it is part of every detach.
- Stop sharing these accounts (Plaid). Once the deletion is confirmed, its confirmation step offers, for each detached Item, Plaid update mode with account selection. This is the existing
create-link-token-relink, which already setsupdate.account_selection_enabled, with the accounts to untick named beside it./api/relinkalready callsaccountsGetafter update mode. It gains one step, which is why the offer comes after confirmation, once the accounts sit in a deleted portfolio: a detached account the Item no longer returns is markedis_active = false, which takes it out of “From deleted portfolios” and ends its syncing. The server duplicate guard already matches only active accounts (app/api/connect-bank/route.ts), so that account can then be linked from the other person’s own login without a clash. An account the Item still returns stays hidden and keeps the offer. The offer also stays under “From deleted portfolios” in “Manage hidden accounts”, so an owner who skipped it can come back to it. At OAuth institutions such as Chase, the account choice happens on the bank’s own consent page. Whether unticking there makes Plaid stop returning the account is confirmed in the sandbox and in the live Chase test (section 5) before this option ships. If it does not, the option is left out for that institution and the preview offers only the disconnect. - Disconnect the whole connection instead (every provider). For each connection that would stay, the preview offers to disconnect it after all, and names the accounts in other portfolios that stop syncing with it: “Disconnect Chase entirely. Chase individual checking in Main stops syncing too.” This is the only way to stop a SnapTrade or Coinbase connection sharing a detached account, since neither provider can drop one account (above). The choice is stored on the operation when it starts, so a retry does not ask again. The owner can link the login again afterwards and choose which accounts to share.
- What is already stored stays. Neither option deletes what Silvia has already stored for the detached accounts. It stays on the tombstone, out of every view, under today’s retention for a hidden or disconnected account (no new hard-purge policy, above). The preview says that too. Whether deleting a portfolio should also offer to purge that stored data is a retention decision for the team, left open here.
BEFORE INSERT trigger returning null) instead of rejecting it. Rejecting would fail the sync’s batch insert, and with it every other token on the wallet.
Re-adding a wallet restores its tokens from a deleted portfolio. Removing a wallet does not delete its rows. deleteWallet and deleteCryptoHolding in hooks/queries/use-crypto-holdings.ts set is_active = false on the wallets row and its crypto_holdings rows, and the rows keep pointing at the deleted portfolio. Adding the wallet again reuses them: add-wallet reactivates the existing wallets row for the owner, address, and chain. The next wallet sync inserts fresh rows, the fill trigger finds each token key in a deleted portfolio, and skips every one of them. The wallet is back but shows nothing.
Without a rule, a wallet that ever had a token in a deleted portfolio could never be added back, whether the deletion detached it or disconnected it outright. Plaid does not have the problem, because re-linking creates a new Item with new account_ids. Nor do SnapTrade and Coinbase (below).
So adding one again is a restore. When a user adds a wallet, and the add creates or reactivates the wallets row, the server does both in one call to restore_detached_crypto, before any holding is written. In one transaction, it locks the connection row FOR UPDATE, creates or reactivates it, and moves every crypto_holdings row on that connection whose portfolio is deleted to the add’s destination. The lock is held until commit. If reactivation committed first and the restore ran after it, a pending deletion step could take the lock in between, find every holding still in a deleted portfolio, and deactivate the connection just added back. For a wallet, the rows moved are every row with the owner, address, and chain, active or not, including legacy rows without a token_address. Legacy rows with no chain cannot be tied to a chain, so they are restored with whichever chain of the address is added first. It runs as a move: it sets the move flag and records an audit event, which advances the assignment revision. Rows in live portfolios keep their assignment, as on any reconnect. History comes with the rows, as it does when a hidden account is unhidden. The first sync after that finds each token key in a live portfolio and writes normally.
The wallet is keyed by chain on both sides, because wallets is one row per address and chain, and the token key includes the chain. Two existing paths still work across the whole address, from before crypto_holdings had a chain column. deleteWallet deactivates the address’s holdings on every chain, even though it removes a single chain’s wallets row (its code comment says crypto_holdings has no chain, which is out of date). And add-wallet refuses a re-add when the address has any active holding on any chain. Together they would leave a chain that is still tracked but fully detached with no way back: once another chain of the address has active holdings, re-adding it is refused. Both become chain-aware. deleteWallet deactivates only its own chain’s holdings, and add-wallet checks active holdings on the requested chain. add-wallet already treats an active wallets row with no active holdings as a re-add. A chain whose every token is detached has none, since the sync skips all of them, so that chain can be added again without being removed first.
- Destination. The one the add already carries:
add-walletreceives it and passes it on tofetch-wallet-holdings. A legacy request without a destination restores into Main, like any legacy addition (section 4). - Only a user-driven add restores. Background syncs and
fetch-wallet-holdingson refresh do not. So while the wallet stays added, a detached token stays out of every view, as before, until the owner adds that chain again. - A deletion still running. If a deletion operation has not yet run its disconnect step for the wallet, the restore marks that step skipped, in the same transaction and under the same connection lock the step takes. Otherwise a slow deletion could deactivate a connection the owner has just added back. The step does not decide this itself by checking for holdings in live portfolios, because the owner may have chosen to disconnect a connection that has them (“Disconnect the whole connection instead”, above).
/api/disconnect-coinbase) sets is_active = false on coinbase_accounts and leaves its crypto_holdings rows where they are, so a holding in a deleted portfolio stays there. The direct sign-in that would reactivate the row has no client path: Coinbase links through SnapTrade, and signIn('coinbase') was removed with that move. So a Coinbase currency that a deletion detached or disconnected is not restored. The owner links Coinbase through SnapTrade instead, which creates new snaptrade_accounts rows in that link’s destination through the link intent, and process-connection deactivates every active coinbase_accounts row the owner has, as it does today. The old holdings and their history stay with the deleted portfolio, out of every view. The SnapTrade link brings back every Coinbase account the login shares, all into that one destination, including currencies whose direct holdings sat in live portfolios; the owner moves any that belong elsewhere. If someone reaches the direct sign-in anyway, it restores nothing: it upserts holdings without portfolio_id, so the fill and update rules (section 2) apply as on any sync.
SnapTrade needs no restore. A deletion disconnects a SnapTrade connection with removeBrokerageAuthorization, as app/api/snaptrade/disconnect does, which deletes the authorization at SnapTrade. SnapTrade documents that an account ID stays the same only while its connection exists, and that a connection deleted and added again gets new account IDs (account details). So linking that login again inserts new snaptrade_accounts rows in the new link’s destination, and the old rows stay on the tombstone. A detached account is different, because its connection still exists, and SnapTrade returns the existing connection when the same credentials connect again (connections). Linking that login again brings back the same account IDs. process-connection and the webhook update those rows in place and leave portfolio_id and is_hidden alone, so the detached account stays hidden in the deleted portfolio and comes back through “Manage hidden accounts”, like any detached account.
Remove SnapTrade connections one at a time with removeBrokerageAuthorization, as the existing disconnect route does. Never delete the owner’s SnapTrade user, which serves every portfolio.
Plaid removal invalidates the Item’s access token; therefore, deletion completion must track provider removal rather than merely hiding local accounts. Plaid Items API
Extract financial cleanup without introducing outbound messages or re-engagement triggers.
4. Read interfaces, authorization, and Silvia
Shared scope contract
Use one explicit scope throughout UI, APIs, tools, and jobs:portfolios scope holds one or more IDs; a single portfolio is a set of one. Canonicalize before use by de-duplicating and sorting, so equal selections produce equal cache keys regardless of the order they were picked in. The server rejects an empty set, and rejects the whole request if any ID is malformed, unauthorized, or deleted. It never silently drops an ID.
The server derives the owner from authentication. Caller-supplied Clerk IDs are never authorization inputs.
Add portfolio management endpoints for listing, creating, editing, move previews/moves, deletion previews/deletion, and operation status. Financial reads accept a scope; financial creation requires a concrete destination. Editing covers name and notes on every portfolio, Main included, and the type of an additional portfolio; Main’s type cannot change, and no other portfolio can take main. Ownership shares (portfolio_owners) are not exposed for edit in phase 1; each portfolio keeps its owner’s row at 100. Creating or renaming a portfolio with a name already in use, a reserved name, or “Main” on anything other than Main returns a typed portfolio_name_taken error.
Plan-limit enforcement
Portfolio creation is the only plan-gated action, and the server enforces it:- Resolve the tier with
getActiveSubscription, the same resolver token gating uses, sotrialingandpast_duekeep their paid tier andunpaid,paused, and canceled resolve to Free. - Count Main plus live additional portfolios, and insert, in one transaction that holds a per-owner lock (for example an advisory lock keyed by owner), so concurrent requests cannot exceed the limit.
- Reject with a typed
portfolio_limit_reachederror carrying the current tier and limit, which the UI uses to show the upgrade path.
getActiveSubscription treats a lookup failure as Free. That blocks creation during an outage, which is acceptable only because tier never feeds reads, moves, edits, or deletion. No financial read path consults the tier.
Consolidation runs in only two ways: the owner confirms their choice, or the operation that the Stripe webhook scheduled reaches its deadline. The webhook acts on the subscription state in its event and only schedules; it never moves data. Nothing consolidates because getActiveSubscription resolved to Free, so a lookup outage cannot collapse anyone’s portfolios. Before running at the deadline, the operation rechecks the subscription and stands down if the owner is back within their limit.
Account-level mutations verify both ownership and current assignment. All and multi-portfolio selections are read scopes, not writable destinations.
For compatibility, legacy requests without a scope retain aggregate reads and Main additions. The filtered pages always supply scope explicitly; every other page sends none and reads All. Malformed, unauthorized, or deleted portfolio IDs fail rather than falling back to Main or All.
Financial views and caches
Apply scope at the query/data-service layer, before pagination and aggregation. Include scope and assignment revision in React Query keys, server caches, generated analysis inputs, and saved-widget execution. The transactions page’s CSV export exports the rows the page has loaded, so it follows the page’s scope with no change of its own. Only the three filtered pages pass a scope. Each page, or a component on it, reads the selection and passes it to the hooks and requests it makes. A shared hook never reads the selection itself, so the same hook used elsewhere (in chat, a sidebar badge, or a page without the selector) keeps All. Every selection counts each included financial root once. Because each root belongs to exactly one portfolio, a selection’s total is the sum of its portfolios’ totals. Results on the filtered pages are labeled “All portfolios”, the portfolio’s name, or “N portfolios” with the names listed. Preserve existing FX conversion, cash-sweep deduplication, ownership percentages, liability signs, and crypto valuation behavior. The one exception to scoping before aggregation is a per-account decision that depends on sibling accounts: the cash-sweep dedup. It runs over every account on the Item first, and scope filters its per-account results afterwards. If it ran on the scoped rows, a sweep account in a portfolio without its brokerage sibling would count its cash again, and that portfolio plus the brokerage’s portfolio would exceed All.account_effective_balances already computes over the whole Item, so a portfolio_id filter on its output is correct. App-side callers of detectDuplicateCashSweepAccountIds() (hooks/queries/use-accounts.ts, lib/financial-data-cache.ts, the snapshot cron) must pass the Item’s full account list, not the scoped one.
Update all valuation surfaces identified by docs/VALUATION-SURFACES.md, including live metrics, SQL views/RPCs, daily snapshots, charts, and exports. Paginate unbounded history reads using the existing helper.
Account-wide objects such as settings remain shared.
Goals have a scope rather than a single portfolio, because net_worth, total_cash, and total_debt goals measure the whole household today. A goal that tracks an account (source_account_uuid or source_manual_account_uuid) resolves its portfolio through that account, like transactions, and follows it on moves. An aggregate goal stores a PortfolioScope: existing ones get All, so their progress is unchanged. Goals are created only on /goals today, which has no selector, so a new aggregate goal defaults to All, and the create dialog lets the owner pick any set of live portfolios instead. If a filtered page later gains a way to create a goal, a goal created there defaults to that page’s selection. Progress is computed from the goal’s own scope, whatever the selection. /goals lists every goal, each labelled with its scope; a filtered page that shows goals lists a goal when the selection includes its whole scope. Reading a goal’s progress drops deleted portfolios from its scope before calling the resolver, so the resolver itself stays strict, and a goal whose whole scope is deleted is deleted with it.
Persist on newly generated reports, analyses, and saved GenUI widgets the explicit scope Silvia used for them, never the selection (app/api/report serves her research reports; app/api/widgets/[id]/refresh re-runs a saved widget’s sourceQuery), so subsequent refreshes reproduce the intended data set. If a portfolio in a saved set is later deleted, a refresh drops it and says so in the scope label; if none remain, it reports that the scope no longer exists instead of falling back to All. The pruning happens before the resolver is called, as the client does for the selector, so the resolver itself stays strict.
Wallets and Coinbase connections receive per-holding assignment, move, and delete support (section 2). Like Plaid and SnapTrade, they keep their existing identity per owner.
Authorization and future roles
Keep Clerk ownership checks and RLS mandatory. Add same-owner constraints for portfolio references and validate service-role operations explicitly. Recreated views must declaresecurity_invoker = on (docs/DATABASE-VIEW-RLS-SAFETY.md). The membership machinery is not fully dormant: the portfolios SELECT policy already admits accepted portfolio_members, and clients can insert member rows. Remove both paths for phase 1 so phase 2 access cannot be enabled by accident.
Centralize authorization behind a portfolio-access resolver. Phase 1 returns only portfolios owned by the authenticated Clerk account. Phase 2 can extend it to account membership and read/write grants without changing financial assignment or provider credentials.
Shared-login activity cannot distinguish which human performed an action. Audit records identify the Clerk principal and session; independent member attribution belongs to phase 2.
Audit events and log lines record portfolio IDs, never names or notes. Names are often a person’s name (“Spouse”), and the repo’s logging rule forbids logging names.
Silvia integration
Silvia knows everything about the owner’s portfolios except which ones the selector currently shows. She can scope any answer to one portfolio or any combination the user names, but she never knows, and is never scoped by, the current selection. The selector state is never sent to chat, and this is not planned for a later phase either. Provide Silvia with portfolio IDs, names, types, and notes. Treat names and notes as user data, never instructions.- Unqualified requests use All portfolios.
- Silvia narrows scope only when the user names portfolios in the conversation (“work with Spouse’s portfolio”, “compare Main and the LLC”). The narrowed scope applies to that operation only.
- She cannot answer “the portfolios I have selected”, because she has no access to the selection. She asks which portfolios the user means.
- Whenever an answer used a scope narrower than All, it says which portfolio or portfolios it covers.
- “Main” or “my main portfolio” resolves through the
maintype, whatever Main is currently named. - A name resolves to exactly one portfolio; names are unique per owner (section 1). A type reference (“my child’s portfolio”, “the revocable trust”) resolves when exactly one portfolio has that type; one that can still match several, or a partial name, requires clarification.
- Financial writes require one concrete authorized target.
- Responses and generated artifacts identify their portfolio scope.
- Portfolio creation through Silvia, if offered, uses the same server-side plan check. At the limit, Silvia explains the plan limit and the upgrade path instead of filing a support ticket.
document-search-tool.ts, document-reference-tool.ts) are unchanged, since the Vault is not portfolio-aware in phase 1 (section 2).
Update all relevant financial views consistently with portfolio_id. Include a coordinated query-service change that exposes portfolios and the new portfolio_id columns, picks up the recreated views, and enforces the explicit scope. Block feature rollout if that dependency is not ready. The SQL tools also block calls to SECURITY DEFINER and portfolio functions, so a prompt-injected query cannot call one such as ensure_main_portfolio for another owner.
Ship companion changes to Silvia’s navigation, database-schema/view, and applicable UI-component skills, including their S3 deployment step. The database-schema skill documents the portfolio tables and columns. The navigation skill documents the page-level selector and which pages it filters, portfolio management, the Free/Pro/Max limits, and what a downgrade does to portfolios, since this is a plan-gating change (docs/SILVIA-SKILLS-SYNC.md). The UI-component skills cover the scope label that GenUI widgets show for a narrowed scope.
5. Delivery and acceptance criteria
Delivery sequence
- Schema and compatibility: verify deployed state; add metadata,
deleted_at,portfolio_owners, ownership constraints, and themaintype; run the Main backfill and setNOT NULL; apply the database-level protections (foreign keys, default-portfolio index, server-only portfolio writes, membership paths removed). - Provider support: destination-bound linking, the cross-portfolio duplicate-guard confirmation, reconnects, workers, and webhooks.
- Financial scope: queries, caches, valuation views, snapshots, exports, read-time history, and the All reconciliation against
daily_snapshots_summary. - Management UI and plan gating: the multi-select page-level selector on the dashboard, transactions, and portfolio pages, create/edit, account-level moves, the account assignment step after linking and update mode, retryable deletion, server-side plan limits, upgrade prompts, the downgrade warning in
components/downgrade-confirm-modal.tsxand before opening the Stripe portal, the over-limit keep-and-destination prompt, the scheduledconsolidateoperation, and the Pro/Max feature bullets inlib/landing/pricing-plans.tsand the in-app plan grid. - Silvia: portfolio metadata in context, enforced tool scope taken from the conversation, the query-service change, and coordinated skills deployment.
- Rollout: staging fixtures and provider sandbox checks, followed by a feature-flagged cohort. The rollout flag and the plan gate are independent: the flag decides whether the feature exists for a user, then the plan decides how many portfolios they may create.
Required verification
- Existing Main accounts connect, refresh, and reconnect without re-registration.
- One Clerk account links separate spouse credentials through both providers under the same provider identity, and every connection stays healthy. Test with two real Chase logins, one of which can see a joint account; Plaid’s sandbox mock banks do not reproduce Chase’s OAuth behavior.
- A second login at an institution already connected in another portfolio links successfully after the different-login confirmation; the same institution in the destination portfolio is still blocked, and an account already connected anywhere under the owner is still rejected.
- Cross-user portfolio IDs fail under APIs, RLS, workers, and AI tools.
- Every portfolio, each backfilled Main included, has exactly one
portfolio_ownersrow: its owner at 100. A client cannot insert, update, or delete one. - OAuth return after selector changes still uses the original destination.
- A SnapTrade connection whose webhook arrives before
process-connection, or whose tab is closed before the redirect, still lands in the intended destination. - A webhook for an older SnapTrade authorization that has no local accounts, arriving while a newer link’s intent is pending, does not take that intent’s destination. It lands in Main, and the newer link’s own authorization still lands in its destination.
- One Chase login with an individual and a joint checking account, the individual account in Main and the joint account in “Joint”: each portfolio shows only its account, All shows both once, and the connection and its health status appear in both portfolios.
- Moving one account of a connection leaves its sibling accounts, the connection, and provider credentials unchanged, and survives concurrent sync. A sync upsert never changes an account’s
portfolio_id. - One wallet token moved to another portfolio stays there after the next wallet sync inserts fresh rows, the wallet’s other tokens stay put, and its history before the move appears under the destination. A Coinbase currency account moved on its own stays there across Coinbase syncs.
- Two tokens with the same symbol on one wallet and chain (for example a spam “USDC” beside the real one) move independently, and each keeps its own history.
- A wallet sync that runs while one of its tokens is being moved leaves every fresh row for that token in the destination. A SnapTrade account discovered while its connection’s accounts are being moved lands under the sibling rule as of the committed move, never a stale read.
- A Coinbase holding for an owner with several
coinbase_accountsrows resolves to the one active row with itswallet_id. - When a second owner connects a Coinbase account another owner already had, the connection and, on the next sync, its holdings land in the new owner’s Main, and that owner’s Coinbase sync keeps running. The first owner’s views drop the holdings.
- Deleting a portfolio that holds one token of a wallet whose other tokens are elsewhere keeps the wallet syncing: later syncs skip the deleted token and write the others.
- Moved history appears under the destination; aggregate values remain unchanged.
- For a split Plaid Item, a scope holding only some of its accounts shows 2025-10-28 through 2026-06-26 as unavailable for that Item, while All and scopes holding the whole Item show that range from
snapshots_by_item. - A cash-sweep account in a different portfolio from its brokerage account stays suppressed, and All still equals the sum of the portfolios.
- Deleting a portfolio disconnects a connection only when none of its live accounts, hidden ones included, remain in another portfolio, or when the owner chose to disconnect it in the preview. A connection that stays keeps its other accounts, its detached accounts are hidden and stay out of every view after later syncs, and the owner’s SnapTrade user stays in place.
- The deletion preview states, for every connection that stays, which of its accounts will be hidden but keep syncing and stay stored, and offers to disconnect that connection instead, naming the accounts in other portfolios that would stop syncing with it. Choosing to disconnect removes the connection, and a retried operation does not ask again.
- After a deletion detaches Plaid accounts, the owner can untick them in update mode. Each account the Item no longer returns is marked inactive, drops out of “From deleted portfolios”, stops syncing, and can be linked from another login without a duplicate-guard rejection. An account still returned stays hidden and keeps the offer. Tested in the sandbox and with the live Chase logins.
- A detached account appears in “Manage hidden accounts” under “From deleted portfolios”; unhiding it asks for a live portfolio, and its balance and history then appear there. Clearing
is_hiddendirectly on it is rejected, and linking it again through another login is still blocked by the duplicate guard. - A detached Coinbase currency account or wallet token stays out of every view while its connection stays connected, including after later syncs.
- A wallet with tokens in a deleted portfolio, whether the deletion detached or disconnected it, can be removed and added again. Those tokens then appear in the destination chosen for the add, with their history, and later syncs keep them there. Its tokens in live portfolios stay where they were. A background sync restores nothing.
- A Coinbase connection that a deletion detached or disconnected comes back by linking Coinbase through SnapTrade: its accounts land in that link’s destination, the direct
coinbase_accountsrows are deactivated, and the old holdings stay out of every view. - A deletion whose disconnect step runs after the owner has added the wallet back leaves it connected, including when the step’s lock request arrives while the add is still running.
- For an address tracked on two chains, where one chain’s tokens are all detached and the other chain has live tokens, the detached chain can be added again and restores into the destination while the other chain’s holdings stay where they were. Removing one chain of an address leaves the other chain’s holdings active.
- A SnapTrade brokerage login disconnected by a portfolio deletion and linked again gets new account IDs, which land in the new link’s destination. Linking again a login whose accounts the deletion only detached leaves those accounts hidden in the deleted portfolio, restorable from “Manage hidden accounts”.
- Partial provider deletion failures retry safely; a late webhook’s insert into a deleted portfolio is rejected and acknowledged, not retried.
- A connection’s first accounts land in the destination chosen when linking, for Plaid, SnapTrade (including webhook-first and closed-tab), Coinbase, and wallets. A later background account lands in the portfolio its connection’s live accounts share, or Main if they are split; one on a connection being removed is rejected and acknowledged. A Plaid account reissued under a new
account_idkeeps its predecessor’s portfolio. - After Plaid update mode adds an account to an existing connection, the app lists it with a portfolio picker preset to where the sibling rule put it, and the owner’s choice sticks.
- No Plaid balance job inserts an
accountsrow for an account the owner never added. - All and every multi-portfolio selection equal the sum of their portfolios, for current totals and history, using the same valuation semantics.
- The same set of portfolios selected in a different order produces the same cache key and results.
- A persisted selection containing a deleted portfolio drops it before any request; an emptied selection falls back to All.
- The selection survives a new tab and a browser restart on the same device, is not shared with another device on the same login, and never appears in a URL.
- The selector appears only on the dashboard, transactions, and portfolio pages, on desktop and mobile. A selection made on one of them carries over to the other two. Every other page, and every surface on it, shows All, including shared hooks such as sidebar badges while a filtered page shows a narrower selection.
- Adding data from a page without the selector preselects the destination when the shared selection is exactly one portfolio, and shows it before anything is added.
- A request whose set includes any unauthorized, malformed, or deleted ID fails as a whole.
- Joint-account duplicates are surfaced for review without automatic merging.
- Charts and transactions remain accurate beyond 1,000 history rows.
- CSV-imported transactions on a manual account appear only in scopes that include the account’s portfolio, move with the account, and are counted once in All. A CSV import into a manual account lands in that account’s portfolio regardless of the current selection.
- The Vault ignores portfolios: it lists every folder and document whatever the selection, uploads ask for no portfolio, and deleting or consolidating a portfolio leaves every folder and document in place. The deletion preview says so.
- Existing
net_worth,total_cash, andtotal_debtgoals keep their progress after other data moves out of Main; an account-tracking goal follows its account; a goal’s progress uses its own scope in every view. A goal created on/goalsdefaults to All. - Rapid selection changes never show cached data from another scope.
- Silvia handles aggregate, named, multi-portfolio, ambiguous, and deleted portfolio requests correctly, and never returns rows in a deleted portfolio.
- An unqualified Silvia request covers All portfolios whatever the selector shows, and a request naming portfolios uses exactly those. Her answers never change with the selection; asked about “the portfolios I have selected”, she asks which portfolios the user means. An answer that used a scope narrower than All names the portfolios it covers.
- Desktop/mobile and light/dark flows use existing semantic tokens and accessible controls.
- Free cannot create a portfolio; Pro stops at 2 portfolios in total and Max at 10, and the next attempt returns
portfolio_limit_reached. Main counts toward the limit. - Concurrent creation requests cannot exceed the limit, and a direct client insert into
portfoliosis rejected by RLS. trialingandpast_duesubscriptions get their paid limit;unpaid,paused, and canceled get Free.- A tier drop from any source (in-app, Stripe portal, cancellation) leaves an owner over the limit able to add data but not create portfolios, and prompts for a keep-and-destination choice. Consolidating moves every financial root, including wallets and exchange connections, into the destination, and All is unchanged.
- With no choice by the deadline, every additional portfolio collapses into Main; an upgrade before the deadline cancels it;
unpaidandpausedschedule nothing. - Consolidation never deletes financial data, runs once under repeated webhooks, and resumes after a partial failure. A subscription-lookup failure never triggers it.
- A direct client delete of a portfolio fails, and no data is reassigned to Main.
- After the backfill, every owner has exactly one Main, every financial root has a non-null
portfolio_id, the legacy rows are gone, and today’s readers behave identically. An insert withoutportfolio_idlands in Main, and a new sign-up gets Main. - Main’s name and notes, and any portfolio’s name and notes, can be edited. Main’s
maintype cannot be removed or given to another portfolio, and no other portfolio can be named “Main”. After Main is renamed, “my main portfolio” still resolves to it in Silvia. - Logs and audit events contain no portfolio names or notes.
6. Phase 2
Phase 2 is limited here to the authorization seam: invitations, independent logins, read/write permissions, and member administration are deliberately deferred. Vault. Phase 1 leaves the Vault owner-level (section 2). Making it portfolio-aware needs:- A
BEFORE INSERTfill trigger onvault_folders, falling back to the owner’s Main. - The document copy trigger: a document takes
portfolio_idfrom its folder on insert, an update of the folder’sportfolio_idcascades to its documents in the same statement, and a direct document write that differs from the folder is rejected. - A backfill of every folder, including those created during phase 1, to Main, with documents following through the cascade.
NOT NULLon both columns, and both tables added to the move-only update guard.- Listing and search scoped through the folder’s portfolio, including chunk search, which filters through each chunk’s document. Documents received by email land in Main.
- An upload destination: uploading from All or a multi-portfolio selection requires choosing a folder, and the document takes its portfolio.
- Deletion and consolidation: the deletion preview lists folders and documents, deletion runs the Vault deletion service on them, and consolidation and moves carry folders with their documents.
- Silvia’s document tools taking the explicit scope from the conversation, like her other tools (section 4).