Skip to content

Portfolio coverage-consistency hardening — implementation plan

Status: FINAL for execution (2026-07-23, revised after adversarial design review). Owner: executing agent. Approver: Fred. Verified against: origin/staging 7d64be1 (== origin/main; git diff v0.18.0..origin/staging over src/lib/portfolio, scripts/refreshers, scripts/sql, src/lib/auth is EMPTY, so all file references match the v0.18.0 code inspected on prod during the incident).

This plan is self-contained: an agent picking it up needs no other context. Follow repo conventions: feature branch → PR into staging, docs updated in the SAME PR, no prod deploy or prod data mutation without Fred's explicit approval. CI has no Postgres: unit-test the pure parts; SQL-dependent behaviour gets explicit staging verification steps.


1. The incident (what actually happened)

Wallet 0xe1590894e25d4ad93be881e315acd9516f2e6b0b (label "ambire", tracked by account 0xaae6ae86621e9d79346129784f61be34bd5ed050) showed its ETH-book cumulative yield fall from +0.097 ETH to −21.197 ETH at the 2026-07-21 18:00 snapshot, permanently.

Causal chain, each link verified against prod on 2026-07-22:

  1. Poisoned discovery state. The wallet had been tracked before, removed, and its accounts row deleted between 2026-07-16 and 2026-07-21. Migration 056-user-table-fks.sql gave portfolio_wallet_index.wallet an ON DELETE CASCADE FK to accounts(uid), so the deletion purged the wallet's membership rows — but its discovery watermark survived in chain_scan_cursors (scope portfolio:discover:<wallet>, a text scope with no FK). Evidence: scope row present, last_scanned_block 25545826, updated_at 2026-07-16 14:28 (minutes after the migration-050 seed); accounts.created_at = 2026-07-21 12:53 (recreated on re-add).
  2. Stale watermark trusted as "fully indexed". loadDiscoveredUniverse (src/lib/portfolio/discovery.ts) treats a wallet as SCANNED iff a watermark EXISTS — no freshness check. Scanned + zero membership rows = bounded to an EMPTY Morpho/erc4626 universe.
  3. Re-add did not repair it. addWallet (src/lib/portfolio/wallets.ts) requeued the backfill, which correctly re-derived the full history (drain log: done: 324 snapshot rows, 23 flow rows, including the 2026-07-21 07:30 Morpho wstETH-collateral deposit + WETH borrow) — but the backfill writes NOTHING to portfolio_wallet_index and never touches the watermark. Enrollment (scripts/refreshers/enroll-discovery.ts) selects only wallets with NO watermark, so it skipped this wallet forever.
  4. One partial snapshot. The 18:50 tick (refresh-portfolio.log line 305: anchor 25583001 … 1611 morpho (read 0) · discovery bounded) read zero Morpho markets and no erc4626 vaults for this wallet; its 18:00 grid point stored 2 of 5 ETH-book legs. The tick's own flow-scan byproduct wrote the Morpho membership at 18:50:07 — after the snapshot read — so 00:00 onward was fine again.
  5. The P&L engine amplified the gap into a permanent phantom loss. The Morpho wstETH collateral leg is accrual 'value'; for value/pt legs legIntervalYield (src/lib/portfolio/pnl.ts) books signedΔvalue − netLegFlow. Present at 12:00 (≈21.30 ETH), absent at 18:00, no flow → −21.30 booked as yield. On reappearance at 00:00 the existing unexplained-birth guard (if (!l0 && netLegFlow === 0) return 0) books 0. Death books −principal, rebirth books 0 → permanent. (The WETH debt and iETHv2 legs are 'index' accrual — index-leg disappearance books 0, qty-agnostic ratio math — which is why the cliff equals the collateral's full value, not net equity.)
  6. Adjacent gap. The wallet's iETHv2 (vault:0xa0d3707c…) leg was missing from every live snapshot from 18:00 on (no erc4626 membership row; deposits months old, outside the per-tick flow-scan window). Index accrual → no yield damage; book value understated ~16.3 ETH until the 2026-07-22 08:04 withdrawal.

Two independent defects and one amplifier:

  • D1 (state lifecycle): memberships and the watermark that certifies them live in different tables and die separately; the cascade can produce "certified-complete + empty" — exactly the state the hasUnscanned full-read fallback was designed to prevent.
  • D2 (no repair on re-add): the one process that provably walks a wallet's full history (the registration backfill) throws away what it learns.
  • A1 (amplifier): a value/pt leg that disappears for one tick with no explaining flow books its full principal as negative yield, unrecoverably.

2. Design principles

  1. A completeness certificate shares the lifecycle of the data it certifies — written together, dies together (enforced by construction, not convention).
  2. A certificate is only stamped/advanced by a process that earned it (a full walk, or a healthy tick with observable veto signals). Never derived from a partial read.
  3. Staleness is a performance state, never a correctness state. A stale, missing, or frozen certificate demotes the wallet to the slow-but-correct full-universe read, and enrollment re-certifies it. A certificate that stops advancing must DEMOTE, not silently keep certifying (this was the key review finding against the draft: a gate that only compares against created_at never demotes a frozen watermark).
  4. The P&L engine is gap-immune, symmetrically. A coverage gap of any length books ≈0 yield and repairs itself on reappearance — including when unrelated flows land inside the gap (the draft's simple death-guard made rebirth-with-flow book a phantom GAIN; the fix is gap bridging, below).
  5. Divergence is detected by machine — reconciliation + tripwires with alert-greppable output (run-cron alerts need BOTH exit≠0 AND a grep-matching line).

3. Phases

PR-sized, in recommended order. Phases 1–2 remove the user-visible damage and the amplifier; 3–4 remove the causes; 5–6 close residual gaps and add detection; 7 is a separate design track.


Phase 1 — P&L engine: gap bridging for value/pt legs (the amplifier fix)

Files: src/lib/portfolio/pnl.ts (buildBookCurve), src/lib/portfolio/pnl.test.ts, docs (docs/metrics.md).

Do NOT implement the naive mirror guard (if (!l1 && netLegFlow === 0) return 0) alone — review showed it fabricates a phantom GAIN when a genuine flow lands inside the gap (death interval flow-less → 0; rebirth interval has a +1.0 deposit → birth guard bypassed → (22.3 − 0) − 1.0 = +21.3 phantom). Instead, implement gap bridging in buildBookCurve (state lives at curve level; legIntervalYield stays pure):

  • Suspend on unexplained death: when a value/pt leg is present at t_k, absent at t_{k+1}, and the interval's net leg flow does not explain the disappearance (see tolerance below): book 0 for the leg this interval, tally unexplainedDeaths: {positionKey, ts, value} on the returned BookCurve, and RETAIN the leg's last snapshot in a suspended map.
  • Bridge on reappearance: when a suspended key reappears at t_m, attribute the rebirth interval using the RETAINED snapshot as l0 (v0 = pre-gap value) and the net leg flows accumulated across the WHOLE bridged span (t_k, t_m]. A pure coverage gap then nets to the leg's genuine accrual over the gap; mid-gap deposits/withdrawals net correctly instead of fabricating gains/losses.
  • Never-reappears: the suspension stands (0 booked, tripwire entry remains). A real exit always leaves a ledger flow, and with the flow present the normal path books (0 − v0) − (−v0) = 0 — so genuine exits are untouched.
  • Explanation tolerance: treat a death/birth as "explained by flows" when |signedΔvalue − netLegFlow| ≤ max(TOL_REL × v_ref, TOL_ABS) with TOL_REL ≈ 2% (6h genuine accrual is ≤ bps; 2% is generous) — otherwise suspend/bridge + tripwire. This also covers the partial-withdraw-plus-gap case the plain === 0 check misses.
  • Interplay: liquidation seizures are already handled BEFORE this code via seizedKeys/keySeized continue — bridging must not re-book a seized leg (keep the existing skip ahead of the bridge logic). Fluid group-prefix flow keying (fluidGroupPrefixesOf/flowSelected) is unaffected — bridging keys on leg position_key exactly as the current netLegFlow loop does.
  • assemble.ts needs NO code change (verified: legPerformance calls legIntervalYield over consecutive EXISTING snapshot pairs of one leg, so a gap never forms a death interval there and the value delta across the gap is computed against the correct flow window). Add a pinning test + a comment, nothing else.
  • Observability without impurity: pnl.ts gets no console calls; callers (api-data.ts read path, refresher) log unexplainedDeaths. At the JIT live tip a bounded live read can create a transient death per-GET — de-duplicate/throttle logging there (log once per (wallet, key, day), not per request).

Tests (pnl.test.ts): (a) the incident shape → cumulative ≈ 0, one unexplainedDeaths entry; (b) rebirth-with-mid-gap-deposit → yield ≈ genuine accrual, NOT ±principal; (c) death-with-partial-withdraw-in-gap → ≈ 0 phantom; (d) real exit with flow → 0, no tripwire; (e) seized value leg still skipped; (f) index/none legs unaffected; (g) the legYieldInvariantResidual shows the expected +v0 residual during suspension (document, don't "fix").

Why first: pure-function change, fully CI-testable, converts every past and future coverage gap (any cause) from "permanent fake loss" into "≈0, self-healing, alarmed". Once deployed, the ambire −21.3 disappears from the SERVED curve even before data repair (curves are computed at read time).


Phase 2 — Clean-slate user reset (ops, prod-gated; REPLACES the earlier surgical repair)

Fred's decision (2026-07-23): no per-wallet data repair for existing users — the product is pre-launch (beta access code), so DELETE all existing portfolio users; they reconnect and their history rebuilds fresh. Acknowledged consequence: history of positions already CLOSED at reconnect time is not reconstructed (first-add open-set semantics — e.g. the ambire wallet's closed iETHv2 history is gone for good); accepted.

One-off reset script (scripts/ops/ style; dry-run first; prod run only with Fred's explicit go-ahead), in ONE transaction:

  1. DELETE FROM onchain_credit.accounts; — the 056 cascades purge account_wallets, portfolio_position_snapshots, portfolio_flow_events, portfolio_wallet_index, portfolio_backfill_state.
  2. DELETE FROM onchain_credit.chain_scan_cursors WHERE scope LIKE 'portfolio:discover:%'; — MANDATORY. Without it, the wipe mass-produces the incident's poisoned state (surviving watermark + empty index) for every reconnecting user: this asymmetric-cascade hazard is the root cause of the whole incident (§1). Verify post-run: zero rows match that scope pattern.
  3. Verify no dangling rows remain in any of the cascaded tables, and that NON-portfolio cursor scopes (other refresher cursors in chain_scan_cursors) are untouched.

Sequencing: after Phase 1 is on prod (so any early reconnect that hits a coverage gap costs ≈0 instead of a phantom loss), ideally after Phases 3+4 (reconnects then certify cleanly via the backfill). Running earlier is safe for correctness — with no watermark and no index rows a reconnecting wallet is UN-SCANNED and gets the full-universe read until enrollment stamps it — just slower per tick.


Phase 3 — The backfill writes what it learned (discovery repair on re-add)

Files: src/lib/portfolio/backfill.ts, src/lib/portfolio/discovery.ts (reuse upsertWalletIndex, membershipsFromFlows, watermark writer), tests, docs/data-pipeline.md.

On the backfill's terminal transition:

  • Derive the wallet's universe keys from the UNION of (a) every snapshot row the replay wrote AND (b) every flow row the run wrote — probe-window flows, fluid flows, and the full-universe TAIL SWEEP (review finding: a position opened mid-replay appears only in the tail sweep's flows; snapshot-rows-only would stamp a certificate that excludes it). Decode with the SAME position_key → universe_key mapping the 050 seed and discovery.ts use.
  • Upsert memberships FIRST, then stamp the watermark (crash between the two leaves un-watermarked = safe; same bake-then-certify order runEnrollmentDiscovery documents).
  • Stamp at the block the certified knowledge actually reaches: the tail sweep's head block (GREATEST with any existing value; never regress).
  • Stamp on done AND on empty ("scanned, holds nothing" is what watermark presence means); NEVER on error.

This alone would have prevented the incident: the 12:53 backfill swept the 07:30 Morpho deposit, so the 18:50 tick would have read the market.


Phase 4 — Certificate lifecycle, freshness demotion, earned advancement

Files: new migration scripts/sql/NNN-*.sql, src/lib/portfolio/discovery.ts, scripts/refreshers/portfolio.ts, scripts/refreshers/enroll-discovery.ts, scripts/refreshers/portfolio-reconcile.ts, scripts/ops/scrub-staging-pii.sql, docs/database.md, docs/data-pipeline.md.

4a. Certificate home: two columns on accounts (discovery_scanned_block bigint, discovery_scanned_at timestamptz) — NOT on portfolio_backfill_state as originally drafted. Review finding: chat-seeded/never-signed-in wallets are cron-eligible (048 self-row seed, b.status IS NULL arm) and watermarked, but have NO backfill_state row; accounts exists for every pipeline wallet by construction, carries the exact cascade lifecycle wanted (it IS the FK parent that purges memberships), and makes 4b a same-row read against created_at. Account deletion then destroys memberships AND certificate together — the poisoned state becomes unrepresentable.

  • Migration (additive): add columns; copy from scope rows with discovery_scanned_at := chain_scan_cursors.updated_at and discovery_scanned_block := last_scanned_block — NEVER now() (review finding: a now() stamp would launder currently-poisoned wallets as fresh); GREATEST-guarded, no overwrite of newer values.
  • Fallback is schema-level only: the scope-row path is taken ONLY when the column read throws 42703 (pre-migration window). Once the columns exist, a NULL column means UN-SCANNED — the scope row must never be consulted per-wallet (review finding: a per-row COALESCE would resurrect orphaned stale scope rows). Pin with a test: "column NULL + scope row present → hasUnscanned = true".
  • Dual-write (column + scope row) for one release; the LATER cleanup migration deletes the scope rows. Before cleanup: grep and port EVERY scope-row reader — portfolio-reconcile.ts selects its wallet batch from chain_scan_cursors (review finding: cleanup would silently empty the weekly reconcile, killing the backstop every other mitigation leans on) — and add a reconcile tripwire: eligible wallets exist but scanned set is empty → alert-greppable line + exit ≠ 0. Keep destructive-tag strings out of the non-destructive file's prose (migrate.sh greps the whole file).
  • Staging scrub (ship immediately, independent of the migration):scrub-staging-pii.sql truncates the index/accounts but leaves portfolio:discover:* scope rows → the nightly reseed manufactures the incident's poisoned state on staging every night. Add DELETE FROM chain_scan_cursors WHERE scope LIKE 'portfolio:discover:%' to the scrub (+ a fail-closed leak check in reseed-staging.sh).

4b. Freshness gate with staleness demotion. In loadDiscoveredUniverse, a wallet is SCANNED iff its certificate exists AND discovery_scanned_at >= accounts.created_at AND discovery_scanned_at >= now() − STALE_WATERMARK_MAX (constant, 18h = 3 ticks). The staleness horizon is the load-bearing half (review finding: the created_at comparison alone NEVER demotes a frozen watermark — created_at doesn't move). Any wallet whose certificate stops advancing demotes to the full-universe read within ≤3 ticks; wrongly-certified states are bounded to an 18h exposure window instead of forever.

4c. Earned advancement, per-scope, with observable vetoes. In the 6h tick:

  • Move the membership upsert INSIDE each scan scope block, ordered scan → writeFlows → upsertWalletIndex(membershipsFromFlows(detected)) → saveCursor; an upsert failure skips that scope's cursor advance (idempotent re-scan next tick). Review finding: the current single end-of-run best-effort upsert can lose memberships under an intact certificate when a later scope crashes the run.
  • After ALL scopes complete, advance discovery_scanned_* (GREATEST-monotone) for every wallet in the tick's scanned set — ONLY IF the enumerated veto signals are clean: (1) assertRegistriesComplete(reg) passes / reg.degraded empty (a silently-shrunk registry must veto — the dangerous failures are target-set shrinkage, not exceptions); (2) no scope scan error this tick; (3) no membership-upsert failure; (4) flow persist not lock-skipped. On any veto: leave certificates alone — 4b demotes within 3 ticks and 4d re-certifies. The implementing agent must thread these signals explicitly; "no exception thrown" is NOT sufficient.
  • Drop the draft's per-wallet contiguity clause entirely (unverifiable against the global per-scope cursor architecture; the staleness horizon makes it unnecessary).

4d. Enrollment selection becomes: no certificate, OR certificate < accounts.created_at, OR certificate older than STALE_WATERMARK_MAX. A demoted wallet is re-certified within one 10-minutely enroll run instead of paying the full-read tax forever. Bound the batch (existing PORTFOLIO_ENROLL_MAX) to prevent a stampede if many demote at once; full reads in the interim are correct, just slower.

Ship 3 and 4 together (or 3 first): 4's demotion assumes something re-certifies promptly, and 3 is what certifies re-added wallets.


Files: scripts/refreshers/portfolio.ts (the flow-scan target set ONLY — enumerate and name the exact call site(s) handed the wallet set for scanning + membership persist; snapshot writes stay tracked-wallets-only), tests, docs/data-pipeline.md.

Residual hole: a wallet unlinked and re-added within 12h skips the requeue (the STALE_HISTORY_INTERVAL guard in enqueue.ts — correct, avoids burning replays on the shared-wallet add case) but was NOT flow-scanned during the gap ticks → gap flows and memberships permanently missed by the incremental path.

Fix: flow-scan population := (tracked wallets, defined as account_wallets JOIN accounts ON accounts.uid = wallet — the join excludes dangling links whose shadow accounts row was deleted, review finding) ∪ (wallets with any snapshot in the last 48h). A briefly untracked wallet keeps being flow-scanned across the gap. No new index needed at current scale (few wallets; note a btree on snapshot_ts if this ever grows); memberships written for temporarily-untracked wallets are harmless (FK-cascaded, ignored while ineligible).


Phase 6 — Detect divergence by machine

Files: scripts/refreshers/portfolio-reconcile.ts, scripts/refreshers/portfolio.ts, alert grep wiring (scripts/ops/alert.ts / run-cron), docs/data-pipeline.md.

6a. Reconcile extension: for each wallet in the weekly batch, ONE full-universe read; diff found universe keys vs portfolio_wallet_index; repair + log [portfolio-reconcile] DIVERGENCE wallet=<w> venue=<v> key=<k> (repaired) + exit ≠ 0 at run end (both required for the alert). Include 48h-arm wallets in the cohort. Add the dangling-link check: account_wallets rows whose wallet has no accounts row → alert + repair via trackedAccountUpsert + enqueueOrRequeueBackfill. State the acceptance honestly: repair lands within one reconcile CYCLE FOR ITS COHORT (the batch is capped/LRU-ordered), not "within a week" unconditionally.

6b. Per-tick shrink tripwire: after writing a tick's snapshots, any leg present at the wallet's previous ts, absent now, with NO flow row in the window → [portfolio] WARNING unexplained leg disappearance wallet=<w> key=<k> (alert-wired). Phase 1's unexplainedDeaths covers the read/JIT path; this covers the write path same-tick.

One-shot flush: immediately after Phases 3+4 reach prod, run the 6a read+repair once across ALL wallets (cheap at current scale) to flush any other ambire-class poisoned state, and confirm zero DIVERGENCE lines on the next two weekly runs.


Phase 7a — Gap patching: re-add preserves history and reconstructs the gap (APPROVED by Fred 2026-07-23; full spec)

Semantics (Fred's rule, verbatim intent): when a wallet with stored history is re-added after a tracking gap, (i) the pre-gap history stays exactly as recorded — it is the live-captured truth; (ii) the gap is reconstructed as if tracking never stopped; (iii) the positions reconstructed through the gap are those OPEN AT GAP START plus those TOUCHED DURING THE GAP — including positions that closed inside the gap — not "open now". First-time adds keep today's behaviour (open-set replay over the 90-day lookback; positions closed before the wallet ever registered are not reconstructed — accepted product semantics).

This REPLACES the current re-add behaviour (destructive full delete + open-set replay), which erases not only gap-closed positions but also positions that closed BEFORE the gap while tracked.

Files: src/lib/portfolio/backfill.ts (new gap-patch path beside the fresh-replay path), src/lib/portfolio/enqueue.ts (comment currency only — the requeue trigger is unchanged), tests, docs/data-pipeline.md + docs/database.md.

Mechanism (revised after a dedicated adversarial pass on this section — the first-draft mechanism had a deterministic PK-collision crash and three idempotency/coverage defects; all folded in below):

  1. Durable anchor + provenance-aware trigger. Add two columns to portfolio_backfill_state (additive migration): covered_through_ts timestamptz, covered_through_block bigint. Written ONLY on a terminal done (fresh replay or gap patch), and advanced by the 6h tick for every eligible wallet — including a wallet holding zero positions, which writes no snapshot rows (this is what keeps the 12h requeue guard meaningful for an all-closed done wallet; the guard's staleness test becomes GREATEST(max snapshot_ts, covered_through_ts) < now() − 12h). Trigger split in the drained run: gap patch IFF a covered_through anchor exists (i.e. the previous run genuinely completed); otherwise fresh replay. Raw "stored snapshots exist" is NOT the trigger — a crashed FIRST-TIME replay leaves snapshot rows behind, and misreading those as preserved history would gap-patch from a half-written seam. (The ≤12h re-add case never reaches here — the requeue guard no-ops it; Phase 5's widened flow scan bridges micro-gaps.)
  2. Gap anchor. gapStartTs := covered_through_ts; gapStartBlock := covered_through_block — NOT blockByTimestamp(ts). Live snapshots key an aligned ts (e.g. 18:00) but READ at the tick's real head block (~18:50 in the incident); a timestamp lookup lands below the true read block and the sweep would re-detect flows already baked into the tip's stored balances, booking a phantom loss/gain at the seam. Sweep strictly (gapStartBlock, now], exclusive.
  3. Group set := (a) one FULL-UNIVERSE archive read at gapStartBlock — the tip re-read from chain, because a stored tip can itself be a partial/bounded snapshot (the incident's 18:00 tip held 2 of 5 legs; trusting stored tip rows would exclude a dormant open position from the whole gap) — ∪ (b) position groups present in stored snapshots within 48h below the tip (cheap belt) ∪ (c) groups with any flow detected by the gap sweep. Log any group found by (a) but absent from the stored tip as a partial-tip tripwire (alert-greppable). Resolution invariant: every group in the set must resolve to a reader-universe entry (e.g. an erc4626 vault still in the registry, its asset() resolvable — for a dead/delisted vault fall back to the accounting_asset stored on the tip row); an unresolvable group ABORTS the patch loudly (status error + alert line), never a silent drop.
  4. Gap sweep machinery, per venue. Non-fluid venues: the wallet-topic-filtered sweeps (scanTransferFlows / scanMorphoFlows / liquidations), windowed to the gap. Fluid: the first draft named a "LogOperate probe" that does not exist in the sweep — fluid gap events come from scanFluidFlowsFromCache over (gapStartBlock, nowBlock] (the event-cache cron kept running during the gap) plus wallet-filtered factory transfers; only the residual (cacheTip, head] uses the chain scan, as today.
  5. Gap grid. A NEW pure helper computeGapWindow — do NOT reuse computeBackfillWindow, whose day-flooring puts grid[0] AT OR BELOW the tip (three of four tick phases are non-midnight) and collides with preserved rows on the snapshot PK via the plain INSERT: a deterministic crash-and-park-on-error for nearly every gap patch. Grid points strictly inside (gapStartTs, now]: first UTC midnight after the tip, then daily, then the 6h seam point. Assert min(grid) > gapStartTs before writing. Degenerate short gap (no grid point fits) → sweep-only no-op patch. Groups open at gap start enter the first grid point as OPENING BALANCES (qty from the step-3 tip re-read), never as flows; gap-opened groups are born by their swept birth flows; closes/liquidations book from the swept flows. hasCurrentPositions plays no gating role in gap mode — the gap-mode open set is built from KEYS (tip re-read ∪ stored keys ∪ swept flow keys), not from a live probe, so the all-closed-now wallet replays normally instead of routing to prune.
  6. Segmented, atomic writes. Patch in bounded segments (tip → min(tip + N days, now), N ≈ 30): each segment commits its FLOWS AND SNAPSHOTS IN ONE transaction (under the shared write lock) and advances covered_through_* in the same commit. This simultaneously (a) fits the drain runner's child-timeout budget for arbitrarily long gaps — every attempt makes durable progress and a re-run resumes a strictly smaller gap; (b) fixes the crash-idempotency hole of the fresh path's two-transaction order (snapshots committed, crash before flow write → re-run re-anchors at the moved tip and the gap's flows are lost forever, a permanent phantom seam); (c) makes re-runs trivially idempotent (each segment's windowed DELETE snapshot_ts > segmentStart covers only rows the segment itself owns, and min(grid) > segmentStart is asserted). The old windowed-delete objection (backfill.ts ownership comment) assumed a REPROBED open set inconsistent with pre-gap rows; here the group set is anchored to the tip itself, so the seam is consistent by construction.
  7. empty semantics change. Prune-and-mark-empty ONLY when the gap-mode group set is empty AND no stored history exists. A wallet whose positions all closed during the gap gets the patch (closes booked) and ends done — six months of chart no longer vanish because the wallet happens to be empty on re-add day.
  8. floor_ts / trackedSince unchanged (the original floor stays). Multi-gap composes: each re-add patches from the then-current covered_through anchor.
  9. Interactions. Phase 3's membership derivation runs on the union (snapshot rows + all swept flow rows), so gap-discovered groups are certified; watermark stamps at the sweep head. Accounts-row DELETED (not just unlinked): cascade already destroyed stored history AND the anchor → fresh replay; that loss is Phase 7b's concern. Read-time M1 classification handles mixed-era rows. Cross-account inheritance is deliberate: account B first-adding a wallet account A previously tracked inherits the preserved history + patched gap — history is wallet-keyed by design, tracking is watch-only, and everything reconstructed is public chain data (product decision confirmed by Fred, 2026-07-23; an account-aware trigger was considered and rejected).

Tests. Pure: computeGapWindow pinned at tip phases 00/06/12/18 (min(grid) > tip, no PK-collidable ts), group-set union, opening-balance entry, segment boundaries. Scenario fixtures: (a) Fred's scenario — tracked 6 months, removed, re-added 1 month later: pre-gap rows byte-identical, gap reconstructed, post-gap live; (b) open at gap start, closed mid-gap → present through the gap, close booked, gap accrual included; (c) opened AND closed inside the gap → present with birth flow and close; (d) all closed during gap → history preserved, done, not pruned; (e) seam continuity: no curve jump at the tip beyond genuine accrual ± flows, including a flow inside the tip's ts→read-block skew window (must NOT double-count); (f) crash between segment commits → re-run resumes from the durable anchor, no lost flows, original pre-gap rows untouched; (g) crashed FIRST-TIME replay then re-run → fresh replay path, not gap patch; (h) long gap → segments compose, every ts covered exactly once; (i) tip-open vault missing from the current registry → loud abort, no silent drop; (j) fluid mid-gap operate (no NFT transfer) → flows present from the cache path.

Cost note: for typical gaps (days) a patch is far cheaper than today's 90-day destructive replay; the extra costs are one full-universe archive read at the tip block and the windowed sweeps. Long gaps amortize across segments.

Phase 7b — Registration-anchor durability (design note, unscheduled)

Account-row deletion resets the 90-day lookback anchor (accounts.created_at), silently shortening rebuilt history (observed on this wallet: floor 04-15 → 04-23 → 05-06 across its three backfills), and — worse under 7a — destroys the stored history 7a exists to preserve. If account deletion stays ops-only: document the blast radius in docs/database.md and require the one-shot reconcile after any manual deletion; if a user-facing deletion path ever ships: revisit with a soft-delete or wallet-keyed history retention design.


4. Rollout order & verification

  1. Staging-scrub fix (from 4a) — tiny, independent, ships first.
  2. Phase 1 (PR): unit tests green; staging deploy; prod in the next release. The −21.3 vanishes from the served chart on deploy (curves compute at read time).
  3. Phases 3+4 (one or two PRs, 3 first): migration is additive; staging auto-applies; prod migration is the gated manual step — apply BEFORE the release merge (v0.9.0 lesson). Then the one-shot reconcile flush (Phase 6 note). 2b. Phase 2 (ops, clean-slate reset): dry-run → Fred approves → run → verify (zero portfolio:discover:* scopes, cascaded tables empty). Best after 3+4; safe-but- slower any time after Phase 1. The reset also supersedes the one-shot reconcile flush for pre-reset state (there is nothing left to flush).
  4. Phase 5 (PR), Phase 6 (PR).
  5. Cleanup migration (scope-row deletion) one release AFTER 4, gated on a grep for remaining portfolio:discover readers.

Every PR: update the relevant docs page in the same PR; PR body includes what/why + server steps; reviews trace 1st/2nd/3rd-order effects; post findings as PR comments.

5. Acceptance criteria (system-level)

  • Replaying the incident (stale certificate + empty index + re-add + position opened between add and first tick) on a test wallet: the backfill re-certifies with the new market included (3); even with 3 disabled, the freshness gate demotes to a full-universe read (4b); even with both disabled, the curve books ≈0 for the gap and self-heals on reappearance, with a tripwire entry (1).
  • No representable state in which membership rows are gone while a live certificate for the same wallet survives (4a: same row/cascade) — and staging's nightly reseed no longer manufactures one (scrub fix).
  • A certificate that stops advancing (any cause) demotes the wallet to full-universe reads within ≤3 ticks and is re-certified by enrollment within ~10 minutes of demotion (4b/4d). Staleness is never a correctness state.
  • A coverage gap of ANY length changes cumulative yield by at most the genuine accrual of the gap span, for every accrual class, including when unrelated flows land inside the gap (1: bridging + tolerance).
  • Every divergence between bounded and full reads is repaired + alerted within one reconcile cycle for its cohort; unexplained leg disappearance alerts same-tick (6).

6. Residual risks (accepted, tripwired)

  • A flow on the SAME leg key, in the same interval, that coincidentally ≈ the leg's value within tolerance during a coverage gap can still mis-attribute up to the tolerance band (≤2%). Tripwired by 6b; considered acceptable.
  • Provider-side eth_getLogs truncation/head-skew can advance a certificate past unseen flows on a "healthy" tick. Not a regression vs today (byproduct-only discovery has the same exposure); bounded by the weekly reconcile (6a).
  • Fluid smart-pool sibling legs are read per-NFT, so a partial-group coverage gap is believed unreachable; if it ever occurs, bridging zeroes one side of a zero-sum pair for the gap span. Tripwired by unexplainedDeaths; revisit only if observed.
  • A backfill parked on error leaves a wallet demoted (full reads) until an operator intervenes — slow but correct by design; the existing alert on error status covers operator visibility.

Private documentation. creddit.xyz