Pricing categories, price sources and loopability (2026-09-22)
Decision record and implementation plan for the asset-pricing simplification Fred settled on 2026-09-22. Everything under "Rulings" is decided; do not re-litigate it in a PR. Everything under "Sub-PRs" is the instruction set for the implementers and reviewers. Ship as ONE final PR feat/pricing-categories -> staging, assembled from sub-PRs merged into that integration branch.
Review of the covered universe with every ruling applied, per asset: https://claude.ai/artifact/6dDWTHVoSk6ntq9C17gsbd (private; the tables in section 8 are the same content and are the source of truth for the seed data).
1. Rulings (settled, not to be reopened)
R1 Two pricing categories, by whether a real secondary market exists.
- Market-priced: valued at its traded price. Where a redemption value also exists, the gap between the two is the basis. Idle stables and no-base tokens are always market-priced.
- Redemption-priced: valued at its redemption rate times the asset it redeems into, chained down until a market-priced asset is reached. Every listed fund (money-market and multi-strategy) is redemption-priced BY DEFINITION and is never tested.
- The total-return line, the history chart and the headline value use these valuations. The accrual line uses redemption values for every asset; no traded price enters it. (Unchanged.)
- Storage: the existing
valuationcolumn keeps carrying it.market,derivedandidentityrows are market-priced;composedrows are redemption-priced. No new category column. The user-facing words are "Market-priced" and "Redemption-priced".
R2 The category test, for yield-bearing wallet tokens only (class = variable_rate, never a fund row). A token is redemption-priced when EITHER
- at least $5M can be minted AND redeemed atomically and permissionlessly by a contract in one transaction, both ways, to an asset of its own book (any idle asset of the book counts as reaching the base: sUSDS -> USDS reaches USD), OR
- no real DEX market exists: fewer than 20 trading days of the last 30, or median daily DEX volume under $100k, or no single pool holding at least $1M against a book asset. Measured on DEX trade data and pool reserves only, NEVER on an aggregator quote (aggregators route through native mint/redeem where it is atomic, which is exactly the primary route the test must exclude). AMENDED (Fred, 2026-09-23), section 6b item 1: the VOLUME bar is measured on the price vendor's reported daily volume, exchanges and DEXes together, falling back to the on-chain median for a token the vendor does not list. The trading-day count and the pool-depth bar stay on chain as written above, and the aggregator-quote exclusion is untouched: a reported volume is a record of trades that happened, not a route offered. Otherwise market-priced. A capped-but-instant route (Rocket Pool's deposit pool, a vault's idle buffer) counts only up to its capacity, so it pins the price only while the capacity is at or above $5M.
R3 Capacity series. Instant mint capacity and instant redeem capacity, in USD, are read every 6 hours for every yield-bearing token that declares an instant route, stored with the block they were read at. The series drives the test and may be shown in the app later. The declared terms (mint, redeem, reader) live on the registry row.
R4 Flips are proposed, never auto-applied. The 6h job computes each token's verdict (category candidate, loopable). When a candidate has disagreed with the declared category for 14 consecutive days it is reported on the cron's alert line (Telegram). Applying it is a registry edit in a PR. A flip re-marks stored history in the ordinary case; for THIS release no re-mark is needed because every tracked wallet is deleted at release (R10).
R5 Loopable flag. One boolean per yield-bearing token (never funds: no listed carry uses a fund share as collateral, verified 2026-09-22): can a levered carry on it be opened AND unwound in one transaction. True when the token passes the DEX-market bar, or when $5M can be minted and redeemed atomically both ways. Neither: false. Derived from the same two facts as the category, stored on the registry row by the 6h job. It is the collateral half of the answer; the venue half is known per carry. No UI in this program.
R6 Live prices. For market-priced assets the live price (page load and Synchronize) comes from CoinGecko (by Ethereum contract address; by listing id where the address is not listed), with DefiLlama as the backup. Live prices are never written into the hourly history. A live reading stored as a wallet's provisional tip (PR #919) may carry them; it is retired by the next 6h checkpoint exactly as today.
- ETH-book wrappers (wstETH, weETH, rETH, cbETH, osETH, ezETH, ETHx; stETH/eETH through their wrappers): the wrapper-to-ETH ratio is the wrapper's USD price divided by ETH's USD price, both taken from ONE CoinGecko response and accepted only when their
last_updated_atstamps are within 120 seconds of each other; otherwise the last stored bar stands for that tick. - Sanity band: a live price more than 3% away from its reference (the redemption value for a rate token; $1 for a USD idle stable; the last stored bar for ETH, WETH, BTC wrappers and no-base tokens) is checked against DefiLlama. If the two vendors agree within 1%, the move is real and is served. If the backup is inside the band, the backup wins. If both are outside and disagree, the asset's live price is withheld (null, M9 dash) and the 6h job's own check (same rule, same code) puts it on the alert line.
- Redemption-priced assets never fetch a live quote: rate x the redeemable asset's live price.
- The Kyber aggregator tip overlay and the pool-quote tip are REMOVED from the sync. Aggregator quotes remain only in the carries calculator (
/api/sim/swap-cost) and will return later as an executable-basis feature.
R7 History. Hourly Dune bars remain the standing history. Dune gets six hours to print; an hour older than that with no accepted bar for a standing-feed token is filled from CoinGecko's hourly history, then DefiLlama's hourly history. A Dune bar that fails a sanity check against the same hour's DefiLlama point (the AUSD-plateau class) is rejected and treated as a hole. A token dark on all three sources, or the sources disagreeing beyond the band, goes on the cron alert line. Every filled bar carries its own source.
R8 No 5-minute bars anywhere. The exact-minutes channel is deleted: code, batching layer, env vars, docs, comments, tests, and the stored dune:prices.minute rows. Bars are hourly.
R9 Pool feeds retired. PST and sUSDai leave the pool-quote feed and take the standard history chain (Dune where it prints, filled per R7). Ethereum PST has no CoinGecko listing (the two "PayFi Strategy Token" listings are Solana tokens) and DefiLlama prices it; most of its history will therefore be DefiLlama-filled. Accepted.
R10 Release. No wallet is repaired. The release deletes every tracked wallet (the existing reset script, coverage rows, SESSION_SECRET rotation, both pm2 restarts), applies the migrations, and sets COINGECKO_API_KEY in prod's environment (already set on staging).
R11 Asset moves shipped by declaration in this release (evidence on the review page):
- sDAI, sUSDS -> redemption-priced (instant, uncapped both ways; Sky savings modules).
- srUSDe -> redemption-priced (Strata senior USDe tranche,
0x3d7d...c003: atomic mint with a cap, atomic redeem bounded by the vault's liquid buffer, exit fee; redeems into USDe0x4c9e...68b3). - sUSDD -> redemption-priced (Ethereum
SavingsUsdd0xc5d6...9930: DSR-style, instant and uncapped both ways; redeems into USDD 2.00x4f8e5de400de08b164e7421b3ee387f461becd1a). USDD 2.0 becomes a new USD idle row (CoinGecko idusdd, listed under that Ethereum address; DefiLlama prices it). - USD3 -> market-priced (Curve frxUSD/USD3 pool ~$2.1M, ~$450k a day, trades at its rate; CoinGecko id
3jane-usd3; DefiLlama prices it). - sGHO stays redemption-priced (instant both ways, uncapped; no CoinGecko listing, none needed).
- rETH and eUSDe stay as they are until the capacity series has 14 days of data.
- Everything else keeps its category. Marks: the mark for a redemption-priced token uses the reported rate (gross); an exit fee is an exit term shown with the asset, never baked in.
2. Vocabulary (registry row additions)
Added to portfolio_tokens and the static seed (src/data/token-registry.ts), loaded through registry-load.ts with the same enum-bounding discipline as feed (a value outside the list loads as NULL, so every list is a VALUE the loader and the migration CHECK both derive from):
| column | values | meaning |
|---|---|---|
mint_terms | instant / capped / queued / gated / none / NULL | how the token is created from its book asset. instant = atomic, permissionless, uncapped; capped = atomic but bounded (Rocket Pool deposit pool, a mint cap); queued = asynchronous; gated = allow-list or signature; none = cannot be minted (borrow-only stables, tranche tokens with closed entry). NULL on idle rows and funds. |
redeem_terms | same values | how it is turned back into its book asset. |
capacity_reader | erc4626 / sky_savings / rocket_pool / none / NULL | which reader fills the capacity series. erc4626: maxDeposit(probe)/maxMint for mint and maxWithdraw(largestHolder) or the vault's liquid asset balance for redeem (implementer chooses the honest reading per vault and documents it). sky_savings: uncapped both ways (report the vault's total assets as the redeem capacity). rocket_pool: deposit pool free space (mint) and deposit pool balance + rETH contract excess (redeem). |
terms_verified | YYYY-MM-DD | evidence date for the declared terms. |
loopable | boolean, NULL | derived by the 6h job (R5). NULL until the first verdict. |
loopable_verified | YYYY-MM-DD, NULL | the day the flag above was last JUDGED, written on every tick the verdict is known whether or not the answer moved. The flag can never be retracted by the job (an unmeasured tick must not publish a false), so without a date a flag judged in June by a job that has since stopped reads exactly like one judged this morning. Added by sub-PR B; migration 111 therefore carries SIX columns, not five. |
New table token_pricing_measurements (append-only, block-anchored, chain_id + address keys): (chain_id, token_address, measured_at, block_number, kind, value_usd, detail jsonb) with kind in capacity_mint, capacity_redeem, dex_trading_days_30d, dex_median_daily_volume, dex_top_pool_liquidity. Verdicts are computed from the newest rows at read time; the 14-day hysteresis reads the series, it is not stored.
Existing liquidity (secondary / primary_buffer) stays as the declared market fact and is what the measurement contradicts or confirms; composed_evidence stays the prose.
3. Sub-PR A: cleanup (deletions first, so B and C work on a smaller surface)
Branch feat/pc-a-cleanup -> PR into feat/pricing-categories.
- Delete the exact-minutes channel in
src/lib/data/dune.ts(syncExactMinutes,MinuteRequestpool,drainMinuteBatches,runMinuteBatch,clusterMinutes,MINUTES_CLUSTER_SPAN,SOURCE_MINUTES,DUNE_QUERY_ID_MINUTES,DUNE_MINUTE_*,DUNE_MAX_CLUSTER_FAILURES, the batching half ofdune-gate.tsif it serves nothing else), their tests, and every comment/doc sentence that describes 5-minute bars, the 300-second grid or the "minute true-up". Reads keep walking back to the newest bar within 48h; nothing else changes on the read path.alignToBarbecomes hour alignment. - Delete the pool-quote feed:
pool-quote.ts,pool-quote-sync.ts,scripts/backfill-pool-quotes.ts, their tests, thepool_quotemember ofFEEDS, thefeedConfigpool fields, the pool tier invaluation-sources.tsmode "now". Keep the Fluid DEX adapter only if something outside this feed imports it (checkfluid-dex-pools.tsand the swap-cost route). PST and sUSDai rows move tofeed: "dune_tape". - Delete the Kyber aggregator tip overlay:
src/lib/portfolio/aggregator-mid.tsand its test, thefetchAggregatorMid/fetchRedemptionForBandseams and the "JIT now tier" block invaluation-sources.ts, themirrorTrackedTokenscoverage assertion that referenced the registry, and every mention inlive.ts,pnl.ts,recompose.ts.src/lib/data/kyber.tsstays (the swap-cost route uses it). Mode "now" keeps only the mirror bars for now; sub-PR C puts the CoinGecko level in its place. - Migration
scripts/sql/110-hourly-bars-only.sql, tagged-- DESTRUCTIVE: deletetoken_price_barsrows withsource = 'dune:prices.minute'andsource = 'pool:fluid_dex_t1', verify every remainingbar_tsis hour-aligned, replace the 300s CHECK with an hourly CHECK, and narrow thefeedCHECK to the three remaining kinds (dune_tape,dune_dex_ratio,llama_aggregate). Keep the migration idempotent and expand/contract-safe: the old code must keep working against the migrated schema until the deploy lands (hourly bars satisfy the old grid; the old loader maps an unknown feed to NULL). Staging auto-applies only additive files, so the PR body states that 110 is applied by hand on staging after the deploy, then on prod. - Docs:
docs/data-pipeline.md(token price bars section, pool-quote section, mark-method notes),docs/deployment.md(env vars),docs/database.md,docs/external-dependencies.md,docs/metrics.mdwhere they describe the removed pieces. Remove, do not strike through. - Tests: unit suite green (
npm test),npx tsc --noEmitclean,cd docs && npm run buildclean. Updatescripts/test-manifestexpectations for deleted test files.
Acceptance: no reference to prices.minute, syncExactMinutes, pool_quote, aggregator-mid or AGGREGATOR_REGISTRY remains anywhere except migration history and this plan.
4. Sub-PR B: terms, capacity series, market measurement, verdicts, asset moves
Branch feat/pc-b-classification -> PR into feat/pricing-categories (after A merges).
- Migration
111-pricing-terms.sql(additive): the five columns of section 2 onportfolio_tokenswith CHECKs derived from the same value lists the loader uses; thetoken_pricing_measurementstable with the conventions ofdocs/database.md(chain_id + address keys, block-anchored rows, append-only, index on(chain_id, token_address, kind, measured_at desc)). - Seed the declared terms for every
variable_raterow from section 8, withterms_verified = '2026-09-22'. The registry sync (sync-portfolio-tokens.ts, T6) carries them to the table like every other seed column. Funds and idle rows stay NULL. - Asset moves of R11 in the seed: sDAI, sUSDS, srUSDe, sUSDD become
valuation: "composed"withfeed: null,liquidity: "primary_buffer",underlyingAddress= DAI / USDS / USDe / USDD 2.0,composedEvidencestating the instant-route evidence and the date; USD3 becomesvaluation: "market",feed: "dune_tape",liquidity: "secondary"with the Curve evidence; USDD 2.0 (0x4f8e5de400de08b164e7421b3ee387f461becd1a, 18 decimals, symbolUSDD, name "USDD 2.0", USD idle par,feed: "dune_tape", auto-backfilled from the 2026-01-01 floor by the mirror's add path) is added as a new idle row; the migration 075-eraPAR_ACCOUNTING_ASSETSUSDD entry is reconciled so the two USDD contracts cannot be confused (name them both explicitly). Checkmirror-coverage.test.tsandtoken-registry.test.tspins and update them deliberately, with the reason in the test. - Capacity readers in
src/lib/data/capacity/(one file per reader kind, injectable RPC, unit-tested with canned calldata):erc4626,sky_savings,rocket_pool. USD conversion through the token's book price at the reading (the underlying's newest bar; for ETH the WETH bar). A read failure stores nothing and is counted, never a 0. - DEX-market measurement, weekly, in
scripts/refreshers/token-market-measure.ts(or a leg ofrefresh-assets.ts; keep the alert contract): a vendored saved Dune queryscripts/dune/dex-market-measure.sqloverdex.tradesparameterised by token list and a 30-day window, returning per token the count of trading days and the median daily USD volume (execute on themediumengine: the API refusessmall); create it with the Dune MCP tools and record its id underDUNE_QUERY_ID_DEX_MARKETin the env docs. Pool depth from GeckoTerminal's public token-pools endpoint (reserve_in_usdof the deepest pool whose other side is a book asset), 30 requests a minute, no key. Both writetoken_pricing_measurements. - Verdict + loopable in
src/lib/data/pricing-verdict.ts(pure, unit-tested): from the newest measurements and the declared terms, computemarketBarPassed,instantBothWays5M,categoryCandidate,loopable; the 6h job (a leg ofrefresh-assets.ts) writesloopableto the row, and reports on ONE line per token whose candidate has disagreed with the declared category for 14 consecutive days (needs 14 days of series: report "insufficient data" otherwise, quietly). The line format follows the cron alert grep rule (a[fail]prefix only when a human must act, else[info]); never a non-zero exit for a candidate flip. OPEN, FOR FRED TO RATIFY OR REFUSE (raised by sub-PR B, 2026-09-22; R5 stands unchanged until he answers). R5 says "Neither: false". As sub-PR B ships it,loopableis NULL in three shapes, and only the first is a deviation from R5 that needs an answer. All three are listed so a reader can tell the three nulls apart rather than reading the column as complete.THE ONE TO RULE ON. The market limb's "no" rests entirely on a pool our own BOOK RULE excluded: the pool bar the only one of the three that failed, the excluded pool at least ten times the counted one and itself over the bar. ETHx is the live shape, $625k counted against $25.79M excluded, and a
falsewritten off that is a stored fact, read later by code that never sees the reasoning, saying a carry cannot be unwound in a token it plainly can. The category CANDIDATE is still computed and still proposed, with the excluded pool named in the reason line, so nothing is hidden from a person. If Fred refuses the third state the withholding comes out andfalseis stored; if he ratifies it, R5's sentence is amended in the PR that does.RULED (Fred, 2026-09-23): REFUSED, and R5 stands as written.
loopableis a plain boolean, the withholding is gone (sub-PR E, section 6b item 3), and a row whose limbs both say no carriesfalsewhatever the counter rule left out. What the exclusion still buys is the SENTENCE: the flip proposal names the excluded pool under the same three conditions, because "no real market" resting on a pool nobody counted and one resting on a pool that is not there are different statements to the person deciding the category. ETHx, the shape this was raised on, is no longer the shape either: its $25.79M "pool" is held against that pool's own share token, which sub-PR E stops reading as a pool side at all (6b item 2), so the exclusion it rested on was never a counter-rule exclusion in the first place.NO RULING NEEDED: the reading could not establish the deepest pool at all. The vendor's pool list outran the pages the weekly walk may read, so the stored depth is a floor rather than a maximum. That limb is UNMEASURED, which R5 already answers with NULL. The six-hourly leg counts these rows and names them on its summary rather than reporting them as covered, so the silence is visible while it lasts.
NO RULING NEEDED, BUT R5'S SCOPE IS WORTH STATING. Every row
categoryTestAppliesexcludes carries NULL: idle rows, fund shares, no-base tokens (eBTC, sUSDat, apyUSD) and derived/identity rows (stETH, eETH). Read to its letter R5 asks for the flag on every yield-bearing non-fund token, and stETH plainly trades in size (the round-2 reviewer measured $106.5M of its own pool depth), so R5's letter makes stETHtruewhile the code stores NULL. The flag is therefore SCOPED TO THE TESTED SET: a row whose category is fixed by rule is never measured, so there is no measurement to derive a flag from, and a derived row is priced through its wrapper rather than off a market of its own. Nothing reads the column yet; the cost of leaving it unstated is that the next release reads it as complete. Confirm the scoping or say the flag should be extended, and R5's sentence picks it up in the same PR as case 1.CONFIRMED (2026-09-23), with case 1. The flag stays scoped to the tested set: a row whose category is fixed by rule is never measured, so there is no measurement to derive a flag from. After sub-PR E that is the only NULL a judged column can hold, beside a row whose readings have not arrived yet.
- Retire the old classification machinery, which the verdict module replaces: the feed-quality gate (
src/lib/data/feed-quality.ts, the[basis-gate]monitor and its 180-day re-verification nudge in the token-basis refresher,scripts/ops/feed-quality-report.ts, their tests), theComposedMarkReasonvaluesprimary_buffer/feed_quality(replace withno_market/instant_pinned, derived from the declared terms andliquidity), and every Market Depth panel state that depended on a feed-quality verdict (the panel keeps two states: market-priced shows the chart, redemption-priced shows the composed note). The old "price-source rule" prose (tape -> pool quote -> NAV; "deepest pool"; "aggregate never for a yield-bearing row") is replaced by R6/R7 wherever it appears in code comments and docs. - Docs:
docs/metrics.md(the two categories and the test, in product words),docs/data-pipeline.md(capacity series, market measurement, verdict leg, env vars),docs/database.md(columns + table),docs/portfolio.mdwhere "composed" is explained to the reader (use the two category names).
Acceptance: the seed loads, the sync writes the new columns, the readers return sane numbers against a forked or live RPC for sDAI, sUSDS, sGHO, srUSDe, sUSDD, USD3, rETH (report the numbers in the PR body), the verdict module has table-driven tests for every branch of R2/R5, unit suite + typecheck + docs build green.
5. Sub-PR C: live prices and history fill
Branch feat/pc-c-prices -> PR into feat/pricing-categories (after A merges; may run in parallel with B, rebase on B if it merges first).
src/lib/data/coingecko.ts: one client.COINGECKO_API_KEY(Demo key headerx-cg-demo-api-key; keyless fallback to the public endpoint when unset, logged once). Batched current prices by contract address (/simple/token_price/ethereum, chunked), by id for the id-mapped rows (/simple/price), both withinclude_last_updated_at. Hourly history (/coins/{id}/market_chart/range,interval=hourlywhen the tier accepts it, automatic granularity otherwise; align each point to the hour it belongs to). In-process cache of the live batch for 5 minutes (the Demo plan is 10k calls a month; the sync cooldown is 5 minutes anyway). 429/5xx/timeout degrade to the backup, never throw into a valuation. Registry gainscoingeckoIdfor rows not listed by address (BTC.b->bitcoin-avalanche-bridged-btc-b; USD33jane-usd3and USDDusddare listed by address and need no id). Unit tests with an injected fetch.- DefiLlama backup: reuse
llama-prices.ts(fetchUsdPricesstrict mode) for live and its hourly chart for history. - Live level in
valuation-sources.tsmode "now": for every market-priced asset, take the CoinGecko USD level (backup DefiLlama) with the R6 band and the ETH-pair 120-second guard; compose redemption-priced assets from it exactly as the mirror path composes them today; identity assets untouched.priceInBookMirrorOut(the stored-row method) is unchanged./api/portfolio/pricesserves the cached live ETH level with the bar as fallback and states the vintage. - History fill in the 6h mirror sync (
mirror-sync.ts+ the token-basis refresher wiring): after the Dune legs, for each standing-feed token, every hour in[max(cursor, now - 48h), now - 6h]without an accepted bar is filled from CoinGecko hourly history, then DefiLlama; bars land withsource = 'coingecko:hourly'/'llama:chart'and the same spike gate applies. Dune-bar sanity: each newly accepted Dune bar is compared with DefiLlama's point for the same hour (free, no CoinGecko budget); more than 3% apart with the two vendors agreeing within 1% among themselves -> the Dune bar is rejected with reasonvendor-disagreementand the hour is filled. A token with no bar from any source for an hour older than 12h ->[fail] dark feed(existing line, extended with the sources tried). Sources disagreeing beyond the band at the newest hour ->[fail] price disagreement. - The 6h job runs the live-price check of R6 for every market-priced asset with the same code the page uses (one shared function), so a withheld live price is on the alert line within 6h.
- Docs:
docs/data-pipeline.md(new "Live prices" and "History fill" sections replacing the aggregator/pool text),docs/external-dependencies.md(CoinGecko: plan, budget, key),docs/deployment.md(COINGECKO_API_KEY),docs/portfolio.mdanddocs/metrics.mdwhere the live tip and the basis are explained.
Acceptance: with a real key on staging, page load and Synchronize price every market-priced asset from CoinGecko (log lines show the source per asset), a simulated 4% off-band print falls to the backup in a unit test, the ETH-pair guard is unit-tested with mismatched stamps, hole filling is unit-tested with an injected fetch, and the 6h sync on staging fills a deliberately deleted test hour. Unit suite + typecheck + docs build green.
6. Sub-PR D: consistency pass and release runbook
Branch feat/pc-d-docs after A, B, C are merged into the integration branch.
Read every docs page touched by A-C and the plan index; make the vocabulary consistent (Market-priced / Redemption-priced). Add this plan to
docs/plans/index.mdif the index is curated. 1b. Leftover sweep (Fred, 2026-09-22: nothing of the old system may remain in comments, docs or unused code). Grepsrc/,scripts/(excludingscripts/sql/history) anddocs/(excluding thedocs/plans/archive, which is a decision record and stays as history) case-insensitively for every term below and remove or rewrite each hit so that it describes only the new system:prices.minute,syncExactMinutes,exact-minute,exact minute,5-minute,five-minute,5-min,300s,300-second,minute true-up,MinuteRequest,drainMinuteBatches,MINUTES_CLUSTER_SPAN,DUNE_QUERY_ID_MINUTES,DUNE_MINUTE_,DUNE_MAX_CLUSTER_FAILURES,batching layer,pool_quote,pool-quote,poolQuote,PoolQuoteFeed,SOURCE_POOL_QUOTE,fluid_dex_t1,backfill-pool-quotes,aggregator-mid,aggregatorMid,AGGREGATOR_REGISTRY,roundTripMid,HALF_SPREAD_,REDEMPTION_BAND,4.3a,JIT "now" tier,Kyber(allowed only insrc/lib/data/kyber.ts, the swap-cost route and their tests),feed-quality,feedQuality,basis-gate,feed_quality,primary_bufferas a composed REASON (the liquidity value itself stays),deepest pool,tape -> pool,mark-method flip,MARK_METHOD_FLIP,dune-price-mirror-planreferences presented as current behaviour. Then find dead code left behind by the removals: every export in the touched modules with zero importers outside its own test, every env var no code reads (updatedocs/deployment.md), every test fixture and script only the deleted paths used, every crontab or runbook line for a job that no longer exists. Delete, do not comment out. The PR body lists what the sweep removed and a final grep transcript showing zero hits for the term list (thedocs/plans/archive andscripts/sql/history excepted). 1c. Composed rows carry no standing feed. A redemption-priced token buys no hourly bars: the weekly market measurement (sub-PR B) is what notices a market appearing, so the old "sync and grade the composed token's bars" doctrine is retired with the grader. Setfeed: nullon everycomposedrow that still declares one (sGHO, sUSDf at least), update the coverage pins with the reason, and remove the doctrine sentence wherever it survives. 1d. USD3's market limb. Sub-PR B's first measurement read zero Dune trading days for USD3 and excluded its $2.1M Curve pool because the counter (frxUSD) is not a tracked row. Both readings contradict R11's evidence, so fix the measurement, not the ruling: (i) check the saved query's coverage of the Curve frxUSD/USD3 pool on Dune (dex.tradesdecoding of that pool; widen the query if it filters by a venue list) and re-execute once (medium engine, fraction of a credit); (ii) widen the pool-depth counter rule to any recognised USD stable or ETH wrapper whether or not it is a tracked row, via an explicit allowlist in the registry module (frxUSD0xCAcd6fd266aF91b8AeD52aCCc382b4e165586E29, crvUSD, USDC, USDT, DAI, USDS, USDe, GHO, PYUSD, RLUSD, USDD, WETH, wstETH, weETH, rETH, cbETH), state the rule indocs/metrics.md; (iii) report the re-measured USD3 numbers in the PR body. If USD3 still fails the bar after (i) and (ii), keep it market-priced as declared and say so in the PR body for Fred; do not flip it.OUTCOME (sub-PR D, 2026-09-23). R11 stands, and the measurement was what was wrong. (i) The saved query filters by no venue list; Dune DECODES the Curve frxUSD/USD3 pool (
0x7ba89bc6…4568, Factory V1 Stableswap Plain NG) and carries all 1,516 of its trades over the 30 days to 2026-09-22, on all 30 days, withamount_usdNULL on every one, because the vendor prices neither side of that pair. The query dropped unpriced trades, so the whole market counted as zero. v2 values such a trade on its recognised dollar counter leg and drops only a trade against a counter on neither path. (ii) The counter allowlist landed asRECOGNISED_POOL_COUNTERSin the registry module, read by the pool rule and handed to the query as its dollar half. (iii) Re-measured over the same window: USD3 reads 30 trading days of 30, a median $318,427 a day, and a $2,099,687 qualifying pool. All three bars clear, so USD3 is market-priced on the measurement as well as by declaration. sGHO reads 29 days and a $28,353 median, below the volume bar, and stays redemption-priced as R11 declares. ETHx's and sUSDe's excluded pools are held against a Balancer BPT and DOLA, neither of them a recognised counter, so neither case moves. 1e. Dead helpers from the merges.derivesCarriedFlag/publishesVolumeinsrc/lib/data/bar-sources.tslost their only consumer with the grader; delete them (or the module, if nothing else imports it) and any other export the sweep finds with no importer.docs/ops/release-steps.md(or the deployment runbook section used for releases): the exact prod steps for this release, in order: deploy;migrate.sh(110 by hand, 111);COINGECKO_API_KEYinto/opt/onchain-credit/.env.local; the full wallet reset (scripts/ops/reset-portfolio-users.tswith its confirmation flag, coverage rows,SESSION_SECRETrotation, restart of the app and the ingester); first 6h tick watched for the new alert lines; Dune query id for the market measurement in the env. No re-mark, no backfill of wallets.The final PR body: what/why in product terms, the per-sub-PR summary, the docs impact line, the server steps verbatim, and rollback (code first; migrations are expand/contract).
6b. Sub-PR E: measurement corrections (Fred, 2026-09-23)
Branch feat/pc-e-measurement -> PR into feat/pricing-categories, after D. Three rulings:
- Exchange volume counts. The volume limb of R2 ("median daily volume under $100k") is measured on CoinGecko's reported daily volume for the token (exchanges and DEXes together, the
total_volumesseries of the 30-day market chart, median of the 30 daily values, by contract address or listing id exactly as the live client resolves the token). The DEX trading-days limb stays on Dune's on-chain trades (exchange-reported volume cannot inflate a day count), and the pool-depth limb stays on-chain. Store the new reading as its own measurement kind; keep Dune's DEX volume as an informational kind; the verdict reads the CoinGecko volume. A token CoinGecko does not list (by address or id) keeps the Dune DEX volume for the limb, and the verdict names which source it used. - A pool's own share token is never a side. Balancer's stable pools list their own share token as a pool member and vendors report its internal balance as liquidity (ETHx read a $25.8M "pool" against its own share token; the same pools report zero today). The depth reading skips any pool side that is the pool's own share token (or any token whose symbol or address identifies it as a Balancer pool token, e.g.
-BPT,bb-a-), counts the pool on its real counter only, and records the skip in the reading's detail. loopableis a plain boolean. R5 stands as written: neither route meansfalse. The withheld third state (plan section 9 item 1 / section 4.6 item 1) is removed; rows the category test never measures (idle, no-base, funds, PTs,derived) stay NULL because they are outside the tested set, which is the only NULL that remains.
Docs: docs/metrics.md (the volume source and the share-token rule), docs/data-pipeline.md, docs/external-dependencies.md (the CoinGecko market-chart call and its budget: 23 tokens a week), the plan's section 9 (retire item 1). Tests: the verdict's table-driven cells for the new source choice, a depth-reading cell with a share-token side, and a cell proving a CoinGecko-listed token's volume reading is the CoinGecko median. Gates and review as in section 7.
7. Cross-cutting rules for every sub-PR
- Follow AGENTS.md: PR into the integration branch (
feat/pricing-categories), independent review by a separate agent, findings posted as a PR comment, implementer fixes or explicitly waives each, re-review until zero blockers. No browser QA and no e2e spec in this program (Fred's instruction); the CI nag is expected. Unit suite,npx tsc --noEmitand the docs build must be green before a PR is called ready. - Never commit the CoinGecko key or any secret. It lives in the servers'
.env.local. - Never run anything against prod. Staging validation only where a sub-PR needs live data.
- Dune credits are scarce: inspect a saved query's cost with one small execution before scheduling it; the API accepts
medium/largeengines only. - Migrations: forward-only, idempotent, expand/contract; destructive files tagged
-- DESTRUCTIVEin a comment and never auto-applied; never write BEGIN/COMMIT into a file. - Comments explain economics and invariants, never the iteration history. Copy: no em-dashes.
- Nothing of the old system stays: when you delete a mechanism, delete its comments, docs sentences, env vars, scripts, fixtures and tests too, and rewrite any comment that explained the code you changed in terms of the old mechanism. A comment that says "this used to be X" is a leftover; say what it is now.
- Work in your own worktree with a real
node_modulescopy (cp -cR); stage files by path. - Commit trailers:
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>and the session link given to you; PR bodies end with the generated-with line.
8. Declared terms seed (yield-bearing rows; evidence 2026-09-22)
| token | mint | redeem | reader | notes |
|---|---|---|---|---|
| wstETH | instant | queued | none | Lido withdrawal queue |
| weETH | instant | queued | none | ether.fi withdrawal queue |
| rETH | capped | capped | rocket_pool | deposit pool bounds both directions |
| cbETH | gated | gated | none | Coinbase account only |
| osETH | instant | queued | none | StakeWise exit queue |
| ezETH | instant | queued | none | Renzo withdrawal delay |
| ETHx | instant | queued | none | Stader unstake delay |
| stETH / eETH | instant | queued | none | rebasing; priced through wrappers |
| sUSDe | instant | queued | none | 7-day cooldown |
| sUSDS | instant | instant | sky_savings | Sky savings module |
| sDAI | instant | instant | sky_savings | DSR |
| sGHO | instant | instant | erc4626 | Aave savings GHO |
| srUSDe | capped | capped | erc4626 | Strata tranche: mint cap, redeem bounded by liquid buffer, exit fee |
| sUSDD | instant | instant | sky_savings | Ethereum SavingsUsdd, DSR-style |
| eUSDe | instant | instant | erc4626 | Ethereal pre-deposit vault; category decided by the series |
| USD3 | instant | capped | erc4626 | 3Jane senior tranche |
| syrupUSDC / syrupUSDT | instant | queued | none | Maple withdrawal requests |
| reUSD | queued | queued | none | Re Protocol NAV redemption |
| PST | gated | queued | none | Huma; periodic redemption |
| sUSDai | instant | queued | none | USDai withdrawal delay |
| sUSDf | instant | queued | none | Falcon cooldown; no market: redemption-priced |
| AA_FalconXUSDC / wFalconX | gated | gated | none | credential-gated |
| eBTC, sUSDat, apyUSD | (declare from the issuer docs; category is fixed as no-base) | none | informational only | |
| fund shares (earnETH, iETHv2, liquidETH, rETHLPC, tETH, yoETH, earnUSD, fLiteUSD, yoUSD, yvUSD, steakUSDC, gtUSDC, gtUSDCcore, gtusdcf, gtUSDT, USUALUSDC+, eUSDC-2, eUSDC-22) | NULL | NULL | NULL | redemption-priced by definition, never tested |
Where a row above says "declare from the issuer docs", the implementer reads the issuer's contract (Herd) and states the evidence in composed_evidence or the PR body; never guess.
OUTCOME (sub-PR D review, 2026-09-23). USD3's row above is corrected by the measurement, the same way 6.1d's pool rule was. This table declares USD3 instant / capped, and instant is defined in section 2 as atomic, permissionless AND uncapped. Sub-PR B then read the pair at the issuer's contracts and measured $0.00 both ways (maxDeposit 0, no idle USDC), which is the evidence the same row's note carries. The seed therefore ships capped / capped: the route exists and is atomic, so the row keeps its erc4626 reader and its capacity series, but it is bounded and the bound is nothing. No number moves, because the verdict is decided by the series either way and USD3's candidate is market as declared. What the correction buys is that terms_verified no longer asserts a reading that contradicts itself, and the next reader of mint_terms alone (the loopable extension 4.6 contemplates, an exit-terms annotation) is not told USD3 can be minted freely.
9. Deviations, as shipped (sub-PR D review, 2026-09-23)
Three places where the build differs from the body above, each listed with what the body says, what shipped, and why. A fourth — R5's "Neither: false" shipping as a withheld NULL in one shape — was settled on 2026-09-23 and is no longer a deviation: Fred refused the third state, sub-PR E removed it, and the reasoning is recorded under 4.6 item 1.
R6's withholding is narrower than the ruling's sentence. SHIPPED, and settled on evidence. R6 says "if both are outside and disagree, the asset's live price is withheld". As shipped (
src/lib/data/live-price.ts), a level is withheld only when the two vendors are further apart than the band the asset is judged by; two vendors inside the band of each other but outside the reference band leave the stored bar standing under the verdictuncorroborated, logged and not paged. The reason is the volatile rows whose reference is their own last stored bar, which the mirror walks back up to 48 hours: on an 8% day two vendors 1.4% apart about AAVE or LINK are not disagreeing about value, they are two venue mixes read against a bar that is hours old. Reading 1% as the contradiction threshold would dash a dozen ordinary holdings and page the six-hourly job every tick through any volatile session. A genuine depeg still draws, and a genuine contradiction still dashes and pages.docs/metrics.mddocuments the shipped rule.Section 8's USD3 row. See the OUTCOME note under section 8.
3.2's deletion of the
feedConfigpool fields shipped in full; 3.2's acceptance line ("no reference topool_quoteanywhere except migration history and this plan") has one more address than it names — the registry's own drift test. Thepool_quotemember ofFEEDS, the six pool fields onFeedConfig, the retired-feed table and the widened seed row type that carried them past the loader are all gone fromsrc/, and both registry rows carryfeed: nullandfeedConfig: null. What cannot be rewritten is migration 099, which wrotefeed: 'pool_quote'with its pool wiring for PST and sUSDai and is applied on every environment: the seed groups insrc/data/token-registry.tsare not a description of the database, they GENERATE each migration's own text, and a unit test fails the build when the two differ by a byte. Sosrc/data/token-registry.test.tsFREEZES those two cells as literal text, per address, and still generates the other 21 columns of both tuples from the registry row — 099 is matched to the byte exactly as every other seeding migration is, which matters becausescripts/fixture/build.shreplays 099 into an empty database on every fixture build (110 is skipped there as DESTRUCTIVE and the fixture seed hand-applies only its feed / feed_config half). What retires the value in a running system is migration 110's amendment block, which moves both rows todune_tapewith a NULLfeed_config; what stops the loader ever reading the string back is thefeedenum itself, since a stored value outsideFEEDSloads as NULL and a row with no feed is withheld rather than valued. No running process can see apool_quotefeed after 110.