Skip to content

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

What is still current: Nothing on its own: it is the deviation record for the portfolio build, and the Portfolio page links it as such. Several rows name modules that have since been replaced.

Landed: append-only companion to portfolio-read-only-plan.md; last row P29 records the old engine's retirement (v0.55.0)

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

Portfolio (read-only) — execution log ​

Companion to portfolio-read-only-plan.md. Every deviation from the plan made while executing it is recorded here with its rationale, so the feature PR can be reviewed against the plan rather than against itself. Agents append; nothing is deleted.

Format: one row per deviation.

WSPlan saidWe didWhy
——Plan committed as-is at branch pointBaseline
WS1Move expectedDomain as-is (it took (req, cfg: ChatConfig))Changed its signature to expectedDomain(req, siweDomain: string | null)The neutral auth layer must not depend on ChatConfig; the SIWE domain is now read from getSessionConfig(), and /api/auth/verify passes cfg.siweDomain.
WS1Introduce getChatConfig + model gating in src/lib/agent/, secret SESSION_SECRET falling back to CHAT_SESSION_SECRETAdded a neutral getSessionConfig() in src/lib/auth/session.ts (reads SESSION_SECRET|CHAT_SESSION_SECRET and SIWE_DOMAIN|CHAT_SIWE_DOMAIN); getChatConfig now delegates sessionSecret to it and no longer exposes siweDomainSingle source of truth for the session secret + domain; only the neutral layer and /api/auth/verify need the domain, so it left ChatConfig.
WS1Seed INSERT ... SELECT DISTINCT ... ON CONFLICT DO NOTHING from chat uidsSeed uses SELECT DISTINCT lower(uid) ... UNION ...Defensive: guarantees a stray non-canonical uid can never trip the uid = lower(uid) CHECK (SIWE already lowercases, so normally a no-op).
WS1Docs: update architecture.md (auth) + database.md (accounts)Also added SESSION_SECRET/SIWE_DOMAIN rows to the deployment.md env table and repointed the architecture.md -> database anchor after the heading renameKeep docs truthful about the newly-read env vars (AGENTS.md §6) and avoid a dead internal link; both are hygiene, not behavior.
WS2Connect chrome "Uses /api/auth/me on mount"; portfolio client "probes /api/auth/me" (read as independent per-component probes)Introduced a shared AccountProvider client context (src/components/auth/AccountProvider.tsx) mounted once in layout.tsx (a single /api/auth/me probe on mount); both connect-chrome instances (sidebar + mobile) and PortfolioClient consume itSigning in/out from any surface must update the others without a reload; one shared probe avoids three independent probes racing. The plan's "on mount" intent is preserved (one probe, on mount).
WS2Reuse the chat SIWE client src/components/chat/siwe.ts; "factor it cleanly rather than duplicating" if sharedMoved the injected-wallet flow to src/lib/auth/siwe-client.ts (neutral auth layer) and made src/components/chat/siwe.ts a re-export shim; added a pure src/lib/auth/address.ts (truncateAddress) + address.test.ts (appended to the package.json test list)Matches the plan's architecture note ("the existing SIWE stack, factored out of the chat namespace"). Chat imports (AgentProvider -> ./siwe) are unchanged; no duplication. The address helper backs the 0x12ab…34cd display in both the chrome and the client.
WS2Section pages wrap <main className="min-h-screen"> (repo convention)/portfolio's <main> omits min-h-screenThe page's content is short, and a bare 100vh inside the .app-shell CSS zoom resolves to zoom * viewport (a phantom scrollbar at the >=1536px breakpoints). The .app-shell min-height (calc(100vh / var(--shell-zoom))) already paints a full black screen. Directly satisfies the WS2 acceptance point about not introducing viewport math the zoom breaks.
WS2 (fix)WS2 shipped AccountProvider (chrome + /portfolio) as a store SEPARATE from AgentProvider's own send-gated sign-in state; both merely shared the creddit_session cookie server-sideUnified onto ONE client store: AgentProvider now consumes AccountProvider (useAccount) for signed-in status/signingIn/error and drops its independent /api/chat/conversations sign-in probe + duplicate state; a status-driven effect replays the dock's pending message on sign-in from any surface. New pure helper src/lib/auth/account-status.ts (signedInFromStatus, tri-state loading→null) + account-status.test.ts.The two stores desynced until a full reload: a dock sign-in left the chrome//portfolio still showing CONNECT (and a redundant second SIWE prompt), a chrome sign-in re-prompted SIWE on the first dock send, and a chrome sign-out left the dock POSTing /api/chat → 401 with no in-surface recovery. Confirmed WS2 review finding; contradicted the app's own "one account" copy.
WS3Migration column list for the two history tables (WS3 spec)Added updated_at timestamptz NOT NULL DEFAULT now() to portfolio_position_snapshots and portfolio_flow_events, marked the identity/anchor columns + accounting_asset/book/basis/qty_raw/amount_raw NOT NULL, and added named CHECKs beyond the spec (chain_id>0, lower-case wallet/asset/tx_hash, tx_hash ^0x[0-9a-f]{64}$, block_number>0, qty/amount>=0, emode>=0, basis IN (live,backfill), log_index>=0)026/042 house style carries updated_at; M9 says a written snapshot always has a real qty (failed reads skipped, not zeroed), so qty_raw/amount_raw are NOT NULL while the valuation/derived columns stay nullable; the extra CHECKs are the agent-grade row-identity guards 026 pilots, all proven firing against the local DB.
WS3venue column (venues: aave, sparklend, morpho-blue, erc4626, pendle)venue CHECK also admits 'fluid' on both tablesWS7 (Fluid T1) is part of this feature; admitting the value now means WS7 needs no follow-up migration, matching the Venue union in src/lib/portfolio/types.ts.
WS3portfolio_backfill_state(uid ... REFERENCES accounts(uid))FK is ON DELETE CASCADE; status NOT NULL DEFAULT 'queued'An account's backfill queue row is meaningless without the account, and WS5 always inserts status='queued'; the default keeps the enqueue INSERT minimal.
WS3buckets.ts "keyed by lower-cased mainnet token address ... build from ... the asset field in src/data/curator-vaults.ts" (read as: enumerate the curator vaults)Keyed the curator-vault contribution by each vault's UNDERLYING accounting asset (eUSD, USR, AUSD, rUSD, RLUSD, PYUSD, USDtb, USDe, sUSDe, USDC, USDT, ...), all verified on-chain via asset()+symbol(), NOT by the ~60 share-token addressesA curator-vault position's accounting asset is vault.asset() (the erc4626 reader reports the underlying, per the PositionRead invariant qty x index in underlying units), so the underlying is the address that surfaces; keying by underlying covers every curator vault uniformly and stays USD-only-correct. Base-yield WRAPPER share tokens that appear directly as reserve/collateral (sUSDe, wstETH, the BASE_YIELD_TOKENS Morpho shares, ...) are still keyed by their own address.
WS3buckets ETH/BTC book names WBTC, cbBTC, LBTC, eBTC and "…" of ETH wrappersIncluded every BASE_YIELD_TOKENS + BASIS_TOKENS address by book; omitted rsETH (not tracked in token_yield_apy) and USD₮0 (its only vault 0xbeeff77c…2492f has no mainnet code)"Include what actually exists; do not invent." rsETH has no on-chain rate row and USD₮0's referencing vault is codeless on mainnet, so neither can be a real position; every included address is sourced from a repo constant or verified on-chain.
WS3types.ts "at minimum PositionRead + a Venue union + a Book union"Also exported Numeraire, Mark, FlowKind, RowBasis, and a PositionReader function typeAdditive shared vocabulary the next-stage readers/pnl/APIs all need; FlowKind/RowBasis are pinned to mirror the 043 CHECKs exactly so the TS and SQL enums cannot drift.
WS3package.json test list: "append new test files" (AGENTS.md)Did NOT add src/lib/portfolio/buckets.test.ts to the "test" listExplicit task instruction: a later agent consolidates the WS3 test list. The test passes when run directly (node --import tsx --test).
WS3erc4626 reader: "convertToAssets(1e18) divided by 10^rateDivisorPow10 from the registry entry"indexRaw stores the RAW convertToAssets(1e18) uint256, NOT divided by 10^rateDivisorPow10indexRaw is a bigint and types.ts fixes it to convertToAssets(1e18). pnl.ts only takes the RATIO of two same-key indexes, in which any constant scale cancels, so the 10^rateDivisorPow10 divisor belongs downstream (valuation), not in the reader. The divisor is algebraically absorbed: value = qtyRaw*indexRaw/1e18 equals shares_human*share_rate because 10^rateDivisorPow10 = 10^assetDecimals * 1e18 / 10^shareDecimals. Verified live vs the vault's own convertToAssets(shares) (rel error 5.2e-7 = integer-rounding of the 1e18-basis index only).
WS3erc4626: "audit the registry for codeless addresses (0xe478…de0) and drop/fix them via the sync script"Did NOT edit src/data/curator-vaults.ts; instead the reader treats empty 0x returndata (codeless) AND totalSupply()==0 as a FAILED/empty read and SKIPS the vault at runtime (M9), with a unit test for the empty-returndata pathFixing the registry is out of this stage's file scope (curator-vaults.ts is another owner + orchestrator instruction). Runtime skip is the robust M9 behavior regardless of the registry; the dead entry Euler Earn USDC 0xe4783824593a50bfe9dc873204cec171ebc62de0 (eth_getCode = 0x on mainnet, index 0 of 60) is reported for a separate sync-script fix.
WS3aave/sparklend reader "universe from onchain_credit.lending_reserves" (read as: the reader queries the DB) with signature readPositions(wallets, blockTag)The reader (readers/aave-family.ts) takes the reserve list as an injected argument and stays DB-free; a separate loadLendingReserves(protocol) does the query, and createAaveReader(reserves)/createSparklendReader(reserves) bind it into the plan's PositionReader (wallets, blockTag) => PositionRead[] shapeExplicit orchestrator instruction (local DB is empty; keep DB access out of the reader so it is unit-testable and callable from both scripts/ and src/). The uniform PositionReader signature is preserved via the factory; only the universe source moved to the caller.
WS3position_key examples list only aave:reserve:<asset>:supply/:debtSparkLend legs use the spark:reserve:<asset>:supply/:debt namespace (Aave legs keep aave:)Orchestrator instruction ("positionKey: aave:reserve:… and spark:reserve:…"); the venue column already separates the two, and a distinct key prefix keeps per-position netting unambiguous when a wallet holds the same underlying on both protocols.
WS3package.json test list: "append new test files" (AGENTS.md)Did NOT add src/lib/portfolio/readers/aave-family.test.ts to the "test" listExplicit scope rule ("Do NOT edit package.json"); a later agent consolidates the WS3 test list, matching the earlier buckets.test.ts deferral. The test passes when run directly (node --import tsx --test, 12/12).
WS3pendle reader "lift the shared decode/rate helpers into src/lib/portfolio/pendle-rates.ts ... have the refresher re-import" (signature readPositions(wallets, blockTag))Lifted the Pendle constants + PENDLE_ABI + refToAddr/isAddr + decodeRate1e18/decodePendleState/impliedApyFromLnRate/decodeOracleReady into the pure pendle-rates.ts; the refresher imports them and RE-EXPORTS PENDLE_API_CORE/PENDLE_CHAIN_ID/PY_LP_ORACLE/PENDLE_ROUTER so scripts/backfill-pendle-history.ts (an out-of-scope importer) keeps working unchanged. Refresher decode sites refactored to the helpers with identical arithmetic (behavior unchanged).The lift is the plan; the re-export is the minimal way to keep the refresher's public surface intact without editing a file outside scope. Verified both pendle-markets.ts and backfill-pendle-history.ts typecheck (scoped tsc).
WS3pendle reader signature readPositions(wallets, blockTag) => PositionRead[] (universe from onchain_credit.pendle_markets)readers/pendle.ts exposes makePendleReader(markets) returning the plan's (wallets, blockTag) => PendlePositionRead[] closure; the market universe is an injected argument (DB-free reader)Explicit orchestrator instruction (local DB empty; keep the reader unit-testable / callable from both scripts/ and src/). PendlePositionRead extends PositionRead, so an array of them IS structurally a PositionRead[] and the uniform reader signature is preserved.
WS3PositionRead is the one shape every reader returnsThe pendle reader widens its rows to PendlePositionRead = PositionRead & { marketAddress; maturityTs; ptToAssetRate }The task requires the reader to "expose what M6 needs: PT balance, market address, maturity, and the current pt_to_asset_rate"; those three do not fit PositionRead (which I cannot edit — another agent owns types.ts). Extending it keeps the base contract and structural compatibility while carrying the M6 inputs for pnl.ts.
WS3PositionRead.decimals = "ERC-20 decimals of accountingAsset"For a pendle leg, decimals = the PT's OWN decimals (the scale of qtyRaw), NOT the underlying'sqtyRaw is a PT balanceOf, so descaling it needs the PT's decimals; pt_to_asset_rate is a whole-unit accounting-per-PT ratio that carries the PT→accounting conversion. Live-verified this is load-bearing: the mHyperBTC market has PT decimals 8 but an 18-decimal underlying, so using accountingAsset decimals would misvalue by 1e10. pnl.ts must descale a pendle qtyRaw by decimals and treat ptToAssetRate as the whole-unit mark.
WS3pendle reader indexRaw (M6 REDEMPTION = pull-to-par factor)Reader returns indexRaw = null for every PT leg (both marks derived downstream)Per types.ts a PT "whose mark comes from a separate rate" carries a null index (ratio math treats it as constant 1). The reader's WS3 job is balance + the MARKET-mode pt_to_asset_rate; the REDEMPTION pull-to-par accrual (entry implied yield + maturity) is a pnl.ts computation (M6), not a reader read, so there is no on-chain index to emit here.
WS3 (morpho, logged by consolidator)morpho-blue reader "assets = shares × (totalAssets+1)/(totalShares+1e6)"Emits indexRaw = toAssetsDown/Up(1e18, accruedTotals) (assets per 1e18 shares, MORPHO_INDEX_SCALE=1e18), NOT the raw shares math per legindexRaw must be a same-key compounding index whose RATIO is the yield (types.ts invariant); a fixed 1e18 basis mirrors the erc4626 reader and cancels in every ratio, at most sub-unit rounding vs Morpho's exact toAssetsDown/Up on the raw shares. The morpho agent could not edit this shared log (file-ownership); recorded here.
WS3 (morpho, logged by consolidator)Morpho interest accrual (decision #4: exact to anchor block via IRM borrowRateView, lastUpdate fallback allowed)Kept the DEFAULT: exact accrual via borrowRateView(marketParams, market) + wTaylorCompounded third-order Taylor + fee share dilution, replicating expectedMarketBalances term-for-term; the lastUpdate-granularity fallback was NOT needed. resolveAnchor pins latest to a concrete block hex+timestamp first so the elapsed math cannot driftThe plan's stated default; exact accrual keeps the M2 index-ratio between two snapshots exact rather than lastUpdate-stale.
WS3 (morpho, logged by consolidator)morpho-blue reader "universe from onchain_credit.morpho_market_registry" (read as: the reader queries the DB), signature readPositions(wallets, blockTag)readMorphoBluePositions(wallets, blockTag, markets) takes the market universe as an injected argument (DB-free), createMorphoBlueReader(markets) binds it to the shared PositionReader shapeSame orchestrator rule the aave/pendle readers followed (local DB empty; keep the reader unit-testable and callable from both scripts/ and src/).
WS3 (consolidation)package.json test list: "append new test files" (AGENTS.md), deferred by the foundation + reader agents to "a later agent" (rows above)Added all six new WS3 test files to the explicit "test" list in one commit: buckets.test.ts, pendle-rates.test.ts, pnl.test.ts, readers/{aave-family,erc4626,morpho-blue}.test.ts (the pendle reader's pure logic is covered by pendle-rates.test.ts; readers/pendle.ts is a thin RPC closure with no pure surface to unit-test)Resolves the deferred consolidation notes; full suite goes 210 → 292 green.
WS3pnl.ts input "window yield from (qty, index) pairs" (read as: consumes PositionRead directly)pnl.ts consumes a pre-valued LegSnapshot ({ indexRaw, valueMarket, valueRedemption, side, accrual, book, ... }) and RawFlow, NOT PositionRead; a valuation layer (WS4/WS6) turns a reader's PositionRead (qty+index) into the two mark values firstpnl.ts must be DB/RPC-free (WS3), but descaling qty×index to a book-unit value needs decimals AND prices (DeFiLlama market price / on-chain redemption rate) that are DB/RPC. Splitting readers → valuation → pnl keeps pnl pure and testable; it still consumes the raw indexRaw for the exact M2 ratio, so no yield precision is lost.
WS3pnl.ts "TWR segmentation between flow events + geometric linking"TWR is a yield-based interval decomposition over the 6h snapshot grid: intervalYield is computed flow-free (M2 index ratio × start value), so TWR = Π(1 + intervalYield_i / bookValue_start_i) − 1 removes flows without an explicit per-flow split; a flow's yield for the partial interval it lands in is approximated to 0Equivalent to standard TWR with cash flows removed, discretized to the finest available valuation grid (6h). Keeps flows strictly out of yield (the "top-up is not profit" guarantee) and avoids needing intra-block index reads; the 6h partial-interval approximation is second-order and documented in metrics.md.
WS3pnl.ts (no explicit note on borrowing apy.ts)pnl.ts RE-DECLARES SECONDS_PER_YEAR and indexRatio locally instead of importing src/lib/data/apy.ts; adds a LegSnapshot.accrual discriminator ('index' | 'pt' | 'none')apy.ts top-level-imports ./postgres (a DB handle), which would violate the WS3 "no DB imports in pnl.ts" contract; a test pins the re-declared SECONDS_PER_YEAR to apy.ts's value. accrual selects the M2 formula: index (share/scaled index ratio), pt (M6 mark value-diff net of flows, never blended), none (raw Morpho collateral earns no lending yield → 0).
WS3 (review fix)M3 TWR = Π(1 + intervalYield_i / bookValue_start_i) − 1 (row 43, formula stated without a per-factor floor)linkTwr now floors each surviving sub-period factor at 0: product *= Math.max(1 + yield/startValue, 0)A bad-debt liquidation can book a realized loss LARGER than the interval's start-equity (the RawFlow contract carries "the equity destroyed"). Without the floor, one such interval gives a TWR below −100% (rendered as an impossible "−250%"), and TWO of them multiply two negative factors into a spurious POSITIVE product (a wiped-out book reporting a large positive realized APY that annualizeTwr's growth>0 guard passed through). Flooring makes a >=100% sub-period loss a −100% return so the linked TWR is −1 (standard TWR). Regression tests: linkTwr([{100,−250}])===−1, two blow-ups do not sign-flip, and a two-liquidation buildBookCurve reports twr=−1/realizedApy=null.
WS3 (review fix)LegSnapshot.accrual set 'index' | 'pt' | 'none'; a Morpho collateral leg is none → contributes 0 yield (row 44)Added a fourth accrual 'value' (generic mark value-diff, same machinery as 'pt'); restricted 'none' to NON-yield-bearing collateral (WBTC/cbBTC/plain stable, par); a yield-bearing WRAPPER held as raw Morpho collateral (e.g. sUSDe in a sUSDe/USDC market) is 'value' and its redemption appreciation is attributed as within-book yieldA same-book Morpho carry's entire positive carry IS the collateral wrapper's appreciation (metrics.md carry-leg section marks it as the Morpho carry target); valuing that collateral 'none' dropped it, turning a real carry into pure funding cost and making WS6's earned-vs-advertised column read far below the net carry. The valuation layer (WS4) assigns 'value' to a yield-bearing wrapper collateral (or 'index' if it attaches the wrapper's redemption index, for full symmetry with a direct erc4626 hold) and 'none' only to par collateral. Reader still emits indexRaw=null for Morpho collateral (Morpho pays no collateral interest); the appreciation rides the mark's value series. Regression tests added (sUSDe-style collateral growth is yield; a collateral top-up is netted out).
WS3 (review fix)portfolio_flow_events PK (chain_id, tx_hash, log_index) (plan line 178; migration row 21)Widened the PK to (chain_id, wallet, tx_hash, log_index) (docs/database.md updated to match)A single ERC-20 Transfer of a position token (aToken / ERC-4626 share / PT) between two REGISTERED wallets is ONE log the WS4 scan nets into TWO ledger rows sharing that log_index — {sender: transfer_out} and {receiver: transfer_in}. The plan's PK admits only one; the second insert collides, dropping the receiver's transfer_in, so its +balance reads as yield (the exact M3 "a flow is not profit" violation the ledger exists to prevent). wallet is a real row dimension (already in the snapshot-table PK); adding it preserves dedup (a self-transfer nets to zero, so one wallet never needs two rows for one log). Forward-only migration is pre-release (no writer yet), so edited in place.
WS3 (review fix)buckets bullet lists "PTs of USD underlyings" under the USD book, and the aave reader "E-mode PT collateral positions come through the same path" (plan lines 193, 210-212); PT omission from buckets was UNLOGGED (row 25 logged only rsETH / USD₮0)bookForAccountingAsset(address, ptUnderlyings?) now resolves a PT accounting asset to its UNDERLYING's book via an optional pt_address → underlyingAddress map (built by the caller from pendle_markets); PT addresses stay out of the static mapAave/SparkLend list Pendle PTs as first-class e-mode COLLATERAL reserves (re-verified on-chain: 15+ live PT reserves with USD underlyings), so lending_reserves.underlying for such a reserve IS the PT address and readers/aave-family.ts emits it as the leg's accountingAsset. The bare static map returned EXCLUDED, and since the Aave account also has debt, classifyLegs dropped the WHOLE e-mode carry (plus any legit USD legs) as unknown-asset — the opposite of what the plan requires. A static entry can't hold ephemeral per-maturity PT addresses, so resolution is dynamic (injected map, keeping buckets.ts DB-free). The leg's accountingAsset stays the PT address (valuation-correct: the leg is in PT units); only the BOOK resolution follows the underlying. Rejected the reviewer's alternative of emitting the underlying AS the accountingAsset (would misvalue the PT as par-underlying pre-maturity). Regression test added; runtime account-vanish is a WS4 wiring outcome not observable at HEAD.
WS3 (M2 correction)M2: "Yield = share/scaled quantity x compounding-index ratio, in the position's native accounting asset" (read as: indexRaw = the bare venue index)Require the COMPOSED index composeIndex(venueIndex, rate) = venueIndex × (accountingAsset→book-unit redemption rate), both at the same block; branded LegSnapshot.indexRaw: ComposedIndex (only composeIndex produces it, so a bare venue index is a compile error). M2 plan row + metrics.md updated.The invariant attributedYield == ΔbookValue − netFlow fails for a yield-bearing wrapper leg (wstETH/weETH/sUSDe supply or debt) fed the bare venue index: its book value = qty×venueIndex×wrapperRate, so it grows with the wrapper's staking/redemption rate while the venue index (≈0 for an LST reserve) attributes ~nothing. Live check: 100 wstETH Aave supply, old ≈0.00003 ETH/yr vs composed ≈2.87 ETH/yr (~89,000×). Gutted creddit's flagship wstETH/WETH e-mode carry to ~0 net carry. Degenerate: book-unit asset + erc4626 vault share use IDENTITY_RATE (no change, no double-count).
WS3The "valuation step" that turns a reader PositionRead into a pnl LegSnapshot is named only abstractly (WS4/WS6)Created the PURE valuation core now: src/lib/portfolio/valuation.ts (+ valuation.test.ts, added to the package.json test list) with composeIndex / RedemptionRate / IDENTITY_RATE / redemptionRate() / buildIndexLeg / buildValueSeriesLeg, and the WS4 runtime CONTRACT as typed signatures + doc (ResolveBookUnitRate, ResolveMarkValues) so WS4 wires DB/RPC correctly. DB/RPC left to WS4.The composed index must be applied at exactly one place (the valuation layer) and be impossible to skip; defining the pure helper + the typed contract now is what makes WS4's valuation layer unable to pass a bare venue index (task requirement). Keeps pnl.ts pure (composition happens upstream).
WS3 (review-class fix)pt/value attribution y = v1 − v0 − netFlow; intervalYield += side==='debt' ? −y : y (row: the value/pt machinery)Refactored buildBookCurve's per-leg loop into one primitive legIntervalYield and corrected the pt/value DEBT flow sign to signedΔvalue − netLegFlow (signedΔvalue = −(v1−v0) for debt). Added exported legYieldInvariantResidual.The old debt branch mis-signed the flow term: for a debt value/pt leg with a borrow/repay flow it gave −(v1−v0) − F instead of the correct −(v1−v0) + F (off by 2F). No venue emits a debt value/pt leg today (unreachable), but the invariant test now covers it and Option-(a) keeps wrapper debt on the correct index path; pinning the fix makes the value path symmetric for both sides. One primitive shared by the curve and the invariant residual so they cannot diverge.
WS3pnl.test.ts test list unchangedAdded the property-style INVARIANT test (attributedYield == ΔbookValue − netFlow over all four accrual types × both sides × both marks), demonstrated failing on the bare-index wrapper leg then passing with composeIndex; added composeIndex / no-double-count / debt-wrapper-funding unit tests + valuation.test.ts.The invariant IS the deliverable (a structural guarantee that yield is the only thing moving a flow-free leg's value); it belongs as the centerpiece regression test. Suite 297 → 311 green.
WS4 (stage 1)getLogsChunked widen to address: string | string[], topics: (string | string[] | null)[], log type + data/transactionHash/logIndex/address (plan lines 255-261)Done exactly; RpcLog gained the four fields, both params widened, body passes address/topics verbatim (no request-shaping change). Verified: the only callers (lending-positions.ts vdebt scan, sync-carries.ts) still typecheck under their ES2020 target with zero errors referencing the widened symbols/call site; OR-array semantics proven live on eth.drpc.org (USDC topic2=[A,B] over a 30-block window returned exactly the 121-log union of A(67)+B(54), a third receiver excluded).The plan's prerequisite, as specified. Backward-compatible: added log fields are always present in a real eth_getLogs response, and a scalar address/topics[i] is still assignable to the widened union.
WS4 (stage 1)ResolveBookUnitRate typed (…, isVaultShare, blockNumber) => RedemptionRate with the rule "IDENTITY when accountingAsset is par OR the leg is a vault SHARE" (valuation.ts WS3 stub)Refined the contract type: async (=> Promise<RedemptionRate>, it does DB/on-chain I/O), dropped isVaultShare, added mode: 'history' | 'now', and the rule now follows the accounting asset only (par → IDENTITY; wrapper → share_rate; unrated → throw).(a) A DB/RPC resolver cannot be synchronous. (b) Carry-forward item 1: forcing IDENTITY for any vault share is wrong when the vault's underlying is itself a non-par wrapper — the rate must follow the underlying so the wrapper appreciation composes; keying the rule on the accounting asset gives IDENTITY for a par underlying (today's USD curator vaults, no change) and the wrapper's rate for a non-par one, with no double-count. mode splits the block-anchored DB history source from the on-chain "now" getter (both named in the WS3 stub doc, but the stub had no way to select them). Fixed the IDENTITY_RATE doc + the metrics.md "vault share also uses IDENTITY_RATE" claim to match.
WS4 (stage 1)ResolveMarkValues typed (positionKey, accountingAsset, book, blockNumber) => {valueMarket, valueRedemption} (valuation.ts WS3 stub)Refined to a PURE function (venue, accountingAsset, book, qtyRaw, indexRaw, decimals, rate, marketUnitPriceBook) => {…}: descaled amount × rate (REDEMPTION) and × marketUnitPriceBook (MARKET).The stub's own doc said "implement from qtyRaw × venueIndex (descaled) × price", but the declared args carried none of qty/index/decimals/rate/price, so a book-unit magnitude was uncomputable. Making it pure (prices resolved once per snapshot upstream by loadMarketContext, batched) both fixes that and keeps a per-timestamp DeFiLlama loop — which 429s — out of the hot path; the pure form is unit-tested.
WS4 (stage 1)Plan: "DB/RPC wiring in a new module (e.g. valuation-sources.ts or under scripts/refreshers/)"Split into TWO src/ modules: pure src/lib/portfolio/valuation-math.ts (descale, market-unit-price, resolveMarkValues, the redemption-rate classification + PAR/UNRATED sets, RateUnavailableError) and I/O src/lib/portfolio/valuation-sources.ts (resolveBookUnitRate, loadMarketContext, the valueLeg glue → LegSnapshot). Both in src/ so the cron (scripts→src) and the JIT API (src) share them; scripts/ stays un-imported by src/. Added valuation-math.test.ts to the package.json test list (15 tests).The plan allowed either location; src/ is forced by "src must not import scripts" since both callers need it. The pure/I-O file split keeps the numeric core unit-testable without a DB (importing postgres at module load would otherwise open a pool during unit tests).
WS4 (stage 1)Carry-forward item 2: stETH/eETH/LBTC "plausibly par/identity"; eBTC "must SKIP/throw, never default rate to 1"Implemented: stETH/eETH (rebasing, par to ETH) and LBTC (par to BTC) added to PAR_ACCOUNTING_ASSETS → IDENTITY_RATE; eBTC in UNRATED_YIELD_ACCOUNTING_ASSETS → resolveBookUnitRate throws RateUnavailableError('unrated') and valueLeg returns null (leg skipped, M9). Verified live: eBTC throws; a wstETH/sUSDe rate resolves on-chain (1.23899 stETH/wstETH, 1.23858 USDe/sUSDe at block 25497599). Unit-tested (redemptionRateKind cases + buckets parity).Honest M9 behavior: a par-rebasing base has book value 1:1 so IDENTITY is correct; a genuinely yield-bearing wrapper with no rate source is unattributable, so it is skipped rather than silently valued at par (which would drop its real appreciation).
WS4 (stage 1)JIT "now" = "the wrapper's own on-chain getter (wstETH stEthPerToken, ERC-4626 convertToAssets, etc.)"Implemented on-chain getters for the direct-hold flagship wrappers (wstETH/weETH/rETH/osETH/ezETH/sUSDe/sUSDS/syrupUSDC/syrupUSDT) in JIT_RATE_GETTERS; a rate-source wrapper with no getter entry falls back to the freshest DB share_rate (≤6h old), never to 1.Replicating token-yields.ts's full per-kind registry into src is out of stage-1 scope (and it lives in scripts/, un-importable); the covered getters are the wrappers that actually appear as Aave/Spark/Morpho reserves. The ≤6h DB fallback is honest (M9-safe) for the tail; lifting the full getter registry is deferred to the WS4 cron stage. Selectors/rateSources verified against token-yields.ts + one live read each.
WS4 (stage 2)Flow-scan cursor scopes portfolio:transfers:<token> (plan line 289)Combined scopes: ONE portfolio:transfers (all position tokens scanned together via an address-array getLogs, two passes topic1/topic2) + portfolio:events:aave / :sparklend / :morpho-blue.Per-token cursors with per-token getLogs means hundreds of scans/run; the address-array batching is the exact pattern WS5's "one sweep across all universe tokens" endorses, and the log.address field (added stage 1) demuxes tokens client-side. Cursors advance in lockstep when always scanned together, so per-token scopes add bookkeeping with no benefit. Event scopes match the plan's portfolio:events:<venue>.
WS4 (stage 2)Snapshot valuation mode unspecified for the 6h cronThe live 6h cron + JIT tick use rate mode 'now' (wrapper on-chain getter at the anchor block, same-block as the venue index); 'history' (block-anchored DB share_rate) is reserved for the WS5 archive backfill.valuation.ts's own doc: "the JIT/live tick uses 'now'". The 6h cron reads current state at the latest block, so 'now' gives the exact same-block rate; 'history' would use the previous token-yields run's share_rate (up to 6h stale). Proven live: sUSDe collateral valued at 1.2386 USDe/sUSDe via the on-chain getter.
WS4 (stage 2)First-run flow scan (no explicit default start)refresh-portfolio.ts first run (no cursor) scans only anchor - DEFAULT_FIRST_RUN_LOOKBACK (7200 blocks ≈ 1 day); PORTFOLIO_FROM_BLOCK overrides. Not the lending-positions "scan from protocol deploy" default.The deep per-account history is WS5's per-account backfill job; the live cron only needs incremental catch-up. Scanning every position token from Aave's 2023 deploy on a fresh run would be enormous and redundant with WS5.
WS4 (stage 2)getLogsChunked (stage-1 widened address/topics/RpcLog)Also added an optional rpcUrl?: string param (defaults to RPC_URL, backward-compatible).The flow scanner must route eth_getLogs at ETHEREUM_ARCHIVE_RPC_URL (dRPC): publicnode REJECTS archive eth_getLogs over wide ranges ("Archive requests require a personal token"). rpcRequest already took an optional rpcUrl; this just threads it through. Existing callers (lending-positions, sync-carries) pass nothing -> unchanged.
WS4 (stage 2)"the logic ... following the repo's refresher shape" (single cron module)Split the shared read/valuation/flow logic into THREE new src/lib/portfolio/ modules (registry.ts loaders, snapshot.ts readAllPositions+buildSnapshotRows, flows.ts detection+valuation); scripts/refreshers/portfolio.ts orchestrates + does the DB writes, and src/lib/portfolio/live.ts reuses the same three.BOTH the cron (scripts/) and the JIT path (src/) need the read+valuation+flow logic, and "src must not import scripts" (AGENTS.md) forces the shared code into src/. Keeps the cron a thin orchestrator and the JIT path a thin reuse; no duplication.
WS4 (stage 2)Snapshot index_raw (WS3 column doc: "same-block compounding index")Stores the BARE venue index (the reader's indexRaw), NOT the composed index; the value_market/value_redemption columns already fold in the accounting-asset->book rate.The composed index is a pnl.ts input (branded ComposedIndex), recomputable by WS6 from the block-anchored share_rate series; storing the bare venue index matches the column doc and keeps the row source-faithful. Value columns carry the final book magnitudes (rate applied), so no information is lost.
WS4 (stage 2)Aave/Spark aToken/vToken Transfer value = "balance units (scaled x index)" (plan lines 278, 288)Confirmed via aave-v3-origin source that the Transfer value is the UNDERLYING amount (human balance delta), so flow amount descales by the underlying's decimals with a null index. Documented that it BUNDLES accrued-since-last-touch interest, so the flow VALUE is approximate at that level, and a token touch emits a near-zero interest-mint Transfer that reads as a tiny extra deposit.The bundled interest never corrupts index-leg yield (pnl.ts attributes those from the composed-index RATIO, ignoring flows); it only perturbs the flow markers / per-tx net at the accrued-interest scale. Precise deposit amounts would need decoding the Pool Supply/Withdraw/Borrow/Repay events instead of the token Transfers (a possible future refinement). Verified against source (AToken._mintScaled/_burnScaled) + observed the near-zero deposit artifact live.
WS4 (stage 2)Morpho event topic positions (plan says "VERIFY yourself before coding") + LiquidationCallVERIFIED against the deployed Morpho Blue ABI (0xBBBB...FFCb): owner indexed at topic3 for Supply/Repay/SupplyCollateral (onBehalf) + Liquidate (borrower); at topic2 for Withdraw/Borrow/WithdrawCollateral (onBehalf), receiver topic3. LiquidationCall borrower = topic3 (verified against aave-v3-origin IPool). Topic0 hashes computed via toEventSelector (no hand-typed hash).The plan mandated self-verification; all positions matched the plan. Liquidations recorded on the collateral leg with amount = seized collateral (Aave liquidatedCollateralAmount / Morpho seizedAssets); precise equity-loss netting is a WS6/pnl refinement (M4).
WS4 (stage 2)Pendle PT snapshot/flow valuationSnapshot: PT value_market = qty x pt_to_asset_rate x underlying market price; value_redemption = NULL (M6 pull-to-par is a WS6/pnl computation from the acquisition flow). Flow: PT amount par-descaled by PT decimals.The reader emits the MARKET-mode pt_to_asset_rate but the REDEMPTION mark needs the entry implied yield (from the acquisition flow event, not available at snapshot time). Recording the PT acquisition flow's block + amount is what lets WS6 reconstruct the entry fill; leaving redemption NULL is M9-honest (no fabricated par pre-maturity).
WS4 (stage 2)WS4 "Ops" bullets (run-cron.sh per-checkout lock, crontab add, staging reseed/scrub-PII rule for the four tables, Telegram alerting)NOT implemented this stage; documented as pending in data-pipeline.md. The crontab line (50 */6) is listed in deployment.md as a manual add.The stage-2 task scope is the pipeline modules (refresher + flow scanner + JIT) and their end-to-end validation. The ops bullets are follow-ups (WS8 covers alerting); flagged so the docs stay truthful about what is wired.
WS4 (stage 3)Per-script run-cron.sh lock: flock on $LOG_DIR/$(basename "$DIR")-$(basename "$SCRIPT" .ts).lock, exit 0 with a log line if held (plan lines 298-301)Implemented exactly (checkout-keyed lock file, non-blocking flock -n, "skipping this tick" + exit 0 when held). Used a FIXED fd (exec 9>), not a dynamic {fd}>; captured the inner run's exit via rc=0; ... || rc=$?; exit "$rc" so set -e never swallows a real failure; and made NODE/TSX env-overridable with the current hardcoded values as defaults.Fixed fd 9 works under the dev box's bash 3.2 (dynamic-fd assignment needs bash ≥4.1); the || rc=$? is the plan's noted set -e failure-path handling and preserves cron seeing the script's true status; NODE/TSX overridability was needed to exercise the real wrapper locally (the dev box has no /usr/bin/node) and is prod-inert (cron sets neither). Tested twice-concurrently: one run held the lock ~4s and completed, the other logged "skipping this tick" and exited 0; a lone later run acquired cleanly (lock released on exit); a failing inner script propagated exit 3.
WS4 (stage 3)Staging: add the four tables to the scrub TRUNCATE (chat-tables precedent) + extend the fail-closed leak check (plan lines 304-309)scrub-staging-pii.sql: accounts + portfolio_backfill_state (which FK-references it) + the two history tables in ONE TRUNCATE (to_regclass-guarded), with an ELSIF truncating accounts alone on a 042-without-043 dump. reseed-staging.sh: a NEW plpgsql DO-block leak check that to_regclass-guards each of the four tables, counts rows, and RAISE EXCEPTIONs (psql non-zero → reseed aborts under set -e) if any survive — NOT an extension of the existing email UNION.Postgres forbids truncating an FK-referenced table unless its referrers are truncated in the same statement, so accounts + backfill_state must share the TRUNCATE (avoids CASCADE, which could reach unrelated tables). The portfolio tables are TRUNCATEd (not email-masked) and carry no email column, so the existing email-UNION check cannot express "these must be empty"; a row-count assertion is the honest fail-closed form. Verified against the local DB: leak check exits 1 (with per-table WARNINGs) while rows exist, the scrub empties all four, and the leak check then exits 0.
WS4 (stage 3)scripts/ops/seed-portfolio-fixtures.ts registers 2-3 public whale wallets (plan lines 309-312)Self-contained script: a direct pg Pool, NO src import and NO @/ path alias; re-registers the three stage-2-verified whales via the app's exact accountUpsert upsert (insert-once/created_block, else bump last_seen_at); does NOT enqueue portfolio_backfill_state.tsx does not resolve the tsconfig @/* alias and no repo script uses it, so importing src/lib/auth/accounts.ts (which does) would break the script; inlining the identical INSERT keeps it runnable. Leaving portfolio_backfill_state empty is deliberate: the live cron's eligible-wallet select is status IS NULL OR status NOT IN ('queued','running'), so a fixtures wallet with no backfill row is picked up on the next tick (enqueuing deep history is WS5's job). Ran twice locally: 3 registered then 3 touched (idempotent).
WS4 (stage 3)(resolves the stage-2 "pending ops" row above)The lock, the crontab line, and the reseed/scrub rule are now implemented + documented (data-pipeline.md, deployment.md, database.md); only Telegram failure/unknown-asset alerting remains, explicitly deferred to WS8. Re-ran refresh-portfolio.ts against the local DB post-change: 3 wallets, 7 snapshots, 4 incremental flows, exit 0.Closes the WS4 ops scope. Alerting is a separate workstream (WS8) per the plan; the docs now state exactly what is wired vs deferred.
WS4 (review fix 1)Liquidation excluded from flow-netting via signedFlowValue → 0 only (M4)netFlowsByTx AND buildBookCurve now also exclude the Aave/Spark liquidation MECHANICS — a withdraw/repay/transfer_out on an aave/sparklend leg sharing a tx with a kind='liquidation' row (liquidationTxHashes + isLiquidationMechanic in pnl.ts). Chose the pnl.ts (consume-side) enforcement point over dropping the rows at scan time.An Aave/Spark liquidation also burns the borrower's collateral aToken (a withdraw) and debt vToken (a repay) and moves the fee (a transfer_out) in the LiquidationCall tx; those are seizure mechanics, but netFlowsByTx summed them into a phantom external NetFlow (seizure read as a user withdrawal/repayment — the exact M4 violation). Consume-side is the robust single choke point: the cron scans transfers and liquidations in SEPARATE scopes/cursors/writes, so a scan-time drop cannot reliably see both; pnl.ts already owns "what counts as a flow." Precise to withdraw/repay/transfer_out so a genuine deposit in another tx still nets (keeps the existing per-tx-netting test green). The aToken/vToken burn rows stay in the ledger (real on-chain facts). Regression tests added.
WS4 (review fix 2)Liquidation flow value = full seized collateral (liquidatedCollateralAmount / seizedAssets); "precise equity-loss netting is a WS6/pnl refinement" (stage-2 row 66)flows.ts now books the liquidation value as the EQUITY DESTROYED = value(seized collateral) − value(debt covered), per mark. scanLiquidationFlows/scanMorphoFlows carry the debt leg (Aave debtAsset topic2 + debtToCover word0; Morpho loanToken + repaidAssets word0), valueFlows prices the debt asset in the same book and nets it (only when same-book; a cross-book position, never charted, keeps the seizure value), extracted into the pure valueDetectedFlow. amount_raw/amount_underlying still carry the seized collateral.The consumer contract (pnl.ts RawFlow doc, pnl.test.ts M4 test, metrics.md M4) requires the value to be "the equity destroyed"; booking the full seizure overstated the loss by the repaid debt and drove TWR to a false total wipeout (linkTwr floor → −100%). Verified on-chain (tx 0x6241…a8ea): LiquidationCall data layout debtToCover@word0 / liquidatedCollateralAmount@word1, debtAsset@topic2. metrics.md M4 now describes the penalty computation (was asserting the persisted value IS the equity destroyed, which the producer contradicted). Regression tests in flows.test.ts. NOTE (not in scope, logged): a Morpho yield-bearing-collateral ('value' leg) liquidation still double-counts the seizure as −yield in buildBookCurve (the seizure is not added as a leg flow to net the value drop); left for a WS6 pnl refinement since no consumer is wired and Morpho collateral seizures are the only affected path.
WS4 (review fix 3)valueFlows descaled a 'shares' flow through descaleToAccountingAmount's null-index branch when the vault convertToAssets(1e18) read failed at the flow blockvalueDetectedFlow now leaves amount_underlying/value_market/value_redemption NULL for a shares flow whose resolved indexAtFlow is null (M9), keeping amount_raw; only underlying/pt amounts (already in accounting units) pass a null index to descaleToAccountingAmount.The null-index branch returns Number(qtyRaw)/10^decimals, but qtyRaw is 18-dec SHARES while decimals is the 6-dec ASSET for the common curator vaults, fabricating a value off by ~10^(shareDecimals−assetDecimals) (verified: Gauntlet USDC Core 18-dec share / 6-dec asset, a 1000-share deposit fabricates 1e15 vs the real ~819). The cron advances the shared cursor past the poison block and the JIT scan never re-scans below lastSnapshotBlock, so the row would be permanent. Matches the erc4626 snapshot reader, which already skips on a failed index. Regression tests in flows.test.ts.
WS4 (review fix 4)live.ts rate limiter: cache set only at the END of liveRefreshWallet, so N concurrent cold-cache calls each ran the full per-wallet pipeline (the module's "a page reload storm cannot fan out RPC load" guarantee held only once warm)Added a pure single-flight coalesce (src/lib/portfolio/coalesce.ts): concurrent callers for one wallet await one in-flight promise; entry cleared on settle (a failed run is not cached, a later call re-runs). liveRefreshWallet body moved to doLiveRefresh.Nit but real; the guarantee is part of the WS4 module contract and reachable-by-design once WS6 wires the summary API. Kept coalesce in its own pure module (no DB/RPC imports) so it is unit-testable in isolation (coalesce.test.ts); force still bypasses the cache and, joining a fresh in-flight run, still gets fresh data.
WS4 (review fix 5)JIT_RATE_GETTERS for syrupUSDC/syrupUSDT used divisorPow10: 6 (the assetDecimals)Changed both to 18 = assetDecimals + 18 − shareDecimals (6 + 18 − 6); corrected the misleading "divisor = assetDecimals" comment (that identity holds only when the share is 18-dec, e.g. sUSDe/sUSDS).Both are 6-dec shares over a 6-dec asset; convertToAssets(1e18) ≈ 1.17e18 (verified on-chain: syrupUSDC 1172749478450632928, syrupUSDT 1134263193147956040), so divisor 6 returns a rate ~1e12x too large, blowing up the leg's REDEMPTION value, spuriously firing the M7 wedge badge, and diverging ~1e12x from the history share_rate (token-yields default divisor 18). Regression test pins the getters to a sane share rate (valuation-sources.test.ts).
WS3 carry-forward (verify)Items 1–3 handed to this stage as "you MUST handle these"Items 1 (vault-over-non-par-wrapper composes the underlying rate, keyed on the accounting asset) and 2 (eBTC unrated → skip; stETH/eETH/LBTC par → identity) were already implemented + documented in WS4 stage 1 (rows 54, 57) and metrics.md 586–609; item 3 (invariant exact in REDEMPTION, only up to wedge drift in MARKET) already stated at metrics.md 616–618 and valuation.ts/valuation-math.ts docs. Only remaining fix: tightened the buildIndexLeg comment, which still read "IDENTITY_RATE for … an ERC-4626 vault share" (implying every vault share → IDENTITY).Confirmed the code is correct and no over-claim remains; the one imprecise inline comment is now aligned with the accounting-asset-keyed rule (metrics.md + IDENTITY_RATE doc already correct). No behavior change.
WS5 carry-forward (Part A)WS4 review fix 2 logged (row 74) but did NOT fix: a Morpho yield-bearing-collateral ('value' accrual) liquidation double-counts the seizure as −yield in buildBookCurvebuildBookCurve now records each interval's seized position_keys (seizedKeysByInterval) and SKIPS a value/pt leg's value-series contribution when a liquidation hit that leg's key in the interval. Its value drop is the realized loss (already booked via the penalty in lossByInterval), not yield. Regression test pnl.test.ts "M4 a 'value'-accrual Morpho collateral seizure is booked once" (proven failing pre-fix: totalYield −900 vs −300, not ok 22; passing post-fix).Morpho emits NO position-token Transfer for a collateral seizure, so — unlike an Aave collateral aToken burn, a netted-out liquidation MECHANIC — nothing existed to net the value leg's drop as a flow, and it was booked BOTH as −yield (value series) and as the penalty. Index legs are unaffected (their yield is qty-agnostic). The forgone same-interval accrual is second-order over 6h (documented).
WS5Replay "via archive multicalls (ethCallAtRaw)" (plan line 344)Replay reuses the LIVE code path verbatim — readAllPositions + buildSnapshotRows (which read via multicall3) — and routes archive by running the backfill in its OWN process with ETHEREUM_RPC_URL defaulted to the archive endpoint (the CLI sets it before its dynamic import; the cron spawns the CLI as a child with the archive env).publicnode REJECTS archive eth_call ("Archive requests require a personal token"; verified on 5d & 90d blocks), and multicall3/rpcRequest capture ETHEREUM_RPC_URL at module load, so no per-call rpcUrl threading through five readers was viable without rewriting them. Reusing the live path verbatim is exactly what the "same *ForSnapshot-style code path" grid-match acceptance requires (proven byte-identical: same-block re-read of 0x5fdc's USDT leg reproduced qty/index/value_market/value_redemption exactly).
WS5Backfill valuation "mode history = block-anchored token_yield_apy.share_rate" (WS4 stage-2 row 60)Refined resolveBookUnitRate so mode history tries the wrapper's own on-chain getter AT the historical grid block FIRST (read via the archive RPC = exact per-block), falling back to the block-anchored DB share_rate only when a wrapper has no getter. Applies to history only (now unchanged; the live cron never uses history).Exact per-block beats a 6h-granular DB snapshot AND makes a backfilled grid point match the live now valuation of a coinciding block (both take the same getter). It is also load-bearing locally: the dev DB has no block_at_snapshot share-rate rows, so history would skip every wrapper leg (M9); the archive getter values them (verified: 0x086e's sUSDe supply leg backfilled with non-null value_redemption). Never defaults to 1 for an unrated/getterless wrapper (still M9-skipped).
WS5Enqueue "built HERE" (plan line 329)Added src/lib/portfolio/enqueue.ts (enqueueBackfillStatement pure + enqueueBackfill) and called it from /api/auth/verify right after upsertAccount, inside the same best-effort try (a DB hiccup must not block a valid sign-in).Kept the queue owned by the portfolio module (not the auth layer), mirroring accounts.ts's pure-statement + executor split for unit-testability. ON CONFLICT (uid) DO NOTHING gives the exact "insert-once on creation / first login, no-op on re-login" semantics WS5 specifies.
WS5Processing "the WS4 cron processes at most 2 queued backfills per tick" (plan line 348)scripts/refreshers/backfill-queue.ts processBackfillQueue: one atomic UPDATE ... WHERE uid IN (SELECT ... FOR UPDATE SKIP LOCKED LIMIT $max) claims ≤2 queued → running, spawns each as an archive-routed child (spawnBackfillChild, injectable runner for tests), awaits both; the child owns done/empty, the cron marks error only on a non-zero exit while still running. Wired at the tail of refreshPortfolio (also runs when there are 0 live wallets, since a queued account is excluded from live). Cap env-tunable (PORTFOLIO_BACKFILL_MAX).The plan's cap + the "child owns terminal state" split keep a backfill crash from wedging the queue and bound archive load (≤2 concurrent). SKIP LOCKED is belt-and-braces under the run-cron flock. Demonstrated: 3 queued → tick 1 claimed 2 (oldest first) → empty, tick 2 claimed the last, tick 3 claimed 0; and a real non-empty wallet through the queue → done.
WS5floor_ts "records the range start" (plan line 345)floor_ts = the range start aligned DOWN to the 6h grid (= grid[0]), set on done; NULL on empty (nothing tracked). Added MAX_GRID_POINTS=400 safety clamp (raises the start, never below the coverage floor) for a pathological old created_at → now span.The "tracked since" anchor must land on a real grid point (a snapshot exists there), so it is the aligned floor of the range start, not the raw start. The clamp bounds a first-login-of-an-old-account replay (created_at can be years before now) to the plan's "~360 grid points" without ever dipping under the 2025-05-21 floor.
WS5(RPC-cost acceptance)Added a process-wide JSON-RPC call meter to rpc-batch.ts (getRpcCallCount/resetRpcCallCount, incremented once per logical rpcRequest); backfillWallet reports the probe's count.The plan's empty-wallet acceptance asks for the observed RPC call count; a one-integer meter at the single rpcRequest choke point captures every multicall + getLogs call. Measured: an empty wallet's 3-day probe = 68 calls (one read pass + one sweep) and ends empty. Imported the meter INTO backfill.ts (same module binding as rpcRequest) after an injected-counter first attempt read a different module instance under tsx and reported 0.
WS5 (review fix 1)setBackfillStatus merged floor_ts = COALESCE(EXCLUDED.floor_ts, existing) — always replaces the stored anchor with the current run's floor, forward includedExtracted a pure backfillStatusStatement builder and made the merge MONOTONE-DOWN: floor_ts = LEAST(existing, EXCLUDED.floor_ts). The anchor can only move DOWN as a longer replay extends coverage, never up. Regression test in backfill.test.ts pins LEAST (not COALESCE) + the NULL-passthrough.A documented shorter repair (--days 7) recomputes a HIGHER range start and delete+inserts only its own window; COALESCE let that higher floor overwrite the anchor and orphan the still-correct rows below it (reproduced live: 0x5fdc had floor_ts 07-07 20:00 with min(snapshot_ts) 07-06 20:00 — 10 orphaned snapshots, 21 orphaned flows). LEAST keeps the earlier anchor so the untouched earlier rows stay consistent (floor_ts == min(snapshot_ts)); Postgres LEAST ignores NULLs, so running/empty writes leave an existing floor_ts untouched exactly as COALESCE did.
WS5 (review fix 2)Replay read each grid point via readAllPositions, which swallows a venue reader throw and returns [] for it — a transient archive failure baked as "held nothing" (total) or a leg silently MISSING from an otherwise-present ts (partial)Added readAllPositionsSettled (reports the venues that threw) + readGridPointOrThrow (retry GRID_READ_MAX_ATTEMPTS=4 while any venue fails, then THROW). Replay now reads through it; the throw fires BEFORE the windowed delete+insert, so prior good rows are untouched and the account is left non-done for a re-run. readAllPositions (live path) unchanged. Regression tests in backfill.test.ts (success / genuine-empty / retry-then-clear / persistent-throw / partial-never-returned).The backfill BAKES each grid point into history (delete+insert), so a swallowed transport failure persists: a partial failure drops a value/PT leg for one 6h window and reads as a phantom loss then gain, collapsing TWR (M9 violation). Distinguishing a failed venue (transport throw) from a genuinely-empty one (all venues succeeded, wallet holds nothing → still written as an empty window) is the honest fix; the 4-attempt retry absorbs a transient drpc hiccup, and a sustained outage fails loud instead of persisting wrong-shaped history. Inner reverts (closed positions) come back null and are NOT counted as failures.
WS5 (review fix 3)A backfill account could be stuck status='running' forever (the queued→running flip commits before the child is spawned/awaited, so a torn-down process tree leaves no terminal write) — permanently excluded from BOTH the live cron and the queueprocessBackfillQueue now reclaims running rows older than STALE_RUNNING_MS (60 min) back to queued BEFORE claiming; the reclaimed row re-enters the queue and re-runs (windowed delete+insert makes it idempotent). Exported CHILD_TIMEOUT_MS; new scripts/refreshers/backfill-queue.test.ts (added to the test list) pins reclaim-before-claim, cutoff older than CHILD_TIMEOUT_MS, max<=0 no-op, and the crash fallback.No recovery existed (grep-confirmed): the only running references were the two exclusion queries and the in-process crash fallback that requires the parent to survive. On a Hetzner+system-cron box a reboot/OOM-kill mid-run orphans the row. The 60-min threshold MUST exceed CHILD_TIMEOUT_MS (30 min): a live child's updated_at is stamped at claim and not heartbeated, and run-cron's flock forbids overlapping ticks, so a row older than the cutoff cannot be a live child. Reclaim runs only for max > 0 (an explicit max=0 "skip the queue" call stays a no-op).
WS6"WS6 recomputes the index for exact M2 ratio math from the block-anchored share-rate series" (snapshot.ts WS4 note)The read-model adapter (src/lib/portfolio/assemble.ts) recovers the accounting-asset→book redemption rate DIRECTLY from the persisted values — rate = value_redemption / qty_underlying — and re-fuses it onto the stored bare venue index via the sanctioned composeIndex/buildIndexLeg. No second pass over token_yield_apy.share_rate is needed.The persisted value_redemption is by construction qty_underlying × rate, so the rate divides out exactly (and equals the on-chain rate the writer used), reproducing the true composed index with no extra DB read and no risk of picking a different-block share_rate than the one the snapshot was valued at. Verified by hand (scratch/verify-ws6.ts): the sUSDe supply leg — where the Aave venue index was byte-identical across the two grid points — attributes 17.99964287 redemption yield both by-hand and via legIntervalYield, i.e. all growth correctly comes from the recovered sUSDe rate, not a gutted venue rate.
WS6M1 "classification at read time" (inclusion computed per position group now)Classification runs over the UNION of every position_key ever seen (most-recent row per key for book/side/e-mode), not just the current snapshot's legs.A since-closed position must still be classified (from its last-known structure) so its earlier yield stays in the book curve; taking the most-recent structure per key still reflects the current account (e.g. e-mode enabled later includes the whole history), matching the "keeps history valid when structure changes" intent.
WS6summary = "JIT live merge + latest snapshot + backfill status"The JIT live read (live.ts) is BEST-EFFORT with an 8s budget: on a stalled/failed mainnet RPC the response falls back to the stored 6h history and reports jit:false.A read-only monitor must still render real, stored numbers when the RPC is slow/unreachable; the summary is never blocked on a live read. Proven both ways locally: dev server merged a live "now" read (jit:true, current book value), the production server fell back to snapshots (jit:false) and returned the backfill-computed figures — both correct.
WS6Positions "realized APY vs current quoted rate … quoted rate from the existing rate tables"Quoted rate composed per leg: Aave/Spark reserve supply_apy/borrow_apy PLUS the wrapper's own token_yield_apy for a wrapper reserve; ERC-4626 vault = its own token_yield_apy total; Morpho = morpho_market_apy; a not-tracked reserve/market shows a dash. All four rate tables are empty in the local fixture DB, so the column renders as a dash locally (null-handled end to end); the composition is the advertised total for holding that exact leg.The realized figure the portfolio attributes to a wrapper collateral leg is venue interest + wrapper appreciation, so the honest "advertised" comparator is the venue rate + the wrapper APY; a bare venue rate would understate what the position is quoted to earn.
WS6Chart "flow tick marks"Flow markers are the per-tx NET external flow (netFlowsByTx, a leverage loop collapses to ~0 and near-zero residue is dropped), coloured capital-in (green) / capital-out (red); liquidations are separate red markers.Charting every raw Transfer would show a leverage loop as a large phantom deposit+borrow pair; the per-tx net is the real external capital move (M3), matching the curve which is already net of flows.
WS6Pendle PT redemption mark on the chart/positionsREDEMPTION uses the persisted value_redemption when present; a PT (whose snapshot leaves value_redemption NULL per the WS4 M6 note) currently contributes its MARKET value only and is charted via the value series. Full pull-to-par-from-entry (M6) reconstruction from the acquisition flow is deferred.No PT positions exist in the fixtures to validate against, and the entry-fill reconstruction is non-trivial; the structure passes PT legs through the pt accrual so wiring the entry-implied-yield accrual later is localized. Flagged so the docs stay truthful about the one M6 gap.
WS6 (review fix 1)buildOutside skipped any group whose aggregate included flag was true (if (g.included) continue), so an EXCLUDED-asset leg (unknown book) held ALONGSIDE a covered supply in a debt-free Aave/Spark account was dropped from the whole UI: not charted (book==EXCLUDED), not valued in any book, and not in "Outside" eitherAdded the pure per-LEG outsideLegGroups(M1Leg[]) in pnl.ts (excluded legs grouped by their group, each under its own reason); buildOutside now drives off it instead of classifyGroups. An unknown-asset leg inside an otherwise-included group now surfaces in "Outside the yield book" while a covered sibling still charts into its book.classifyGroups sets a debt-free group included=true if ANY leg is included (pnl.ts:882), so the group flag can't gate per-leg exclusion; snapshot.ts persists an EXCLUDED leg with a MARKET USD value expressly for the Outside section (redemptionRateKind→'unknown', not 'unrated', so the M9 skip does not fire). Regression tests in pnl.test.ts; docs/portfolio.md Outside-reason list extended to name the unknown-asset case.
WS6 (review fix 2)loadContext read the flow ledger in the Promise.all BEFORE loadLiveRows ran the JIT mini flow scan, so a newly-detected acquisition flow written during the request was absent from flows; a live-merged newborn 'value'/'pt' leg (Morpho yield-bearing collateral, Pendle PT) booked its ENTIRE current value as book yield on first loadloadLiveRows now returns { rows, flowsWritten }; loadContext re-reads loadFlowRows(w) after the live scan when flowsWritten > 0, so the opening deposit nets the newborn leg's birth value to ~0.The engine is correct given the flow; the defect was I/O ordering. Reproduced through the assemble->engine path: a newborn sUSDe 'value' collateral + one prior USD index leg gives totalYield 501.0 (stale flowRows) vs 1.0 (re-read), both marks, matching the positions table. Gated on flowsWritten>0 to keep the parallel fast path when no recent flows; index legs were already immune (flows ignored in index yield). Not unit-tested at the API boundary (no DB+RPC harness); the pure-engine newborn+flow netting is pinned by the pnl.ts invariant tests and the scratch repro above.
WS6 (review fix 3)loadQuotedRates gave a Morpho COLLATERAL leg the market's loan-token SUPPLY APY (a lender rate the collateral never earns) as its advertised comparatorExtracted the pure quotedRateForRow into src/lib/portfolio/quoted-rates.ts; a morpho:*:collateral leg now quotes the collateral WRAPPER's own token_yield APY (yield-bearing) or null/dash (par collateral); morpho :supply/:debt keep morpho_market_apy.Morpho pays no interest on collateral, so its only advertised yield is the wrapper appreciation, mirroring the Aave/Spark wrapper handling and metrics.md's Morpho carry rule ("no additive venue-supply term"). Pure module = unit-testable; quoted-rates.test.ts pins collateral-wrapper / par-collateral / supply / debt / aave-wrapper / erc4626.
WS6 (review fix 4)HeadlineTiles hardcoded the realized-APY dash tooltip to the 30-day-gate reason for EVERY suppressed APY, giving a false "0 more days to go" when a book was a total loss past 30 daysAdded the pure realizedApyDashTooltip(observedDays) in theme.ts: the 30-day-gate copy only while under the gate (daysToGate>0), a distinct "recorded a total loss over the tracked period" copy once the gate is met but the APY is still null.annualizeTwr returns null for two independent reasons (span<30d OR growth<=0); observedDays alone distinguishes them in the null branch (gate met => the null must be the total loss, since both marks share the grid so observedDays == observedSeconds/86400). theme.test.ts pins under-gate / boundary / total-loss copy.
WS6 (review fix 5)The PillGroup pills conveyed the active book/mark by colour only (no aria-pressed), so assistive tech could not tell which mark/book was selected (WCAG 4.1.2)Extracted PillGroup into src/components/portfolio/PillGroup.tsx with aria-pressed={on} on each native button; PortfolioView imports it.Native buttons keep keyboard/focus operability; aria-pressed exposes the selected state. Render test (PillGroup.test.tsx) asserts exactly one aria-pressed="true". Left CarryChart / MoneyMarketRatesChart PillGroups untouched: out of the WS6 portfolio scope, separate pre-existing components on other pages.
WS8Alert helper location "e.g. scripts/ops/alert.ts or similar"Created scripts/ops/alert.ts exactly (pure predicate unknownAssetsWithValue + summarizeUnknownAssets + formatUnknownAssetAlert, fail-soft sendTelegramAlert/alertUnknownAssets); unit test scripts/ops/alert.test.ts (15 tests, added to the package.json list).The plan named this path; a scripts→scripts import from scripts/refreshers/portfolio.ts keeps it out of src/ (no src → scripts), and no src import keeps the pure parts unit-testable without a DB/RPC.
WS8Alert env is only ALERT_TG_BOT_TOKEN + ALERT_TG_CHAT_ID (plan lines 451-458)Added two OPTIONAL vars used by BOTH paths: ALERT_TG_API_BASE (endpoint base, default https://api.telegram.org) and ALERT_ENV (checkout-label override, else the repo-root basename).ALERT_TG_API_BASE is what makes the run-cron failure path locally testable against a stub endpoint (the required local test) without a real Telegram chat; it is prod-inert (unset → the Telegram default). ALERT_ENV lets the message name the environment explicitly when the repo-root basename is ambiguous. Both documented in deployment.md's env table.
WS8"the refresher must POST ... when the bucket map yields EXCLUDED for an asset with nonzero value, AFTER committing its writes"Wired alertUnknownAssets into the LIVE refresher (refreshPortfolio, right after writeSnapshots), NOT into the WS5 backfill child; ONE aggregated message per tick (distinct wallets + summed MARKET value per unknown asset).The live 6h cron snapshots EVERY registered wallet, so every unknown-with-value asset surfaces there — the single natural choke point. Adding it to the per-account backfill child too would double-fire and could spam on an archive replay. Aggregating per tick bounds it to one message/6h no matter how many wallets hold the same unmapped asset; the predicate uses valueMarket > 0 so an M9 null-price EXCLUDED leg (read failed, not a real position) does not alert.
WS8Docs sweep scoped to the portfolio invariants (plan "In particular:")Also corrected pre-existing, non-portfolio doc drift the sweep surfaced: architecture.md's stale staging topology (claimed "no automated staging deploy pipeline" / "not publicly served" while deploy-staging.yml + the served staging.creddit.xyz are shipped) and the scripts/sql/001..037-*.sql migration-range references (now 001..043, since 042/043 are this feature's own migrations) in architecture.md, data-pipeline.md, processes.md; added the missing 50 */6 refresh-portfolio.ts row to architecture.md's crontab.The task requires the seven docs be "accurate and consistent with the SHIPPED code (do not trust earlier docs)"; leaving a flatly-contradictory staging claim or a migration range that stops before this feature's own 042/043 would fail that bar. All are factual corrections verified against the shipped workflows / scripts/sql/.
Final review (fix 1, blocker)M6/buckets: a Pendle PT's book is its underlying's book, so an Aave/SparkLend e-mode PT-COLLATERAL reserve (accountingAsset = the PT address) should bucket to USD/ETH via the pt->underlying mapbookForAccountingAsset no longer PROMOTES a PT into its underlying's book — a known PT resolves to EXCLUDED (surfaced in "Outside the yield book"), and refresh-portfolio.ts filters known PTs out of the WS8 unknown-asset alert.The promotion was incomplete: it fixed the BOOK but not the valuation. On a lending venue a PT has no token_yield_apy share_rate and no JIT getter, so redemptionRateKind(PT,book)='rate-source' sent buildSnapshotRow into resolveBookUnitRate, which THREW no-share-rate and DROPPED the whole snapshot row (M9) — leaving only the paired stable-debt leg, which classifyLegs then charted as an emode-carry-single-book with negative book value + negative APY (the flagship PT loop rendered as pure debt). Populating pendle_markets in prod made it strictly WORSE than the empty-map fixture (which correctly showed EXCLUDED → Outside). The full M6-for-lending valuation (MARKET pt_to_asset_rate + REDEMPTION pull-to-par + reader plumbing for the underlying price) is a feature, not a review fix, and would additionally risk transient negative-equity on a PT price-read gap; EXCLUDED→Outside is the honest, deterministic, no-wrong-number deferral (mirrors the deferred M6 pull-to-par). A direct Pendle-venue PT is unaffected (its accountingAsset is the underlying). Regression: buckets.test.ts (PT→EXCLUDED) + pnl.test.ts (the e-mode PT-collateral carry is Outside/unknown-asset, never a negative-equity carry).
Final review (fix 2, bug)metrics.md M6: "REDEMPTION accretes pull-to-par at the entry implied yield" (ptImpliedApyFromFill/ptRedemptionAssetPerPt), stated in present tense as shippedThose functions have ZERO production callers (grep-verified) — the pull-to-par reconstruction is deferred (execution log row 94). A direct PT stored value_redemption=NULL, so it dropped to $0 in the REDEMPTION book, its positions cell showed a dash, and a newborn PT booked its whole value as redemption yield. Added the pure ptRedemptionFallback (assemble.ts), applied at the DB->Lite boundary (api-data.ts loadSnapshotRows/loadLiveRows): a pendle row's null redemption falls back to its MARKET value. Corrected metrics.md M6 + portfolio.md to state pull-to-par is deferred and PTs are marked at MARKET in both modes for now.Marking a PT at market in redemption is a conservative interim (a PT's traded price and pull-to-par fair value both converge to par at maturity, so it never OVERstates redemption; wedge reads 0) and makes REDEMPTION consistent with the already-correct MARKET mark, removing the $0-drop/dash without a re-backfill (applied at read time, so existing stored NULL rows are covered). Docs-sync hard rule: the M6 overclaim cited dead functions. Regression: assemble.test.ts (the fallback + a PT-only book charts its redemption value, not $0).
Final review (fix 3, convention)quoted-rates.ts: the wrapper's own token_yield APY was added to the quoted rate only on the ASSET side (side==='asset' gate)Compose the wrapper APY on BOTH sides.The realized funding cost the engine attributes to a wrapper DEBT leg (e.g. a wstETH borrow, which SparkLend allows) composes the wstETH redemption appreciation on the debt side (valuation.ts buildIndexLeg keys the composed index on the accounting asset, side-independent; metrics.md M2), so quoting the bare borrow rate is an unlike-rate comparison in the earned-vs-advertised column (reads ~2.8% cheaper-than-advertised for wstETH). Regression: quoted-rates.test.ts (a wstETH SparkLend debt leg quotes borrow + wrapper; symmetry with the supply side).
Final review (fix 4, nit)PortfolioView syncing banner: "Building your archive from {trackedSince} to now"During syncing show a range-agnostic "Building your history archive."While backfilling, floor_ts is still null so trackedSince falls back to the account created_at (~signup day), but the backfill replays up to 90 days BEFORE signup, so from <today> to now misstated the archive's lower bound on the common first-run path. It self-corrected once floor_ts was written; the range-agnostic copy is honest throughout. No em-dash.
FWS1 (item 4, factual correction — REQUIRED)The plan assumed a live PT-sUSDe Aave reserve to demonstrate a promoted PT carryAdded srUSDe (0x3d7d6fdf07ee548b939a80edbc9b2256d0cdc003) → 'USD' to buckets.ts ACCOUNTING_ASSET_BOOKS AND to JIT_RATE_GETTERS (convertToAssets(1e18), divisorPow10 18); did NOT add it to PAR_ACCOUNTING_ASSETS15 of Aave's 16 PT reserves matured before pendle_markets was seeded, so the ONLY live tracked PT reserve is PT-srUSDe-22OCT2026 (0x59bc9fae5d62b19d4f8d07d758047acb9ee19d34, Aave reserve_index 66, aToken 0x01e69a58…), underlying srUSDe (0x3d7d…cdc003), which was NOT in buckets.ts. srUSDe is a plain ERC-4626 over USDe (verified live: asset() == USDe 0x4c9edd…, convertToAssets(1e18) == 1026114031059704080 ≈ 1.0261, 18-dec share over 18-dec asset → divisorPow10 18; DeFiLlama prices it 1.0257723). It is genuinely yield-bearing (rate 1.0261, NOT par), so it must NOT go in PAR. token_yield_apy has no row for it, so the on-chain getter is the only redemption source — without the JIT getter a directly-held PT-srUSDe FLOW's redemption mark (and any direct srUSDe leg) resolves rate-source and throws no-share-rate (M9-skipped). Without this the flagship PT-loop acceptance is undemonstrable on any real account. Verified live: the PT-loop wallet 0x5c430ff6… charts as an INCLUDED USD carry (collateral $2.41M > USDe debt $2.15M), promoted from EXCLUDED.
FWS1 (item 2, D1 — entry-basis weighting)"quantity-weighted average fill across buys" (unspecified: weight the PRICE or the per-fill YTM)Quantity-weight the per-fill implied APY (each acquisition tranche locks its own yield-to-maturity y = p^(−1/τ) − 1 at its own fill price and tenor; the position's entry yield is Σ(qty_i·y_i)/Σ qty_i)A weighted-average PRICE has no single well-defined tenor when buys land at different times; weighting each tranche's locked-in YTM by size is the standard bond-ladder blended-yield and is unambiguous. Reuses the tested ptImpliedApyFromFill/yearsBetween. Adversarially unit-tested (multiple buys, buy-sell-buy, opening balance, null-valuation skip, maturity-in-past) in pt-basis.test.ts.
FWS1 (item 3, M12 — underlying price recovery)value_redemption = qtyPT × ptRedemptionAssetPerPt(...) × underlying par/redemption price in the book (a separate underlying redemption-price read)Recover the underlying's per-token book value at each ts as u(t) = value_market(t) / qty_underlying(t) from the STORED MARKET columns, and use that same u(t) in both the MARKET and REDEMPTION PT marksThe read-model layer (api-data/assemble) is DB-only (no RPC/price fetch), so re-resolving the underlying's redemption rate per historical ts is not available there; u(t) is exact and recoverable with no new I/O. Using the shared u(t) makes the PT wedge isolate EXACTLY the PT rate move since entry (market pt_to_asset_rate vs entry-locked pull-to-par), which is precisely what M7 says the PT wedge measures. Verified live: the PT-srUSDe leg shows REDEMPTION $2,409,143 ≠ MARKET $2,406,968 (wedge 0.090%) at t1, and 0% at the opening (synthetic fill), 14 days apart.
FWS1 (item 2, opening balance timing)"an opening balance (position predates the window) uses the opening snapshot's pt_to_asset_rate as a synthetic fill"Opening = the EARLIEST snapshot with a valued PT (qtyPT>0 and readable qty_underlying), synthetic fill rate = qty_underlying / qtyPT; real acquisition fills are the flows STRICTLY AFTER the opening ts (flows at/before it are baked into the opening balance)Mirrors the backfill's opening-balance convention (a position predating the range enters as the first snapshot's balance, not a flow), so a buy captured in the first snapshot is not double-counted with its flow. An aave/spark PT-collateral leg has no honest per-fill price (its aToken-transfer flow does not encode the PT rate, so amount_underlying is null and the fill skips), so its basis is synthetic-only (basisSynthetic=true) — the honest outcome for a leg whose fills we cannot price.
FWS1 (item 5, M13 held-by-anyone)The held-by-anyone join covers THREE holding shapes (pendle position_key, aave/spark accounting_asset, flow asset)Union of FOUR sources: also split_part(position_key,':',3) of pendle-venue FLOW rowsA direct-pendle redemption/acquisition flow carries the pendle:pt:<addr> position_key (not the PT in its asset, which is the underlying), so adding the flow position_key is a strict superset that also keeps a market held only via past flows. Harmless (the grace/active clause dominates for active markets); "keep any market held by anyone" is the intent.
FWS1 (item 4, signature ripples — named per the plan)The plan names the accrualForRow/legSnapshotsFromRows/legSnapshotFromRow ripple; buildSnapshotRow plumbing to be discoveredaccrualForRow(row, isKnownPt?), legSnapshotFromRow(row, isKnownPt?), legSnapshotsFromRows(rows, isKnownPt?) gained an optional IsKnownPt predicate; buildSnapshotRow/buildSnapshotRows opts gained pendleMarketsByPt: Map<string, PtMarketLite>; accountingAssetsOf(reads, pendleMarketsByPt?) expands a known-PT accounting asset to also price its underlying; PortfolioRegistries gained pendleMarketsByPt (new loadPtMarketsByPt); FlowDetected/TransferTarget gained pendle marketAddress/maturityTs; FlowValuationContext gained ptRate; SnapshotRowLite gained qtyRaw, FlowRowLite gained amountRaw/amountUnderlying (widened loadSnapshotRows/loadFlowRows queries)All are the plumbing the plan enumerated (item 1 buildTransferTargets drops market/maturity; item 2a loadFlowRows lacks amount_raw/amount_underlying; item 2b opening rate recovered from qty_raw/qty_underlying; item 4 thread reg.pendleMarkets into the writer + isKnownPt into accrualForRow). Optional params keep every existing caller/test source-compatible; a value-series leg carries no ComposedIndex (the stored RAY index is a write-time descale helper only).
FWS1 (item 2/6, basisSynthetic surfacing)M11/M6: "labeled basis_synthetic in the API/UI"derivePtEntryBasis returns and unit-tests basisSynthetic, but it is NOT yet threaded through api-types.ts / the positions UIScope containment: surfacing the flag needs an api-types.ts field + a positions-table cell + a React render test, none of which change a number. The derivation (the load-bearing part) is shipped and tested; the label is a cosmetic follow-up. Flagged here so the docs stay truthful about what is wired.
FWS1 (review fix, blocker)flows.ts buildTransferTargets emits an aave/sparklend reserve's supply+debt Transfer targets through the generic lending() helper with amountKind='underlying' and accountingAsset=r.underlying; for a PT reserve r.underlying IS the PT address, which FWS1 left un-special-casedbuildTransferTargets now looks up r.underlying in reg.pendleMarketsByPt; a known-PT reserve emits its supply (and, defensively, debt) targets with amountKind='pt', accountingAsset = the PT's UNDERLYING, decimals = the PT's decimals, plus marketAddress/maturityTs, and the positionKey UNCHANGED (<venue>:reserve:<PT>:supply/:debt). The existing 'pt' path in valueDetectedFlow then applies the flow-block pt_to_asset_rate (M11, par at/after maturity, null on a young-TWAP revert -> unvalued per M9), and resolveFlowRate resolves the UNDERLYING's redemption rate (srUSDe has a JIT getter)The flow was valued on a DIFFERENT basis than its LEVEL series (the M3 "a flow is not profit" violation, on the flagship PT-loop carry 0x5c430ff6f374da12218b380d2040299ae5b6d40e). The 'underlying' path booked the PT at PAR: value_market = parPT x the DeFiLlama PT price 0.9886 (a plausible-but-WRONG number), and value_redemption = NULL (a PT address is a 'rate-source' with no JIT getter and no token_yield_apy row, so resolveFlowRate throws no-share-rate -> null). Meanwhile snapshot.ts values the LEVEL leg (accrual='pt') at descaled aToken qty x pt_to_asset_rate x underlying book price (0.9889 x 1.0258 = 1.0143). Because the flow's redemption is null it contributes 0 to netLegFlow in legIntervalYield, so a mid-window PT-collateral top-up books its ENTIRE notional as REDEMPTION yield, and the 0.9886-vs-1.0143 gap as MARKET phantom yield. The fix puts BOTH flow marks on the level basis so a top-up nets to ~0. Regression tests: flows.test.ts (buildTransferTargets wiring; a deposit books 0.95N underlying + 0.969N redemption, never N or null; a null-rate flow stays unvalued, never par) and assemble.test.ts (end-to-end: a mid-window top-up nets to ~0 in BOTH marks, proven FAILING pre-fix at market -1.96 / redemption +96.9).
FWS1 (review fix, comment corrections)(a) scripts/refreshers/portfolio.ts WS8 alert comment asserted a lending-venue PT is "EXCLUDED by design (its MARKET/REDEMPTION valuation on that venue is not wired yet)"; (b) snapshot.ts PT-collateral branch comment said the EXCLUDED-underlying case would "fall through as EXCLUDED"Rewrote (a) truthfully: a known PT is now PROMOTED into its underlying's book and fully valued, so it never reaches the unknown-asset list; the pt->underlying filter now only suppresses the alert for a known PT whose UNDERLYING is itself unmapped (remedy: map the underlying, not hand-add the churning per-maturity PT). Corrected (b) wording: the code returns the row with book=EXCLUDED and a null market value, it does not "fall through". No behavior change in eitherAGENTS.md same-commit docs rule: the blocker fix makes the WS8 comment's premise false (the valuation IS wired now), and the snapshot.ts comment mis-described a return as a fall-through (the reviewer refuted the snapshot.ts BEHAVIOR finding but required this wording fix). Both are comment-only.
FWS1 (gate hygiene)tsconfig exclude = ["node_modules","scripts","docs"]; scratch/ was not gitignoredAdded "scratch" to tsconfig exclude and /scratch to .gitignoreThe untracked scratch/ verification dir accumulated ad-hoc *.test.ts repros using BigInt literals (forbidden by the ES2017 target), which broke tsc --noEmit and next build even though they are throwaway and not in the npm test list. Excluding scratch from the typecheck/build scope (exactly as scripts/docs already are) restores clean gates and prevents accidental commits of the working dir. No app or runtime effect.
FWS2 (item 1, factual correction)plan §FWS2 item 1: a T1/normal leg's "decimals from the resolver payload"The reader reads token decimals() on-chain (one multicall over the distinct tokens per pass, cached), NOT from the payloadThe VaultEntireData payload carries the token ADDRESSES but NO decimals (verified against the decoded 97-word struct + scripts/refreshers/vault-capacity.ts, which itself reads decimals() per Fluid leg token). So decimals cannot come from the payload; a per-pass cached decimals() multicall is the source (the ETH 0xeeee pseudo-token, whose decimals() REVERTS, is hardcoded 18 and never read, per VERIFIED-FACTS). A token whose decimals() read fails skips that leg (M9).
FWS2 (item 1, shared ABI lift)"LIFT [VAULT_ENTIRE_DATA_PARAMS] into src/ so both the refresher and the reader share ONE definition ... vault-capacity.ts re-imports it"Created src/lib/portfolio/fluid-abi.ts holding the VAULT_ENTIRE_DATA string, VAULT_ENTIRE_DATA_PARAMS, POSITION_BY_NFT_ID_PARAMS, NFT_IDS_PARAMS, the resolver addresses, the ETH-pseudo/WETH normaliser, and the decodeDexPerShare word-slicer; scripts/refreshers/vault-capacity.ts now imports VAULT_ENTIRE_DATA_PARAMS from it and dropped its inline duplicate (and the now-unused parseAbiParameters import)The mandated lift. The param strings are typed string (not literal) so viem's parseAbiParameters takes its non-generic overload (a literal 97-field tuple blows the tsc type-instantiation depth — TS2589). getDexState stays a documented word-slice (words 26-29, exactly as vault-capacity's getFluidDexPerShare and VERIFIED-FACTS specify) rather than an ABI decode of the deeply-nested DexState/ShiftChanges struct; the ABI-decode mandate is for the positionByNftId payload, which IS decoded via decodeAbiParameters.
FWS2 (item 2, classifier test seam)"a SINGLE const that FWS3 flips" gates the interimclassifyLegs/classifyGroups/outsideLegGroups gained an OPTIONAL opts?: { fluidFlowCoverage?: boolean } that DEFAULTS to the FLUID_FLOW_COVERAGE constThe const stays the production default (api-data.ts calls classifyLegs(m1) with no opts, so FWS2 ships gated). The optional override is a pure-unit-test seam so the M14 cross-book / same-book rules (which only fire once coverage is on) can be exercised without waiting for FWS3 to flip the const. FWS3 flips the ONE const; no call site needs to pass the opt.
FWS2 (item 2, loadFluidState shape + wiring)"registry.ts loadFluidState(): join carry_registry (protocol=Fluid) for {label, status} per vault address ... return {label, status} per vault"loadFluidState() returns Map<vaultAddrLower, {label, status, vaultId, vaultType}> (label = ${collateral_label} / ${debt_label}) and is ALSO wired into PortfolioRegistries.fluidVaults via loadRegistries (best-effort, empty-map on failure)carry_registry has no chain_id column (mainnet-only), so the query is unfiltered by chain. Added vaultId/vaultType beyond {label,status} because FWS4's annotation wants the human vault number + tier for the positions-table label; both are already columns. Wiring it into loadRegistries (not only a standalone export) makes it flow to the cron/live path so FWS4 can annotate off reg.fluidVaults without a second loader. FILTERS NOTHING OUT (D2): a wound-down / below_floor vault still holds live user debt.
FWS2 (item 2, UI-copy in an FWS2-touched file)REASON_LABEL had cross-book: "Debt spans more than one book" and no coverage-pending entryAdded coverage-pending: "Fluid flow tracking pending"; reworded cross-book to "Spans more than one book"; labelForRow renders a fluid leg as <sym> <side> · Fluid #<id>The M14 cross-book rule now excludes a DEBT-FREE cross-book Fluid NFT too, so "Debt spans..." became inaccurate (a debt-free cross-book group has no debt). coverage-pending needs user copy for the interim Outside section (no em-dash). The fluid label carries the NFT number so multiple NFTs of the same token in the Outside section are distinguishable; richer labels (FWS4) build on this. All copy is em-dash-free.
FWS2 (item 4/books, the 9 unmapped tokens)"PAR_ACCOUNTING_ASSETS/buckets coverage for every token observed in the live vault set (report unmapped ones)"Added NONE of the 9 unmapped live-vault tokens (USDai, wstUSR, tBTC, PAXG, XAUt, FLUID, weETHs, rsETH, mETH) to buckets.tsPer VERIFIED-FACTS + execution-log row 104: all nine have ZERO token_yield_apy rows, so mapping one as a wrapper makes resolveBookUnitRate throw no-share-rate and the leg gets SKIPPED entirely (M9) — strictly WORSE than EXCLUDED, which at least shows a MARKET USD value in "Outside". PAXG/XAUt are gold and FLUID is governance (no book, ever). A Fluid leg on any of them buckets to EXCLUDED with a MARKET USD value and fires the WS8 unknown-asset alert (refreshPortfolio -> alertUnknownAssets, which filters only PT underlyings, so a non-PT unmapped Fluid token still alerts). Confirmed the alert path covers them.
FWS3 (exhibit (a) log_index, FACTUAL CORRECTION)VERIFIED-FACTS + the task exhibit say tx 0x6fbc…a7d LogOperate is at logIndex 664Used the real canonical block-level logIndex 527 (re-verified live: eth_getLogs on vault 166 at block 25478581 returns the LogOperate at logIndex 527; the same-tx ERC-20 transfers are 523/525, all inside 523..527)The plan/VERIFIED-FACTS 664 is a transcription error (likely a different indexer's per-tx numbering). The HARD RULE is "verify on-chain before asserting"; 527 is what the node returns and what the flow-events PK stores. The nftId/colAmt/debtAmt all match VERIFIED-FACTS to the wei; only the logIndex differed. Acceptance (a) shows the two rows under log_index 527.
FWS3 (leg discriminator design)Migration 044 adds leg text NOT NULL DEFAULT ''; "Fluid sets it" (value unspecified)leg = the fluid position_key TAIL after the nft id: supply/debt (normal), <tokenLower>:supply/<tokenLower>:debt (smart), and the NFT id for a state-diff liquidation row (whose position_key is the 5-part group prefix). fluidLeg() in fluid-flows.tsThe tail is unique within one NFT under one log (a LogOperate is one nftId), so an operate's col+debt (and a smart leg's 2 tokens/side) get distinct PK rows. A liquidation row's provenance is a SHARED last-LogLiquidate (tx, log) on the vault, so multiple NFTs liquidated in one window need the NFT id as leg to stay distinct. Non-fluid venues emit '' (one row per wallet/tx/log), the DB DEFAULT.
FWS3 (fluid_event_log ts)D7 table has ts timestamptz NOT NULLThe 90d deploy seed LINEAR-INTERPOLATES ts from the two window endpoints (2 real block reads) instead of ~12,780 per-block getBlockTimestamp reads; the 6h cron fetches ts per block over its tiny windowts is PROVENANCE only — the flow pipeline never consumes fluid_event_log.ts (it re-reads block ts via getBlockTimestamp wherever it values a flow or a state diff). Per-block reads for the seed would add ~an hour; interpolation over a monotone-ish 12s/block chain is accurate to minutes, which is irrelevant for a debugging column. writeFluidEventLog(pool, ev, { tsByBlock }) takes the pre-resolved map.
FWS3 (M15 equity-destroyed clamp)M15: "valued as EQUITY DESTROYED (delta collateral value minus delta debt value at the window anchors)"equity = max(Δcol value − Δdebt value, 0) per mark (clamped at 0)A realized loss is non-negative; float/drift on the two anchor valuations could make the raw difference slightly negative for a near-zero seizure, which the pnl engine would then book as a phantom GAIN (negative realized loss). Clamping at 0 keeps it a loss (or nothing). Verified live on the vault-93 exhibit: the raw difference is +0.0849 (MARKET) / +0.0866 (REDEMPTION), well above 0, so the clamp is inert on the real case.
FWS3 (RPC robustness)dRPC "intermittently returns a timeout error on wide getLogs -> retry"(1) rpcRequest now retries HTTP 408 alongside 429/5xx (dRPC free tier returns 408, not a JSON-RPC timeout, on a wide getLogs); (2) the chain-wide Fluid getLogs uses initialSpan 800 / minSpan 200 (the plan's ≤1000-block cap for dRPC)Without (1) a 408 threw immediately (deterministic-4xx path) and the seed aborted; with it the request retries. A chain-wide (no-address) getLogs is expensive server-side even for few results, so the ≤1000-block cap (2) keeps each chunk inside the free-tier budget; getLogsChunked still halves + retries on any residual timeout. Both are backward-compatible (other callers keep their defaults).
FWS3 (chain-wide getLogs address)Chain-wide LogOperate/LogLiquidate scan by topic0, no address filtergetLogsChunked OMITS the address key from the eth_getLogs filter when it is passed an EMPTY array (address: [])A LogOperate/LogLiquidate has ZERO indexed params and cannot be vault/wallet-filtered, so the scan is topic0-only. Passing address: [] verbatim is ambiguous across nodes; omitting the key is the unambiguous "any address" filter. A present scalar/array address still passes through verbatim (existing callers unaffected).
FWS3 (mid-window NFT ownership)Tracked NFT set attributes each nftId to a registered walletFor an NFT transferred between two registered wallets MID-window, its LogOperate flows are attributed to the wallet from the latest of (snapshot / positionsNftIdOfUser / factory transfer); the M16 transfer rows book the position move itselfThe LogOperate user is a DSA/wrapper (D6) and cannot give per-block ownership; a precise per-block owner split of operates across a mid-window transfer between two tracked wallets is an edge case the transfer rows already cover at the equity level. Documented approximation.
FWS3 (M15 liquidation state-diff span)M15: "diff the position across the WINDOW anchors" (read as: the scan window [fromBlock, toBlock])detectFluidLiquidations diffs each seized NFT across the fixed per-vault LIQUIDATION SPAN [firstLiquidate-1, lastLiquidate] (the vault's first/last LogLiquidate in the window), NOT the scan window (fluid-flows.ts ~1004-1006)The backfill's scan window toBlock is nowBlock, which drifts run-to-run, so window anchors made the synthesized liquidation row NON-deterministic (its equity-destroyed value moved with each re-scan) and let ordinary yield accrued elsewhere in a wide window leak into the seizure diff. Anchoring on the liquidation-event blocks makes the row deterministic and window-width independent: the 6h cron and a wide registration backfill produce the byte-identical row (proven in acceptance (e): the backfill's 0.08490506940988496 / 0.08655403177722576 equals the cron's (c) value), a re-scan is byte-identical (acceptance (d)), and the seizure is isolated from same-window yield (booked as yield in its own interval, not as loss). Builder flagged this in the FWS3 report but the log row was omitted; added here.
FWS3 (review fix, bug, fws3-multiliq-double-count)detectFluidLiquidations collapsed ALL of a vault's LogLiquidates in the scan window into ONE realized-loss row anchored at the LAST liquidate, valued over the FULL span [firstLiq-1, lastLiq] (row 131 + fluid-flows.ts ~958)Emit ONE realized-loss row PER LogLiquidate BLOCK where the tracked NFT decreased, each valued on its OWN span [prevLiquidateBlock .. thisLiquidateBlock] (the first span starts one block before the first liquidate); provenance (tx_hash + log_index) = the LAST LogLiquidate in that block, so the PK stays deterministic and idempotent. New pure fluidVaultLiquidationBlocks groups the distinct liquidate blocks ascending (two LogLiquidates in ONE block collapse to the last). Added fixture EXHIBIT (multiliq) (two partials >6h apart in different 6h intervals): asserts totalYield == -(total equity destroyed) AND `realizedLoss ==totalYield
FWS3 (review fix, bug, legperf-seizure-phantom-yield)legPerformance (assemble.ts ~411-462), which produces the user-facing "yield earned since tracking" column (api-data.getPositions surfaces it as yieldMarket / yieldRedemption), had NO seizure skip, unlike buildBookCurveMirror buildBookCurve's seizure skip inside legPerformance: for a value/pt leg, if a liquidation flow in the passed-in flows prefix-matches the leg's fluid group (or exact key, for a Morpho/Aave/Pendle seizure) in the interval (l0.ts, l1.ts], contribute 0 for that interval. Reuses the now-exported keySeized from pnl.ts rather than duplicating the match. Added an assemble.test.ts fixture: a seized smart NFT's per-leg yields are ~0 (never positive on a debt leg) while the book curve still reports the loss once; proven FAILING pre-fix (a seized supply leg read −10 = the raw value drop, a debt leg read +5 phantom POSITIVE).Without the skip a seized smart-collateral 'value' leg booked its value drop as NEGATIVE yield and a seized smart-DEBT 'value' leg booked PHANTOM POSITIVE yield in the positions table, verbatim the failure M15 names. NOTE (must NOT be reconciled back): this DELIBERATELY makes sum(per-leg yields) != book totalYield. That asymmetry is CORRECT AND INTENDED. A realized loss is NOT yield (M4): the book curve subtracts it ONCE separately as realizedLoss and the chart marks it, and the per-leg "yield earned" column must not contain it. The same asymmetry already holds for Aave/Morpho seizures in buildBookCurve. Index legs are qty-agnostic (index ratio only) so their seizure never leaked into yield and they are not skipped. Stated explicitly in the code comment so a future reader does not "fix" it back.
FWS3 (review fix, docs/ops, 044 deploy hazard)The 044 note (deployment.md ~307) + database.md documented only the ROLLBACK hazard; the reviewer's proposed remedy "apply 044 BEFORE the code deploys" was refuted (deploy.yml is a single atomic ssh script git reset --hard FETCH_HEAD; npm ci && npm run build; pm2 restart, so the 044 SQL arrives on the box WITH the code)Documented the hazard HONESTLY in BOTH directions without prescribing an impossible order. FORWARD (deploy -> migrate): a window, bounded by how soon migrate.sh runs, in which EVERY flow insert fails for EVERY venue (column "leg" ... does not exist, or there is no unique or exclusion constraint matching the ON CONFLICT specification if leg exists but the PK swap has not run); it is LOUD (cron-failure alert), ATOMIC/non-corrupting, SELF-HEALING on the next tick, and the JIT path degrades to stored history (jit:false), so run migrate.sh 044 IMMEDIATELY after the deploy, before the next 6h tick. ROLLBACK: the previous release's 4-column-ON CONFLICT writers are hard-broken for all venues; accepted, staging-first, two-release sequencing considered and rejected. Stated the exact `scripts/ops/migrate.sh "$(grep ^DATABASE_URL= .env.localcut -d= -f2-)"invocation and the one-timescripts/seed-fluid-event-log.ts` run as manual server steps, in order.
FWS3 (orchestrator refinement on the multiliq fix)The multi-liquidation fix valued each realized-loss row on the span [prevLiquidateBlock .. thisLiquidateBlock] (the reviewer's suggestion (a), first span [firstLiq-1, firstLiq])Every liquidation row is now valued on the TIGHT span [thisLiquidateBlock - 1, thisLiquidateBlock][prevLiq .. thisLiq] leaves a later row's value DEPENDENT ON THE SCAN WINDOW: the 6h cron (narrow window, sees only the second partial) anchors it at liq2-1, while a wide registration backfill (sees both) anchors it at liq1 and folds every block of intervening interest accrual into the "equity destroyed". The two writers would then persist different values for the same liquidation, breaking the cron/backfill agreement that acceptance (e) proves. The tight span is the state diff across the liquidation block itself, which is exactly what the seizure did (a LogLiquidate is one tx in one block), so it is window-independent by construction and genuine interest accrual between two partials stays in the intervening interval where it is correctly attributed as yield. Single-liquidation behaviour is unchanged ([block-1, block] either way), so acceptance (c)/(d) re-run BYTE-IDENTICAL (seized 1979925862438313469, equity destroyed 0.08490506940988496 market / 0.08655403177722576 redemption). The multiliq fixture's mock gained the B2-1 anchor it had been missing; its assertions (8 + 8 = 16 total, totalYield == -16, realizedLoss == 16) were already tight-span values and are unchanged.
FWS4 (GAP resolution — smart-leg DEX pool)Pick one: (a) widen loadFluidState to carry supplyDex/borrowDex (read the vault set on-chain once), or (b) have the fluid reader emit + thread/persist the DEX per smart legChose a SCOPED variant of (a): readFluidVaultDexes(vaults, blockTag) (new, readers/fluid.ts) reads getVaultEntireData.constantVariables.supply/.borrow for ONLY the fluid vaults the wallet currently holds (from currentRows' keys), inside api-data.loadQuotedRates; quotedRateForRow stays pure via a new fluidVaultDex: Map<vault,{supplyDex,borrowDex}> table. NOT loadFluidState (it is on the cron/registry path, not the quoted-rate path) and NOT (b)'s persistence.A smart-leg position_key carries the vault but not the DEX pool, and fluid_dex_apy is keyed by pool. Option (b)'s persistence needs a migration + touching all four snapshot/flow writers + SnapshotRow for a value that is inherently a "current advertised" concept; the quoted rate is only shown on the positions table (currentRows). Resolving it on demand from the handful of vaults a wallet holds is O(vaults-held) getVaultEntireData calls on a path that already does a JIT read, keeps FWS4 to the surfacing layer with ZERO schema churn, and is correct for the T4 dual-DEX case (vault 98: supplyDex 0x1DD1…, borrowDex 0x66770…) by reading per-side constantVariables. Best-effort (never throws): a failed read degrades that vault's smart legs to a DASH (M9), never a wrong number. The carry_registry config.poolAddr was REJECTED as a source: it stores only ONE pool (vault 98's is the supply DEX), so a smart-DEBT leg on a dual-DEX vault would resolve the wrong pool — the notes' "do not guess the pool from the token pair" applies. A NORMAL leg's constantVariables address is the Liquidity Layer 0x52aa8994…, mapped to null (FLUID_LIQUIDITY_LAYER), so a normal 6-part key never consults fluid_dex_apy.
FWS4 (quoted-rate composition)Plan: T1 supply = fluid_ll_apy supply + wrapper (both sides); smart = pool fee_apy_usd + LL leg rates, labeled approximateImplemented exactly, and ALSO compose the wrapper token_yield_apy for a SMART leg whose token is a tracked wrapper (e.g. sUSDe in vault 98), summed via sumMaybe. Return type of quotedRateForRow widened from number | null to { rate, approximate }; a Fluid smart leg is the only approximate: true case; the marker surfaces through PositionRow.quotedApyApproximate (api-types.ts) and renders as a leading ~ with a tooltip.The realized value-series growth of a smart wrapper leg embeds the wrapper appreciation too, so the honest advertised comparator adds it (matching the normal-leg + Aave-wrapper both-sides rule, execution-log row 106 — do not regress it). approximate had to ride alongside the rate (same branch decides both), so a struct return is cleaner than a parallel predicate; sumMaybe(null,null)=null keeps a not-tracked token/pool a DASH, never 0% (M9). ETH ALIAS: fluid_ll_apy keys ETH under the pseudo 0xeeee but the reader maps an ETH leg's accounting asset to WETH, so latestFluidLl aliases the pseudo row to WETH — without it a normal ETH leg would quote a false dash.
FWS4 (label — vault id vs NFT id)Plan example wstETH supply · Fluid #16 reads as the VAULT id; FWS2 shipped labelForRow using split(":")[4] = the NFT idRender BOTH (new pure fluid-labels.ts): <sym> <side> · Fluid #<vaultId> (NFT <nftId>) for a normal leg, <sym> LP <col|debt> · Fluid #<vaultId> (NFT <nftId>) for a smart leg; the vault id comes from carry_registry (fluidVaults.vaultId), the NFT id from the key. A vault absent from carry_registry degrades to … · Fluid NFT <nftId>.The notes' "honest option": the vault id names the market (matches /carries), the NFT id names the position (a wallet holds several NFTs on one vault). The plan's terse smart form USDe-USDT LP (col) is approximated as USDe LP col · Fluid #93 (NFT 9266) — the two per-token smart legs (USDe, USDT) MUST differ in a positions table, so the specific token leads and the vault id (which maps to the pair on /carries) names the market; a bare pair label would collide across the two legs. LP marks a smart (DEX) leg vs a normal one; col/debt are the smart-vault sides. Short, mono-friendly, one middot, no em-dash.
FWS4 (wound-down annotation)Plan: wound-down vaults annotated in the positions table (D2), tolerate a carry_registry missannotationForRow (api-data) + fluidStatusAnnotation (fluid-labels.ts) map carry_registry status -> a short note: wound_down->"Wound down", below_floor->"Below floor", blocked/blocked_review->"Blocked"; active/directional/reward_*/absent -> null. Surfaced via PositionRow.annotation AND OutsideGroup.legs[].annotation (api-types.ts), rendered as a small amber tag.D2: a below-floor/wound-down vault still holds live user debt, so its positions are read + valued + shown, annotated in place, never hidden. Annotated in BOTH the positions table (a same-book below-floor NFT, e.g. wallet 0xb0BC's vault 3 wstETH/ETH -> ETH book) AND "Outside the yield book" (a cross-book below-floor NFT, e.g. vault 1 ETH/USDC), since a below-floor vault is often legacy/cross-book. directional/reward_* are vault-shape/reward classifications, not lifecycle risk, so they carry no annotation (avoids noise). A vault absent from carry_registry yields null (tolerated).
FWS4 (UI copy — venue coverage)Plan: remove/adjust any "Fluid not yet tracked" gap-notice if one shippedNone shipped; instead the two venue-coverage copy spots omitted Fluid (PortfolioView EmptyBook + portfolio/page.tsx intro). Added Fluid to both ("Aave v3, SparkLend, Morpho, Pendle, or Fluid"). The coverage-pending reason + label are UNCHANGED (retained in the type/REASON_LABEL for older history that may still carry it, per the plan).Fluid now charts (FWS3 flipped the gate), so the coverage prose must name it. No gap-notice existed to remove. coverage-pending stays honest for any pre-FWS3 history rows.
FWS4 (review fix 1, bug, fluid-smart-debt-fee-wrong-sign)quoted-rates.ts smart-leg branch composed the rate SIDE-AGNOSTICALLY: const dex = side==='debt' ? borrowDex : supplyDex; const fee = fluidDex.get(dex); rate = sumMaybe(sumMaybe(fee, llRate), wrapper) — ADDING the DEX pool fee on BOTH sidesNegate the fee on the debt side: smart-COL fee + llSupply + wrapper (unchanged), smart-DEBT llBorrow + wrapper − fee (const signedFee = side==='debt' ? −fee : fee). Corrected the branch doc comment in quoted-rates.ts and the smart-leg sentences in docs/metrics.md (both the API-summary bullet and the "Fluid quoted rates" section) + docs/data-pipeline.md. Added quoted-rates.test.ts cases (smart-DEBT == llBorrow + wrapper − fee = 0.06; smart-COL == fee + llSupply + wrapper = 0.15) and corrected the existing dual-DEX smart-debt test (0.0641, was 0.0739); proven FAILING pre-fix (debt got 0.16 vs 0.06; dual-DEX debt got 0.0739 vs 0.0641).A Fluid smart-DEBT position supplies debt-side DEX liquidity and EARNS the trading fee, which REDUCES its funding cost — this project's own authoritative carry engine is explicit at carries-table.ts:657-658 (smartColApy: feeApy + weightedSup, smartDebtApy: weightedBor − feeApy), under the comment "Same pool on both legs, so the trading fee is both earned (collateral) and saved (debt) — its net contribution is 2× the per-leg fee." Adding the fee overstated the quoted debt rate by 2× the fee (vault 98 USDC smart-debt quoted 7.89% where the correct figure is 6.91%; vault 77's 8.3% pool fee makes the error several points). Because the realized side of a smart leg is M14 accrual='value' (mark-to-market of the fee-reduced debt shares), the earned-vs-advertised column then showed a spurious "earned cheaper than advertised" gap on EVERY Fluid smart-debt leg, caused purely by the sign.
FWS4 (review fix 2, bug, fluid-smart-quoted-fee-null-renders-not-dash)quoted-rates.ts smart branch composed sumMaybe(sumMaybe(fee, llRate), wrapper), which is null ONLY when ALL operands are null; a smart leg whose DEX pool fee is UNRESOLVED (fee=null) but whose LL rate is present silently DROPPED the fee and rendered a fee-less number: fee=null + llRate 0.0539 → ~5.39% (missing the DOMINANT DEX-fee term); fee=null + llRate 0 → ~0.00%. Deterministically reachable: fluid_dex_apy curates ~40 pools while 80+ smart vaults exist on-chain and D2 tracks EVERY vault a wallet touches, so an off-list pool's fluidDex.get returns null; a transient readFluidVaultDexes multicall failure empties the whole map for every smart leg.For a SMART leg an unresolved pool fee now nulls the WHOLE quoted rate to a DASH (if (fee == null) return EXACT(null)), with approximate: false (a dash needs no ~ marker). The NORMAL-leg branch is untouched (sumMaybe(llRate, wrapper) still nulls only when both are absent — a par token legitimately has no wrapper APY). Aligned docs/metrics.md + docs/data-pipeline.md to describe the shipped behaviour exactly (an unresolved fee — untracked pool OR failed DEX resolution — nulls the whole smart-leg rate to a dash, never a fee-less number). Tests added: fee=null → dash (incl. the ~5.4% case, approximate:false); fee present but llRate null → composed from what exists; normal leg wrapper null → still returns the LL rate.RETRACTION of the docs over-claim (required by the fix task): metrics.md, data-pipeline.md AND the FWS4 GAP row execution-log row 136 all asserted a failed/not-tracked read "degrades to a DASH (M9), never a wrong number." That was FALSE for the shipped code whenever the LL rate was non-null (it rendered a fee-less number), violating M9 ("a failed read is skipped, never written as zero; a missing quoted rate must be a DASH, never 0%") — precisely the over-claim class row 105 records. The code now MAKES the claim true and the docs state it precisely; row 136's "never a wrong number" wording is retracted here and superseded by this fix.
FWS4 (review fix 3, nit, approx-tooltip-omits-wrapper)PortfolioView.tsx approximate-rate tooltip read "Approximate: composed from the Fluid DEX pool fee APY plus the liquidity-layer rate; a smart leg has no single advertised figure." — but the shipped composition ALSO folds in the leg token's own token_yield APY (for a wrapper like sUSDe this term is several percent and dominates), so an analyst could not reconcile the number from the two terms named.Tooltip now names all three composed terms and states the fee's sign honestly on the debt side: "Approximate: the Fluid DEX pool fee APY, the liquidity-layer rate, and the token's own yield. On a debt leg the pool fee reduces the funding cost. A smart leg has no single advertised figure."The tooltip must name exactly what the shipped composition sums (signed fee + LL rate + wrapper) so the earned-vs-advertised comparator is reconcilable; after FIX 1 the debt-side "reduces the funding cost" is honest copy. No em-dashes (AGENTS.md); no CSS help cursor (the tooltip stays a plain title on the span, unchanged).
FWS5 (item 1, Dune reconciliation)Clone q7490982 over ~10 NFTs; write the note into docs/metrics.mdThe orchestrator RAN the reconciliation (recorded in scratch/DUNE-RECONCILIATION.md): q7490982 executed directly (already parameterized per nft_id, no clone) over 5 NFTs spanning T1-T4 (NFT 1, 18343, 9266 with its liquidation, 18557, 18524), 3.573 credits (budget ≤200). I wrote the summarising NOTE as a new ### Dune reconciliation (FWS5, dev-time cross-check) subsection in docs/metrics.md (before "Where each metric is read"), capturing: the EXPECTED per-NFT liquidation blindness on 9266 (Dune's event-sum reports beforeSupply 46408309641881485000, our resolver the true post-seizure 42345540526148139088 — we disagree by exactly the seized amount); LL-vs-vault exchange-price drift on NFT 1 (2.4e-4 coll / 1.2e-3 debt); the third-party matview coverage gap (NFT 1 coll_usd=0 / pnl_usd=-332.74 for a live 0.109-ETH position); Dune omitting accrual + dustBorrow (NFT 18524); why NOT to adopt Dune's unfloored exp(sum(ln)) TWR (the linkTwr defect PR #348 fixed, row 45); and the AGREE cases (18343/18557/18524).Only 5 NFTs were needed to exercise all four vault types plus the liquidated 9266 (well under budget); "clone" was unnecessary since the query is already per-nft_id. The note is factual, em-dash-free, and every discrepancy is shown to favour our engine.
FWS5 (item 2, invariant sweep)Extend the property test with a fluid T1 leg, a fluid smart 'value' leg, and a post-fix PT leg (entry at discount)Added THREE scenarios to invariantScenarios() in src/lib/portfolio/pnl.test.ts (run under the SAME INVARIANT: attributedYield == ΔbookValue − netFlow ... test): (a) a fluid T1 wstETH/ETH supply accrual='index', 6-part key, indexRaw = composeIndex(vault-exchange-price-1e12, wstETH rate) grown between snapshots (LL interest x wrapper appreciation), book ETH; (b) a fluid smart accrual='value', indexRaw=null, real 7-part key fluid:vault:<V93>:nft:9266:<USDe>:supply, book value 100->152 with a +50 mid-window deposit netted out (DEX-fee yield 2); (c) a post-fix PT entered at a DISCOUNT accrual='pt', acquisition flow valued at the discounted fill (market 92 / redemption 95), pulled to par (market 96 / redemption 97). Residuals (buildBookCurve + signedFlowValue, both marks): (a) market 1.95e-14 / redemption 2.31e-14; (b) 0 / 0; (c) 0 / 0 — all under the 1e-6 gate. The M15 smart-col+smart-debt liquidation fixtures FWS3 added were NOT touched and still pass.The three new accrual paths (M14 fluid index, M14 fluid smart value, M11/M12 discounted PT) must keep "yield is the only thing that moves a flow-free leg's book value"; each passes in BOTH marks, proving the entry basis + M12 redemption curve are self-consistent net of the discounted acquisition flow, and the fluid composed-index / value-series attribution holds. No new test() block (scenarios ride the existing property test), so the suite count is unchanged.
FWS5 (item 3, alert coverage)(a) an unknown Fluid token with nonzero value must hit the WS8 unknown-asset alert; (b) a matured-PT par fallback must log distinctly(a) VERIFIED, no code change: refreshPortfolio passes EVERY snapshot row to alertUnknownAssets after filtering only known-PT underlyings (reg.ptUnderlyings[...] == null); a fluid leg with book=EXCLUDED and value_market>0 (FLUID governance token, PAXG) is not a PT underlying, survives the filter, and unknownAssetsWithValue (book===EXCLUDED && valueMarket>0) admits it. Proven end-to-end against a LOCAL HTTP stub (ALERT_TG_API_BASE, real fetch, no Telegram): FLUID (~$12,345) and PAXG (aggregated ~$7,789 across 2 wallets) POST to the stub; a known PT, an included USD leg, and an M9 null-price EXCLUDED leg do not. (b) FIXED: snapshot.ts now emits a distinct, greppable console.warn with the stable prefix [portfolio/pendle] matured-PT par fallback ... at the M13 par-substitution site (reader TWAP reverts post-maturity -> ptToAssetRate null -> value at par rate=1), deduped to ONE line per (market, block) via a module-level Set so a whale holding one matured PT across many wallets does not spam.(a) was already correct (the WS8 filter is PT-specific, never venue-specific), so the deliverable was proof, not a fix; the stub proof exercises the real scripts/ops/alert.ts code via the caller's exact filter. (b) the par-fallback previously logged nothing, so an operator could not confirm a matured position was being marked at par (expected) versus silently dropped; the deduped warn makes it observable without noise.
Post-launch (headline return metrics)Headline tiles per book show cumulative yield | realized APY (>=30d gate) | book value (plan WS6; api-types SummaryBook.market/redemption.realizedApy)Replaced the tiles' annualized realized APY with TWO non-annualized metrics: cumulative yield | realized return | TWR | book value. Added a pure realizedReturn(curve: BookCurve): number | null in pnl.ts (= totalYield / baseCapital, baseCapital = the first curve point with bookValue > 0, scan ascending; null when none is positive → dash, M9). BookHeadline (assemble.ts) dropped realizedApy + meetsAnnualizeGate, added realizedReturn + twr (= curve.twr). Wire BookMarkHeadline (api-types) now carries { cumulativeYield, bookValue, realizedReturn, twr, realizedLoss } (was …, realizedApy); getSummary updated; observedDays kept on SummaryBook. Extracted the tiles into src/components/portfolio/HeadlineTiles.tsx (four tiles + economics tooltips via native title, en-dash for null, mono tabular-nums) so they render/unit-test without pulling Recharts; PortfolioView imports it. Dropped the 30-day gate on the tiles (both returns honest at any length). Removed the now-unused realizedApyDashTooltip + ANNUALIZE_GATE_DAYS from theme.ts; added CUMULATIVE_YIELD_TOOLTIP / REALIZED_RETURN_TOOLTIP / TWR_TOOLTIP. Tests: 5 realizedReturn cases in pnl.test.ts (no-flow≈twr; mid-window deposit DIVERGES; funded-later base = first positive; wiped/negative base = first positive, return negative; all-nonpositive → null); rewrote the assemble.test.ts bookHeadline assertion; repurposed theme.test.ts to the three tooltips (no em-dash, key economics); new HeadlineTiles.test.tsx render test (labels, both %s, tooltips, no em-dash, no help cursor, null → dash); added it to package.json "test". Docs: docs/portfolio.md + docs/metrics.md (M8 + summary bullet). Gates: tsc clean; npm test 482 → 491; npm run build clean; docs build clean.The product owner chose these exact definitions: realized return is a SIMPLE holding-period return (cumulative yield as a % of first-deployed capital, NOT annualized, NOT money-weighted — MWR/IRR stays excluded, M8), and TWR is the flow-neutral engine curve.twr surfaced as a %. The two match with no flows and diverge once capital moves mid-period, which the APY-only tile could not express. realizedReturn bases on the first POSITIVE point so a book born mid-series (0 opening equity) or starting underwater gets a real denominator, never 0/Infinity, and an all-nonpositive book honestly dashes (M9). Surfaced with plain, em-dash-free tooltips for the TradFi-analyst audience (native title, never the help cursor).
Post-launch (headline return metrics — SCOPE)(scope decision, settled by the orchestrator)This replaces realized APY ONLY on the headline tiles (SummaryBook / HeadlineTiles). The positions table "earned vs advertised" column KEEPS its per-leg annualized realizedApy (PositionRow.realizedApyMarket/Redemption, legPerformance.realizedApy, annualizeTwr) and the 30-day gate; quoted-rates, quoted-APY, and legPerformance were NOT touched. BookCurve.realizedApy (engine output) and annualizeTwr are retained.The positions-table APY is a like-for-like comparison against ADVERTISED ANNUAL rates and would be meaningless against a non-annualized return; only the per-BOOK headline metric is the product owner's target.
Post-launch (return metrics, review fix, bug: twr-cannot-compute-renders-zero-not-dash)The headline TWR tile passed curve.twr straight through as a non-nullable numberAdded the pure realizedTwr(curve): number | null in pnl.ts and route the headline TWR through it (BookHeadline.twr / BookMarkHeadline.twr widened to number | null)linkTwr returns 0 in BOTH a genuine 0% return (a positive-equity sub-period that netted to zero) AND an UNDEFINED TWR (no sub-period ever had a positive equity base, e.g. a leveraged carry net-underwater at every observed snapshot under one mark). Because twr was non-nullable, PctValue's null->dash branch was unreachable for it, so an undefined TWR rendered a definitive green +0.00% right next to a Realized-return tile that CORRECTLY dashed the same book (M9 violation + a self-contradictory pair). realizedTwr returns curve.twr iff some interval-start snapshot had bookValue > 0 (exactly linkTwr's own any condition, reconstructed from the public points: an interval's start equity is the book value at every point except the last), else null. A genuine 0% (positive base, zero net) still shows 0.00%; only the truly-undefined case dashes. linkTwr / annualizeTwr / legPerformance / the positions-table APY are untouched (the fix is display-nullability at the headline layer only, preserving the scope decision). Confirmed by two independent review lenses and both adversarial verifiers; verified live (bookVals [-10,-5] -> realizedReturn dash AND twr dash; [100,100] net-0 -> twr 0.00% shown). Tests: pnl.test.ts (realizedTwr dashes on no-positive-base, passes a genuine 0% through).
MW-WS1Plan §2: "Tests: vitest, colocated *.test.ts(x) (npm test)"Tests are node:test (node --import tsx --test) driven by an EXPLICIT file list in package.json "test"; new test files must be appended there or they never run. Added src/lib/portfolio/wallets.test.ts + scripts/refreshers/portfolio.test.ts to that list.Plan inaccuracy (no vitest in the repo). Flagged because a WS2-WS6 agent writing a colocated test and running npm test would see it pass without the file having executed.
MW-WS1§3.3 requeue predicate: requeue when "status 'error'/'empty', or no snapshot within the last 2 aligned 6h windows"Added a guard the plan omits: the row is requeued only when it is terminal — WHERE b.status NOT IN ('queued','running') AND (error/empty OR no snapshot inside 12h).As literally specified, a 'running' row (mid-first-backfill, so it has no recent snapshot) SATISFIES the stale arm and would be flipped to 'queued' — the minutely drain would then claim it while its child is still replaying, racing two full delete+insert backfills over one wallet. Skipping 'queued' additionally preserves the row's FIFO position (the drain orders by updated_at), so re-adding a wallet cannot push an already-pending backfill to the back of the queue. Both branches proven against a local PG16 scratch DB (042/043/045/048).
MW-WS1§3.3 "flip status back to 'queued' (also reset attempts to 0)"Also clears error; deliberately does NOT touch floor_ts.A requeued row that keeps its old error string reads as failed while it is pending. floor_ts is the "tracked since" anchor the backfill child rewrites on its terminal transition; blanking it at requeue time would empty the methodology footer for the whole replay window, so the stale-but-true anchor stands until the replay lands.
MW-WS1Decision 2: "A shadow account is distinguishable from a real user: last_seen_at IS NULL (only SIWE sign-in sets it)"True of the WRITERS, but not a safe reading of existing data: migration 042's seed inserts chat-era accounts with last_seen_at NULL (its INSERT ... (uid) SELECT ... never stamps it), so last_seen_at IS NULL today means "shadow OR chat-seeded and not signed in since 042".Load-bearing for §3.5: those accounts are real users' and must keep being snapshotted. They are rescued by 048's self-row seed (they match the EXISTS(account_wallets) arm), so the predicate is correct as written — but only BECAUSE of the seed. Pinned by a test (048 seeds a self-row for every pre-existing account) so a future edit cannot drop the seed and silently freeze those users' portfolios. Risk #2's "audit any admin/metrics query that counts accounts rows as users" is unaffected and still open for WS6.
MW-WS1§3.6: "Add account_wallets to the combined TRUNCATE in scrub-staging-pii.sql:61"Replaced that block's static IF/ELSIF ladder with a dynamic to_regclass list (build the present-tables array, one EXECUTE 'TRUNCATE ' || ...) — the same idiom reseed-staging.sh's leak check already uses. Also added account_wallets to that leak check (the plan does not mention it).accounts now has TWO FK referrers, and a dump can predate any of 042/043/048, so a static ladder needs one branch per combination. The plan's claim that the scrub would otherwise start FAILING is confirmed exactly: with account_wallets present, the old 4-table statement errors cannot truncate a table referenced in a foreign key constraint. Table "account_wallets" references "accounts". Verified both directions on the scratch DB: shipped scrub exits 0 and empties all five tables; it also still exits 0 on a pre-048 schema. The leak-check addition is the same rule as the scrub (the account↔wallet mapping is the private part of this feature).
MW-WS1§3.3 enqueueOrRequeueBackfill(uid)Signature is enqueueOrRequeueBackfill(uid, chainId) — chain id is an explicit arg, not an import of CHAIN_ID from ./registry.enqueue.ts is imported by the SIWE verify route; registry.ts's module graph pulls the venue readers and rpc-batch, which has no business in the sign-in path. Same precedent as row 44 (pnl.ts re-declaring rather than importing apy.ts to avoid its DB import). Callers pass registry.CHAIN_ID.
MW-WS1§3.1 migration DDL (4 CHECK constraints)Added a fifth: CHECK (label !~ '[[:cntrl:]]').§4.1 already requires the API to "reject control characters"; enforcing it in the schema means no writer can persist one. Additive, cannot break WS2 (which rejects them before the insert anyway).
MW-WS1§3.4: call ensureSelfWalletRow(address) "after upsertAccount" inside the verify route's best-effort blockCalled LAST in that block (after enqueueBackfill), not immediately after upsertAccount.Staging's deploy runs migrate.sh AFTER the build+restart, so there is a window where the new code runs against a schema without account_wallets. Ordering the new (throwing) statement last means that window costs a sign-in nothing: the account upsert and the backfill enqueue have already committed, and the failure is logged and swallowed by the existing guard. The next sign-in re-seeds the self-row, and 048's seed already covers every pre-existing account.
MW-WS1§3.7 tests (behavioral: "fresh wallet → queued; done+recent → untouched; …"; "shadow referenced → eligible; unreferenced → excluded")CI has no Postgres, so the COMMITTED tests are CI-safe: pure statement-shape assertions (repo convention, cf. enqueue.test.ts) + loadEligibleWallets driven by a fake pool (cf. backfill-queue.test.ts). The behavioral truth tables the plan asks for were executed against a local PG16 scratch DB carrying 042/043/045/048, importing the SHIPPED statements (no copies): 12/12 requeue cases and 9/9 eligibility cases pass, including "un-tracking prunes no history" and "re-add of a stale wallet requeues the full replay".The repo has no DB test harness and CI runs tsc + npm test with no Postgres service, so a DB-backed test would be red in CI. The shape tests pin every guard that the DB run proved (the NOT IN ('queued','running') race guard, attempts = 0, the 12h bound, both eligibility arms), so a regression fails CI even though the semantic proof was out-of-band. Migration also verified: applies cleanly on the current schema and is idempotent on re-run.
MW-WS1§8 puts ALL docs in WS6Updated docs/database.md (the account_wallets row + the five-table scrub note) and docs/data-pipeline.md (the new eligibility predicate + the scrub) in THIS PR; left docs/portfolio.md (product shape, aggregation semantics) to WS6.AGENTS.md §6 / deployment.md step 2 are explicit: a change that touches the schema or a refresher updates the matching docs/ page in the SAME change. WS1 does both. The product-level docs genuinely belong in WS6, where the UI exists to describe.
MW-WS2 (BUG, caught by the DB)§4.1 "enforce MAX_TRACKED_WALLETS (400)"First implementation enforced the cap inside the INSERT (INSERT ... SELECT ... WHERE (SELECT count(*) ...) < cap, ON CONFLICT DO NOTHING). That is not atomic, and a concurrency check against a real Postgres proved it: under READ COMMITTED two concurrent adds each see the pre-insert count, neither conflicts on the PK (they are DIFFERENT wallets, so ON CONFLICT never fires), and BOTH land — 4 rows at a cap of 3. Rewrote addWallet to run the whole add in ONE transaction behind a per-account transaction-scoped advisory lock (pg_advisory_xact_lock(hashtextextended('onchain_credit.account_wallets:' || $1, 0))), the same idiom as write-lock.ts, with the RPC (currentBlockNumber) moved BEFORE BEGIN so a slow node can never hold the lock.Exactly the failure risk #4 names ("do not write code that only survives because the cap is small"). A read-then-write cap is only safe under a lock, and the SQL that looks atomic is the trap. Invisible to CI (no Postgres, and the race needs two live transactions), so it is pinned two ways: the shipped ADD_WALLET_LOCK_SQL is exported and unit-tested (xact-scoped, keyed per account), and the scratch-DB run asserts "two concurrent adds into ONE free slot -> exactly one succeeds, cap not exceeded". Keyed per account, not globally, so two users adding wallets never block each other.
MW-WS2§4.3 "Route-level tests in the repo's existing API-test style"There is no route-level test precedent in the repo (no src/app/api/**/*.test.ts exists) and CI has no Postgres, so route tests would need both an HTTP harness and a DB. Instead: the logic lives in wallets.ts as pure validators + result-typed functions ({ok:false, reason}), the routes are thin mappers from reason to status code, and the committed tests cover the pure surface plus every guard that short-circuits before the module touches the DB. DATABASE_URL is unset under npm test, so query() throws on contact: a guard that stopped short-circuiting makes those tests THROW rather than quietly pass. Everything DB-shaped (list order, synthesized primary, atomic cap, duplicate, resolver 401/403/back-compat, unlink-without-pruning) was run against the scratch DB: 39/39.Matches how the repo already tests an I/O module (valuation-sources.test.ts tests only the pure exported surface). Keeps CI green without a Postgres service while still proving the semantics the plan asks for.
MW-WS2§4.1 add flow: "ensureTrackedAccount(addr) -> insert account_wallets row -> enqueueOrRequeueBackfill(addr)"Same steps, but the self-row is written FIRST (inside the lock, before the count) and the shadow account is created only AFTER the cap/duplicate check passes.(a) The cap counts the account's OWN wallet (Decision 4), so if the self-row were missing the account could add MAX wallets on top of its own. Writing it first inside the lock makes the count correct by construction. (b) Creating the shadow accounts row before the cap check would leave a junk account row behind on every rejected add — inert (a shadow with no referencing link is not cron-eligible, WS1) but pointless, and accounts is the table admin/metrics queries read as "users" (risk #2).
MW-WS2§4.1 PATCH/DELETE "404 if not in the account's list"A malformed/unparseable [address] is also a 404, not a 400.The caller is naming a wallet this account does not track either way. Returning 400 for "malformed" and 404 for "not yours" leaks which addresses exist in someone's list; one answer for both is simpler and says nothing.
MW-WS2§4.2 resolver: "Load the account's wallet set from account_wallets (fallback: the session address alone…)"Implemented as: always ensure the SESSION address is in the returned set (unshift it when the self-row is absent), rather than replacing the whole set with [session] when the query returns nothing. listWallets likewise SYNTHESIZES the primary entry when the self-row is missing.The fallback's purpose is "an account whose self-row has not landed can still see its own portfolio". Swapping the entire set for [session] would ALSO be wrong when the set is non-empty but merely missing the self-row (it would silently drop the tracked wallets). Making the session address always present is the invariant the plan actually wants, and it keeps a read path from having to repair data.
MW-WS2Docs deferred to WS6 (§8)Documented the four wallet routes + the watch-only / unlink-only semantics in docs/portfolio.md now, and explicitly noted that the five read routes still serve the SESSION wallet until ?wallet= lands.AGENTS.md's docs-in-the-PR rule again (this PR ships a new API surface). Deliberately did NOT document ?wallet= / all yet: those routes do not accept it until WS3/WS4, and a doc that describes an unshipped parameter is just a lie with a timestamp.
MW-WS3§5.3 tests ("summary/positions/history for a tracked wallet return that wallet's data; 403 for an untracked address; omitted param still serves the session wallet")Proven by calling the REAL exported route handlers (GET as summaryGET etc.) with a real NextRequest against a scratch DB carrying every migration + two wallets' snapshot fixtures: 22/22, asserting on SummaryResponse.address (no param -> session wallet; ?wallet=<tracked> -> that wallet; checksummed spelling -> same wallet; ?wallet=<untracked> -> 403 on all four GETs; ?wallet=all -> 400; signed out -> 401 on all four) plus a check that the two wallets' payloads genuinely DIFFER (not just the address field). Committed to CI: src/lib/portfolio/api-routes.test.ts, the signed-out 401 on all six routes, which needs no DB.Route handlers are plain (req) => Response functions, so they can be driven directly without an HTTP server -- that turns out to be a much better test than mocking the data layer, since it exercises the real resolver + route wiring. The 401 half is CI-safe and is the regression that matters most (WS3 changed the auth path of all five routes at once); DATABASE_URL is unset under npm test, so a route that reached the DB on the signed-out path would THROW rather than pass. The test file lives in src/lib/portfolio/ rather than under src/app/ so nothing in the Next app directory is a non-route file.
MW-WS3§5.1 "treat selection === "all" as 400 ("not yet supported") behind a small guard that WS4 removes"Done exactly, on all five routes, with the message "Aggregate view is not available yet."Kept the WS3/WS4 split rather than landing them together (the plan allows either). The 400 is deliberate: silently serving ONE wallet's numbers under an "All wallets" selection would be a wrong answer, and the frontend does not offer the option until WS5 anyway.
MW-WS3§5.1 "Replace the inline verifySessionCookie + fixed address with resolveWalletSelection"Done on the five data routes. The four wallets CRUD routes (WS2) deliberately KEEP verifySessionCookie.Those routes resolve the ACCOUNT, not a wallet selection: they take no ?wallet=, and the [address] they act on is the wallet being managed. Running them through the selection resolver would be wrong (it would 403 an address the caller is trying to ADD). Added WALLET_SELECTION_ERRORS so the five data routes answer a bad ?wallet= identically instead of each inventing its own message.
MW-WS4§6.2 "reuse the exact getHistory internals (extract its curve->points block into a helper rather than duplicating)"No extraction. getHistoryAll calls the EXISTING getHistory(w, book, mark) once per wallet and merges the HistoryResponses (daily points via mergeDailyPoints, markers concatenated + re-sorted).Merging at the RESPONSE level is strictly more outputs-only than extracting an internal helper, and it needs no surgery on a working function: HistoryResponse.points is already the daily-reduced, null-carrying series the merge rule is defined on. Degenerate equivalence is exact (getHistoryAll([w]) returns byte-identical points to getHistory(w)), which is the property the extraction was meant to protect.
MW-WS4§6.3 TWR: "rebuild intervals on the merged series ... If the interval plumbing cannot be reused without copying engine internals, fall back to twr: null"The fallback was NOT needed: an honest aggregate TWR ships. buildBookCurve pushes its point and its interval from the same two quantities (pnl.ts:654-656), so for every i intervals[i].startValue === points[i].bookValue and intervals[i].yield === points[i+1].cumulativeYield - points[i].cumulativeYield hold EXACTLY. intervalsFromPoints reconstructs them from the MERGED series and chains with the engine's own exported linkTwr.This is the engine's algebra, not a copy of its internals: the reconstruction is a published invariant of the curve, and it is pinned by a test that asserts the reconstructed intervals reproduce curve.twr to 1e-12 on real engine output. Null when no interval ever had a positive equity base, mirroring realizedTwr (a dash, never a definitive 0%).
MW-WS4§6.3 realizedReturn: "if the base-capital denominator is exposed on the curve ... aggregate exactly; reuse, not reimplement, the edge-case handling"BookCurve does NOT expose it (realizedReturn re-derives it per call by scanning points for the first bookValue > 0, pnl.ts:690-700). aggregate.ts replicates that 4-line scan per wallet and computes Σ totalYield / Σ baseCapital.The scan is not reusable as an export (there is none), so the choice was replicate-the-scan or back it out arithmetically as totalYield / realizedReturn(curve) -- the latter divides by zero for any wallet with zero yield. Replicating the scan on OUTPUTS keeps the null edge case identical (no positive base anywhere -> dash, M9). Capital-weighting is the same formula as the single-wallet definition with both sides summed; a mean of per-wallet returns would let a $10 wallet swing a $10M one, and that is pinned by a test.
MW-WS4§6.2 "aggregate point is null iff any wallet whose series spans that ts reports null"Implemented, plus one case the plan does not name: a day that no wallet spans (a hole between two wallets' non-overlapping histories) is also null.The plan's rule sums "contributions" and nulls on a spanning wallet's null; with no spanning wallet at all the empty sum is 0, which would print a confident zero for a day we know nothing about. Null is the honest answer (M9: a dash/break, never a fabricated 0). Pinned by a test.
MW-WS4§6.3 "jit = any"Implemented as some per the plan.Noting it as a judgement call, not a deviation: jit drives the dim "LIVE SYNC" note, and with per-wallet rate limits it is normal for one wallet's live read to land while another's is still warming. every would hide the note almost always at a cap of 3; some says "at least part of this view reached the current block", which is what the note means.
MW-WS4§6.1 "loadContext(w, {live:true}) per wallet via Promise.all (<=3 wallets)"Done. Known, accepted cost: 3 of loadContext's 8 queries are wallet-INDEPENDENT registry reads (PT underlyings, PT markets, Fluid state), so they run once per wallet rather than once per request -- at the cap of 3, six extra cheap DB reads per aggregate load. getPositionsAll likewise re-runs loadQuotedRates' wallet-independent tables per wallet.Deliberately NOT memoized: a request-scoped cache is another lifetime to get wrong, and a process-lifetime one can go stale against a registry the 6h cron rewrites. Six extra indexed reads on a force-dynamic route is not worth that. If the cap rises materially, hoisting the registry reads into the orchestrator is the obvious first optimisation.
MW-WS4§6.6 refresh: "Promise.allSettled of liveRefreshWallet(w) per wallet under the existing 30s race; report refreshed: true if any succeeded"Done exactly. The single-wallet path now runs through the same code (a one-element array), so there is only one refresh implementation to reason about.Risk #4 ("do not write code that only survives because the cap is small"): concurrent + allSettled is correct at any cap, whereas sequential refreshes would blow the 30s bound as soon as the cap rose. allSettled so one wallet's failed read cannot sink the others'.
MW-WS5§7.6 "ManageWalletsDialog add-flow validation states" as a render testSplit the component into ManageWalletsDialog (the Base UI Dialog + portal chrome) and an exported ManageWalletsPanel (all the list, copy and rules), and test the PANEL.Base UI renders a dialog through a PORTAL, which emits NOTHING to renderToStaticMarkup -- the first version of the test asserted against an empty string and "passed" nothing, then failed once the assertions were real. The split is better structure anyway (the portal chrome carries no logic), and it follows the precedent that HeadlineTiles was extracted from PortfolioView precisely so it could be render-tested.
MW-WS5 (BUG)§7.5 "PositionsTable: when selection === "all", prepend a Wallet column"Also changed the row's REACT KEY from r.positionKey to `${r.wallet ?? ""}:${r.positionKey}`.The column alone is not enough. position_keys are wallet-agnostic, so in "All wallets" two wallets holding the same Aave USDC supply produce two rows with the IDENTICAL positionKey -- and React drops one of them as a duplicate key. The aggregate would then silently show one row where there are two, understating the book on screen while the tiles (which sum correctly) disagree with it. Pinned by a test that asserts both rows render for one shared positionKey.
MW-WS5§7.1 PortfolioClient renders <PortfolioView address={sessionAddr} selection={...} wallets={...} onSelect={...} onWalletsChanged={refetch} />PortfolioView takes selection/wallets/onSelect/onManage and NO address/onWalletsChanged. The dialog is rendered by PortfolioClient, which owns the list.address became dead once the header showed the SELECTED wallet rather than the session one (the account is the session cookie server-side regardless), and onWalletsChanged belongs to whoever renders the dialog. Keeping dead props would invite a future reader to wire the wrong wallet into a fetch.
MW-WS5§7.1 "re-run the mount+syncLive effect when selection changes (bump epoch)"Every fetch is scoped by q = wallet=<selection>, q is the dep of the mount effect and of syncLive, the history cache key carries the selection, AND the mount effect RESETS the per-wallet refs (liveLanded, bookDefaulted, lastSyncMs) on every switch.Risk #5 in the plan ("a missed dep shows wallet A's chart under wallet B's header") is really two bugs, and the second is the subtle one: liveLanded is a ref, not state, so without the reset the PREVIOUS wallet's landed live read would suppress the new wallet's mount fetch entirely (if (!alive || liveLanded.current) return) and the view would sit on stale data. bookDefaulted likewise would strand the reader on a book the newly selected wallet does not hold.
MW-WS5§7.2 MAX_TRACKED_WALLETS used by the dialogMoved the constant (and MAX_LABEL_LENGTH) from wallets.ts into api-types.ts; wallets.ts re-exports them so every server import site is unchanged.wallets.ts is SERVER-ONLY (it imports pg, next/server, the session layer). A client component importing the cap from it would drag the DB layer into the browser bundle -- exactly what api-types.ts's own header warns against ("PURE types only, so a client component can import them without pulling the server-only DB layer into the browser bundle"). api-types.ts already carries runtime constants (REAL_BOOKS, ALL_WALLETS), so this is where a shared constant belongs. Verified by npm run build compiling clean.
MW-WS5§7.4 "poll getSummary every 20s (max ~15 min, cleared on unmount/selection change)"Done, keyed on summary.syncing + the selection, with the cap enforced from the poll's own start time.As specified. The cap matters more than it looks: a wallet parked in error never flips syncing to false, so an uncapped poll would run for as long as the tab is open.
MW-WS6 (HAZARD, found in WS6)§8 ops: "no crontab changes ... Confirm staging scrub covers account_wallets and fixtures stay cron-eligible"Found and fixed a deploy hazard the plan does not mention: the deploy ships CODE BEFORE SCHEMA (staging builds + restarts before migrate.sh; prod migrations are a gated MANUAL step AFTER the release deploy), and resolveWalletSelection -- which every one of the five portfolio routes now calls, OUTSIDE its try/catch -- queries account_wallets. Reproduced against a pre-048 database: GET /api/portfolio/summary THROWS, so the ENTIRE portfolio surface 500s between the deploy and the migration, not just the multi-wallet parts. Fixed by tolerating 42P01 (undefined_table) on the three read paths, degrading to exactly the pre-multi-wallet behaviour: the resolver serves the account's own wallet (and still 403s a foreign ?wallet=, never a 500), listWallets returns the primary alone, and the 6h cron falls back to the pre-048 eligibility predicate with a loud warn rather than losing the tick (which would punch a hole in EVERY wallet's history for a schema change that had not happened yet). Only "relation does not exist" is swallowed -- a permission or syntax error still throws, so a real fault cannot masquerade as "you have one wallet".This is the same expand/contract discipline AGENTS.md already requires ("keep migrations backward-compatible with the previous release, since a code rollback does not roll back the DB") applied in the other direction: the NEW CODE must survive the OLD SCHEMA, because the deploy pipeline cannot sequence the migration first. The repo already has both precedents -- the newsletter writer's pre-046 42703 fallback and carries-table.ts's information_schema probe for the pre-047 columns, whose comment spells out the very same "build runs BEFORE migrate.sh" ordering. Verified end-to-end on a pre-048 DB (surface stays up, 403 still enforced, cron falls back) and pinned by two CI tests (fallback fires on 42P01; any other error still fails the tick). Documented in deployment.md.
MW-WS6§8 docs: "update ... AGENTS.md if it inventories portfolio routes/tables"No AGENTS.md change.It does not inventory them: its route table lists only the four PUBLIC section pages (/, /asset-coverage, /carries, /strategies, /money-market-rates), and the auth-gated /portfolio and /agent surfaces are absent from it entirely, as are all portfolio tables. Adding them would be a broader edit than this feature (the whole portfolio surface is missing from that file), so it is left to whoever owns that gap.
MW-WS6§8 docs, all deferred to WS6Docs were written INTO each workstream's PR instead (database.md + data-pipeline.md in WS1 when the schema and the refresher changed; portfolio.md's API section in WS2; ?wallet= in WS3; the aggregation semantics in WS4; the UI in WS5), and WS6 only adds the deployment note + the status line.AGENTS.md's docs-in-the-PR rule ("if you touched a page/metric/schema/refresher, update the matching docs/ page in the same change"). It also keeps each PR self-consistent: WS2's docs deliberately did NOT describe ?wallet=, because those routes did not accept it until WS3, and a doc that describes an unshipped parameter is just a lie with a timestamp.
MW-REVIEW (BUG)WS4 §6.2/§6.3: the chart nulls an absent wallet, the tiles sum BookCurvesThe tiles and the chart applied OPPOSITE rules to a wallet that has EXITED a book, and the tiles' answer was fabricated. The snapshot writer emits a row only for a leg that currently exists, so a wallet that closes a book simply stops producing rows and its BookCurve ends at its last held value. stateAt carried that value forward for ever (a wallet that closed a $250k book kept adding $250k to the aggregate's book value, and to the denominators of realizedReturn and TWR), while mergeDailyPoints dropped the wallet entirely, so the chart under the tile disagreed with it. Both paths now share ONE rule: past a wallet's last point its book value is 0 (rows stopping means the book was exited) and its cumulative yield carries forward (yield already earned does not un-earn).Two contradictory numbers on one screen, one of them invented, is the exact failure mode the M9 honesty rules exist to prevent -- and it is worse than a dash. Degenerate equivalence is unaffected (with one wallet the merged grid ends at that wallet's last point, so the past-last branch never fires), and a test now pins chart-last == tile. A wallet whose backfill is mid-flight is also briefly excluded from the 6h pass and so can look "exited" for a minute or two; that is precisely when syncing is true and the view says the history is still building.
MW-REVIEW (BUG)WS2 §4.1 add flow: "insert account_wallets row -> enqueueOrRequeueBackfill(addr)"The enqueue ran AFTER the transaction committed. Moved INSIDE it (on the transaction's own client, via the pure statement builder).As written, a failure in the enqueue returned a 500 while the wallet was ALREADY committed as tracked: it sat in the user's list with no backfill row, no archive replay and no way back, because a retry hits the duplicate check and 409s. The link and its history now commit together, so the add is all-or-nothing. Proven against a real DB.
MW-REVIEW (BUG)WS1 §3.4: sign-in keeps enqueueBackfill (insert-once)Sign-in now calls enqueueOrRequeueBackfill.Insert-once was right when the only way to get a portfolio_backfill_state row was to sign in. This feature makes a row possible for a wallet that has NEVER signed in (someone else tracked it, minting the shadow account). If that person un-tracks it, the wallet stops being snapshotted and its history goes stale -- and when its real owner later signs in for the first time, INSERT ... ON CONFLICT DO NOTHING no-ops on the existing row, so they get a permanent hole in their chart that nothing repairs (they cannot even re-add their own wallet: it is the primary, so it 409s). The requeue arm is the statement this feature already wrote for exactly this case; it was simply never wired into the path where the collision it created actually lands.
MW-REVIEW (BUG)WS5 §7.1: "re-run the mount + syncLive effect when selection changes"Added a per-selection cancellation guard: syncLive records WHICH selection it is running for, and every async write checks it is still painting into the wallet on screen.Threading selection through the deps was necessary and not sufficient. A live refresh legitimately takes tens of seconds (a 30s bound, retried), so a user can switch wallets mid-flight -- and the OLD wallet's summary and positions then landed under the NEW wallet's header, the precise failure risk #5 names and the one the prop's own comment claims is impossible. The in-flight guard was also GLOBAL, so the newly selected wallet's sync was skipped entirely and it never got a live read at all.
MW-REVIEW (BUG)WS4 §6.5: "widen the predicate to wallet = ANY($…) -- correct pagination for free"Not free: added wallet, tx_hash, leg to the ORDER BY.(block_number, log_index) stopped being unique the moment the predicate widened. A single ERC-20 transfer BETWEEN two tracked wallets is ONE log that the ledger deliberately stores as TWO rows (the PK carries the wallet), so they tie -- and Postgres may order a tie differently between the page-1 and page-2 queries, showing one row twice and silently dropping the other. The remaining PK columns make the sort a total order.
MW-REVIEW (BUG)WS4 §6.6 refresh: "Promise.allSettled … under the existing 30s race"The bound is now PER WALLET, not around the whole set.Racing one timeout against Promise.allSettled(...) waits for the SLOWEST wallet, so one slow read reported refreshed: false for the entire aggregate even though the others had already landed and were sitting in the cache. The client does not re-fetch on false (by design), so it would sit on stored data while live data was available -- the opposite of what the code's own comment promised ("ANY wallet landing is worth a re-fetch").
MW-REVIEW(not in the plan)resolveWalletSelection's wallet query had no ORDER BY; the verify route pulled ./registry + ./live (venue readers, rpc-batch) into the auth path via wallets.ts; rename/remove in the dialog never checked res.ok; the client mounted on the primary before restoring the remembered selection (double fetch); the "syncing" chip never cleared. All fixed. The self-row writer moved to a leaf module (self-wallet.ts) that imports nothing but the DB handle.Heap order made the aggregate positions table reshuffle between fetches. fetch does not reject on 4xx, so a REJECTED rename fell through as a success and the uncontrolled input kept showing a name the server had refused. The auth-path coupling is the very thing enqueue.ts already documents avoiding -- it crept back in through a different import.
MW-REVIEW (accepted, NOT fixed)—Two confirmed findings are accepted as-is and documented rather than changed. (a) A transfer BETWEEN two tracked wallets draws TWO flow markers on the aggregate chart (a withdraw from one, a deposit into the other) though no capital entered or left the aggregate. (b) A freshly added wallet's capital enters realizedReturn's denominator while its yield is still 0, diluting the aggregate until its backfill lands.(a) Each marker IS a real capital move for the wallet it belongs to, the markers are reference lines (the plan's §6.2 says to concatenate), and netting them across wallets would need tx-level matching that could just as easily hide a real pair of moves. The aggregate's CURVE and TWR are unaffected: they are built from cumulative yield, which is flow-neutral by construction. (b) This is not new: a single wallet's first sign-in shows the same partial numbers while its backfill runs, and in both cases syncing is true and the banner says the history is still being built. Nulling the tiles for a minute after every add would be a worse trade.
T3 (taxonomy §3.4)Registration backfill (WS5) reads wallet BALANCES only (T2 shipped the forward wallet-venue flows; the backfill's open-set already restricts the replay to the held wallet tokens)Added the wallet-venue FLOW replay to the backfill (backfill.ts): the OPEN wallet tokens' ERC-20 Transfer streams are swept in the probe (a SEPARATE getLogs pass, off targets, so a bare token cannot collide with a position-token address in the by-token map) + the tail, and native-ETH transfer_in/transfer_out flows are derived from the per-grid-point eth_getBalance diffs (new pure deriveNativeEthFlowsFromAnchors), all under the SAME open-set filter / valuation / full-history delete+insert as the other venues. NO new migration: 049 already admits venue 'wallet', and the native-ETH synthetic tx satisfies portfolio_flow_tx_chk.A bare variable_rate wallet token is a value-accrual leg (Δvalue − netLegFlow), so a balances-only replay booked any mid-history balance change as phantom yield (a top-up read as pure profit). Verified live (isolated local DB + mainnet archive) on a real held-through sUSDe holder (0x44285c71…, held 0.81 sUSDe then a +2015.6 mid-window top-up at tx 0x91e20b0f…): through the production PnL engine the backfilled curve attributes +0.21 USDe genuine share-rate yield vs +2498.2 phantom without the flow, per-interval yield-invariant residual 0, the native-ETH gas spend books as a transfer_out (window-keyed, matching nativeEthDiffTxHash), the run is idempotent, and a forward re-scan of the seam UPSERTs (no double-count).
M18 (new feature)(separate plan: portfolio-entry-basis-plan.md)Added entry basis + dislocation P&L on carry positions: a pure src/lib/portfolio/entry-basis.ts (deriveLegEnteredBasis) derives each leg's equity-signed entered basis from the dual-mark flow ledger (M5), getPositions sets PositionRow.basisEntered*, signed-in-model.ts fuses them (positionEnteredBasis/positionBasisDelta), and PortfolioDashboard's expanded carry detail leads with a Basis-attribution strip (At entry / Now / Dislocation P&L). No migration, no refresher, no backfill. Exported liquidationTxHashes/isLiquidationMechanic from pnl.ts for the M4 prefilter. Docs: metrics.md M18, portfolio.md.The shipped Basis column re-marks on every sync and cannot show whether the secondary-market gap moved for or against the holder since entry. Every flow is already valued in both marks at its block, so the entered basis derives at read time (stable across syncs) with the same honesty rules as M11 (skip null-mark flows -> incomplete; opening-snapshot anchor for pre-tracking positions -> synthetic; PT legs enter at 0 by construction since M12 already anchors their redemption mark at entry).
P29 (the old engine's retirement)The programme's last step: delete the retired reader and everything that only it read, and drop the relations it read from.Three PRs. A deleted the shadow-parity harness, the old chain-scanning flow pass and its Fluid decoded-event cache; B1 deleted the last writers of the old ledger and made the rebuilt writes unconditional; B2 (this one) deleted the reader switch, the v1 data layer, the v1 attribution modules, the v1 Activity renderer and the availability probe, re-pointed the three rebuilt reads that still named the retired relation, folded the switched Playwright project into the baseline, and added 095-drop-old-ledger.sql (-- DESTRUCTIVE, hand-run once per environment). pnl.ts, assemble.ts and aggregate.ts were SPLIT rather than deleted, keeping exactly what surviving importers use. The fixture's ledger was rebuilt in the served ledger's own rows: every wallet's history now carries both quantity columns, a liquidation is a seizure plus the debt_relief paid for it, and every on-grid movement sits one block inside the interval it belongs to.Three things the retirement made visible rather than caused, all recorded in the PR body: a cross-book liquidation books its two halves on two curves and nets in neither (the contract's own algebra, and different from the retired reader's stored penalty); the flag for such a liquidation is drawn only on the curve the collateral left; and a receipt landing exactly on a reading's block falls outside every span of the leg it opens. One behaviour was FIXED here rather than deferred, because the retirement would otherwise have shipped a regression: the entered-basis derivation was never handed the opening endpoint it is written to take, so every holding older than the ledger served a dash where the retired reader served a figure.

Private documentation. creddit.xyz