Skip to content

built Built. This is a decision record, not documentation.

What is still current: Multiple wallets per account, the wallet index, the add-wallet lock and the aggregate view are live (src/lib/portfolio/wallets.ts). The modules it calls the engine were replaced.

Landed: migration 048 (v0.9.0) and migration 050 (v0.10.0)

Header updated 2026-09-14. The body below is frozen history. All plans.

Portfolio multi-wallet tracking — implementation plan ​

Verified against the working tree (2026-07-13). All file/line references below were checked against the repo on branch feat/pt-carry-current-fixed-rate (post-#389). Re-verify line numbers before editing; structure claims (schemas, function signatures, flow) are stable.

Prereq reading for every executing agent: AGENTS.md (all of it), docs/database.md, docs/data-pipeline.md, docs/deployment.md, docs/portfolio.md.

Each workstream below is one PR into staging, in order. WS1–WS2 are backend-only and invisible to users; WS3 makes single tracked wallets viewable; WS4 adds the "All wallets" aggregate; WS5 ships the UI; WS6 closes out docs/ops.


1. Product definition ​

A signed-in user can register additional wallet addresses to track on /portfolio:

  • Add a wallet by pasting an address and giving it a readable name (label). Adding immediately enqueues the same registration backfill the user's own wallet got at first sign-in, and the same JIT live read paints current positions right away. The "History syncing" banner behaves exactly as it does today for a fresh sign-in.
  • All tracked wallets are persisted server-side, linked to the account (the SIWE session address). They survive sign-out/sign-in and devices.
  • A dropdown in the portfolio header selects which wallet the whole view (headline tiles, chart, positions table) shows. The signed-in wallet is always in the list and is the default selection.
  • An "All wallets" option shows every position across all tracked wallets (one row per wallet+leg, with a Wallet column) and aggregated historic PnL/cumulative-yield charts plus aggregated headline tiles.
  • Wallets can be renamed and removed (the signed-in primary wallet cannot be removed, only renamed).

Decisions (made, not open) ​

  1. Watch-only, no ownership proof. Tracked wallets require no SIWE signature — everything shown is public on-chain data, and the tracked list itself is private to the account. This matches the industry norm for portfolio trackers. (The AGENTS.md "not a wallet tracker" line is about net-worth/token-balance framing, which is unchanged — tracked wallets get the same covered-venue fixed-income book view, nothing more.) Confirmed by product owner 2026-07-14.
  2. Identity model stays wallet-as-account. We do NOT restructure accounts. Tracked wallets get a shadow accounts row (see WS1) so the entire existing pipeline — portfolio_backfill_state FK, queue drain, 6h cron eligibility, backfillWallet(uid) — works unchanged. A shadow account is distinguishable from a real user: last_seen_at IS NULL (only SIWE sign-in sets it).
  3. Aggregation happens on engine OUTPUTS, not inputs. buildBookCurve and the M1/wedge/grouping logic key legs by position_key with venue-prefix parsing and prefix matching (src/lib/portfolio/pnl.ts:526, :881-891, keySeized :490-492). Two wallets holding the same leg (e.g. both supply Aave USDC → identical position_key) would collide in the engine's ts → positionKey maps, and namespacing keys by wallet breaks the prefix parsing. So "All wallets" runs the existing single-wallet pipeline per wallet and merges the resulting curves/summaries/rows (WS4). The battle-tested engine is not touched.
  4. Cap: 2 added wallets on top of the signed-in one (3 total including the primary), constant MAX_TRACKED_WALLETS = 3 in code (count of account_wallets rows per account, self-row included). Bounds archive-backfill and 6h-cron cost; deliberately conservative for v1 — raising it later is a one-constant change. Server-enforced (400). Confirmed by product owner 2026-07-14.
  5. Address input only, no ENS in v1 (non-goal; note for later).
  6. Default selection / back-compat: portfolio API routes without a wallet param serve the session address, exactly as today.

2. Current architecture (what the executing agent must know) ​

  • Identity: accounts.uid = lowercase wallet address = the account (scripts/sql/042-accounts.sql). Session = HMAC cookie holding that address (src/lib/auth/session.ts, verifySessionCookie). Every /api/portfolio/* route resolves the wallet only from the cookie.
  • First-sign-in sync: POST /api/auth/verify calls upsertAccount(address) then enqueueBackfill(address) (src/app/api/auth/verify/route.ts:64-73, src/lib/portfolio/enqueue.ts). A minutely cron (scripts/drain-portfolio-backfills.ts → scripts/refreshers/backfill-queue.ts) claims portfolio_backfill_state rows 'queued' → spawns scripts/backfill-portfolio-wallet.ts --uid <uid> (archive RPC) → backfillWallet (src/lib/portfolio/backfill.ts:663-886) probes current positions, replays a UTC-midnight daily grid (~90d lookback from accounts.created_at), writes basis='backfill' snapshot + flow rows, sets status done|empty|error.
  • Ongoing sync: 6h cron (scripts/refreshers/portfolio.ts, refreshPortfolio()) snapshots all eligible wallets in one pass (loadEligibleWallets :78-86 — the readers already take wallets[]). JIT request-time refresh: liveRefreshWallet(wallet) (src/lib/portfolio/live.ts:184), rate-limited 60s per wallet, triggered by POST /api/portfolio/refresh.
  • Read path: each route → getSummary/getPositions/getHistory/getEvents(wallet) in src/lib/portfolio/api-data.ts → loadContext(wallet, {live}) (:325) → SQL WHERE chain_id=$1 AND wallet=$2 → engine (pnl.ts, assemble.ts) → wire types in src/lib/portfolio/api-types.ts.
  • Frontend: no SWR/react-query; bespoke fetch+state in src/components/portfolio/PortfolioView.tsx. PortfolioClient.tsx gates on useAccount() (src/components/auth/AccountProvider.tsx) and renders <PortfolioView address={address} />; the address prop is display-only — no fetch sends an address today. Sync status arrives on SummaryResponse.backfill/syncing/hasSnapshots/jit and renders the "History syncing" banner (PortfolioView.tsx:272-281).
  • Dropdown building block: BoxedSelect<T> in src/components/ui/filter-controls.tsx:213-350 ({label?, value, options: {key,label,icon?}[], onChange, ariaLabel?, minWidth?}). Dialog primitive exists at src/components/ui/dialog.tsx.
  • Migrations: forward-only scripts/sql/NNN-*.sql, applied by scripts/ops/migrate.sh. Latest is 047-fluid-dex-pershare.sql → this feature is 048. CI guards duplicate numbers.
  • Tests: vitest, colocated *.test.ts(x) (npm test); CI also runs tsc --noEmit.

3. WS1 — Schema + identity plumbing (backend, invisible) ​

3.1 Migration scripts/sql/048-account-wallets.sql ​

sql
-- Tracked wallets per account. The account's own wallet is seeded as a row
-- (label '') so the wallet list is uniform; is-primary = (wallet = account_uid).
CREATE TABLE IF NOT EXISTS onchain_credit.account_wallets (
  account_uid text        NOT NULL
                REFERENCES onchain_credit.accounts(uid) ON DELETE CASCADE,
  wallet      text        NOT NULL,
  label       text        NOT NULL DEFAULT '',
  created_at  timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (account_uid, wallet),
  CONSTRAINT account_wallets_uid_lower_chk    CHECK (account_uid = lower(account_uid)),
  CONSTRAINT account_wallets_wallet_lower_chk CHECK (wallet = lower(wallet)),
  CONSTRAINT account_wallets_wallet_hex_chk   CHECK (wallet ~ '^0x[0-9a-f]{40}$'),
  CONSTRAINT account_wallets_label_len_chk    CHECK (char_length(label) <= 64)
);

-- Reverse lookup: "is this wallet referenced by any account?" (cron eligibility).
CREATE INDEX IF NOT EXISTS account_wallets_wallet_idx
  ON onchain_credit.account_wallets (wallet);

-- Seed a self-row for every existing account so eligibility (3.3) and the
-- wallet-list API see a uniform shape.
INSERT INTO onchain_credit.account_wallets (account_uid, wallet)
SELECT uid, uid FROM onchain_credit.accounts
ON CONFLICT (account_uid, wallet) DO NOTHING;

GRANT SELECT, INSERT, UPDATE, DELETE
  ON onchain_credit.account_wallets TO onchain_credit;

Note: tracked wallets need a FK target in accounts — that is the shadow row (3.2), created before the account_wallets insert in the add flow.

3.2 Identity helpers — src/lib/auth/accounts.ts ​

Add ensureTrackedAccount(wallet: string): like upsertAccount (:17-27) but must not touch last_seen_at (that column now means "has signed in"):

sql
INSERT INTO onchain_credit.accounts (uid, created_block)
VALUES ($1, $2)              -- created_block best-effort as upsertAccount does
ON CONFLICT (uid) DO NOTHING -- existing account (real or shadow): keep anchors

accounts.created_at of the shadow row = when the wallet was first tracked = the backfill "tracked since" anchor, same semantics as sign-in today.

3.3 Backfill enqueue/requeue — src/lib/portfolio/enqueue.ts ​

Keep enqueueBackfill as-is. Add enqueueOrRequeueBackfill(uid) for the add-wallet flow:

  • New wallet: existing INSERT ... ON CONFLICT DO NOTHING behavior.
  • Wallet already known (e.g. previously tracked and removed, or it's another user's account): if its history has gone stale — status 'error'/'empty', or no portfolio_position_snapshots row within the last 2 aligned 6h windows — flip status back to 'queued' (also reset attempts to 0, added by 045-portfolio-backfill-attempts.sql). backfillWallet is an idempotent full delete+replay, so requeueing repairs any gap from the untracked period.
  • Wallet actively covered (recent snapshot exists, status 'done'): do nothing — history is already current.

Do NOT replace the stale-requeue with a "fill only the gap" variant (considered and rejected 2026-07-14): history is open-set-relative (computeBackfillWindow, backfill.ts:142-147 — legs exist only for groups held NOW), so a gap-fill would leave pre-gap rows inconsistent with the re-probed open set (phantom yield / orphan legs in the engine); the gap's flows need an archive sweep either way, which is the bulk of the cost; and the full replay is ≤120 grid points in a background child. The conditional skip above is the cheap re-add path.

3.4 Sign-in seeds the self-row — src/app/api/auth/verify/route.ts ​

Inside the existing best-effort block (:64-73), after upsertAccount:

ts
await ensureSelfWalletRow(address); // INSERT INTO account_wallets (uid, uid) ON CONFLICT DO NOTHING

(Small helper in a new src/lib/portfolio/wallets.ts; see WS2.)

3.5 Cron eligibility — scripts/refreshers/portfolio.ts:78-86 ​

loadEligibleWallets currently selects from accounts LEFT JOIN backfill state. Change the predicate so wallets stop being snapshotted when nobody tracks them, and shadow wallets are included while referenced:

sql
SELECT a.uid FROM onchain_credit.accounts a
LEFT JOIN onchain_credit.portfolio_backfill_state b ON b.uid = a.uid
WHERE (b.status IS NULL OR b.status NOT IN ('queued','running'))
  AND (
    a.last_seen_at IS NOT NULL                    -- a real signed-in account
    OR EXISTS (SELECT 1 FROM onchain_credit.account_wallets aw
               WHERE aw.wallet = a.uid)           -- referenced by someone's list
  )

Removal therefore costs nothing at delete time: the wallet just drops out of the 6h pass; its historical rows stay (cheap, and re-adding is instant with a gap-repair requeue per 3.3). scripts/ops/seed-portfolio-fixtures.ts sets last_seen_at = now() on its upsert (:79-81), so staging fixtures remain cron-eligible under the new predicate with no change.

3.6 Staging PII scrub ​

Add account_wallets to the combined TRUNCATE in scripts/ops/scrub-staging-pii.sql:61 (the statement that already truncates accounts + the three portfolio tables in one go). This is not optional hygiene: account_wallets FK-references accounts, and Postgres refuses to TRUNCATE a table with FK referrers unless they are in the same statement — the scrub would start failing after 048 lands. The account↔wallet mapping is also the private part of this feature; it must never survive a prod→staging copy.

3.7 Tests (WS1) ​

  • enqueueOrRequeueBackfill: fresh wallet → queued; done+recent-snapshot → untouched; done+stale / error / empty → requeued with attempts reset.
  • loadEligibleWallets: shadow wallet referenced → eligible; unreferenced shadow (never signed in) → excluded; real account with empty list → still eligible.
  • Migration applies cleanly on a copy of the current schema (existing migration-test harness if present; otherwise migrate.sh against the dev DB).

4. WS2 — Wallet management API ​

New file src/lib/portfolio/wallets.ts: listWallets(accountUid), addWallet(accountUid, address, label), renameWallet, removeWallet, ensureSelfWalletRow, plus the shared request resolver (4.2). New routes under src/app/api/portfolio/wallets/. All routes: dynamic = "force-dynamic", authenticate via verifySessionCookie exactly like summary/route.ts, 401 when signed out. Follow the existing route-comment style (address never from body for auth purposes — only the session cookie identifies the account).

4.1 Routes ​

RouteBehavior
GET /api/portfolio/walletsList the account's wallets joined with portfolio_backfill_state: { wallets: [{ address, label, isPrimary, addedAt, backfill: BackfillStatus, trackedSince }] }, primary first, then by created_at.
POST /api/portfolio/wallets {address, label?}Validate with viem isAddress (400), lowercase; enforce MAX_TRACKED_WALLETS (400); duplicate → 409. Then, in order: ensureTrackedAccount(addr) → insert account_wallets row → enqueueOrRequeueBackfill(addr) → fire-and-forget liveRefreshWallet(addr) so current positions paint before the archive backfill lands (mirror of the sign-in UX). Return the created row (201).
PATCH /api/portfolio/wallets/[address] {label}Trim, ≤64 chars (400). Renaming the primary is allowed. 404 if not in the account's list.
DELETE /api/portfolio/wallets/[address]400 if address === session address (primary is irremovable). Delete the row; 404 if absent. No data pruning (see 3.5).

Labels are plain text (React escapes on render); reject control characters.

4.2 Wallet selection resolver (used by WS3/WS4) ​

resolveWalletSelection(req): Promise<{ ok: true; selection: "all" | string; wallets: string[] } | { ok: false; status: 401 | 403 }>

  • 401 if no valid session cookie.
  • Load the account's wallet set from account_wallets (fallback: the session address alone, covering accounts created before 048 that haven't re-signed).
  • ?wallet= absent → selection = session address (back-compat).
  • ?wallet=all → selection "all", wallets = full set.
  • ?wallet=0x… → must be in the set after lowercasing, else 403 (comment why: the tracked list is account-private; serving arbitrary addresses would make every route an open indexer).

4.3 Tests (WS2) ​

Route-level tests in the repo's existing API-test style: 401 signed out; add happy path (row + shadow account + queue row exist); invalid address; cap; duplicate 409; rename primary ok; delete primary 400; delete foreign address 404; resolveWalletSelection 403 on an address outside the set.


5. WS3 — Single tracked-wallet read path ​

Goal: ?wallet=<addr> works on all five portfolio routes for any wallet in the account's list. No aggregation yet.

5.1 Routes — src/app/api/portfolio/{summary,positions,history,events,refresh}/route.ts ​

Replace the inline verifySessionCookie + fixed address with resolveWalletSelection. For this WS, treat selection === "all" as 400 ("not yet supported") behind a small guard that WS4 removes — or land WS3+WS4 together if the executing agent prefers; the split exists to keep PRs reviewable.

  • GET routes pass the selected single wallet into the existing getSummary/getPositions/getHistory/getEvents — no api-data.ts changes needed for single selection; loadContext already takes an arbitrary wallet and loadAccountCreatedAt/loadBackfill resolve via the shadow account + its backfill row.
  • POST /api/portfolio/refresh passes the selected wallet to liveRefreshWallet(wallet) (still under the existing 30s race bound, refresh/route.ts:21,30-33).

5.2 Wire types — src/lib/portfolio/api-types.ts ​

No shape changes required for single selection (address in each response is already the served wallet). Add the WalletsResponse/TrackedWallet types from WS2 here so client and server share them.

5.3 Tests (WS3) ​

Summary/positions/history for a tracked wallet return that wallet's data (fixture rows keyed to a second wallet); 403 for an untracked address; omitted param still serves the session wallet (back-compat test).


6. WS4 — "All wallets" aggregation ​

New module src/lib/portfolio/aggregate.ts + getSummaryAll(wallets), getPositionsAll(wallets), getHistoryAll(wallets, book, mark) in api-data.ts (thin orchestrators). Strategy per Decision 3: run the existing single-wallet path per wallet in parallel, merge outputs.

6.1 Contexts ​

loadContext(w, { live: true }) per wallet via Promise.all (≤3 wallets; each JIT read is coalesced/rate-limited per wallet already — live.ts:44,184-198 — so repeated aggregate loads don't stampede RPC).

6.2 History merge (the aggregated PnL chart) ​

Per wallet and book/mark, reuse the exact getHistory internals (extract its curve→points block into a helper rather than duplicating). Merge:

  • Grid points: union of daily ts values (all wallets share the UTC-midnight grid, so this is alignment-free for the daily spine). At each ts, for each wallet: absent (before its trackedSince / after its last point) contributes nothing; null (an explicit gap) poisons the point. Aggregate value = sum of contributions; aggregate point is null iff any wallet whose series spans that ts reports null (gaps stay gaps — never show a false dip; this is the existing single-wallet honesty rule, PortfolioChart renders connectNulls={false}). Apply the rule independently to cumulativeYield and bookValue.
  • Trailing live point: each wallet's JIT "now" point sits off-grid at its own anchor ts. Emit ONE aggregate now-point at max(lastTs) whose value is the sum of each wallet's latest value (null if any is null). Same approximation the JIT merge already accepts within one wallet.
  • Flow + liquidation markers: concatenate, sort by ts. (Markers are reference lines; overlaps are fine.)
  • Response: address: "all", trackedSince = min across wallets.

6.3 Summary merge (aggregated headline tiles) ​

Per book and mark, with per-wallet BookCurves in hand (buildBookCurve output, pnl.ts:466-475):

  • Additive: cumulativeYield, bookValue, realizedLoss — sum. present = any; observedDays = max.
  • realizedReturn: the single-wallet definition is totalYield / baseCapital (pnl.ts:676-690). Aggregate = Σ totalYield / Σ baseCapital (capital-weighted; consistent with the single-wallet formula). Read the BookCurve type first: if the base-capital denominator is exposed on the curve (or derivable from the same helper realizedReturn(curve) uses), aggregate exactly; the agent must check pnl.ts:690-716 and reuse, not reimplement, the edge-case handling (no-positive-base → null).
  • TWR: rebuild intervals on the merged series — per grid interval, r = (V_end − V_start − netFlows) / V_start over the summed bookValue series with flows unioned per interval, then chain with the existing linkTwr + annualizeTwr (pnl.ts:445,659). Skip intervals where the merged series is null (same rule the engine applies to gaps — verify how buildBookCurve treats null intervals at pnl.ts:513-666 and mirror it). If, on inspection, the interval plumbing cannot be reused without copying engine internals, fall back to twr: null for the aggregate (HeadlineTiles already renders null) and record that in the execution log — do not ship a subtly-wrong TWR.
  • Wedge: union per-wallet wedge assets; diverged = any. Add optional wallet to WedgeAsset so duplicated positionKeys stay distinguishable.
  • Top-level: syncing = any; backfill = worst-of (error > running > queued > done > empty > none); hasSnapshots/jit = any; address: "all".

6.4 Positions merge ​

Concatenate per-wallet PositionRows per book — one row per (wallet, leg), no cross-wallet merging (honest, and avoids qty/index math). Extend PositionRow with optional wallet?: string and walletLabel?: string (labels come from account_wallets); populate only in aggregate mode so existing consumers are untouched. outside groups likewise concatenated and tagged.

6.5 Events ​

getEvents reads SQL directly with limit/offset (api-data.ts:658). For wallet=all, widen the predicate to wallet = ANY($…) and add wallet to EventRow — correct pagination for free, no in-memory merge. (The frontend does not render events today; keep the route consistent anyway.)

6.6 Refresh ​

POST /api/portfolio/refresh?wallet=all → Promise.allSettled of liveRefreshWallet(w) per wallet under the existing 30s race; report refreshed: true if any succeeded. Per-wallet rate limits/coalescing already prevent abuse.

6.7 Tests (WS4) — the highest-value tests in this plan ​

  • Degenerate equivalence: aggregate of [w1] === single-wallet output for summary/history/positions (golden comparison on a fixture).
  • Additivity: two wallets with disjoint legs → summed points, summed headline yields/book values.
  • Null semantics: wallet A has a gap at ts → aggregate point null; wallet B starts later (trackedSince differs) → earlier points equal A alone (B absent ≠ null).
  • Same-leg collision: two wallets both holding the identical Aave leg → two position rows, both counted in the aggregate curve (this is exactly the case that forbids the naive union-into-engine approach — pin it).
  • realizedReturn/TWR: aggregate of (wallet, empty wallet) equals the single wallet's values; a hand-computed 2-wallet, 3-point fixture for TWR.

7. WS5 — Frontend ​

All in src/components/portfolio/ unless noted. Follow AGENTS.md UX rules: terminal aesthetic, mono/tabular numerals, uppercase labels, single amber accent, no em-dashes in user-facing copy.

7.1 State + data flow ​

PortfolioClient.tsx (signed-in branch):

  • On mount, GET /api/portfolio/wallets → wallets state.
  • Selection state: "all" | <address>, default = session address; persist per-account in localStorage (creddit_portfolio_wallet:<uid>); validate the restored value against the fetched list (fall back to primary).
  • Render <PortfolioView address={sessionAddr} selection={sel} wallets={wallets} onSelect={...} onWalletsChanged={refetch} />.

PortfolioView.tsx:

  • Append wallet=<selection> to all four GETs and the refresh POST (:106-125, :131-170, :198-209). Re-key the history cache ${epoch}:${selection}:${book}:${mark} (:197) and re-run the mount+syncLive effect when selection changes (bump epoch).
  • Header (:246-263): replace the static truncated address with the wallet switcher.

7.2 WalletSwitcher.tsx ​

BoxedSelect<string> (src/components/ui/filter-controls.tsx:213) with options: each wallet as label || truncateAddr(address) (primary annotated, e.g. suffix · signed in), plus "All wallets" only when the list has more than one entry. Next to it, a small "Manage" button opening the dialog (7.3). truncateAddr is in src/components/portfolio/theme.ts:51.

7.3 ManageWalletsDialog.tsx ​

Uses src/components/ui/dialog.tsx. Contents:

  • List: label (inline-editable → PATCH on blur/Enter), truncated address (full address in title), per-wallet backfill status chip (queued/running → "syncing"), remove button (hidden for primary). Remove → DELETE → refetch list; if the removed wallet was selected, fall back to primary.
  • Add form: address input + name input + Add button → POST → on 201 refetch, select the new wallet, close. Surface 400/409 errors inline (invalid address / limit reached / already tracked). Client-side isAddress check before submitting for instant feedback.

The post-add experience must match first sign-in: summary for the new wallet returns syncing: true → existing "History syncing" banner; the JIT refresh (fired server-side on add + by the view's own syncLive) paints live positions immediately.

Today nothing re-checks summary.syncing (PortfolioView has no poll), which was tolerable when syncing only happened at first sign-in. With adds it's front-and-center: while summary.syncing is true, poll getSummary every 20s (max ~15 min, cleared on unmount/selection change); when it flips false, re-fetch everything (bump epoch) so the chart fills in without a manual reload.

7.5 All-wallets table ​

PositionsTable (PortfolioView.tsx:344-439): when selection === "all", prepend a "Wallet" column rendering walletLabel || truncateAddr(wallet). Rows keep their existing per-book grouping; no other changes.

7.6 Tests (WS5) ​

Component tests in the existing style (PillGroup.test.tsx, HeadlineTiles.test.tsx): WalletSwitcher renders labels + hides "All wallets" for a single-wallet list; ManageWalletsDialog add-flow validation states; PositionsTable shows the Wallet column only in aggregate mode.


8. WS6 — Docs, ops, verification ​

  • Docs: update docs/portfolio.md (feature + aggregation semantics, especially the null/gap rule and the watch-only decision), docs/database.md (048 table), docs/data-pipeline.md (eligibility change), AGENTS.md if it inventories portfolio routes/tables. Append to docs/plans/portfolio-execution-log.md per repo convention.
  • Ops: no crontab changes (drain + 6h cron unchanged). Confirm staging scrub covers account_wallets (3.6) and fixtures stay cron-eligible (3.5).
  • End-to-end verification (staging, before promoting): sign in with a dev wallet → add a known whale address (one of the scripts/ops/seed-portfolio-fixtures.ts wallets — data already exists) and a fresh address → watch: queue row appears, drain picks it up within ~1 min, banner clears via the poll, dropdown switches views, "All wallets" chart = visual sum, remove → wallet gone from dropdown and (next 6h pass) from eligibility. Run npm test and tsc --noEmit per WS; migrations via scripts/ops/migrate.sh.

9. Risks / sharp edges for the executing agent ​

  1. Do not namespace position_key by wallet anywhere near the engine. Prefix parsing (pnl.ts:881-891), keySeized prefix matching (:490), fluid group prefixes (api-data.ts curveFor :419-431) and labelForRow/fluidVaultOfKey all parse the key structurally. Aggregation is outputs-only (Decision 3).
  2. last_seen_at now carries meaning ("has signed in"). upsertAccount must remain the only writer that sets it; ensureTrackedAccount must not. Audit any admin/metrics query that counts accounts rows as users.
  3. Backfill statement shapes are strict (backfill.ts:572-627 status CHECK constraints, attempts reclaim budget in backfill-queue.ts:124-175). Requeue must reset attempts or a previously-parked wallet re-parks immediately.
  4. The 30s refresh race (refresh/route.ts) with multiple wallets: allSettled, never sequential. Fine at the v1 cap of 3, but do not write code that only survives because the cap is small.
  5. getJson cache keys and effect deps in PortfolioView are subtle (epoch re-keying, stale-while-revalidate). Thread selection through every key and dep array; a missed dep shows wallet A's chart under wallet B's header.
  6. CI migration guard: the file must be 048-*.sql; check no parallel branch (oc-* clones) has claimed 048 before merging.

Private documentation. creddit.xyz