Skip to content

built-with-deviations Built, with deviations. This is a decision record, not documentation; the body is annotated where the build diverged.

What is still current: Every tracked asset is valued either at what it trades for or at the rate it redeems into, and which one it is is now a measured question rather than an asserted one: the declared mint and redeem terms, the six-hourly capacity series and the weekly market measurement all sit on the registry row, the verdict module reads them, and a candidate that disagrees with the declared category for fourteen straight days is PROPOSED on the alert line and never applied. The market limb's three bars are measured where each can be answered honestly: the trading-day count and the pool depth on chain, and the median daily volume on CoinGecko's reported figure across exchanges and DEXes, falling back to the on-chain median for a token it does not list and saying which it read. A pool side that is the pool's own share token is never counted as depth. `loopable` is a plain boolean for every row the test measures. The five asset moves of R11 shipped as written (sDAI, sUSDS, sUSDD and srUSDe redemption-priced, USD3 market-priced, USDD 2.0 added). The signed-in page and Synchronize price every market-priced asset from a live CoinGecko quote with DefiLlama behind it, corroborated before it is shown; the hourly tape is still what values every point of history, and the two are never mixed inside one series. The 5-minute bar channel, the pool-quote feed and the Kyber live tip are gone from the pricing path. Three things shipped differently from the body and are listed in section 9: R6 withholds only on a vendor CONTRADICTION rather than on any two off-band readings, section 8 declared USD3 instant where the measurement read a bound of zero (shipped capped), and the retired pool-quote vocabulary leaves the code entirely while the two cells migration 099 wrote in it are frozen in the registry's drift test, so that applied file is still matched to the byte.

Landed: PR #938 (feature PR against staging, 2026-09-23); migrations 110, 111 and 112

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

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 valuation column keeps carrying it. market, derived and identity rows are market-priced; composed rows 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_at stamps 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 USDe 0x4c9e...68b3).
  • sUSDD -> redemption-priced (Ethereum SavingsUsdd 0xc5d6...9930: DSR-style, instant and uncapped both ways; redeems into USDD 2.0 0x4f8e5de400de08b164e7421b3ee387f461becd1a). USDD 2.0 becomes a new USD idle row (CoinGecko id usdd, 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):

columnvaluesmeaning
mint_termsinstant / capped / queued / gated / none / NULLhow 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_termssame valueshow it is turned back into its book asset.
capacity_readererc4626 / sky_savings / rocket_pool / none / NULLwhich 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_verifiedYYYY-MM-DDevidence date for the declared terms.
loopableboolean, NULLderived by the 6h job (R5). NULL until the first verdict.
loopable_verifiedYYYY-MM-DD, NULLthe 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.

  1. Delete the exact-minutes channel in src/lib/data/dune.ts (syncExactMinutes, MinuteRequest pool, drainMinuteBatches, runMinuteBatch, clusterMinutes, MINUTES_CLUSTER_SPAN, SOURCE_MINUTES, DUNE_QUERY_ID_MINUTES, DUNE_MINUTE_*, DUNE_MAX_CLUSTER_FAILURES, the batching half of dune-gate.ts if 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. alignToBar becomes hour alignment.
  2. Delete the pool-quote feed: pool-quote.ts, pool-quote-sync.ts, scripts/backfill-pool-quotes.ts, their tests, the pool_quote member of FEEDS, the feedConfig pool fields, the pool tier in valuation-sources.ts mode "now". Keep the Fluid DEX adapter only if something outside this feed imports it (check fluid-dex-pools.ts and the swap-cost route). PST and sUSDai rows move to feed: "dune_tape".
  3. Delete the Kyber aggregator tip overlay: src/lib/portfolio/aggregator-mid.ts and its test, the fetchAggregatorMid/fetchRedemptionForBand seams and the "JIT now tier" block in valuation-sources.ts, the mirrorTrackedTokens coverage assertion that referenced the registry, and every mention in live.ts, pnl.ts, recompose.ts. src/lib/data/kyber.ts stays (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.
  4. Migration scripts/sql/110-hourly-bars-only.sql, tagged -- DESTRUCTIVE: delete token_price_bars rows with source = 'dune:prices.minute' and source = 'pool:fluid_dex_t1', verify every remaining bar_ts is hour-aligned, replace the 300s CHECK with an hourly CHECK, and narrow the feed CHECK 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.
  5. 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.md where they describe the removed pieces. Remove, do not strike through.
  6. Tests: unit suite green (npm test), npx tsc --noEmit clean, cd docs && npm run build clean. Update scripts/test-manifest expectations 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).

  1. Migration 111-pricing-terms.sql (additive): the five columns of section 2 on portfolio_tokens with CHECKs derived from the same value lists the loader uses; the token_pricing_measurements table with the conventions of docs/database.md (chain_id + address keys, block-anchored rows, append-only, index on (chain_id, token_address, kind, measured_at desc)).
  2. Seed the declared terms for every variable_rate row from section 8, with terms_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.
  3. Asset moves of R11 in the seed: sDAI, sUSDS, srUSDe, sUSDD become valuation: "composed" with feed: null, liquidity: "primary_buffer", underlyingAddress = DAI / USDS / USDe / USDD 2.0, composedEvidence stating the instant-route evidence and the date; USD3 becomes valuation: "market", feed: "dune_tape", liquidity: "secondary" with the Curve evidence; USDD 2.0 (0x4f8e5de400de08b164e7421b3ee387f461becd1a, 18 decimals, symbol USDD, 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-era PAR_ACCOUNTING_ASSETS USDD entry is reconciled so the two USDD contracts cannot be confused (name them both explicitly). Check mirror-coverage.test.ts and token-registry.test.ts pins and update them deliberately, with the reason in the test.
  4. 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.
  5. DEX-market measurement, weekly, in scripts/refreshers/token-market-measure.ts (or a leg of refresh-assets.ts; keep the alert contract): a vendored saved Dune query scripts/dune/dex-market-measure.sql over dex.trades parameterised by token list and a 30-day window, returning per token the count of trading days and the median daily USD volume (execute on the medium engine: the API refuses small); create it with the Dune MCP tools and record its id under DUNE_QUERY_ID_DEX_MARKET in the env docs. Pool depth from GeckoTerminal's public token-pools endpoint (reserve_in_usd of the deepest pool whose other side is a book asset), 30 requests a minute, no key. Both write token_pricing_measurements.
  6. Verdict + loopable in src/lib/data/pricing-verdict.ts (pure, unit-tested): from the newest measurements and the declared terms, compute marketBarPassed, instantBothWays5M, categoryCandidate, loopable; the 6h job (a leg of refresh-assets.ts) writes loopable to 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, loopable is 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.
    1. 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 false written 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 and false is 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. loopable is a plain boolean, the withholding is gone (sub-PR E, section 6b item 3), and a row whose limbs both say no carries false whatever 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.

    2. 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.

    3. NO RULING NEEDED, BUT R5'S SCOPE IS WORTH STATING. Every row categoryTestApplies excludes 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 stETH true while 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.

  7. 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), the ComposedMarkReason values primary_buffer / feed_quality (replace with no_market / instant_pinned, derived from the declared terms and liquidity), 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.
  8. 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.md where "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).

  1. src/lib/data/coingecko.ts: one client. COINGECKO_API_KEY (Demo key header x-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 with include_last_updated_at. Hourly history (/coins/{id}/market_chart/range, interval=hourly when 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 gains coingeckoId for rows not listed by address (BTC.b -> bitcoin-avalanche-bridged-btc-b; USD3 3jane-usd3 and USDD usdd are listed by address and need no id). Unit tests with an injected fetch.
  2. DefiLlama backup: reuse llama-prices.ts (fetchUsdPrices strict mode) for live and its hourly chart for history.
  3. Live level in valuation-sources.ts mode "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/prices serves the cached live ETH level with the bar as fallback and states the vintage.
  4. 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 with source = '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 reason vendor-disagreement and 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.
  5. 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.
  6. 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.md and docs/metrics.md where 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.

  1. 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.md if the index is curated. 1b. Leftover sweep (Fred, 2026-09-22: nothing of the old system may remain in comments, docs or unused code). Grep src/, scripts/ (excluding scripts/sql/ history) and docs/ (excluding the docs/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 in src/lib/data/kyber.ts, the swap-cost route and their tests), feed-quality, feedQuality, basis-gate, feed_quality, primary_buffer as a composed REASON (the liquidity value itself stays), deepest pool, tape -> pool, mark-method flip, MARK_METHOD_FLIP, dune-price-mirror-plan references 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 (update docs/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 (the docs/plans/ archive and scripts/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. Set feed: null on every composed row 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.trades decoding 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 (frxUSD 0xCAcd6fd266aF91b8AeD52aCCc382b4e165586E29, crvUSD, USDC, USDT, DAI, USDS, USDe, GHO, PYUSD, RLUSD, USDD, WETH, wstETH, weETH, rETH, cbETH), state the rule in docs/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, with amount_usd NULL 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 as RECOGNISED_POOL_COUNTERS in 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 / publishesVolume in src/lib/data/bar-sources.ts lost 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.

  2. 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_KEY into /opt/onchain-credit/.env.local; the full wallet reset (scripts/ops/reset-portfolio-users.ts with its confirmation flag, coverage rows, SESSION_SECRET rotation, 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.

  3. 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:

  1. 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_volumes series 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.
  2. 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.
  3. loopable is a plain boolean. R5 stands as written: neither route means false. 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 --noEmit and 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/large engines only.
  • Migrations: forward-only, idempotent, expand/contract; destructive files tagged -- DESTRUCTIVE in 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_modules copy (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) ​

tokenmintredeemreadernotes
wstETHinstantqueuednoneLido withdrawal queue
weETHinstantqueuednoneether.fi withdrawal queue
rETHcappedcappedrocket_pooldeposit pool bounds both directions
cbETHgatedgatednoneCoinbase account only
osETHinstantqueuednoneStakeWise exit queue
ezETHinstantqueuednoneRenzo withdrawal delay
ETHxinstantqueuednoneStader unstake delay
stETH / eETHinstantqueuednonerebasing; priced through wrappers
sUSDeinstantqueuednone7-day cooldown
sUSDSinstantinstantsky_savingsSky savings module
sDAIinstantinstantsky_savingsDSR
sGHOinstantinstanterc4626Aave savings GHO
srUSDecappedcappederc4626Strata tranche: mint cap, redeem bounded by liquid buffer, exit fee
sUSDDinstantinstantsky_savingsEthereum SavingsUsdd, DSR-style
eUSDeinstantinstanterc4626Ethereal pre-deposit vault; category decided by the series
USD3instantcappederc46263Jane senior tranche
syrupUSDC / syrupUSDTinstantqueuednoneMaple withdrawal requests
reUSDqueuedqueuednoneRe Protocol NAV redemption
PSTgatedqueuednoneHuma; periodic redemption
sUSDaiinstantqueuednoneUSDai withdrawal delay
sUSDfinstantqueuednoneFalcon cooldown; no market: redemption-priced
AA_FalconXUSDC / wFalconXgatedgatednonecredential-gated
eBTC, sUSDat, apyUSD(declare from the issuer docs; category is fixed as no-base)noneinformational only
fund shares (earnETH, iETHv2, liquidETH, rETHLPC, tETH, yoETH, earnUSD, fLiteUSD, yoUSD, yvUSD, steakUSDC, gtUSDC, gtUSDCcore, gtusdcf, gtUSDT, USUALUSDC+, eUSDC-2, eUSDC-22)NULLNULLNULLredemption-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.

  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 verdict uncorroborated, 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.md documents the shipped rule.

  2. Section 8's USD3 row. See the OUTCOME note under section 8.

  3. 3.2's deletion of the feedConfig pool fields shipped in full; 3.2's acceptance line ("no reference to pool_quote anywhere except migration history and this plan") has one more address than it names — the registry's own drift test. The pool_quote member of FEEDS, the six pool fields on FeedConfig, the retired-feed table and the widened seed row type that carried them past the loader are all gone from src/, and both registry rows carry feed: null and feedConfig: null. What cannot be rewritten is migration 099, which wrote feed: 'pool_quote' with its pool wiring for PST and sUSDai and is applied on every environment: the seed groups in src/data/token-registry.ts are 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. So src/data/token-registry.test.ts FREEZES 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 because scripts/fixture/build.sh replays 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 to dune_tape with a NULL feed_config; what stops the loader ever reading the string back is the feed enum itself, since a stored value outside FEEDS loads as NULL and a row with no feed is withheld rather than valued. No running process can see a pool_quote feed after 110.

Private documentation. creddit.xyz