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)
- 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.
- Identity model stays wallet-as-account. We do NOT restructure
accounts. Tracked wallets get a shadowaccountsrow (see WS1) so the entire existing pipeline —portfolio_backfill_stateFK, 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). - Aggregation happens on engine OUTPUTS, not inputs.
buildBookCurveand the M1/wedge/grouping logic key legs byposition_keywith 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 → identicalposition_key) would collide in the engine'sts → positionKeymaps, 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. - Cap: 2 added wallets on top of the signed-in one (3 total including the primary), constant
MAX_TRACKED_WALLETS = 3in code (count ofaccount_walletsrows 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. - Address input only, no ENS in v1 (non-goal; note for later).
- Default selection / back-compat: portfolio API routes without a
walletparam 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/verifycallsupsertAccount(address)thenenqueueBackfill(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) claimsportfolio_backfill_staterows'queued'→ spawnsscripts/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 fromaccounts.created_at), writesbasis='backfill'snapshot + flow rows, sets statusdone|empty|error. - Ongoing sync: 6h cron (
scripts/refreshers/portfolio.ts,refreshPortfolio()) snapshots all eligible wallets in one pass (loadEligibleWallets:78-86— the readers already takewallets[]). JIT request-time refresh:liveRefreshWallet(wallet)(src/lib/portfolio/live.ts:184), rate-limited 60s per wallet, triggered byPOST /api/portfolio/refresh. - Read path: each route →
getSummary/getPositions/getHistory/getEvents(wallet)insrc/lib/portfolio/api-data.ts→loadContext(wallet, {live})(:325) → SQLWHERE chain_id=$1 AND wallet=$2→ engine (pnl.ts,assemble.ts) → wire types insrc/lib/portfolio/api-types.ts. - Frontend: no SWR/react-query; bespoke
fetch+state insrc/components/portfolio/PortfolioView.tsx.PortfolioClient.tsxgates onuseAccount()(src/components/auth/AccountProvider.tsx) and renders<PortfolioView address={address} />; theaddressprop is display-only — no fetch sends an address today. Sync status arrives onSummaryResponse.backfill/syncing/hasSnapshots/jitand renders the "History syncing" banner (PortfolioView.tsx:272-281). - Dropdown building block:
BoxedSelect<T>insrc/components/ui/filter-controls.tsx:213-350({label?, value, options: {key,label,icon?}[], onChange, ariaLabel?, minWidth?}). Dialog primitive exists atsrc/components/ui/dialog.tsx. - Migrations: forward-only
scripts/sql/NNN-*.sql, applied byscripts/ops/migrate.sh. Latest is047-fluid-dex-pershare.sql→ this feature is 048. CI guards duplicate numbers. - Tests: vitest, colocated
*.test.ts(x)(npm test); CI also runstsc --noEmit.
3. WS1 — Schema + identity plumbing (backend, invisible)
3.1 Migration scripts/sql/048-account-wallets.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"):
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 anchorsaccounts.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 NOTHINGbehavior. - 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 noportfolio_position_snapshotsrow within the last 2 aligned 6h windows — flip status back to'queued'(also resetattemptsto 0, added by045-portfolio-backfill-attempts.sql).backfillWalletis 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:
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:
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.shagainst 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
| Route | Behavior |
|---|---|
GET /api/portfolio/wallets | List 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— noapi-data.tschanges needed for single selection;loadContextalready takes an arbitrary wallet andloadAccountCreatedAt/loadBackfillresolve via the shadow account + its backfill row. POST /api/portfolio/refreshpasses the selected wallet toliveRefreshWallet(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
tsvalues (all wallets share the UTC-midnight grid, so this is alignment-free for the daily spine). At eachts, for each wallet: absent (before itstrackedSince/ after its last point) contributes nothing; null (an explicit gap) poisons the point. Aggregate value = sum of contributions; aggregate point isnulliff 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,PortfolioChartrendersconnectNulls={false}). Apply the rule independently tocumulativeYieldandbookValue. - 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 theBookCurvetype first: if the base-capital denominator is exposed on the curve (or derivable from the same helperrealizedReturn(curve)uses), aggregate exactly; the agent must checkpnl.ts:690-716and 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_startover the summedbookValueseries with flows unioned per interval, then chain with the existinglinkTwr+annualizeTwr(pnl.ts:445,659). Skip intervals where the merged series is null (same rule the engine applies to gaps — verify howbuildBookCurvetreats null intervals atpnl.ts:513-666and mirror it). If, on inspection, the interval plumbing cannot be reused without copying engine internals, fall back totwr: nullfor 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 optionalwallettoWedgeAssetso duplicatedpositionKeys 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 (
trackedSincediffers) → 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→walletsstate. - Selection state:
"all" | <address>, default = session address; persist per-account inlocalStorage(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+syncLiveeffect whenselectionchanges (bumpepoch). - 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
isAddresscheck 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.
7.4 Syncing poll (small, recommended)
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.mdif it inventories portfolio routes/tables. Append todocs/plans/portfolio-execution-log.mdper 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.tswallets — 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. Runnpm testandtsc --noEmitper WS; migrations viascripts/ops/migrate.sh.
9. Risks / sharp edges for the executing agent
- Do not namespace
position_keyby wallet anywhere near the engine. Prefix parsing (pnl.ts:881-891),keySeizedprefix matching (:490), fluid group prefixes (api-data.tscurveFor:419-431) andlabelForRow/fluidVaultOfKeyall parse the key structurally. Aggregation is outputs-only (Decision 3). last_seen_atnow carries meaning ("has signed in").upsertAccountmust remain the only writer that sets it;ensureTrackedAccountmust not. Audit any admin/metrics query that countsaccountsrows as users.- Backfill statement shapes are strict (
backfill.ts:572-627status CHECK constraints,attemptsreclaim budget inbackfill-queue.ts:124-175). Requeue must resetattemptsor a previously-parked wallet re-parks immediately. - 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. getJsoncache keys and effect deps inPortfolioVieware subtle (epoch re-keying, stale-while-revalidate). Threadselectionthrough every key and dep array; a missed dep shows wallet A's chart under wallet B's header.- CI migration guard: the file must be
048-*.sql; check no parallel branch (oc-*clones) has claimed 048 before merging.