Valuation policy (#810) and yield-bearing assets: implementation plan
Decisions R1–R6 settled with Fred 2026-09-06/07 (issue #810); decisions Y1–Y7 for yield-bearing assets settled 2026-09-07/08 (recorded as a comment on #810). Pendle PTs are deferred to #811; this plan only leaves the hooks §8 names.
This document is the contract every piece of the program is implemented and reviewed against. Where it disagrees with an older plan under docs/plans/, this one wins.
0. Program rules
- Structure. Integration branch
feat/valuation-policy-810offstaging. Five pieces (§4), each its own PR into the integration branch, each with an implementer and an independent reviewer. The integration branch then goes tostagingas ONE PR. - No QA. Explicit instruction from Fred (2026-09-08): no e2e specs, no Playwright, no browser verification, no screenshots, for every PR of this program. This overrides the QA paragraphs of
AGENTS.mdfor this program only. What IS required on every PR:npx tsc --noEmit,npm run typecheck:scripts,npm test(manifest-enforced: every new*.test.tsmust be added to thetestscript inpackage.json), andcd docs && npm run buildwheneverdocs/changes. - No prod, no staging backfills. Data steps go in the PR body under "Prod (after release)" and are consolidated in §9. Nothing is run against prod without Fred's explicit go-ahead. Staging is reseeded nightly from prod; do not backfill it.
- Test wallets only. Prod holds test wallets. Restatement is a full re-derive of every tracked wallet after the prod data steps. Do NOT build scoped re-mark tooling for this program.
- Migrations. Additive, backward compatible (expand/contract; a code rollback does not roll back the DB). Never write the destructive or no-transaction tag strings with their leading dashes inside prose (the ledger runner greps the whole file; spell them "DESTRUCTIVE tag"). Never write BEGIN/COMMIT into a migration file (the runner owns the transaction). Any reader that runs during
next build(prerendered pages) and names a NEW column must probeinformation_schema.columnsfirst and emit typed NULL literals when the column is absent (seecarries-table.tshasPerShareColumns), or the staging deploy deadlocks (build fails → rollback → migration never applies). - Cron alerting fires only on exit code != 0 AND a log line matching
\[fail\]|\[partial\]|fatal|error|...; put the aggregate plus the identities that matter on ONE line printed LAST (the alert takes the last 8 matching lines, cut at 220 chars). - Write ownership. Market data (bars, registries, rates) is written only by the crons in
scripts/; app code never writes it. - Docs in the same PR (
docs/metrics.md,docs/data-pipeline.md,docs/database.md,docs/processes.mdas applicable). Every PR body states its docs impact. - PR body = what / why / how verified / docs impact / "Prod (after release)" steps / "For #811" (any implementation detail the PT ticket must know).
- Style. No em-dashes in user-facing copy. Comments explain the decision, not the diff.
1. Decisions (normative)
1.1 Funds (R1–R3, from #810)
- R1 One fund registry. The lists shown on the multi-strategy tab and the money-market tab ARE the portfolio's fund coverage. The portfolio derives its reader universe, its category (
managed_strategy_fund/money_market_fund) and its pricing method from that registry.MANAGED_STRATEGY_VAULTSand the curator subset insrc/data/erc4626-universe.tsstop being sources of truth (they may survive only as test fixtures). - R2 Coverage is "ever listed". A fund that drops below the listing floor leaves the tab but stays in the portfolio's reader universe for every wallet that holds it.
- R3 Funds are never priced off Dune.
value(fund share) = shareRate(block) × value(underlying); on the total-return line the underlying is at its Dune market price, on the accrual line at its redemption value (par / identity). Chain down through wrappers until a traded asset is reached (iETHv2 → stETH → wstETH → WETH). Every listed fund has a block-pinned share-rate getter; a stored rate series is not acceptable for a fund. Fund shares never get a price bar, never carry a secondary-market fact, and never get a divergence badge: they do not trade.
1.2 Idle assets and the mirror (R4, R6, from #810, refined)
- R4 Idle assets are marked at Dune, none pinned. All 31 idle assets that trade are priced from Dune hourly bars on the six-hourly sync; the ETH sentinel is the ETH book's own unit and has no series, which makes 32
parrows in the seed. (The 34 this decision was written with counted stETH and eETH, which §3.4 moves out of the class.) The 15 par pins (UNCOVERED_PAR_ASSETS) and the wholepinnedbasis class (M19 / the M26 pinned shortcut) are removed. ETH stays the ETH book's unit (identity), WETH is identity in the ETH book and Dune-marked for USD. - R6 Mirror hardening.
- History floor 2026-01-01 for every token with a standing feed. Never earlier.
- Per-token sync cursor (last accepted bar, last attempt, last success, consecutive failures). A failed sync leaves the cursor where it was, so the next tick retries the lost window. No global
MAX(bar_ts). - Auto-backfill on add. A token whose registry row declares a feed and has no bars (or has not completed its floor load) is loaded from the floor by the next tick.
- Spike gate, accept-then-retract. The newest bar is written and served immediately (a real depeg must show at once, including at the live tip, which reads the newest bar for idle assets). On the next sync, every bar that now has BOTH neighbours is judged: it is rejected when it departs from both neighbours by more than 5% and the neighbours agree with each other within 1%. A rejected bar stays in the table with
rejected = trueand a reason; every read skips rejected bars (walk-back lands on the last accepted one). A rejection re-marks the stored snapshot and flow marks that consumed the bar (asset × hour), using the existing remark library. A price that moves and stays moved is never touched. Never a band around par. - Dark-feed alert. Any token with a standing feed and no accepted bar newer than 12h makes the six-hourly refresher exit non-zero with a
[fail]line naming the tokens (aggregate line last). - Dune per-hour volume and source. The saved queries add
volumeandsourceto their output; the sync stores them (volume_usd,dune_source). A bar with no volume is a carried bar (Dune forward-fills for up to 7 days). The feed-quality gate uses the carried flag as the exact frozen-quote signal where it is present and keeps the statistical estimate where it is not. - The 48h read walk-back stays; #802's per-line bridge handles longer holes.
- R4's open verification is closed: AAVE, XAUt, LBTC, EURC and apxUSD each print 336/336 hours with 327–336 distinct prices over 2026-08-25 → 2026-09-07 (Dune
prices.hour, 2026-09-08). Every idle asset is a dense tape.
1.3 Rebasing tokens (R5, from #810)
- R5 stETH and eETH are variable-rate assets accounted in shares. Quantity is the share count (constant between transfers); redemption = shares × ETH-per-share read on chain at the block; market = shares × the wrapper's (wstETH / weETH) Dune price (one share IS one wrapper token). Receipts are recorded in shares from the share-transfer events, never converted from token amounts. They show an APY like every other variable-rate wrapper.
1.4 Yield-bearing assets (Y1–Y7, settled 2026-09-07/08)
- Y1 One registry row decides everything. For every yield-bearing token (portfolio category
variable_rate_asset) the token registry row decides: whether wallets are scanned for it, its category, its rate source, its price source, and its asset-profile link.COVERED_ASSETS,BASIS_TOKENS,MIRROR_EXTRA,DEX_RATIO_SOURCES,PRIMARY_BUFFER_COMPOSED,FEED_QUALITY_COMPOSED,DERIVED_MARKET_BASESand thebuckets.tsbook map stop being sources of truth. A profiled token therefore always has full portfolio support; today cbETH (no wallet-token row) and USD3 (never synced) break that and are fixed by the seed. - Y2 Valuation column.
valuation ∈ {market, composed, derived, identity}.pinneddoes not exist. sUSDS ismarket. - Y3 One declared price source per token.
feed ∈ {dune_tape, dune_dex_ratio, pool_quote}or NULL (no standing series). The source is a property of the token for its WHOLE series. Assignment rule: the Dune tape where trading is dense; a block-pinned pool round-trip quote where the market is deep but quiet; composition from the underlying only where no market exists at size ($1M round trip loses percent, not basis points). A composed token MAY still declare a feed: then its bars are synced and graded (so a market that appears is noticed) but never used for its marks. - Y4 Changing a source is a registry edit plus a re-mark, never a per-bar switch. The feed-quality gate measures; the monitor announces transitions; a person edits the row; the token's history is reloaded from the new source from the floor and its stored marks are recomputed. Code never picks a source per bar.
- Y5 Pool-quote feed. An hourly bar for a
pool_quotetoken is the round-trip mid of a fixed-size quote (default $100k notional in the counter asset) on ONE named pool, read at the last block at or before the hour, times the counter asset's own Dune bar at that hour (multiplied, never divided: the same construction as the dex-ratio route). The round trip's half-spread is the health check: over the class cap (reuse the aggregator-mid caps) the hour stores no bar and is logged. History is loaded from the archive node from the floor. At the live tip the pool round trip is compared with the aggregator mid; a divergence above 50 bps on three consecutive ticks is logged as a transition, never acted on automatically. A pool choice carriesverifiedOn. - Y6 Rate source. A block-pinned getter by default (the existing getter kinds: ERC-4626
convertToAssets, wstETHgetStETHByWstETH, LSTgetRate/getExchangeRate, cbETHexchangeRate, PST's on-chain rate feedlatestAnswer, reUSD's NAV consumer), read at the leg's block in BOTH the history and the "now" mode (archive endpoint for history, cached per asset × block). A stored series (rate_kind = series) is allowed only where the rate is not readable on Ethereum at a block, and is declared: sUSDai (Arbitrum hub). The stored six-hourly series keeps serving the profile pages and APY. - Y7 Category. Every profiled token is
variable_rate_asset, sGHO included: composition is how it is valued, the category is what it is (the same separation the liquidity class already makes). The eight variable-rate rows without a profile get the same declaration by the rule in §3.3 or sit outside coverage.
2. Registry contract (schema every piece codes against)
2.1 onchain_credit.portfolio_tokens (extend; migration 099-token-registry-valuation.sql)
All new columns nullable or defaulted, so the previous release keeps working (expand phase).
| column | type | meaning |
|---|---|---|
valuation | text NOT NULL DEFAULT 'market' CHECK IN ('market','composed','derived','identity') | Y2 |
feed | text NULL CHECK IN ('dune_tape','dune_dex_ratio','pool_quote') | Y3; NULL = no standing series. The standing sync set is exactly feed IS NOT NULL AND status='active'. |
feed_config | jsonb NULL | dune_dex_ratio: {envVar, minResyncSeconds}. pool_quote: `{kind:'fluid_dex_t1', pool, counter, quoteSizeUsd, spreadClass:'major' |
rate_kind | text NULL CHECK IN ('getter','series') | Y6; NULL for par rows |
rate_getter | jsonb NULL | {kind, target?, divisorPow10?, shareDecimals?} where kind names an existing getter implementation (erc4626, wsteth, lst_get_rate, lst_exchange_rate, cbeth_exchange_rate, chainlink_feed, reusd_nav, steth_shares, eeth_shares) |
underlying_address | text NULL (lowercase) | for composed: the asset composed against (sGHO → GHO). Also set for market wrappers: the asset the redemption line composes down to. |
wrapper_address | text NULL (lowercase) | for derived: the wrapper whose bar prices it (stETH → wstETH, eETH → weETH) |
unit | text NOT NULL DEFAULT 'token' CHECK IN ('token','shares') | R5 |
liquidity | text NULL CHECK IN ('secondary','primary_buffer') | moved from the static registries |
liquidity_verified | date NULL | evidence date |
profile_ticker | text NULL | the asset-profile ticker (moved from COVERED_ASSETS); href is /asset-profiles?asset=<ticker> |
issuer | text NULL | moved from COVERED_ASSETS |
Existing columns keep their meaning: class (par → category idle, variable_rate → category variable_rate_asset), book, wallet_tracked, rate_source, source, status. Add a CHECK that composed rows have underlying_address, derived rows have wrapper_address, and feed = 'pool_quote' rows have feed_config.
2.2 Fund registry (extend onchain_credit.money_market_fund_registry; migration 100-fund-registry-kind.sql)
The table already carries address, kind of vault, manager, asset, decimals, status, became_listed_at. Add:
| column | type | meaning |
|---|---|---|
kind | text NOT NULL DEFAULT 'money_market' CHECK IN ('money_market','multi_strategy') | R1 |
rate_getter | jsonb NULL | same shape as 2.1; every listed fund must have one (R3) |
rate_divisor_pow10 | integer NULL | from erc4626-universe.ts |
portfolio_category | text GENERATED or derived in code | money_market_fund / managed_strategy_fund |
Seed the ten multi-strategy funds (iETHv2, earnETH, yoETH, liquidETH, tETH, IPOR Liquity Carry, fLiteUSD, yoUSD, yvUSD, Lido Earn USD) with kind='multi_strategy', their asset, decimals and getter. became_listed_at is the date each fund first appeared on the tab (from git history of the static lists / the PR that added it), never now(). "Ever listed" = became_listed_at IS NOT NULL. The table keeps its name; renaming it is a follow-up, not part of this program. The multi-strategy tab reads kind='multi_strategy' rows; the money-market tab reads kind='money_market'. Every reader that runs during next build probes for the new columns (§0).
Fluid fTokens stay in the static ERC-4626 universe (they are neither tab's funds); the portfolio's ERC-4626 reader universe becomes: fund registry (ever listed, both kinds) ∪ Fluid fTokens ∪ the MetaMorpho factory rows already assembled at runtime.
2.3 The composition function (replaces mark-enrichment.ts composition, the derived
bases, and the pinned shortcut)
unitPrices(asset, block, ts) -> { market: number|null, redemption: number|null, barTs }
market(asset):
identity -> 1
market -> bar(asset, ts) in the asset's book (mirror read; rejected bars skipped;
48h walk-back; null on a miss, never a fallback to another vendor)
composed -> rate(asset, block) × market(underlying)
derived -> market(wrapper) ÷ rate(wrapper, block) (unit=token)
market(wrapper) (unit=shares: 1 share = 1 wrapper)
fund share (registry kind ∈ funds) -> shareRate(fund, block) × market(underlying)
redemption(asset):
identity / par (class par, book unit) -> 1
any wrapper / fund share -> rate(asset, block) × redemption(underlying)
derived (unit=shares) -> rate(wrapper, block) (ETH per share)Recursive, cycle-guarded, depth-capped, terminating at identity or at a market par asset (which is where "the underlying at Dune market" enters the total-return line). A missing rate or bar propagates null (M9). One function feeds snapshots, flows, the live tip and the re-derive; loadMarketContext becomes a thin batch over it.
2.4 Mirror tables (migration 098, its own file and the first to apply)
token_price_bars: addrejected boolean NOT NULL DEFAULT false,reject_reason text,volume_usd numeric,dune_source text. Reads filterNOT rejected.token_price_sync_state (chain_id, token_address, last_bar_ts, last_attempt_at, last_success_at, consecutive_failures, floor_loaded boolean).- Bars from the pool feed carry
source = 'pool:fluid_dex_t1'; exclusivity is enforced inupsertBarsexactly as for the dex-ratio route (a token's bars come from ONE source).
3. Token-by-token assignment (normative seed for migration 099)
3.1 Idle (class par, category idle)
All 32 par rows in the seed (the USD stablecoins, WETH, WBTC, cbBTC, AAVE, XAUt, LBTC, EURC, apxUSD and the rest of today's par rows): valuation='market', feed='dune_tape', rate_kind=NULL. 31 of the 32 carry a tape; the ETH sentinel is the exception: valuation='identity', feed=NULL, because it is the ETH book's own unit and has no series to draw. WETH: identity in the ETH book, feed='dune_tape' (the USD conversion needs its bar). stETH and eETH LEAVE this class (§3.4), which is why this is 32 and not the 34 R4 was written with.
3.2 Profiled yield-bearing tokens (class variable_rate, wallet_tracked=true)
| token | valuation | feed | feed_config / notes | rate |
|---|---|---|---|---|
| wstETH, weETH, rETH, ezETH, osETH | market | dune_tape | getter (existing kinds) | |
| cbETH | market | dune_tape | NEW ROW (today absent) | getter cbeth_exchange_rate |
| sUSDe | market | dune_dex_ratio | existing route (DUNE_QUERY_ID_SUSDE, 12h cooldown) | getter erc4626 |
| syrupUSDC, syrupUSDT, reUSD, sUSDS | market | dune_tape | sUSDS: no pin | getter |
| PST | market | pool_quote | Fluid DEX T1 pool 0x40d66b5f8f1521f97c2aca54dd200fe3ca035328 (PST/USDC), counter USDC, $100k, class thin | getter chainlink_feed (existing PST rate feed) |
| sUSDai | market | pool_quote | Fluid DEX T1 pool 0xb9b87a1b79891a8c9251f501b1b5d71bc7c8aa24 (sUSDai/USDT), counter USDT, $100k, class thin | series (Arbitrum) |
| sUSDf | composed(USDf) | dune_tape (graded only) | its only route to size is the vault itself | getter erc4626 |
| sGHO | composed(GHO) | dune_tape (graded only) | getter erc4626 | |
| USD3 | composed(USDC) | NULL — the feed came off (§9 P1: zero hourly rows, so it was grading nothing). Still graded, by the composed + secondary rule | getter erc4626, shareDecimals 6 |
Both pools were the aggregator's route on 2026-09-08 (Kyber routeSummary); the implementer verifies token order, fee and reserves on chain and records verifiedOn. underlying_address is set on every wrapper (the redemption line composes to it).
3.3 Unprofiled variable-rate rows (8 today)
Apply, and list the outcome in the PR body: srUSDe → market / dune_tape (it has a feed and a getter). tETH, liquidETH, earnETH → become fund-registry rows (§2.2) and their wallet-token rows are set wallet_tracked=false (the ERC-4626 reader reads them as fund positions). For the rest (stUSDS, sUSDD, wsrUSD, apyUSD, sUSDat, wFalconX, AA_FalconXUSDC): with a rate getter AND a feed that passes the gate → market/dune_tape; with a rate getter but no usable feed and a tracked underlying → composed; with no rate getter → valuation='market', feed='dune_tape' if bars exist (valued USD-only under Other), else status stays but the row is documented as outside coverage. Most of these exist only because a PT accounts in them; #811 will revisit.
3.4 stETH, eETH (R5)
class='variable_rate', valuation='derived', unit='shares', wrapper_address = wstETH / weETH, rate_kind='getter', rate_getter = steth_shares (stETH getPooledEthByShares(1e18)) / eeth_shares (ether.fi LiquidityPool amountForShare(1e18)), feed=NULL (priced through the wrapper's bar), wallet_tracked=true.
3.5 Funds
Every fund-registry row (both kinds, ever listed): valued by §2.3's fund branch; no portfolio_tokens feed; the 10 multi-strategy shares' token rows get valuation='composed' with underlying_address = the fund's asset, for the one place that still resolves a share through the token registry.
4. Pieces, ownership, sequencing
| piece | branch | scope | depends on |
|---|---|---|---|
| A mirror | feat/810-a-mirror | §1.2 R6 entirely: per-token cursor, retry, auto-backfill, accept-then-retract spike gate with re-mark, dark-feed [fail] line, volume_usd/dune_source storage and their use in the feed-quality gate, floor 2026-01-01 in the bulk loader; migration 098 mirror columns + token_price_sync_state; docs data-pipeline.md. Touches: src/lib/data/dune.ts SYNC functions only (syncBars, upsertBars, getBarSeriesAt, series readers), scripts/refreshers/token-basis.ts, src/lib/data/feed-quality.ts, scripts/backfill-token-price-bars.ts, scripts/repair/remark-lib.ts (consumer). Does NOT touch the registries in dune.ts. | nothing |
| B registry | feat/810-b-registry | §2.1, §2.2, §3 seeds, §1.4 Y1/Y2/Y6/Y7: the registry loaders (server-side, cached, injectable for tests), PORTFOLIO_ERC4626_VAULTS / composed sets / basis sets / mirror sets / profile list DERIVED from the registry (the static arrays become test fixtures or are deleted), the tabs reading kind, ever listed coverage, Lido Earn USD in coverage, cbETH and USD3 rows, pinned retired (pinned-marks.ts, basisClassForAddress, M19/M26 docs), rate getter registry-driven in both modes (Y6), holdings-table profile link served from the API row. Docs database.md, metrics.md, processes.md. | nothing (codes to §2; A's mirror columns are independent) |
| C pool feed | feat/810-c-pool-feed | §1.4 Y5: src/lib/data/pool-quote.ts (interface + Fluid DEX T1 adapter, round-trip mid, half-spread cap), hourly leg in the sync (partitioned out of the Dune CSV, exclusivity in upsertBars), scripts/backfill-pool-quotes.ts (archive RPC, from the floor), tip cross-check vs the aggregator mid in the JIT tier, tests, docs data-pipeline.md. Reads its token list through a small injectable registry accessor shaped like §2.1 feed_config; wires to B's loader on rebase. Learn the pool contracts through the Herd MCP (never selector-probing); the app already reads Fluid DEX pools in src/lib/portfolio/fluid-dex-pools.ts and src/lib/data/adapters/fluid-dex.ts. | A for the sync hook (rebase after A merges) |
| D shares | feat/810-d-rebasing-shares | §1.3 R5 / §3.4: wallet reader in shares for stETH/eETH, share-transfer receipt decoding in the derive path (never converted from token amounts), the two rate getters, unit='shares' handling in valuation, APY on the row, class flip in the seed, tests (deposit → rebase → full withdrawal leaves no negative balance and no ghost row), docs metrics.md M-rule text. Codes to §2.1 (unit, wrapper_address) through B's loader shape with a local shim; rebases onto B. | B (rebase) |
| E composition + unpin | feat/810-e-composition | §2.3: the one function, loadMarketContext over it, mark-enrichment.ts composition/derived-base code replaced, fund branch with a block-pinned getter for every listed fund (R3; chain-down), liquidETH/tETH/earnETH composed by rule, idle assets unpinned (UNCOVERED_PAR_ASSETS, parPinnedUnitPrice, marketPinnedToRedemption deleted), the standing sync set = feed IS NOT NULL, mirror-coverage tests rewritten against the registry, the consolidated prod runbook §9, docs metrics.md new M-rule. | A, B merged |
Merge order into the integration branch: A → B → C → D → E. C and D start in parallel with A and B and rebase when their dependency lands. Reviewer for each piece: an independent agent with fresh context; findings posted as a PR comment; at most two review rounds; after a zero-blocker round, remaining should-fixes become a follow-up issue linked from the PR body (review-loop budget).
5. Acceptance (program)
- Every covered asset has exactly one valuation method and at most one price source, both stated in the registry, and no code list decides either. The mirror-coverage test enforces it against the registry, not against static arrays.
- The #802 census (dark endpoints per leg) returns zero rows for every token on the standing sync after the prod backfill. Demonstrated by §9 P2 (which writes the census down as a query for the first time, projecting each row's token and whether it is on the standing sync, since the criterion is stated per token) and §9 step 11.
- Dune-vs-mirror hourly counts agree within the spike-gate rejections for every synced token over the last 60 days. Demonstrated by §9 P2's third measurement and §9 step 11: the mirror side is a
token_price_barscount per row with a feed, the Dune side is the orchestrator'sdune.prices.hourcount over the same window (§6 forbids an agent from executing a Dune query), and the test isdune_hours − accepted_hours ≤ rejected_hoursper token. Nothing in the tree reconciles the two automatically, and this program builds no such tool: the two named steps are how the item is met. - A fund delisted on staging keeps its holders' positions and history.
- A stETH deposit → rebase → full withdrawal sequence books the rebase as yield and leaves no negative balance and no ghost row.
- A profiled token without a wallet-token row cannot exist (test), and every
variable_raterow withwallet_tracked=truehas arate_kind(test). - The pool feed refuses an hour whose round trip exceeds the class cap (test with a thin fixture) and never writes a Dune bar for a
pool_quotetoken (test). - The spike gate rejects a one-bar spike between agreeing neighbours, keeps a step that persists, and never judges the newest bar (tests).
- Marks for assets whose method did not change are byte-identical before and after E on the fixture corpus.
- Docs: a new M-rule in
docs/metrics.mdstating R1–R6 and Y1–Y7; M19 retired; M26 rewritten as "the method is the registry's, never per tick"; the liquidity-classification section updated;database.mdfor the schema;data-pipeline.mdfor the mirror and the pool feed;processes.md"onboarding a tracked asset" = one registry row.
6. Out of scope (do not build)
Depth series or any new UI number; PT changes beyond §8; renaming the fund registry table; scoped re-mark tooling; e2e specs; any Dune query execution from an agent (the orchestrator owns the saved queries, §7); any prod or staging data step.
7. Dune saved queries (orchestrator step, not an agent's)
The hourly (8033816) and minute (8033818) saved queries gain volume and source output columns (p.volume AS volume_usd, p.source AS dune_source). The sync parses rows by column name, so the extra columns are harmless to the release currently on prod. DONE 2026-09-08: both saved queries emit the two columns; a smoke execution confirmed the SQL. Piece A only has to store what arrives.
8. Hooks for the PT ticket (#811)
- H1 The fund registry's
kindis an open enum;principal_tokenis the value #811 adds. - H2
rate_getter.kindis an open enum; #811 adds the Pendle oracle getter (TWAP before maturity, redemption index after) and the composition function's fund branch is written so a maturity-aware getter drops in without a new branch. - H3 "A registry row declaring a feed auto-loads its history" and "listing a row auto-tracks its underlying" are generic over rows, not over funds.
- H4 The unpin (E) changes PT marks over previously pinned dollars (apxUSD etc.) from $1 to the Dune price. Recorded on #811.
9. Prod runbook (consolidated by piece E; executed only with Fred's go-ahead)
Every data step the five pieces owe, in one list, in the order they have to run. Nothing here has been executed. Staging is NOT part of it: staging reseeds from prod nightly, so a backfill run there is thrown away (program rule §0).
Two of these steps are DESTRUCTIVE (6 and 9) and two are ordering traps. Both labels are carried on the steps themselves rather than in a footnote: step 6 purges two tokens' tape bars, step 9 deletes every tracked wallet's bare-token coverage row. The traps are the tape backfill's resume gauge, which silently skips a whole-universe load, and the pool feed, which refuses every hour whose counter has no bar.
Every step below states its executable form: the exact command or SQL, who runs it (the onchain_credit app role through run-cron.sh, or the postgres owner through psql), and what "done" looks like. A step with no command is a step somebody improvises at 2am.
Before the integration branch is promoted
P0. The dark-feed alert will not page for a token that is still LOADING. Piece E exempts any row whose token_price_sync_state.floor_loaded is false: a token whose history load has not completed is loading, not dark. That is what keeps the ~20 newly-taped tokens from reddening refresh-assets every six hours between the deploy and step 5 (and it is moot while the cron is paused, which step 1 makes it for the whole window). The exemption is narrow — it lapses the moment the load completes, and it never widens on a failed state read — so P1 below is still the check that matters.
Two windows are still expected to page, and they are both short. Between step 6's purge and step 7's completion, PST and sUSDai have bars neither loading nor arriving, so the exemption above does not cover them and the Dune leg correctly will not refill a pool token's window. Run those two steps back to back, and read a dark-feed line naming only those two in that window as the expected state rather than as a fault.
The mechanism behind that sentence is G1's, and it is deliberate. Until G1 the two pool tokens had no token_price_sync_state row at all, so "their floor_loaded is already true" was the right conclusion from the wrong premise. The pool leg writes them a row now, with floor_loaded set explicitly true rather than left to the column default — because the default is false and false is exactly what the dark-feed check exempts, so a defaulted row would have silenced the page for a bar-less pool token indefinitely. A pool token that goes dark therefore pages, which is what this window is a short, expected instance of.
P1. DONE (2026-09-08), and it moved three rows. The check: every row declaring a feed, measured against Dune's prices.hour over 2026-08-25 → 2026-09-08, read-only against prod and Dune. A row with a feed nobody fills puts the whole refresh-assets job into a standing red state every six hours — A's dark-feed alert fails the refresher for any standing feed with no accepted bar in 12h — which trains the alert channel into noise (#819 F7).
Three rows declared a dune_tape that does not exist, zero hourly rows each:
| row | outcome |
|---|---|
stUSDS 0x99cd4ec3…eeb9 | composed over USDS (§3.3's second branch: a rate getter, no usable feed, a tracked underlying), feed NULL |
USD3 0x056b269e…5ecc | stays composed over USDC; the feed was for GRADING only and had nothing to grade, so it comes off. Still in the graded set, which is derived from composed + secondary rather than from the feed |
wsrUSD 0xd3fd6320…3094 | stays market with NO feed — honest null MARKET marks (M9), redemption still resolves off its getter. It cannot compose: it wraps srUSD, which this product does not track, and rUSD is a different token that srUSD accrues against. liquidity NULL, i.e. declared outside coverage until srUSD gets a registry row |
The five other uncertain rows print and stay: cbETH 335 distinct hourly prints of 336 hours (coinpaprika), srUSDe 70, sUSDD 31, sGHO 17, sUSDf 3 (dex.trades).
So the standing sync is 48 rows (feed IS NOT NULL), of which 45 are dune_tape, 1 dune_dex_ratio (sUSDe) and 2 pool_quote (PST, sUSDai). Re-run the check if the branch sits unpromoted for long enough for a feed to go dark.
P2. Record the "before" measurements, so the release's effect is measurable rather than asserted. Keep all three outputs; step 11 re-runs them and diffs.
The #802 census (dark endpoints per leg), which §5 requires to return zero rows for every token on the standing sync afterwards. The census has never been written down as a query —
docs/plans/mark-gap-per-line-bridge-plan.md§0 states its predicate in prose and publishes its 2026-09-05 result, and nothing in the repo runs it — so here it is, once, in runnable form (read-only,postgresor the app role):sqlWITH pt AS (SELECT lower(pt_address) AS a FROM onchain_credit.pendle_markets WHERE chain_id = 1), s AS ( SELECT wallet, position_key, accounting_asset, snapshot_ts, value_market, lag(value_market) OVER w AS prev_v, lead(value_market) OVER w AS next_v FROM onchain_credit.portfolio_position_snapshots WHERE chain_id = 1 AND qty_raw > 0 AND book <> 'EXCLUDED' AND venue <> 'pendle' AND lower(accounting_asset) NOT IN (SELECT a FROM pt) WINDOW w AS (PARTITION BY wallet, position_key ORDER BY snapshot_ts) ) SELECT s.accounting_asset, t.symbol, (t.feed IS NOT NULL AND t.status = 'active') AS on_standing_sync, s.wallet, s.position_key, count(*) AS dark_rows, min(s.snapshot_ts) AS first_dark, max(s.snapshot_ts) AS last_dark FROM s LEFT JOIN onchain_credit.portfolio_tokens t ON t.chain_id = 1 AND t.address = s.accounting_asset WHERE s.value_market IS NULL AND s.prev_v IS NOT NULL AND s.next_v IS NOT NULL GROUP BY 1, 2, 3, 4, 5 ORDER BY 3 DESC, 1, 4;The acceptance criterion is per TOKEN, so the token has to be in the output.
accounting_assetis constant per leg, and theon_standing_syncflag is what §5 and step 11 actually test: a surviving row withon_standing_sync = trueis a failure, a row withfalseis a leg on a composed or unfed row and is expected — the criterion does not reach it. Done = the row list is saved somewhere the release window can read it back. On 2026-09-05 it was two legs; expect fewer afterwards, not because of the #802 bridge (a read-path change with no data step, so it removed no stored dark row) but because steps 5 and 7 fill the mirror holes those endpoints were dark over and step 10 re-marks them.The feed-quality inventory:
/opt/onchain-credit/scripts/run-cron.sh ops/feed-quality-report.ts(read-only, free, never exits non-zero on a bad verdict). Done = the per-asset verdict table is saved.The Dune-vs-mirror hourly count per synced token, which is the §5 acceptance item nothing else in this runbook measures. Mirror side, saved per token:
sqlSELECT t.address, t.symbol, count(*) FILTER (WHERE NOT b.rejected) AS accepted_hours, count(*) FILTER (WHERE b.rejected) AS rejected_hours FROM onchain_credit.portfolio_tokens t LEFT JOIN onchain_credit.token_price_bars b ON b.chain_id = t.chain_id AND b.token_address = t.address AND b.bar_ts > now() - interval '60 days' WHERE t.chain_id = 1 AND t.status = 'active' AND t.feed IS NOT NULL GROUP BY 1, 2 ORDER BY 3;The Dune side is the orchestrator's, not an agent's (§6): the same 60-day window counted off
dune.prices.hourper token, which is the shape P1 already ran over 14 days. Done = both columns exist for all 48 rows. Step 11 is where they are compared.
The release
1. PAUSE THE TWO CRONS THAT WRITE WHAT THIS RELEASE CHANGES, before the first migration. (app-role step, on the box.) Nothing in docs/deployment.md documents a pause marker for either job — the reseed marker is staging's, and refresh-assets is a plain crontab line — so the pause IS commenting the crontab line out, which is the convention the migration-082 runbook already uses for the five portfolio jobs:
sudo crontab -e
# comment out these two lines. ONE root crontab holds prod and staging, separated only by
# the /opt/onchain-credit vs /opt/onchain-credit-staging prefix, so read the prefix first.
# 0 */6 * * * … /opt/onchain-credit/scripts/run-cron.sh refresh-assets.ts
# 50 3 * * * … /opt/onchain-credit/scripts/run-cron.sh sync-money-market-funds.tsDone = sudo crontab -l | grep -E 'refresh-assets|sync-money-market-funds' shows both prod lines commented, AND no tick is still in flight. run-cron.sh takes a non-blocking flock keyed by checkout and script, so the lock file is the in-flight test:
ls -l /tmp/onchain-credit-cron/onchain-credit-refresh-assets.lock
flock -n /tmp/onchain-credit-cron/onchain-credit-refresh-assets.lock true && echo "no tick in flight"
tail -5 /tmp/onchain-credit-cron/onchain-credit-refresh-assets.log # keyed by checkout, like the lockBoth pauses are load-bearing, and neither is tidiness:
refresh-assetswould race steps 5 to 7.planTokenSyncputs every token whose series does not reach the floor into a floor load and runs it inside the tick, in 90-day chunks. Left running, the first tick after migration099starts buying the ~20 newly-taped tokens' whole floor-to-now span, and step 5 then buys it again — two loads, two sets of Dune credits, for one history. With the cron paused, step 5's bulk loader is the loader, which is why it carries--force(see there) and why nothing is bought twice. The purge window in step 6 must not be re-filled either: between the purge and step 7, PST and sUSDai have no bars at all, and a live tick would try to fill that window from the standing leg while the pool loader is mid-run.sync-money-market-fundswould empty the multi-strategy tab if the deploy were ever rolled back after100. The release's gone-sweep is kind-scoped; the previous release's is not (… SET status='gone' WHERE chain_id = 1 AND NOT (address = ANY($1)) AND status <> 'gone'), and the ten multi-strategy funds are in neither the Morpho factory walk nor the API list, so one nightly run under the old code marks all tengone. Nothing re-lists them:--relist-fundwritesproposed, the sync never scores a hand-listed fund, and100's ON CONFLICT deliberately never touchesstatus. The repair is step 4's.refresh-portfolio(50 */6) and the minutelydrain-portfolio-backfillsstay RUNNING. They write on the new code from step 3 onward, the drain is what completes step 10's deepening, and anything they write across the load window is restated by step 10 anyway.
2. Apply migration 098 (gated manual step, postgres owner), BEFORE the deploy, and with the cron already paused from step 1.
migrate.sh applies every pending file present in the checkout it is run from, and before the deploy that checkout is the PREVIOUS release, which carries none of these three. So take 098 alone out of the release branch first — the pattern migration 093 already uses — and the runner then has exactly one file to apply:
cd /opt/onchain-credit
# GH_TOKEN is NOT on the box: the deploy workflow's token is ephemeral and nothing persists one.
# Mint a short-lived one for this fetch (`gh auth token` on your own machine, or a fine-grained
# read-only PAT on this repo) and export it in the shell you run this in, then discard it.
git fetch "https://x-access-token:$GH_TOKEN@github.com/FredCoen/onchain-credit.git" staging
git checkout FETCH_HEAD -- scripts/sql/098-mirror-hardening.sql # 098 ONLY, deliberately
sudo -u postgres psql -d creddit -c \
"SELECT filename FROM onchain_credit.schema_migrations
WHERE filename IN ('098-mirror-hardening.sql',
'099-token-registry-valuation.sql',
'100-fund-registry-kind.sql');"
# expect NO rows first. Spelled as an IN list on purpose: PostgreSQL's LIKE has only
# % and _, so a bracket class like '09[89]%' is three literal characters and matches
# nothing — a guard that reads as passing whatever the ledger holds.
scripts/ops/migrate.sh "$(grep ^DATABASE_URL= .env.local | cut -d= -f2-)"
# expect exactly one `APPLY: 098-mirror-hardening.sql` linepm2 serves the built bundle, so a source file dropped into the checkout is inert until the next build, and step 3's deploy does a git reset --hard over it. Prod's deploy workflow runs migrate.sh nowhere, so this run is the only thing that creates these columns, and a no-op run prints 0 applied and reads exactly like success — check the ledger, not the exit code.
098 is purely structural — the mirror columns (rejected, reject_reason, volume_usd, dune_source, carried), the column-level GRANT UPDATE for the gate's two columns, and token_price_sync_state. It is additive, idempotent and untagged. Between it and the deploy the sync keeps writing bars without the new columns, which is the pre-098 behaviour; that is what its own header asks for. It needs the cron paused: its ADD COLUMNs take a brief ACCESS EXCLUSIVE on token_price_bars under lock_timeout, so a six-hourly tick mid-INSERT aborts the file. Done = schema_migrations carries 098 and information_schema.columns shows rejected on token_price_bars.
3. Deploy.
The unpin takes effect at the deploy, not at a later step: from this instant the ~15 formerly par-pinned assets are marked off their Dune bars rather than at exactly $1, sUSDS with them, and tETH / liquidETH stop being marked off their own quotes. Record the deploy timestamp — #811 needs it, because a PT over one of those dollars inherits the change in both its market mark and its M12 pull-to-par reference from that moment.
ONE stETH / eETH window opens here, and it closes at step 4. It is a display effect on test-wallet history rather than a valuation error, and it is an argument for running step 4 on the heels of the deploy rather than leaving the runbook half-run overnight.
- Step 3 to step 4: a bare stETH or eETH balance is not shown at all. The disclosed "Holdings outside coverage" list is a compiled constant, so the deploy removes those two from it in the same instant the new bundle starts serving; the wallet sweep reads
wallet_trackedfrom the database, which does not flip until099lands at step 4. In between they are in NEITHER, so a wallet's bare stETH or eETH holding is absent from /portfolio entirely rather than mislabelled — no row, no dash, and no alert, since a token in neither set is a token nothing asks about. It is the shortest window here and the one most likely to be read as data loss, so it is worth closing in minutes: run step 4 immediately after the deploy. Note that a wallet whose stETH the ledger already HAS rows for is not silent in this gap — those rows are served, and they state the same balance before and after the re-derive, for the reason below.
THE SECOND WINDOW — the one earlier drafts of this runbook described from step 3 to step 10 — DOES NOT EXIST, and it is worth saying why rather than deleting the paragraph. The concern was real: every stETH and eETH row written BEFORE this release holds a TOKEN amount, the release's own decoder writes a SHARE count, and nothing stored with a row says which. A display that took the unit from today's registry and the number from the stored row would therefore print one under the other for the whole stretch until step 10 re-lays those ledgers.
What closes it is that the served quantity is not the stored one. The screen never prints a share count (#810 R5 display rule, docs/metrics.md M5.3): it prints the token balance, which is shares × ETH-per-share at the row's own block — the same product the valuation path already formed and stored as that row's REDEMPTION mark, since one stETH token redeems for one ether. And shares × ETH-per-share and tokens × 1 are the same number, so the served figure is IDENTICAL on a pre-release row and on a post-release one. The quantity is continuous across the flip exactly as both value columns are (shares × perShare ≡ tokens × perToken), and no reader sees anything move at the deploy or at the re-derive.
The alternative would have been the worst window in this runbook, which is why the rule is stated as a rule and not left implicit: multiplying the stored quantity by the share rate reads a pre-release row as tokens × 1.24 and overstates every stETH holding and movement by the whole share rate — a plausible-looking number, on the most prominent surface of the page, for the length of the window.
4. Apply migrations 099 then 100 (gated manual step, postgres owner), AFTER the deploy. Both are additive and idempotent; neither carries a transaction-control or skip-guard tag string. The deploy has put both files in the checkout and 098 is already in the ledger, so the runner applies exactly these two, in filename order:
cd /opt/onchain-credit
scripts/ops/migrate.sh "$(grep ^DATABASE_URL= .env.local | cut -d= -f2-)"
# expect `APPLY: 099-token-registry-valuation.sql` then `APPLY: 100-fund-registry-kind.sql`099—portfolio_tokens' valuation columns and the 77-row seed.100—money_market_fund_registry'skind/rate_getter/rate_divisor_pow10and the ten multi-strategy fund rows. It approves no manager house, deliberately.
AFTER, not before, and this is the half of the ordering that has to be right. 099 is not only new columns: its ON CONFLICT … DO UPDATE sets class, wallet_tracked and unit on rows that already exist, and it is what turns the wallet sweep on for six tokens (stETH and eETH flip from par/untracked to variable_rate/tracked/unit='shares'; liquidETH, tETH and earnETH flip tracked; earnUSD arrives tracked). The release currently on prod has no unit handling anywhere: its wallet reader always calls balanceOf, and its wallet-token decoder books a plain Transfer. Applying 099 first would therefore have the OLD code sweep two rebasing tokens in TOKEN units for the length of the window — a balance that drifts below the chain's and goes negative on a full withdrawal, which is the W9 ghost-row shape piece D exists to remove. Deploying first removes the window entirely. 100 follows for the same reason on the funds side: the ten status='listed' rows only stop showing on the money-market tab because THIS release's query filters on kind.
Nothing is lost by deferring them. Every reader of their columns probes information_schema.columns first (unitColumnSelect, hasTokenValuationColumns, hasFundKindColumns) and emits typed NULLs when the column is absent, so the deploy and its prerender are safe against a database that has not applied either file; class and wallet_tracked come straight off the database in both releases, so the pre-migration state is simply the sweep being OFF rather than wrong.
Rollback exposure, stated rather than discovered. A code rollback taken after this step does NOT roll back the database, so two things survive it and neither self-heals:
The six rows stay
wallet_tracked, and the rolled-back release resumes counting stETH and eETH in token units. Recovery is a handUPDATEof those rows back to their049state, or roll forward:sql-- ONLY after a deliberate rollback of this release. Repeats 049's state for the two -- share-accounted rows; the other four are fund shares whose worst case is a duplicate -- reading, not a wrong quantity. UPDATE onchain_credit.portfolio_tokens SET class = 'par', wallet_tracked = false WHERE chain_id = 1 AND address IN ('0xae7ab96520de3a18e5e111b5eaab095312d7fe84', '0x35fa164735182de50811e8e2e824cfb9b6118ac2');The ten multi-strategy rows are visible to the old unscoped money-market query, and the old nightly gone-sweep would mark them
gone— which the roll-forward does NOT undo, because nothing re-lists a hand-listed fund. Step 1's pause is what prevents it; if the sweep ran anyway, this is the repair, and it is the only one:sqlUPDATE onchain_credit.money_market_fund_registry SET status = 'listed', status_reason = NULL WHERE chain_id = 1 AND kind = 'multi_strategy' AND status = 'gone';
Done = schema_migrations carries 099 and 100; SELECT count(*) FROM onchain_credit.portfolio_tokens WHERE chain_id = 1 AND feed IS NOT NULL AND status = 'active' returns 48; SELECT count(*) FROM onchain_credit.money_market_fund_registry WHERE chain_id = 1 AND kind = 'multi_strategy' AND status = 'listed' returns 10.
5. Load the tape from the floor — dune_tape is 45 tokens, about 20 of them newly on it. App role, on the box:
/opt/onchain-credit/scripts/run-cron.sh backfill-token-price-bars.ts --floor=2026-01-01 --force--force is not optional here, and leaving it off is a silent no-op. The script's resume gauge is WETH's on-hour bar count, which is the right proxy for "did the whole-universe load already run" and the wrong one for "does THIS token have bars". The mirror already holds a year of WETH bars, so without --force the chunk reads ≥90% complete, the run prints already loaded … skip, exits 0, and every newly-taped token gets nothing. --tokens=<the new ones> is the alternative (it judges the resume on the emptiest named token); --force is simpler for a one-time whole-universe load and costs one execution.
This is the ONE loader, because the cron is paused (step 1). Piece A's in-tick floor load would otherwise do the same work first and --force would then re-buy it; with the cron drained there is no race and no double spend. If an operator chooses to skip this step and let A's loader do it instead, that is a legitimate alternative — but then do not run this command at all, and expect the history to arrive over several ticks rather than in one execution.
Dune credits: one hourly execution for the whole span. The script's own measure is ~16 credits for a year across ~40 tokens and the cost is sublinear in span, so an 8-month window across 45 tokens is ~11–16 credits. Bounded by DUNE_MAX_CREDITS_PER_RUN and resumable.
sUSDe is ROUTED and is excluded from that default set (its dune_dex_ratio leg is priced per dex.trades month partition, ~15 credits for a year). Load it only if the P2 inventory shows it short of the floor, and name it explicitly:
/opt/onchain-credit/scripts/run-cron.sh backfill-token-price-bars.ts --floor=2026-01-01 --tokens=0x9d39a5de30e57443bff2a8307a4256c8797a3497The two pool_quote tokens are refused by name by this tool, which is correct: their bars come from step 7.
Done = the run exits 0, and every dune_tape row's series reaches the floor. P2's count query cannot answer that — it is windowed on the last 60 days and covers all 48 feed rows — so use a floor-bounded one over the 45 tape rows:
SELECT t.symbol, min(b.bar_ts) AS first_bar,
count(*) FILTER (WHERE NOT b.rejected) AS accepted_hours
FROM onchain_credit.portfolio_tokens t
LEFT JOIN onchain_credit.token_price_bars b
ON b.chain_id = t.chain_id AND b.token_address = t.address
AND b.bar_ts >= '2026-01-01T00:00:00Z'
WHERE t.chain_id = 1 AND t.status = 'active' AND t.feed = 'dune_tape'
GROUP BY 1 ORDER BY 2 NULLS FIRST;Every row should report a first_bar at or near 2026-01-01 and an accepted_hours in the low thousands (the floor to the release date is roughly 6,000 hours, and a dense tape fills most of them). A NULL first_bar is a token the load missed; a first_bar months after the floor is a chunk it skipped; both are re-run territory, and the loader is resumable. This loader installs the database registry before it reads (initRegistries(), piece G1), so what it loads is the registry's feed assignment rather than the compiled seed's. The two agree at the release, but the step-6 restore path below depends on it being the registry.
6. Purge PST's and sUSDai's tape bars from the floor — DESTRUCTIVE, gated, postgres owner. token_price_bars is append-only (ON CONFLICT DO NOTHING, and migration 055 REVOKEs UPDATE/DELETE from the app role), so every pool bar for an hour that already holds a Dune-tape bar would be silently discarded and the stored series would still be the old feed. backfill-pool-quotes.ts detects the collision, counts the hours, exits non-zero and prints exactly these two statements with the advisory:
DELETE FROM onchain_credit.token_price_bars
WHERE chain_id = 1 AND token_address = '0x22ae3d9a738471f405169af055d31c687087d4c7'
AND bar_ts >= '2026-01-01T00:00:00Z' AND source <> 'pool:fluid_dex_t1';
DELETE FROM onchain_credit.token_price_bars
WHERE chain_id = 1 AND token_address = '0x0b2b2b2076d95dda7817e785989fe353fe955ef9'
AND bar_ts >= '2026-01-01T00:00:00Z' AND source <> 'pool:fluid_dex_t1';The boundary is the history floor, not the pool's birth (settled 2026-09-08): Y3/Y4 make the source a property of the token for its whole series, so a birth-block boundary would deliberately build the two-method series this program exists to remove, with the seam on a date nothing downstream knows about. To restore: set the token's registry feed back to dune_tape and re-run step 5 naming it — that restores the HOURLY series; the dune:prices.minute true-up rows the DELETE also removes are not reloaded by that path.
If you run the shadow-ledger build during this window, read its remedy line, not its verdict. Its mirror-coverage preflight will report both tokens short — correctly, there is nothing there — and it names each pool-quoted token's own loader (run-cron.sh backfill-pool-quotes.ts --token <SYMBOL> --from <floor>, which is step 7's command) rather than folding them into the tape loader, which refuses a pool_quote token by name and exits 1. Do not answer a shortfall on these two with the tape command.
Done = both statements report a non-zero DELETE n, and each token holds NO bar at or above the floor from any source but the pool. "Zero rows" is the right test only against a floor-bounded, per-address count — P2's 60-day query has no floor bound and returns one grouped row per token whatever it finds, so a survivor between January and July would read as clean there:
SELECT token_address, source, count(*), min(bar_ts), max(bar_ts)
FROM onchain_credit.token_price_bars
WHERE chain_id = 1
AND token_address IN ('0x22ae3d9a738471f405169af055d31c687087d4c7',
'0x0b2b2b2076d95dda7817e785989fe353fe955ef9')
AND bar_ts >= '2026-01-01T00:00:00Z'
GROUP BY 1, 2;Expect zero rows here immediately after the purge (nothing is left in the window at all), and after step 7 expect exactly one row per token, both with source = 'pool:fluid_dex_t1'. Run step 7 immediately: between the two the tokens have no bars at all.
7. Load the two pool series (app role; archive RPC, no Dune credits; resumable and idempotent; pre-birth hours are skipped by a one-time bisect, so --from 2026-01-01 self-limits):
/opt/onchain-credit/scripts/run-cron.sh backfill-pool-quotes.ts --token PST --from 2026-01-01
/opt/onchain-credit/scripts/run-cron.sh backfill-pool-quotes.ts --token sUSDai --from 2026-01-01~2,700 and ~2,200 priceable hours at concurrency 3. Step 5 must have run first: a pool bar is the round-trip mid times the COUNTER's own bar at the EXACT hour, so an hour with no USDC / USDT bar is refused as no-counter-price.
Budget the archive reads properly — an hour is not three requests. Each hour resolves its block through blockByTimestamp(hour, "at-or-before"), which is one DefiLlama HTTP lookup plus two to nine eth_getBlockByNumber reads against ARCHIVE_RPC_URL to verify the boundary (the hint resolves in a direction, not to an answer, so the walk-back proves it), and only then the two eth_calls of the round trip. Call it ~4 to 5 archive requests per hour in the healthy case, ~25,000 across the two tokens — not the ~10,000 a "one resolution plus two calls" reading suggests. And it depends on DefiLlama being up. When the Llama block index fails the breaker falls back to blockByTimestampViaBisect, ~25 archive probes per hour, which is ~125,000 requests for the same two runs. Against the default free https://eth.drpc.org that is where a rate limit turns into no-block refusals.
Done = read the summary line, not the exit code. A refused hour is just an hour with no bar, so a rate-limited, truncated run still exits 0 and looks finished:
[backfill-pool-quotes] PST: N hour(s) in window, … Refusals: cap C, counter bar missing M,
quote failed Q, block unresolved B, degenerate D.block unresolved B above single digits means the block index or the archive endpoint gave out; the run is resumable and idempotent, so re-run it until that figure is small and the earliest hour this feed actually priced line reports a date at or near the floor. That second line is the evidence line: "the pool exists" and "this feed can quote $100k two-way" are different dates, and only the second is measured.
8. Run the refresher ONCE BY HAND, with the cron still paused, and read the spike gate's whole-history first pass. App role:
/opt/onchain-credit/scripts/run-cron.sh refresh-assets.ts
grep -E '\[spike-gate\]|\[remark-needed\]|dark feed' /tmp/onchain-credit-cron/onchain-credit-refresh-assets.logrun-cron.sh keys the log by checkout, as it does the lock and the toolchain marker, so this file is /opt/onchain-credit's alone and /opt/onchain-credit-staging writes its own. (It was keyed by script name only when this plan was written, and both checkouts appended here.) This checkout still runs its own six-hourly tick, so read the timestamps before attributing a [spike-gate] line to the hand run, or tail -f the file across the invocation instead.
This step exists because the gate has no standalone entry point. runSpikeGate is called in exactly one place, inside the token-basis refresher's mirror step, which only refresh-assets.ts runs; scripts/refreshers/token-basis.ts has no CLI main, so naming it directly imports a module and exits 0 having done nothing. A full refresh-assets.ts tick is the only way to execute the gate, and the crontab line stays commented out while it runs.
Why it must precede step 10 rather than follow it. token_price_sync_state is created empty by 098 and neither bulk loader writes it, so every standing feed's cursor is still null here — the gate's cursor map covers the tape, routed and pool-quoted rows alike — and the gate judges each from epoch: one whole-history pass over everything steps 5 and 7 just loaded, once per token, since this tick writes the state rows the next tick reads. That pass is what retracts the historical spikes the gate was built for (AUSD's $17.6M hour, USR's $0.116, earnETH's $35,467). Every read skips a rejected bar, but the gate deliberately does NOT re-mark: it prints one line per bar naming the asset and the hour, and the restatement is step 10's. Run this after the re-derive and every retraction lands on marks that have already been restated, with no second re-derive anywhere in this runbook to fix them.
Read the ETH-class rejections specifically (WETH, wstETH, weETH, rETH, ezETH, osETH) before letting step 10 run: the 5% / 1% rule is deliberately asset-blind, so a genuine one-hour wick that fully reverts is retracted by design on those feeds, and the [spike-gate] line plus the retained row is the only recourse.
This tick also refills sUSDe's routed dune_dex_ratio leg, which an earlier round of this program had dropped from the standing sync and G1 restored; budget the ~15 credits/year of dex.trades if its cursor is far back, and if step 5 already named it explicitly the incremental window here is small.
Done = the run actually RAN and exited 0; every [remark-needed] line is copied out with its asset and hour (step 10 is what restates them, and step 11 checks that nothing new arrives); and the dark-feed line, if any, names only tokens whose floor_loaded is still false. "Ran" is the part to check, not the exit code: run-cron.sh also exits 0 when it skips, either on a held flock ("skipping this tick") or inside a deploy window ("deploy in flight, skipping this tick"). Neither should apply here — the crontab line is commented out and the deploy was two steps ago — but a skipped tick and a clean tick are the same exit code, so confirm the log carries this invocation's own timestamps before reading its silence as "no spikes".
9. Reset the wallet-token stream's coverage certificate and re-scan — DESTRUCTIVE, gated, postgres owner. Piece D widened that stream's event set with TransferShares, and every PER-WALLET event_coverage row for it was earned by a one-topic filter — so it now reads as a completeness claim for an event nobody requested. The statement, scoped:
-- 1. SAVE THE ADDRESS SET FIRST. It is not only a certificate: the same rows are the fifth
-- arm of the ingester's wallet enrolment union, and the union must never shrink.
CREATE TABLE onchain_credit.wallet_token_coverage_reset_810 AS
SELECT address FROM onchain_credit.event_coverage
WHERE chain_id = 1 AND stream = 'wallet-token' AND address <> '*';
-- 2. Then the reset.
DELETE FROM onchain_credit.event_coverage
WHERE chain_id = 1 AND stream = 'wallet-token' AND address <> '*';(The address <> '*' scope is the whole point; the paragraph below is why. This is a hand statement, not a migration file, so it carries no runner tag. Drop the saved table once the step's done check has passed.)
Why the set has to be saved, and not just the marker. The ingester's enrolment universe is a five-arm UNION, and the fifth arm IS these rows. Four of the arms come from elsewhere (account_wallets, a signed-in accounts row, a portfolio_backfill_state row, a snapshot inside 48h), so a wallet held by any of them re-enrols and re-scans, which is what this step wants. A wallet held ONLY by arm five — an accounts row that never signed in, no wallet link, no backfill state, no recent snapshot — drops out of the stream's address set altogether when these rows go, and nothing puts it back: it is never re-scanned, its bare-token history is withheld for good, and its missing-coverage alert never clears. The only sanctioned unscoped delete of this arm in the repo is the staging PII scrub, which empties all five in the same run so the set shrinks with no hole behind it. That is not what this step does.
The '*' row must survive, and address <> '*' is the only thing that saves it. That row is not a certificate for any wallet: it is the operator-stamped ROLLOUT MARKER that switches the stream on. requiredScopes adds a wallet's bare-token requirement only when the stream reads as rolled out, and readRolledOutStreams admits a stream only on that one row being live at or below the ingestion floor (22,527,558). Delete it and the requirement is not vetoed, it is DROPPED: coverageReachesBackTo then certifies every wallet over a stream that has just been emptied, which is the inverse of this step's cost and the direction the codebase calls "the direction that publishes wrong numbers". The missing-coverage alert is built from the same requiredScopes, so nothing pages either. Nothing re-stamps it: the backfill runner stamps a marker only for a stream with a finishing address set, and wallet-token's never finishes, so its row is an operator INSERT by hand and always has been. Two hazards make this easy to get wrong: the only wallet-token coverage DELETE written anywhere in the repo (scripts/ops/scrub-staging-pii.sql) is unscoped, and the two coverage resets in docs/deployment.md are AND address = '*' — correct for their singleton streams, and exactly backwards here.
What it costs while it is in flight: coverageReachesBackTo fails for EVERY tracked wallet until that wallet's re-scan finishes and its row flips back to live, so the product withholds their bare-token history for the duration. Run it at the head of the same maintenance window as step 10, not on its own.
What re-earns the rows, and how to watch it finish. Nothing has to be launched: the always-on ingester's per-address catch-up re-scans each wallet unattended, at 200,000 blocks per wallet per ~60s cycle, three wallets at a time. Watch it:
pm2 logs creddit-event-ingester --lines 200 | grep 'ingester/wallet-token'
# [ingester/wallet-token] catch-up progress <address> -> <scannedTo>/<liveCursor>
# N wallet(s) pending catch-up; advancing 3 this cycleThe walk goes UP, and from_block is not the thing to watch. planCatchUpWindows starts at the ingestion floor (22,527,558) and climbs toward the live cursor in 200,000-block rounds, and EVERY committed window stamps the row with from_block = the floor and status = 'backfilling'. So the from_block column reads 22,527,558 from the first committed window onward and never "crosses" anything; what actually makes the wallet derivable is the flip to status = 'live', written once and only when the walk reaches the live cursor. That is the FULL span, roughly 17 cycles ≈ ~17 minutes for one wallet, and ceil(W/3) × ~17 minutes for W of them, since the slots are handed out three at a time in ascending address order. There is no earlier point at which step 10 may start: the gate is a conjunction over status = 'live' AND from_block <= 24136053, and until the flip the first half is false.
(docs/deployment.md's "A new wallet's enrolment backfill" section described the same walk as crossing the derivation floor after ~nine cycles. That was the same error, it predated this branch, and it is corrected in this one.)
Done = the SAME address set is back and live, every one of them reaching the derivation floor, and the marker survived. Checking accounts alone is not enough: a wallet that was in the deleted set and is in no other enrolment arm will never reappear, and an accounts-only check cannot see that it is missing.
-- the saved set, minus what has come back live: must be EMPTY before step 10
SELECT r.address
FROM onchain_credit.wallet_token_coverage_reset_810 r
LEFT JOIN onchain_credit.event_coverage c
ON c.chain_id = 1 AND c.stream = 'wallet-token' AND c.address = r.address
AND c.status = 'live' AND c.from_block <= 24136053
WHERE c.address IS NULL;If a row here does not clear while others advance, that wallet is out of the enrolment union and has to be put back by hand (give it the arm it is missing) rather than waited on.
The per-wallet gate itself, and the marker:
-- the gate step 10 is blocked on, per wallet
SELECT a.uid,
c.from_block,
(c.status = 'live' AND c.from_block <= 24136053) AS certifies
FROM onchain_credit.accounts a
LEFT JOIN onchain_credit.event_coverage c
ON c.chain_id = 1 AND c.stream = 'wallet-token' AND c.address = a.uid
ORDER BY certifies NULLS FIRST, c.from_block DESC;
-- the marker, which must still be exactly one row and still 'live'
SELECT address, status, from_block FROM onchain_credit.event_coverage
WHERE chain_id = 1 AND stream = 'wallet-token' AND address = '*';If that second query returns nothing, STOP: the marker was deleted, every wallet is now certifying vacuously, and it has to be re-inserted by hand before step 10 runs.
The re-stamp of the per-wallet rows is honest by construction (stampCoverage merges LEAST(from_block) / GREATEST(to_block), writes only updated_at = now(), and its gap gate throws before any write), so a partial re-scan cannot leave a certificate claiming a range nobody read.
10. Full re-derive of every tracked wallet — and WAIT for step 9's re-scan to reach the floor before starting it. (Test wallets only; the program builds no scoped re-mark tooling, by rule.) This is the restatement, and it must run AFTER steps 5–9 or it restates against bars that are not there yet. Starting it while wallet-token coverage is still short is the worse of the two failure modes: at best every wallet's bare-token history is withheld for the length of the run (the certificate gate is a conjunction), at worst the re-derive reads a partial log set and writes it. Step 9's first query is the check.
The executable form is the destructive full replay, once per wallet, serially — it revokes the coverage anchor up front, replaces the wallet's snapshot history and re-derives its v2 ledger whole-history in one call. A gap patch is not enough: it would faithfully preserve every mark this release changes.
cd /opt/onchain-credit
set -a; . ./.env.local; set +a # not a cron job: it needs the env here
sudo -u postgres psql -At -d creddit -c "SELECT uid FROM onchain_credit.accounts ORDER BY uid;" # the wallet list
npx tsx scripts/backfill-portfolio-wallet.ts --uid <wallet> --fresh # once per wallet--fresh can REFUSE, and a refusal parks that wallet. If any asset named by the wallet's stored rows is not in the replay universe, the run writes nothing, deletes nothing, exits 1 and leaves the wallet at status='error' with NO coverage anchor. That is a wallet out of the product until somebody fixes the universe and re-runs it, so read every exit code rather than the last one. The hand-run scripts on this path install the database registry first (initRegistries(), piece G1), which is what makes the replay universe the registry's rather than the compiled seed's; without it the restatement would write history under the seed's valuation rules and Y4's "registry edit plus a re-mark" could not work at all.
--fresh lays tier 1 (a ninety-day window, span-bounded) and re-queues the wallet for the backward extension to the history floor; the minutely drain completes that, so the script's exit is not the end of the step. Done = every wallet reaches status='done' with floor_ts at the floor:
SELECT uid, status, floor_ts, covered_through_ts
FROM onchain_credit.portfolio_backfill_state ORDER BY status, uid;⚠ A position closed more than ninety days before the run is pruned from the replayed window until the queued deepening reaches it. That is --fresh's documented behaviour, not a fault of this step.
It covers, in one pass:
- the unpin — ~15 assets written at exactly $1 and Dune-marked from the deploy on;
- sUSDS leaving the declared pin;
- tETH and liquidETH moving from their own bar to their NAV per share;
- PST and sUSDai moving from the tape to the pool feed;
- every bar step 8's gate retracted, since a rejected bar is skipped by every read;
- the five fund rows whose rate moves from a ≤6h stored series to a block-exact read (liquidETH, tETH, earnETH, earnUSD, reUSD — piece E);
- the four funds that enter the wallet sweep (liquidETH, tETH, earnETH, earnUSD — R1: all ten multi-strategy funds read as fund positions, and these four cannot ride the ERC-4626 reader). Their history does not have to be re-scanned: the
wallet-tokenstream filters on the WALLET with no address filter, so everyTransferof these shares touching a tracked wallet is already inraw_eventsfor the covered range — the re-derive is what turns those logs into legs, and they render under "Multi-strategy funds" from the fund registry rather than under Idle; - stETH / eETH ledgers rebuilt in SHARES. The two VALUE columns are continuous across that boundary (
shares × perShare ≡ tokens × perToken); what changes units mid-series is the QUANTITY column, and the engine's self-audit compares the ledger's balance against the spine's quantity, so the spine re-derive is not optional tidying.
Expect it to be slower than the last one. The set of addresses with a block-pinned rate is 39 rows after this program, up from 16 before it — but only 6 of those are piece E's (it takes jitRateGetters()'s 33 single-call getters to 39 by compiling the four multi-call kinds; tETH costs two eth_calls per read rather than one). The rest of the growth is piece B's. So the re-derive walks thousands of grid blocks issuing an archive eth_call per held asset per block where it previously read token_yield_apy. The asset × block cache bounds a snapshot TICK to one call per held asset; a whole-history re-derive is the case it does not bound.
11. Re-enable the two crons, re-run the three "before" measurements, and diff. Uncomment the two crontab lines from step 1 — this is the last step, not an earlier one, because the gate's whole-history pass belongs to step 8 and the restatement to step 10.
sudo crontab -e # uncomment both prod lines
sudo crontab -l | grep -E 'refresh-assets|sync-money-market-funds' # both back, uncommented
# Then re-run all THREE P2 measurements. Only one of them is a command:
/opt/onchain-credit/scripts/run-cron.sh ops/feed-quality-report.ts
# the other two are P2's SQL, unchanged: the #802 census, and the per-token mirror count
# that pairs with the orchestrator's dune.prices.hour figures.Done, item by item:
- The #802 census (P2's first query, re-run) returns zero rows with
on_standing_sync = true. Rows flaggedfalseare legs on composed or unfed tokens and are outside the criterion; a survivingtruerow names its token, its leg and its window. - The feed-quality inventory diffs against P2's copy; the diff IS the record of which assets changed grade, and every change should be explainable by a feed that moved in
099. - Dune-vs-mirror hourly counts agree within the spike-gate rejections, per synced token, over 60 days (§5's acceptance item). Re-run P2's count query, put it beside the orchestrator's
dune.prices.hourcounts for the same window, and requiredune_hours − accepted_hours ≤ rejected_hoursfor each of the 48 rows. A token where the shortfall exceeds its rejections is a chunk the loader skipped or a window the gate over-rejected; both are visible in step 5's and step 8's logs, and step 5 is resumable. - The first two six-hourly ticks after the re-enable should retract NOTHING new, on any of the 48 standing feeds. The whole-history pass was step 8's and it happens once per token for every one of them (the gate's cursor map covers the routed and pool-quoted rows too), so a
[spike-gate]line here names a bar that arrived after step 8, and its[remark-needed]partner means the stored marks for that asset still carry the retracted price: re-run step 10 for that wallet set, since the gate does no re-marking of its own and this runbook has no other restatement.