SnapTrade Positions — Soft-Delete Reconciliation
Last Updated: 2026-07-08 Introduced by: PR #2407 (fix/snaptrade-positions-soft-delete)
Related Docs: snaptrade-integration.md (overall architecture), SNAPTRADE-ACCOUNTS-TABLE.md (accounts schema), SNAPTRADE-TRANSACTIONS.md (activities feed)
This document explains how we keep our stored copy of a user’s brokerage positions
(snaptrade_account_positions) in sync with what SnapTrade actually reports — and
why that is harder than it sounds.
Table of Contents
- The Problem
- The Core Insight: Absence Is the Signal
- History: Why Hard Delete + Count Guards Failed
- The Soft-Delete Model
- The Trust Gate
- Write Sites (Where Reconciliation Runs)
- Readers (Everything Must Filter
status = 'active') - Telemetry & Monitoring
- The Backfill Script
- Gotchas & Lessons Learned
- Deliberate Non-Goals & Follow-Ups
The Problem
We persist a copy of each SnapTrade account’s positions so the dashboard, AI chat, net-worth functions, and daily snapshots don’t have to call SnapTrade on every read. Any persisted copy can drift from reality, and positions drift in one specific, nasty way: when a user sells or transfers out a position, SnapTrade simply stops returning it. There is no “sold” record in the positions feed, no zero-unit row — the position just disappears from the response. If our sync doesn’t actively remove rows that stopped being reported, they live in our DB forever as ghost positions: holdings the user no longer owns, still counted in their investments breakdown. Before this system shipped, production had accumulated ~$55.8M of ghosts across 8,139 rows / 716 accounts. Note the blast radius: the headline net worth mostly telescopes to the broker’s reported account total, so ghosts corrupt the holdings breakdown (what you own) more than the total (what it’s worth). Both matter — the breakdown feeds the AI, the PFS export, and every “what do I hold?” answer.The Core Insight: Absence Is the Signal
getUserAccountPositions returns the account’s current holdings. So:
A symbol we have stored, that a healthy response no longer contains, has left the account.The word doing all the work is healthy. A broker outage, a partial sync, a parse failure, or SnapTrade’s “empty but HTTP 200” failure mode all look identical to “the user sold everything.” The whole design revolves around distinguishing trusted absence (act on it) from untrusted absence (skip and wait).
History: Why Hard Delete + Count Guards Failed
The original implementation hard-DELETEd rows missing from the response. Because a wrong delete was irreversible, the sweep was wrapped in count-based safety guards:no_response_with_existing— refuse to delete if the response was empty but we had existing rows (a real full liquidation could never be recorded).below_50pct_threshold— refuse if the response contained fewer than 50% of the rows we had (a real consolidation/partial sale wave could never be recorded).
The Soft-Delete Model
Schema (migration 20260708_01)
snaptrade_account_positions gained three columns:
Two CHECK constraints enforce the contract at the DB layer:
status must be one of
the two values, and an active row carries no removal_reason while a removed
row must carry a known reason and a status_changed_at. A composite index
(account_id, status) serves the hot read path.
UNIQUE(account_id, symbol) is unchanged and remains the upsert conflict target —
a symbol has exactly one row that flips between states.
Lifecycle
- Reactivation is automatic and ordering-safe: every sync upserts reported
symbols with
status: 'active', removal_reason: nullbefore the reconcile pass runs, so a previously-removed symbol that comes back is revived by the same upsert that refreshes its price. - A wrong removal self-heals: if an outage ever slips past the trust gate and wipes an account, the next healthy sync reports the symbols again and the upsert flips them all back. This reversibility is what justified deleting the count guards.
The decision + write, in one place
The logic is deliberately split into pure, unit-tested helpers inlib/snaptrade-positions-sweep.ts:
-
decidePositionsReconcile({reportedSymbols, syncTrusted})→ either{action:'skip', reason:'sync_untrusted'}or{action:'reconcile', symbolsToKeep}(the deduped reported set). An emptysymbolsToKeepis a valid reconcile — a trusted empty response means full liquidation, the formerly-forbidden case. -
applyPositionsReconcile(client, accountId, symbolsToKeep, nowIso)— the shared soft-delete writer used by all four routes. One UPDATE:The.eq('status','active')predicate makes the write idempotent and keepsstatus_changed_athonest (stamped only on the actual active→removed flip). -
buildPostgrestInList/escapeSymbolForPostgrestInValue— PostgREST-safe NOT-IN construction (supabase-js has.in()but no.notIn(), so the negated list is built manually with proper escaping).
units <= 0 are excluded from reportedSymbols,
so they fall outside symbolsToKeep and are removed by the same pass — there is no
separate zero-quantity delete anymore.
The Trust Gate
isSyncTrustedForReconcile(input) is the only remaining gate, replacing the
count guards. Reconciliation runs iff ALL of:
holdingsSyncComplete is intentionally the holdings-specific signal
(sync_status.holdings.initial_sync_completed), NOT the account-level
sync_status. The account-level status goes 'partial' whenever transactions
are incomplete, and some brokers (notably Empower) sit at 'partial' forever
for transaction reasons while their holdings sync perfectly. Gating on the overall
status would permanently block ghost cleanup for those accounts.
Per-route mapping of the signals:
A missing/malformed nested
symbol on a position (seen on some corporate
actions/delistings) also sets positionsUpsertHadError at every site — a symbol we
cannot parse must not be treated as “no longer held,” so the sync skips
reconciliation instead of soft-removing the stored row.
Write Sites (Where Reconciliation Runs)
Four sites, all wrapped inwithSnaptradePositionsLock (serializes holdings-class
writers per account — SnapTrade fires NEW_ACCOUNT_AVAILABLE within seconds of the
OAuth redirect, so process-connection and the webhook race on fresh connections):
app/api/internal/process-snaptrade-holdings-sync/route.ts— the QStash-fanned holdings sync (chunked batched upserts). A failed reconcile marks the sync'partial'viahadPersistenceErrors.app/api/snaptrade/webhook/route.ts—NEW_ACCOUNT_AVAILABLEhandler.app/api/snaptrade/process-connection/route.ts— existing-account path.- Same file — new-account path.
status:'active') → compute the trust inputs → decidePositionsReconcile →
applyPositionsReconcile → telemetry.
Readers (Everything Must Filter status = 'active')
Removed rows keep their units/price values (we only flip status), so any
reader that forgets the filter silently resurrects ghosts. The failure mode is
over-counting — visible and reversible, but wrong. Current coverage:
TypeScript:
hooks/queries/use-accounts.ts— dashboard positions fetchlib/financial-data-cache.ts— 4 read sites (getFinancialMetrics,getAccountDetailscrypto,getInvestmentDetails,getCryptoHoldingsDetails)app/api/cron/process-daily-snapshot/route.ts— snapshots record held positions only; disappearance is encoded by omission across daysapp/api/holdings/[id]/route.ts— the cash-override PATCH only targets active rows (404s otherwise, viamaybeSingle)
20260708_02):
get_user_net_worth_for_clerk,get_user_net_worth_export— re-emitted withAND sp.status = 'active'inagg_snaptradeget_total_assets,get_user_asset_distribution— patched in place (see Gotchas)audit_usd_driftandrefresh_*_usd_*are intentionally unfiltered — maintenance passes should see every row
account_details, crypto_holding_details, public_holding_details
AI prompts: both system prompts (app/api/claude/config/system-prompt.ts, chat config) instruct generated SQL to filter snaptrade_account_positions.status = 'active' in addition to the parent snaptrade_accounts.is_active — otherwise AI/tool SQL resurrects ghosts in chat answers.
A regression test (lib/__tests__/snaptrade-reader-status-filter.test.ts) greps the
known reader files for the filter.
Telemetry & Monitoring
In-app, sync-path emission vialib/snaptrade-reconcile-telemetry.ts
(there is deliberately no monitoring cron — alerts fire the moment the event
happens, not up to an hour later):
captureReconcileFailure(source, accountId, error)— Sentry error when a reconcile UPDATE fails (stale rows stay active; the sync is also marked'partial'in the holdings-sync route).captureMassRemoval(source, accountId, removedCount, keptCount)— Sentry warning when a trusted sync soft-removes an entire account. Self-gates onkeptCount === 0 && removedCount >= 3so routine small liquidations stay out of the alert stream. Taggedsnaptrade_full_liquidation:true.
area:snaptrade_reconcile + snaptrade_full_liquidation:true to page on
the cluster, not the singleton. Expect a one-time burst on release day as the
backlog of ghost accounts gets its first trusted sync.
The evidence log — snaptrade_reconcile_log
Sentry only fires on full wipes; a trusted-but-partial payload that trims
an account without emptying it (observed live 2026-07-10: two Robinhood
accounts dropped from steady ~50/~36-position payloads to 16/22 with no
corroborating sells) fires nothing, and snaptrade_account_positions keeps
only the current state plus the last transition per symbol — an oscillating
payload leaves no trail. snaptrade_reconcile_log
(lib/snaptrade-reconcile-log.ts)
closes that gap: one row per reconcile decision at every write site with
the reported/zero-quantity counts, the removed symbols, and the reactivated
symbols (previously removed symbols the payload brought back — the signature
of a transient glitch). Best-effort writes; never blocks a sync. Uses:
- SnapTrade support cases — “your API returned 50, 50, 50, then 16 (these 34 symbols missing), then 50 again,” with timestamps and symbol lists.
- Partial-payload detection —
removed_count > 0followed shortly byreactivated_count > 0on the same account.
/api/cron/cleanup-snaptrade-reconcile-log
cron (Mondays 04:30 UTC) deletes rows with synced_at older than 90 days
(≈1 MB/day of inserts — bounded by design, unlike the append-only audit tables
on the disk-pressure list).
The Backfill Script
scripts/backfill-snaptrade-stale-positions.ts
proactively sweeps existing ghosts instead of waiting for each account’s next
organic sync. Per account: fetch fresh positions from SnapTrade, run the same
decidePositionsReconcile decision, and apply the same soft-delete UPDATE with
removal_reason: 'backfill'. Supports --dry-run (SELECT-only preview of what
would flip), --clerk-id / --account-id targeting, and concurrency/rate-limit
tuning.
Gotchas & Lessons Learned
- Never re-emit a SQL function from a stale snapshot. The original version of
migration 02 re-emitted the two net-worth functions from a June-25 copy of the
live definitions — which would have silently reverted the cash-sweep dedup
and FX-over-cash changes applied to prod in the meantime. Function re-emits in
ever-new migration files never git-conflict, so concurrent redefinitions slip
through review. Before re-emitting, diff your base against live
pg_get_functiondef(). When the repo has no current source for a function, prefer the patch-in-place pattern used in20260708_02forget_total_assets/get_user_asset_distribution: readpg_get_functiondef()at apply time, assert the patch anchor appears the exact expected number of times,replace(),EXECUTE— the migration fails loudly on drift instead of clobbering someone else’s change. - Migrations are hand-applied before the code release. There is no CI
auto-apply. Code that writes/reads
statusmust not deploy before20260708_01exists in prod. status_changed_aton active rows means “last confirmed active.” Every upsert stamps it, so for active rows it is refreshed each sync rather than recording the last transition. For removed rows the transition semantics hold exactly (only the reconcile UPDATE touches them), which is what the future hard-purge cursor needs.removal_reason = 'zero_quantity'is reserved but currently unwritten. Zero-quantity symbols are removed by the same NOT-IN pass as absent symbols and record'reconciliation'. The value is admitted by the CHECK for a future writer that wants the distinction.- A row-build parse failure must trip the trust gate. If a position throws
during row construction — or arrives with a missing/malformed nested
symbol(position.symbol?.symbol?.symbolresolves empty, seen on some corporate actions/delistings) — its symbol is missing fromreportedSymbols, and the reconcile would remove it as if the broker dropped it. All routes setpositionsUpsertHadError = truein both the per-row catch and the missing-symbol skip so the sync skips reconciliation instead. - The residual accepted risk:
initial_sync_completedstaystrueforever once set, so an upstream outage that returns an empty array with HTTP 200 and healthy account details is trusted and wipes the account. This is the deliberate trade-off of dropping the count guards; the mitigations are reversibility (next healthy sync restores everything) and the full-liquidation Sentry warning (clusters = outage). - Empower accounts sit at
sync_status='partial'forever (transactions never complete). That’s why the trust gate keys on the holdings-specific completion signal — see The Trust Gate.
Deliberate Non-Goals & Follow-Ups
- Current state only, not history.
UNIQUE(account_id, symbol)+ soft-delete gives current state plus the last removal. It cannot represent sold → re-bought → sold-again history; a true positions event/audit table is a separate follow-up (needed for robust “when did I sell X?” chat answers). removal_reasonenrichment (planned PR): when a symbol is removed, look up recent SnapTrade transactions foraccount_id + symbol— a matching SELL means'sold', none means'transferred'. Enriches the reason; never gates removal.- $0-account reconstruction (CFO-6245) — SHIPPED separately: rebuilding a
null broker total from summed positions lives in
lib/snaptrade-derived-balance.ts(strict-null trigger, cash-equivalent exclusion,balance_is_derivedlabeling onsnaptrade_accounts). It reuses this system’s trust gate and reads onlystatus='active'rows — see the derived-balance section ofsnaptrade-integration.md. - Hard purge of long-removed rows:
status_changed_atis the cursor; nothing is purged today.