Aave V3 / SparkLend account boundary — implementation plan (issue #717)
The product decisions in §1 were settled with the owner on 2026-09-02 and are NOT open questions. The engineering anchors in §0 and §2 were verified against origin/staging at a4548d35 (v0.51.0). Where this plan says "verify", the executor confirms with a test or a primary-source read, not by re-litigating the design. Read AGENTS.md first; its shipping, review, UI, copy and docs rules all bind here.
0. What already exists (do not rebuild)
Issue #717 was written against an older picture of the portfolio. Most of what it asks for shipped in July 2026:
- E-mode no longer gates a carry. Dropped by the T1 taxonomy (2026-07-15). Any same-book supply+debt loop in an Aave/Spark account is a carry, e-mode or not. The stored
emode_categoryis display metadata only (the E-MODE chip). - The Cross-asset view exists (M22, 2026-07-28).
groupIsCrossAssetinsrc/lib/portfolio/pnl.ts(search the function name; ~line 2668) moves the WHOLE Aave/Spark account into the Cross-asset view when some debt book has no same-book supply. Membership is time-varying (src/lib/portfolio/segments.ts), and a move never books a flow. Both served read paths consume this one classifier throughsegments.ts: the v1 path (api-data.ts/assemble.ts) and the rebuilt-ledger path (ledger-v2-api.ts). Prod serves v2 since 2026-09-02 (PORTFOLIO_LEDGER_SOURCE=v2); staging serves whatever its env says. A change to the classifier therefore reaches both engines; verify both. - Server decides classification.
LegVerdict/InclusionReasonare server-derived; the browser (signed-in-model.ts groupKeyOf) only mirrors the group KEY from the position key so a carry's legs fuse into one row. - Account-level risk is already the contract's.
risk-params.tsreadsPool.getUserAccountData(wallet);carryRiskinsigned-in-model.tstakes current LTV, leverage and health factor from that account read for Aave/Spark (ACCOUNT_SCOPED_VENUES), never from the row's own legs. Nothing to do here. - The reader (
src/lib/portfolio/readers/aave-family.ts) full-sweepsscaledBalanceOfon every reserve's aToken and variableDebtToken at one anchor block via multicall3, readsgetUserEModeper wallet, skips a failed leg read (M9, never writes zero), and applies rule R4: a wallet holding ANY debt leg whose e-mode read failed emits NOTHING that pass (previous rows stay latest). - History starts 2026-01-01 (v0.47.0), so every reclassification is bounded.
- Snapshot rows (
onchain_credit.portfolio_position_snapshots, partitioned by migration 067) carryemode_categoryper Aave/Spark leg. The new collateral flag follows the same plumbing end to end (reader →LegSnapshot→ writers → loaders →M1Leg). Count every non-test site that namesemode_category/emodeCategoryand treat that list as the checklist for the new column. - The collateral-toggle events stay OUT of the ledger streams. See the measured rationale in
src/lib/portfolio/tracked-contracts.ts("EXCLUDED, ON PURPOSE, AND MEASURED"). Widening a stream resets its coverage certificate. This plan reads the flag from STATE at the anchor block, exactly as that comment anticipates.
1. Product decisions (settled)
- D1 — Account rule. An Aave/SparkLend account is a same-book carry only when ALL of its active collateral and ALL of its debt sit in one book. Otherwise the whole account — every active collateral leg and every debt leg — moves into the existing Cross-asset view as one unit. This replaces the narrower trigger "some debt book has no same-book supply". There is no new "Other" / "Cross-margin" bucket; the Cross-asset view is the product surface.
- D2 — Active collateral. A supply leg is active collateral when (a) the account has it toggled ON as collateral (
getUserConfigurationbit2*id+1) and (b) the reserve's liquidation threshold that applies to this account is above zero. A supply that is not active collateral cannot be seized for the account's debt, so it is a standalone Repo lending position in its own book, beside the account, and never travels with it. - D3 — Debt-free account. Unchanged: every supply is standalone Repo lending by book, whatever its toggle says.
- D4 — Completeness. If ANY leg read (supply or debt, any reserve) or the collateral-configuration read of a DEBT-BEARING account fails in a pass, that account emits nothing that pass and its previous rows stay latest — the existing R4 e-mode rule, extended. A supplies-only wallet keeps today's per-leg skipping.
- D5 — Transitions. A toggle flip or a threshold change is a classification transition handled by the existing segment engine; it never creates a deposit or withdrawal. Nothing about the toggle events enters the ledger.
- D6 — History. Existing Aave/Spark snapshot rows get the flag by backfill: an archive read of
getUserConfiguration(wallet)at each row's ownblock_number. No guessed fallback. A row whose archive read fails keeps a NULL flag. - D7 — NULL flag. The classifier treats a NULL flag on a supply leg as ACTIVE collateral. That is the conservative direction (an account is never shown as a cleaner carry than it might be) and it is exactly today's pooling behaviour, so shipping the code BEFORE the prod backfill only widens the cross-asset trigger; the backfill can only move toggled-off supplies OUT of accounts afterwards.
- D8 — Surface. No new UI. The Cross-asset view already shows one chart per denomination, every leg, and the risk strip. What changes is which accounts land there and which legs a carry row contains. Any copy touched follows
AGENTS.md(no em-dashes, nohelpcursor, TradFi vocabulary, economics not methodology).
2. Engineering scope
A. Retain the collateral flag (reader → snapshot → classifier)
- Reader (
readers/aave-family.ts): addgetUserConfiguration(wallet)to the per-wallet round (same multicall batch, same anchor block asgetUserEMode). Decode bit2*reserveId+1per reserve (reserve id from the injected reserve universe; verify the id source is the Pool's reserve id, not an array index). Emit on every SUPPLY legcollateralEnabled: boolean | null(null = the configuration read failed). Debt legs carry nothing. - Liquidation threshold (for D2b): the reserve's liquidation threshold as it applies to the account. Verify at primary source (aave-v3-origin
GenericLogic.calculateUserAccountDatafor the Pool version live on mainnet, which is v3.5 per the derive layer's notes, and SparkLend v3.0.2) how the threshold is chosen when the account is in an e-mode category, and implement that. Read thresholds once per run (they are per reserve, not per wallet). Emit the resolvedactiveCollateral: boolean | nullalongside the flag, or resolve it inm1LegsFromRows; pick the one that keeps the classifier data-layer free and say why in the code comment. - Type + storage:
LegSnapshotgains the flag (Aave/Spark supply legs only, likeemodeCategory); migrationscripts/sql/091-*.sqladdscollateral_enabled boolean(nullable, additive, backward compatible; ALTER the partitioned parent, see how 067/082 touch the table). Every snapshot writer (live JIT, refresher, backfill, repair, fixture builders, v2 fixture transcriptions that the tests check against the SQL) carries it. Every loader that buildsM1Legpasses it through. - Prerender guard. Any reader of the new column on a path that executes during
npm run buildmust probeinformation_schema.columnsand emit aNULL::boolean AS collateral_enabledliteral when absent (pattern:hasPerShareColumnsincarries-table.ts), caching only the positive result. Verify whether any prerendered page selects from the snapshot table; if none does, say so in the PR body and skip the guard. Staging reseeds from the prod dump nightly, so the column disappears from staging until promoted. - Backfill
scripts/repair/backfill-collateral-flag.ts: for every Aave/Spark SUPPLY row with a NULL flag, readgetUserConfiguration(wallet)at the row'sblock_number(archive RPC, batched per distinct (wallet, block)), set the flag. Idempotent,--dry-runfirst,--wallet=filter, the same advisory lock the other repairs take, exits non-zero on any failed read with the (wallet, block) list. Runs on PROD ONLY, after release, on the owner's go — never on staging (nightly reseed wipes it). Fixture-proven idempotent.
B. Widen the account rule (D1)
In groupIsCrossAsset and classifyLegsAtTs for aave / sparklend:
- Let S = the real books of ACTIVE collateral legs, D = the real books of debt legs. Same-book carry iff S is non-empty and
|S ∪ D| == 1. Cross-asset iff S is non-empty and|S ∪ D| > 1. S empty with debt present stays the bare-debt hold-out (unchanged; R2 forbids charting it). - Inactive supplies (toggle off, or threshold zero) are carved out per leg as
debt-freeRepo legs in their own book and never travel with the account. - The
carriedadjacency guard keeps its semantics: a missing row for one tick is not evidence of a withdrawal. - Fluid and Morpho are untouched.
segments.tsneeds no rule change; verify the new transitions (toggle flip, threshold change, second-book collateral added) produce a segment boundary and no flow, in both read paths.
C. Threshold-zero test (D2b)
Covered by A.2. Add the case explicitly: a toggled-on supply whose applicable liquidation threshold is 0 is inactive.
D. Completeness (D4)
In the reader, track per wallet whether any leg read or the configuration read failed. A debt-bearing wallet with any failure emits nothing that pass, with a log line naming wallet, venue and the failed reads. Supplies-only wallets keep per-leg skipping. Keep R4 (e-mode) as it is.
3. Verification (all required before the PR is reported complete)
- Unit (magnitude-unique fixtures; every assertion must fail under the old rule — mutation-test at least the D1 and D2 cases by temporarily reverting the rule):
- sUSDe + wstETH active, USDC debt → whole account cross-asset (D1).
- sUSDe active, wstETH toggled off, USDC debt → USD carry + standalone ETH Repo leg (D2a).
- Only wstETH active, sUSDe toggled off, USDC debt → cross-asset account of wstETH + USDC, sUSDe standalone USD Repo.
- Debt-free multi-book account → Repo per book regardless of toggles (D3).
- Toggle flips mid-history → segment boundary, zero flows, prior history stays in its view (D5). Both directions.
- NULL flag → active (D7).
- Toggled on, threshold 0 → inactive (C).
- Reader: debt-bearing wallet with one failed leg read → nothing emitted; supplies-only wallet with one failed leg → other legs emitted (D4).
- Reader: configuration bit decoding against a hand-built bitmap with ≥3 reserves, including a reserve id that is not the array index.
- Two loops in one account (sUSDe/USDC + wstETH/WETH) → one cross-asset account (D1 cost, deliberately accepted).
- Migration applies clean on the fixture DB (
scripts/fixture/build.sh) and the ledger can still be replayed from scratch. - e2e (
tests/e2e/*.spec.ts, per.claude/skills/qa/SKILL.md): fixture rows for a mixed multi-collateral account rendering in the Cross-asset view, and a toggled-off supply rendering as standalone Repo lending beside it. Assert structure and counts, never market numbers. 1360 / 1140 / 900 wide, hydration-gated. Runnpm run e2eandnpm run e2e:ledger-v2. - Live: local dev against the STAGING DB through the SSH tunnel with a minted cookie (procedure in the local-verify memory /
docs/processes.md), for at least one real mainnet wallet holding a debt-bearing Aave V3 account with two collateral reserves in different books. Find one cheaply (Herd MCP on the Pool's recentBorrowevents, thengetUserConfiguration; a Dune query only if Herd cannot, and inspect the query before executing). POST/api/portfolio/refreshfirst, then load/portfolioin a real browser and screenshot all three widths. Confirm the account is in the Cross-asset view, the legs and the health factor matchgetUserAccountDataat the same block. npx tsc --noEmitclean,npm testgreen,npm run docs:build(or the repo's VitePress build command) green — a dead link fails it.- CI green on the final commit.
4. Documentation (same PR)
docs/portfolio.md, the classification section: the account rule, the definition of active collateral, the NULL-flag rule, the completeness rule, and what a toggle flip does to history. Note plainly which parts of #717 were already true before this PR.docs/database.md: the new column, nullable, what NULL means.docs/processes.md§D (ordocs/data-pipeline.md): the backfill runbook under "Prod (after release)", with dry-run, run, and the verification query.
5. PR
- Branch
fix/717-aave-account-boundary→ PR intostaging.Closes #717. - Body: what changed and why in product terms first, then the technical summary, then Prod (after release) server steps: migration 091 (gated manual), the backfill dry-run then run, and the note that until the backfill runs NULL flags pool every supply exactly as before, so the release is safe without it. Add: the backfill must NOT run while the ledger-truth campaign (#715) has its proof inputs frozen; it runs after that campaign closes.
- Independent review by a separate agent, findings posted as a PR comment (first/second/third-order effects, financial soundness). Maximum two rounds; once a round ends with zero blockers, remaining should-fixes become a follow-up issue linked from the PR body. Full QA once, on the zero-blocker commit.
- No prod deploy, no prod backfill, no staging backfill from this work.
6. Traps
- Shared checkout: work only in the
oc-717worktree; stage files by explicit path; nevergit add -A. - Node 20 (
/usr/local/opt/node@20/bin), realnode_modules(already installed bynpm ci),rm -rf .nextafter any dependency change. - The snapshot table is partitioned (067); ALTER the parent.
- Fixture older than 24h mass-fails e2e — rebuild it first.
- e2e reuses a foreign dev server on the same port — use a distinct
E2E_PORTand kill your own server afterwards. - Do not touch
derive/, the streams, orscripts/ops/reconciliation code: another program owns them this week. - Aave mainnet is v3.5 (directional rounding); SparkLend is v3.0.2. Only the configuration bitmap layout matters here and it is identical, but say so with a test.