Skip to content

Operational processes ​

Step-by-step team processes for the data and content that drives creddit. This is the "how do I actually do X" page. For the underlying mechanics see Data pipeline (refreshers, schema, cadence) and Database & schema (the canonical APY math and per-page readers).

Two facts hold across everything below:

  • The app is read-only against Postgres. Pages read the onchain_credit schema; refreshers and ad-hoc syncs are the only writers. On-chain reads go through src/lib/data/rpc.ts.
  • Code ships automatically; data does not. .github/workflows/deploy.yml redeploys the app on every push to main, but it runs code only (no migrations, backfills, or refreshers). Anything data-shaped is a manual server step. See Deploy and prerender.

Environments at a glance ​

ProductionStaging
Path on box/opt/onchain-credit/opt/onchain-credit-staging
pm2 processonchain-credit (:3001)onchain-credit-staging (:3002)
DBcreddit (role onchain_credit)creddit_staging (role onchain_credit_staging)
nginx / URLhttps://creddit.xyz (Cloudflare in front)https://staging.creddit.xyz (basic-auth .htpasswd-staging, noindex)
Git branchmainstaging
DeployCI on push to main (deploy.yml)CI on push to staging (deploy-staging.yml)

Server: Hetzner box, ssh root@dexhq.io (key ~/.ssh/hetzner_ed25519). Prod tracks origin/main, staging tracks origin/staging. Prod also runs two always-on portfolio processes from its checkout, the event ingester (creddit-event-ingester) and the ledger worker (creddit-ledger-worker); every prod deploy restarts both, and staging runs neither (Deployment). Other pm2 processes on the box: rindexer, dexhq, creddit-indexer.

Develop on staging, promote to prod. Feature branch → draft PR into staging, marked ready for review once the independent review has converged → validate on staging.creddit.xyz → staging → main release PR → prod. Staging runs no refreshers: its data is a nightly PII-scrubbed reseed of prod (scripts/ops/reseed-staging.sh, 03:00 UTC), so it mirrors prod at reseed time rather than drifting. Full runbook, versioning, and the migration ledger: Deployment.

Cron schedule (server crontab, all UTC) ​

All scheduled jobs run through scripts/run-cron.sh <script>, which sources .env.local, cds to the repo, and runs node tsx scripts/<script> with output appended to /tmp/onchain-credit-cron/<checkout>-<script>.log. On a non-zero exit it best-effort alerts a Telegram bot naming the checkout + script + code (WS8; fail-soft, gated on ALERT_TG_BOT_TOKEN + ALERT_TG_CHAT_ID, silent when unset). A tick that lands while a deploy is rebuilding that checkout's node_modules skips and exits 0 rather than paging; if the misses continue past a bounded window it fails loudly instead. Full behavior: Deployment → running a cron by hand.

ScheduleJobPurpose
0 */6 * * *refresh-assets.ts6h grid: Fluid LL/DEX, token yields, basis, Aave/Spark/Morpho reserves, assets table, curator state
15 */6 * * *refresh-vault-capacity.tsborrow/supply caps + headroom
30 */6 * * *refresh-collateral-exposure.tsmarket collateral exposure
45 */6 * * *refresh-lending-positions.tsposition-attributed underwritten capital
50 */6 * * *refresh-portfolio.tsread-only portfolio spine + flow ledger (WS4)
30 1,7,13,19 * * *refresh-ledger-reconcile.tsthe permanent reconciliation alarm: every tracked wallet's booked figures re-derived a second time from the stored rows, on a 6h cadence, so a disagreement that appears after the ledger cutover is not silent. 40 minutes after the portfolio tick above, and clear of the 03:00 reseed and the 03:20 / 03:40 universe jobs.
5 * * * *check-ingester-freshness.tsthe ingester alarm, arm by arm. Freshness: pages when the always-on event ingester is older than the checkout's current build, is not online, or is missing. The deploy restarts it; this catches what bypasses that restart (a manual deploy or rebuild, a failed or skipped restart, a stopped or crashed process). Feed lag: behind a process that IS healthy — so its own page is never displaced by feed lines — pages when one of its event feeds has fallen more than 6h behind the chain, which nothing else reports until a wallet is stuck building or a chart stops advancing. Derive lag: the ledger worker, behind the same gate: pages when it runs old code, has stopped or never started, is wedged on a job, parked a job failed, or is not keeping up with its queue, and when the ingester's continuous producer has stalled. Ledger audit: behind the same gate, pages when a reading audit booked an unexplained correction, when a venue whose trailing week was clean books its first one, and when a leg the movement records hold went unread at two readings in a row; an accepted correction never pages. A worker or ingester whose grace period is not the documented one is a note, not a page. See the ingester alarm.
* * * * *drain-portfolio-backfills.tsregistration-backfill queue drain (WS5): FIFO 1-at-a-time, daily replay of the held-plus-closed-in-window group set, ~1 min signup-to-history, then a lower-priority background pass that deepens the same series to the history floor. A failed child retries once then parks; every failure pages (exit 2) and names the wallet
*/10 * * * *refresh-portfolio-discovery.tsdiscovery enrollment drain (T4 §3.6): one full-universe read per wallet with no FRESH completeness certificate (missing, older than its accounts row, or past the 18h staleness horizon), capped PORTFOLIO_ENROLL_MAX (default 10)
20 3 * * *refresh-morpho-universe.tsexhaustive Morpho Blue market universe (T4 §3.5), cursor-driven CreateMarket scan
40 3 * * *refresh-metamorpho-factory.tsexhaustive MetaMorpho vault universe (T5 §3.5), cursor-driven CreateMetaMorpho scan over both factories
30 4 * * 0refresh-portfolio-reconcile.tsweekly (Sun) discovery reconciliation safety net (T5 §3.6), capped PORTFOLIO_RECONCILE_MAX (default 50)
30 4 * * 1sync-portfolio-tokens.tsweekly (Mon) portfolio token-registry sync (T6). Propose + alert only — never writes without --approve
0 5 * * 1refresh-token-market.tsweekly (Mon) DEX market measurement for the pricing categories: trading days, median daily volume, and the deepest single pool holding the token against an asset of its own book or against a recognised counter of that book (a dollar stable at par, or ether and its liquid wrappers). Appends readings; decides nothing
50 3 * * *sync-money-market-funds.tsmoney market fund coverage: discover, confirm on chain, propose (section F)
30 3 * * 1refresh-vault-risk.tsweekly LTV / liquidation thresholds
0 13 * * 1-5refresh-sofr.tsweekday SOFR from NY Fed
15 2 * * *ops/backup-creddit.shdaily prod DB backup
0 3 * * *ops/reseed-staging.shdaily staging reseed from the backup (pause: touch /root/.reseed-paused)

The five portfolio-taxonomy jobs are ordered deliberately: the two universe ingests (03:20, 03:40) land before the weekly reconcile (Sun 04:30) reads against "the complete universe", and the discovery drain runs often enough (/10min) that a new signup's reads bound within minutes rather than forcing the whole 6h batch to a full-universe read. Per-job detail: Data pipeline.

sync-carries.ts and sync-curator-vaults.ts are NOT on this schedule. Vault/fund membership changes only when an admin runs them by hand (see the processes below). Data refreshes on the 6h grid regardless; the registries decide which vaults/funds the refreshers and pages act on.

sync-portfolio-tokens.ts IS scheduled, but it is the same shape: the cron only DIFFS + ALERTS. A human applies an addition with --approve <SYMBOL>.


A. Listing a new carry trade ​

The /carries screener has two classes of strategy. Know which you are adding.

ClassDefined whereKey shapeAdded by
Curated Aave v3 / SparkLend e-modehand-written STRATEGIES list in src/lib/data/carry-strategies.ts, with the math subset mirrored in CURATED_STRATEGY_CORES (src/lib/data/carries-table.ts)aave-… / spark-… semantic keyediting source
Auto-discovered Fluid vaultsonchain_credit.carry_registry (protocol Fluid), read live by getActiveRegistryVaults()fluid-vault-<vaultId>sync-carries.ts (discover) + --approve

At render time src/lib/data/carry-strategies.ts resolveShownStrategies() reads the live registry, drops any Fluid vault whose address is already curated (dedup by vault address), gates curated Aave/Spark rows on registry visibility, and merges the rest in via autoStrategiesFrom() / autoAaveSparkFrom() / autoMorphoFrom() (Fluid key fluid-vault-${vaultId}). Auto-discovered vaults flow through the exact same carry readers as curated ones. This resolver is the single code path both src/app/carries/page.tsx and the /api/carry-history?key= route call, so a row and its lazily-fetched expansion chart always resolve to the identical strategy wiring: the page still computes each series server-side for the collapsed row's scalars, but the full CarryHistoryPoint[] now crosses the wire only when a row is first expanded (via that route), not in the prerendered payload.

A.0 Unified coverage report (scripts/sync-carries.ts) ​

scripts/sync-carries.ts is the admin command that reviews carry coverage across all four venues at once (Fluid, Aave v3, SparkLend, Morpho Blue) against one shared criteria set, persists them to carry_registry (and Morpho markets to morpho_market_registry, see A.7), and prints a report. Fluid is discovered live from Fluid's API (scripts/fluid-discovery.ts, folded in from the retired sync-fluid-vaults.ts); Aave/Spark carries are discovered live on-chain by enumerating every e-mode category, taking yield-bearing collateral against same-asset-class borrowable debt, and deduping by economic pair (keeping the best-LTV category); Morpho Blue markets are discovered from the Morpho Blue API (veto-only) and re-verified on-chain (scripts/morpho-discovery.ts, rule in scripts/morpho-rule.ts) against the Morpho admission rule (A.7). Flags: --dry-run (report, no writes), --approve <key> / --reject <key> (promote/demote a carry). It prints three groups:

  • LISTED / PROPOSED — passes all six criteria (size floor, delta-neutral, live/tradeable, modellable yield, renderable structure, self-sufficient economics). Already-live carries show as LISTED; newly-discovered ones as PROPOSED (they do not auto-list).
  • EXCLUDED BY POLICY — correctly held off, tagged with the failing criterion (below floor / directional / wound-down / reward-dependent).
  • NEEDS A DECISION — right size and delta-neutral but not renderable yet (missing yield adapter, missing pool/structure reader, unrecognised type).

Pendle PT collateral (term carries). A PT collateral leg passes the modellable-yield criterion through pendle_markets instead of token_yield_apy: its yield is the FIXED implied rate from pendle_market_state, and the carry has an end date. sync-carries joins discovered PT reserves against pendle_markets (a PT missing there still lands in NEEDS A DECISION), applies the runway floor below, and persists maturity_ts plus pendleMarket/colDecimals in the config so an approved maturity renders, simulates and quotes with zero hand-wiring. Note PTs are e-mode-ONLY collateral on Aave (base usageAsCollateral false by design); the liveness check accepts e-mode bitmap membership for them.

The 30-day runway floor. A term carry is shown and offered only while it has strictly more than 30 days of term left, so a maturity exactly 30 days away is already delisted. Under a month of term, the slippage to enter is not worth the carry (Fred, 2026-09-02). The floor applies to already-listed carries as well as new ones: this replaced a 14-day floor that gated new listings only and let a listed carry run to its real maturity. A carry with no maturity is not a term carry and is untouched.

Delisting is layered, in order of authority:

  1. Read time — the guarantee. Five readers filter on the floor, so a short-runway carry cannot reach a surface a would-be entrant sees whatever its status column says. Worst case it survives one ISR window (3600s). It is five and not one because the entry decision is not made on the screener row alone — it is made on the oracle mechanics and the market-depth card, which are fetched lazily by their own route:

    • getActiveAaveSparkCarries() (carries-table.ts) — the /carries screener, the carry chart, and four of the six chat carry tools, which resolve through the carry catalog.
    • aaveSparkMetaFromRegistry() + getMorphoOracleReport() (oracles.ts) and resolveLegMeta() (basis.ts) — /api/carry-oracle, i.e. the oracle panel and the market-depth card (including the PT implied-rate history and what a rate move costs to unwind against), plus the agent's get_oracle_report / get_basis. These resolve a strategy key directly rather than through the catalog, so the catalog's gate does not cover them.

    carries-table.test.ts holds the enumerated inventory of every status='active' registry read and which of them this list must contain; a read that is in no class fails the suite.

  2. 6h cron — hygiene, and the single owner of the status flip. The pendle-markets refresher flips carry_registry rows active -> matured on the same clock, with a status_reason that names the runway rather than a maturity so the two are distinguishable in ops output. It owns this because sync-carries is ad-hoc, not a cron. The PT's own pendle_markets row still flips only at real maturity: a market 20 days out must keep snapshotting, because portfolio marks read it long after the carry stops being offered.

  3. Discovery — hygiene. sync-carries classifies a short-runway PT the same way ([matured] in EXCLUDED BY POLICY, with the runway named in the reason), so an ad-hoc run agrees with the clock instead of re-listing a row the refresher delisted.

  4. Capacity. The 6h capacity refresher drops vault_capacity rows for carries inside the floor, so no Borrowable / tight-capacity figure outlives the listing.

--approve REFUSES a term carry inside the floor rather than writing a status the read-time filter would ignore, so the manual path cannot put one back on the page either.

The floor, the boundary and the SQL predicates live in one place: src/lib/data/carry-runway.ts.

Floors: Fluid vault TVL ≥ $100k; Aave/Spark enterable size ≥ $100k, where enterable size is the lower of the collateral's supply-cap headroom and the debt's borrowable (free liquidity capped by the borrow cap) — real headroom, not total existing supply. (Loosened from $1M 2026-08-03: entry room stopped being the listing gate — the page's liquidity filter defaults to $100k and each row shows its Borrowable + the tight-capacity warning, so a real-but-tight market lists rather than hides. A usage floor is inherently satisfied on these pooled venues — every mainnet Aave/Spark reserve dwarfs it.) Morpho floors are per-track (A.7). Run via scripts/run-cron.sh sync-carries.ts (logs to the cron log) or directly for stdout.

The registry drives the /carries listing for Aave/Spark. getActiveAaveSparkCarries() returns carry_registry rows with status='active'; the page shows a curated Aave/Spark row only if it is active in the registry (so a frozen reserve like spark-reth-weth drops automatically), and adds any approved carry not in the curated list with auto-generated copy. A passing-but-new carry lands proposed (not shown) until an admin runs --approve <key>; on approval it renders at full parity automatically, because description, carry APYs, borrowable/capacity, LTV, oracle and rate history are all derived from the e-mode config + the collateral's profile (see A.1). If carry_registry is absent the page falls back to all curated rows (no regression).

Running it (manual, by design, NOT on a cron). sync-carries.ts is the tool you run by hand to see what is newly available to list and to update the registry. The supporting data refreshers (capacity, risk, reserve rates) ARE on crons and read whatever is active in the registry; sync-carries itself only changes membership/status, so it runs on demand:

# 1. See the current picture (read-only, no writes):
scripts/run-cron.sh sync-carries.ts --dry-run      # or run directly for stdout
# 2. Persist the latest classification (writes carry_registry, nothing auto-lists):
scripts/run-cron.sh sync-carries.ts
# 3. Promote a PROPOSED pair you want live (or demote one):
scripts/run-cron.sh sync-carries.ts --approve aave-oseth-weth
scripts/run-cron.sh sync-carries.ts --reject  aave-syrupusdt-gho

Run it after a market change you care about (a new e-mode pair, a reserve freezing, a vault crossing the floor) or just periodically to review the PROPOSED and NEEDS A DECISION groups. A run never changes what is listed on its own — only --approve moves a pair to active. After approving, the next capacity/risk/rate cron tick (or a manual run, see A.5) fills in that pair's data; the page renders it immediately with the templated copy and fills the live numbers as they land.

A.1 Listing a new Aave / SparkLend e-mode carry (approve a discovered pair) ​

Aave/Spark carries are now registry-driven: sync-carries.ts discovers and classifies every e-mode pair, and the page lists the active ones. The normal flow to list a new one is just an approval:

  1. Run scripts/run-cron.sh sync-carries.ts (manual; it is not on a cron). A new qualifying pair is written to carry_registry as proposed.
  2. Review the report's PROPOSED group, then sync-carries.ts --approve <key> (e.g. aave-oseth-weth). Status flips to active.
  3. It now renders on /carries at full parity automatically — description (templated), carry APYs, borrowable/capacity, LTV/liquidation threshold, oracle panel, and rate history are all derived from the e-mode config and the collateral's profile. The capacity / risk / reserve-rate refreshers and the oracle resolver all read carry_registry, so no per-pair source edits.

The 5 original curated rows (aave-weeth-weth, …) keep their hand-written descriptions/oracle copy; the registry merely gates their visibility. A curated row whose reserve goes inactive (e.g. spark-reth-weth, rETH frozen) drops off automatically.

The one manual prerequisite — a brand-new collateral ASSET we do not yet model. Such pairs never reach proposed; they sit in the report's NEEDS A DECISION group. To make them proposable, wire the collateral once:

  • a token_yield_apy adapter for its yield (see Section B), and
  • a CollateralKind + COLLATERAL_PROFILE + COLLATERAL_LABEL_TO_KIND entry in src/lib/data/oracles.ts so the oracle panel resolves (a single-token profile; some collaterals only have an LP profile today, e.g. osETH).
  • an asset-coin mark for the Position column, if the token has none yet: add it to the maps in src/components/carries/PositionCell.tsx (the carries table's own token-coin resolver — reuses the public/token-icons/ files but also covers base coins, debt-only stablecoins like USDe/USDtb/GHO, and Pendle PT underlyings). Ship the official image, never an approximation; PositionCell.test.tsx locks that every active-carry token resolves to a specific mark. The merged COLLATERAL → DEBT column renders each row as collateral → debt; a smart col/debt pair (a/b) fuses into one overlapping coin cluster with its symbols middot-joined and flags the leg with a borderless amber SMART COLLATERAL / SMART DEBT caption on a quiet second line beneath the ticker; a Pendle PT (PT-<u>-<date>) wears its underlying <u>'s coin (roll-proof across maturities) and drops its maturity to that same muted second line (PT-srUSDe over 22 OCT 2026). After that the next sync surfaces its pairs as proposed for approval.

A.2 Adding an auto-discovered Fluid vault ​

Fluid is now part of the unified sync-carries.ts (A.0) — sync-fluid-vaults.ts was retired and its discovery moved to scripts/fluid-discovery.ts. The flow is the same proposed→approve one as Aave/Spark: run sync-carries.ts (it discovers

  • persists Fluid to carry_registry), then --approve fluid-vault-<id> a PROPOSED vault to list it. Run it when the listed set should change: a vault crosses the $100k supply-TVL floor, a newly snapshotted DEX pool or token yield adapter unblocks a vault, a Merkl campaign starts/ends, or a vault is delisted.

What the discovery does: pulls every live vault from the Fluid API (https://api.fluid.instadapp.io/v2/1/vaults), computes supply-side USD TVL, classifies each into a creddit StrategyConfig (T1/T2/T3/T4), checks whether we can render it correctly today (is every leg token's yield adapter present and every smart-col/debt DEX pool snapshotted?), then sync-carries upserts the carry_registry and the report prints a diff. Coverage comes from our own latest data (distinct token_yield_apy.token_address, keyed by address not symbol, and distinct fluid_dex_apy.pool_address), so adapters and pools added elsewhere unblock vaults automatically on the next sync.

Only status = 'active' vaults appear on the page. Status values:

StatusMeaning
activeTVL ≥ floor, supported, delta-neutral same-single-class → shown
directionalnet price exposure (cross-class or mixed-class LP) → held off
reward_dependentclean carry but return leans on a live Merkl incentive → held off
reward_token_externalcollateral's real yield is an off-chain reward we don't model (USDe points, USDtb), held out on the collateral side
wound_downborrowing disabled on-chain (Fluid collapses borrowLimit to dust below minimumBorrowing)
below_floorTVL < $100k → dropped
blocked_adaptera leg token's intrinsic yield isn't modelled yet
blocked_poola smart-col/debt DEX pool isn't snapshotted (for a cross-pool T4, BOTH pools must be)
blocked_reviewunrecognised token / unsupported type (a T4 with different col vs debt pools is no longer here — it maps to t4-cross-pool and flows through the standard gates)
gonewas in the registry, no longer returned by the API

Safety rails worth knowing: the script refuses to sync if the API returns fewer than MIN_EXPECTED_VAULTS (80), otherwise the gone-marking pass would mass-mark real vaults gone and strip the page. BASE_TOKENS (the strict non-yield allowlist, compared upper-cased) decides which tokens need no adapter; a yield-bearing token slipping in here would let a mis-stated carry display, so keep it strict. Classification uses asset classes (ETH_CLASS/BTC_CLASS/ GOLD_CLASS/USD) to decide delta-neutral (carry) vs directional.

BTC is still an asset class here. The portfolio retired its BTC book in August 2026 and WBTC/cbBTC are declared exclusions THERE (see Portfolio -> Retired: the BTC book), but a market screener asks what an instrument settles in, not which of creddit's books it charts into. So the carries side resolves a token's class through the ANALYTICS numeraire (numeraireForAddress, derived from basisTokens()), falling back to the portfolio book map only for the plain bases that registry does not list. Reading a pool's two sides through the portfolio map instead would resolve two bitcoin wrappers as two separate unknowns and fail the real WBTC-cbBTC smart pool's same-class test closed, silently withholding a realized series that has always been correct.

If a new Fluid vault comes back blocked_adapter or blocked_pool, that is the signal that Section B / a DEX-pool snapshot is the prerequisite: wire the missing piece, let the 6h refreshers populate it, then re-run the sync.

A.3 Wiring oracle transparency copy for a Fluid vault ​

Fluid vault oracle meta is generated from the registry, not hand-keyed: fluidMetaFromRegistry() in src/lib/data/oracles.ts resolves fluid-vault-<id> straight from carry_registry (collateral/debt labels, LTV, liquidation threshold, vault address). The live facts (oracle address, the 1e27 operate rate) are read on-chain via the Fluid VaultResolver. But two curated pieces gate whether the ORACLE tab renders for a vault:

  1. src/lib/data/oracles.ts COLLATERAL_LABEL_TO_KIND must map the vault's collateral label (e.g. "reUSD/USDT") to a CollateralKind. If it doesn't, fluidMetaFromRegistry() returns null and the panel is omitted rather than shown wrong. New collateral kinds also need a COLLATERAL_PROFILE entry (layers 2/3: how the rate is read, what it protects, what it does not) and, for any new token, a TOKEN_INFO entry (valuation + issuer risk) used by the generated pricing prose.
  2. src/lib/data/fluid-oracle-copy.ts FLUID_VAULT_COPY holds the curated per-vault liquidation narrative, keyed by `${collateral_label}|${debt_label}` exactly as the registry stores the labels (so vaults #6 and #16, both weETH|wstETH, share one entry). fluidVaultCopy(colLabel, debtLabel) returns null for an uncovered pair, and the caller falls back rather than render half a panel. Reuse the shared paragraph constants (e.g. SUSDE_COL_SINGLE, reusdLiquidation(...)) so sibling vaults can't drift. House rules baked in: no em-dashes; the oracle-fault clause is FAULT_SINGLE for single-leg vaults and FAULT_LP for any smart (LP) leg.

For the redemption wrapper rate read live in the pricing box, the collateral token must be in REDEMPTION_TOKEN_ADDR (keyed by upper-cased symbol → address), and its share_rate must be flowing into token_yield_apy.

Aave/Spark pricing-mode nuance. The same CAPO adapter family is redemption-style or market-linked depending on the debt leg. aave-susde-usdt is redemption (both legs ride the capped USDT/USD feed, so they cancel); aave-susde-usdc is market-linked (collateral on USDT/USD, debt on USDC/USD, leaving a USDT-vs-USDC cross). Set the pricingMode override on the STRATEGY_ORACLE entry accordingly; resolvePricingMode() falls back to the methodology default otherwise.

A.4 Risk params and capacity coverage ​

  • Risk params (max_ltv, liquidation_threshold) come from onchain_credit.vault_risk_params, populated weekly by refresh-vault-risk.ts and read by getVaultRiskParams() (src/lib/data/vault-risk.ts). A missing key (refresher hasn't seen the strategy yet) degrades to the hard-coded vaultLTV in the registry / strategy core; the page does not 500.
  • Capacity is registry-driven for Fluid (issue #81): the vault-capacity.ts refresher iterates every status='active' Fluid carry_registry vault, derives the full per-vault config on-chain from getVaultEntireData.constantVariables (type via isSmartCol/isSmartDebt, DEX pools, leg tokens, decimals), then reads live limits. Fluid capacity rows are keyed by lowercased vault address (matching strategy.vaultAddress); Aave/Spark rows use semantic keys. Orphaned Fluid rows (vault left active) are pruned each run, guarded on a non-empty active set so a failed read can't wipe the table.

A.5 Post-add refresh steps ​

After the registry write (Fluid) or source edit + deploy (Aave/Spark), force the data that does not ride the 6h grid:

bash
# Fluid: capacity now covers the new active vault; risk params on the weekly cron
/opt/onchain-credit/scripts/run-cron.sh refresh-vault-capacity.ts
/opt/onchain-credit/scripts/run-cron.sh refresh-vault-risk.ts        # or wait for Monday 03:30

The 6h refresh-assets.ts will fill the leg APY tables on its own cadence; if the vault was previously blocked_*, the leg data already exists (that is what unblocked it).

A.6 Deploy, prerender, and the ISR trap ​

See Deploy and prerender. The short version: pages are prerendered at build with per-page ISR (revalidate 1800 or 3600s). For a curated strategy you changed source, the push-to-main build re-prerenders it. For an auto-discovered Fluid vault you only changed data (the registry row), so re-run the deploy after the sync/refreshers so the build re-prerenders /carries with the new vault; otherwise it stays stale up to the revalidate window.

A.7 Morpho Blue markets: the admission rule (repo + carry) ​

Morpho Blue is permissionless — thousands of markets, and the raw top of the borrow ranking is exactly the garbage a naive size sort would list (a multi-B fake self-referential book, drained markets frozen at 100% utilization, markets carrying tens of millions in unrealized bad debt). So membership is governed by an explicit admission rule with three layers, implemented as a pure function in scripts/morpho-rule.ts (classifyMorphoTracks, unit-tested in scripts/morpho-rule.test.ts) and fed by scripts/morpho-discovery.ts. This section is the canonical statement of that rule; the code mirrors it.

Two surfaces (tracks) are governed by the one rule:

  • repo — the isolated-market rows on /repo-lending.
  • carry — the delta-neutral loops on /carries (kind: "morpho-blue").

A market can serve both (e.g. sUSDS/USDT is a deep USDT repo book AND a delta-neutral USD carry).

The Morpho Blue API is veto-only. It can keep a market out (unlisted, RED warning, bad debt, too-few curators) but can never put one in, and every listing number (sizes, rates, params, oracle) stays chain-canonical: discovery re-reads idToMarketParams + market() on-chain and re-checks keccak256(abi.encode(params)) == marketId. Fail closed for additions (if the API is down, hold new proposals) and open for removals (don't delist an existing active market on API silence — only the chain-derived gates below can auto-delist between API reads).

Hard gates (H1–H8) — a failure excludes the market from BOTH tracks:

GateSourceWhy
Ethereum mainnet, Morpho Blue singletonchainscope
AdaptiveCurve IRM + non-idle (collateral ≠ 0)chainwe only model this IRM; idle markets are unmodellable. The IRM compared is the CHAIN's idToMarketParams.irm (the API's field is a convenience, and this gate is the whole reason the parameter matters): every rate-model figure published for a Morpho market, the 90% target utilization included, is a constant of the AdaptiveCurve CONTRACT, and irm is an immutable creation parameter that can be any address. A market whose params would not decode reports NO IRM rather than the API's value, so this field always means "what the chain says, or nothing", and a read failure never reads as a foreign model (it has already failed the id gate above). A failure excludes the market and raises a Telegram alert naming it, once per market — tracked by irm_flagged_at (migration 081), so the first run after that migration reports the markets standing on an unmodelled IRM today and every later run is quiet. Silently dropping a market whose rate model nobody has read leaves the decision unmade. Note this gate now judges the chain rather than the API field, which WIDENS admission for a market whose API irmAddress came back empty but whose chain IRM is the AdaptiveCurve. See Data pipeline
Morpho-listed (listed = true)API (veto)Morpho's own curation; drops fake/unlisted books
No RED API warningAPI (veto)catches oracle-derivation / custom red flags
Bad debt < 0.1% of supplied USDAPI (veto)drained/impaired books
Utilization ≤ 99%chaina ~100%-util book is drained: lenders can't exit, "borrow APY" is fiction
Decomposable oraclechainthe oracle must be a family we can decompose: a flat MorphoChainlinkOracle (BASE_FEED_1 + SCALE_FACTOR; BASE_VAULT optional, v1 uses VAULT), OR a MetaOracleDeviationTimelock (primaryOracle/backupOracle + deviation threshold + timelocked failover, decomposed by recursing into the primary). An unrecognised family → blocked_oracle (decision queue), not silent listing. src/lib/data/morpho-oracle.ts reads the chain on-chain for the /carries ORACLE tab
≥ 2 independent supplying curated vaultsAPI (veto) + chainthe "someone else underwrote this" bar. cbBTC/USDC has 16; standalone/affiliated books (kBTC/RLUSD, PRIME/PYUSD, msY) have 0. NOT "in our curator registry" — that list is USD-only and would wrongly veto every WETH-loan carry. The count is MetaMorpho (V1) supplyingVaults from the market API plus distinct listed Morpho Vaults V2 supplying the market — the market API is blind to V2 (sUSDS/AUSD read 0 while 97% of its supply was a listed $25M Steakhouse V2 vault). V2 counting (scripts/morpho-v2-vaults.ts): only listed vaults, only direct MorphoMarketV1 adapters (a V2 vault routing through a V1 vault is already in the V1 count — counting the wrapper would double-count one curation decision), every adapter re-verified on-chain (parentVault() must match; the API is veto-only), and a position only counts at ≥ $10k (V2_MIN_POSITION_USD — dust probes must not stand up a market). Only markets whose sole hard-gate failure is the vault count are topped up (hardFail.gate === "vaults")

Track gates + floors:

  • repo: loan ∈ {USDC, USDT, USDS, GHO} (REPO_LOAN_ASSETS in scripts/morpho-rule.ts, asserted equal to the page's MONEY_MARKET_ASSETS tabs), collateral is perpetual (no PT — the repo tab has no maturity machinery), and total borrowed ≥ $25M. As of 2026-07 no USDS or GHO market clears this: GHO has no mainnet Morpho market at all, and the largest USDS market is ~$3.4M borrowed with 0 supplying vaults (so it also fails the ≥ 2-vault gate). The loan-asset gate is widened so a qualifying market lists on its own; the floors, not the asset list, are what keep the tab honest.
  • carry: loan is a base (non-yield) token (a yield-bearing loan would need the additive wrapper-appreciation funding term → nonbase_loan), collateral and loan are the same asset class (assetClass from carry-criteria.ts; OTHER is never delta-neutral → directional), collateral yield is modellable (token_yield_apy row, or a PT in pendle_markets; else blocked_adapter), collateral is not an external-reward token (USDe/USDtb → reward_token_external), and the two size floors: total borrowed ≥ $1M (real usage — a market nobody borrows from is not a carry venue, however much idle deposit sits in it) and free loan liquidity ≥ $100k (entry sanity; loosened from the old $1M gate 2026-08-03 — entry room is surfaced per-row and filterable on the page, default $100k, rather than gating the listing). Morpho has no supply/borrow caps, so free liquidity = (supply − borrow) × price is the only enterable bound; the public-allocator's reallocatable liquidity is NOT counted as enterable.

Hysteresis. An already-active market only auto-delists when it falls below 50% of its track floor ($12.5M repo; $0.5M borrowed / $50k free carry — both carry floors halve); between 50% and 100% it stays listed and the report flags it. Prevents flapping around the threshold.

Dedup. Registry key is morpho-<col>-<loan>-<first 8 hex of marketId> (the hex suffix is required — the same pair exists at multiple LLTVs/oracles). Per (collateral, loan) pair, only the deepest market per track is proposed; the rest classify duplicate_market.

Persistence + state machine. Every discovered market lands in morpho_market_registry (the governance table + the refresher's work-list + the repo tab's source); carry-track markets ALSO land in carry_registry as kind: "morpho-blue" so /carries reads one table across venues. New markets land proposed (nothing auto-lists). --approve <key> flips BOTH registries (so the repo tab shows it AND the refresher snapshots it — a carry with no snapshots renders a 0/sign-flipped funding leg). Report buckets: LISTED/PROPOSED (active), EXCLUDED BY POLICY (hard-gate / directional / below-floor / …), NEEDS A DECISION (blocked_adapter / blocked_oracle).

scripts/run-cron.sh sync-carries.ts --dry-run              # review Morpho + others
scripts/run-cron.sh sync-carries.ts                        # persist (nothing auto-lists)
scripts/run-cron.sh sync-carries.ts --approve morpho-susds-usdt-<hex8>
# then snapshot the newly approved market's history + funding leg:
scripts/run-cron.sh backfill-morpho.ts --days 90 --market 0x<marketId>

A scoped run fills rates only; it cannot also fill collateral-exposure history (--exposure refuses to combine with --market, and says so). The new market's exposure row appears at the next 6h cron tick, and its exposure HISTORY is a separate unscoped run — see Data pipeline.

ORACLE tab (wired). The /carries ORACLE tab now renders for morpho-blue carries: src/lib/data/morpho-oracle.ts reads the market oracle's family + feed chain on-chain (flat MorphoChainlinkOracle v1/v2, or a MetaOracleDeviationTimelock decomposed by recursing into its primary + surfacing the deviation guard), and getMorphoOracleReport in oracles.ts turns it into the transparency panel with copy generated from the live feed descriptions.

Known limitations (deferred, tracked): the tight-capacity warning panel is not yet wired for morpho-blue (capacity reads null, so it hides gracefully); a follow-up adds a Morpho borrowableUsd = free liquidity source in the capacity refresher.


B. Staying in sync with asset-profile changes ​

The /asset-profiles screener is built from two layers: live numbers in Postgres, and curated narrative in source.

  • The roster. src/data/covered-assets.ts coveredAssets() is no longer a list anybody edits: since #810 Y1 it is a PROJECTION of the token registry — every row that carries a profile_ticker and an issuer, shaped as address, ticker, name, issuer, profile href. So a profile is added by setting those two columns on the asset's own registry row (B.2a), never here. Client-safe plain data still, and still read server-side by two consumers that must not drift — the refresher that writes onchain_credit.assets, and the /portfolio API, which resolves each holding's profile link and issuer onto the served row. The Variable rate assets row group in the browser reads those fields off the row it was served rather than importing the module.

  • Numbers. src/lib/data/assets-table.ts getAssetsFromDb() reads onchain_credit.assets (trailing APY, 1M/YTD/1Y returns, productive vs underlying mcap). That table is written daily by the yield-token-assets.ts refresher (part of refresh-assets.ts), which derives every figure from the per-snapshot onchain_credit.token_yield_apy history. current_apy is the latest apy_30d (trailing-30d), so the table value equals the yield-history chart's latest point.

  • Narrative. src/data/asset-narratives.ts ASSET_NARRATIVES holds the per-asset memo (lede, sectioned prose, links) shown in the row expansion, keyed by ticker. Every entry now follows one standard: headline lede, then the yield-history chart, then three question-style sections: "Where does the yield come from?" (capital traced end to end: who deposits, where it goes, who pays and why, the protocol take, how the return reaches the holder), "Risks and the loss absorption waterfall" (the waterfall from first loss to the holder, with an optional numbered waterfall stack graphic on the section; no closing "main risks" summary, which editorialises), and "Redemptions and liquidity" (redemption path, cooldowns/queues/gates with structural parameters, stress behavior). A redemptions section that mentions selling closes with one line, "The alternative to waiting is selling on secondary markets at the liquidity and price available.", never a venue-by-venue tour. Describe the product as it works today; mechanisms that apply only to older or legacy versions are left out. Paragraphs support inline [text](url) links so a claim can point at its live evidence (a dashboard, an on-chain balance). The per-asset facts grid is retired: every memo is headline, chart, three sections.

    Voice (locked in; PST is the reference entry). Clarity of an explainer, register of a memo. Write for a TradFi credit analyst reading cold: fluent in credit, collateral, tranches, margin and custody, with zero DeFi vocabulary. The rules, each of which came from a specific rejection:

    • The lede opens "<TICKER> is ...", saying what the token is and what backs it in short direct sentences ("sUSDai is the yield-bearing version of USDai, USD.AI's synthetic dollar."), not legal-claim framing ("sUSDai is what a holder receives for locking..."). One idea per sentence.
    • Say what a thing is, never what it is not. ("Yield accrues as a rising redemption price", never "balances never grow".)
    • Transition into every section and paragraph. Nothing starts flat.
    • Explain a DeFi-native term the moment it appears, in a tight clause (rebase, restaking, slashing, an AVS, perpetual funding, minting, oracle, MEV, impairment). Do not explain what a credit analyst already knows (a block, a futures contract, arbitrage, a margin call, a tranche); that reads as condescension and it is the main source of bloat.
    • Exactly one worked example per memo, on the mechanism hardest to picture in the abstract, with hedged round numbers ("say, $1 million"). An example may only make concrete a mechanism the entry already describes: it must never assert a real parameter, counterparty or statistic of its own.
    • Plain nouns over jargon. Banned outright: "sleeve", "obligor", "holder rate". Waterfall labels stay plain ("Borrower repayment"); the detail goes in note.
    • Density: match PST (~720 words for lede plus all paragraphs, ~1.6x the terse pre-2026-07 entries). Explanation earns length; restatement, second definitions and asides do not. Bloomberg-terminal aesthetic, not info-overload.
    • Never mislead by compression. Copy states what a protection actually is: in PST, "backed by the tokenized payment order" implied escrowed collateral, when the advance is really a claim on customer money in transit that settles into the borrower's own accounts. If a mechanism's strength is ambiguous, verify at the primary source before writing it.
    • The protection test (apply to every waterfall layer). Before writing a layer as loss-absorbing, answer three questions at the primary source: is it cash set aside or a promise to pay; who holds it; and what mechanism draws on it on default. A layer that fails these is still worth describing, but it must be labelled for what it is, not stacked as though it were funded. Verified instances, each of which changed copy (#412):
      • sUSDS: agents' junior risk capital is not money the agents raised. Sky transferred it out of the Surplus Buffer as Genesis Capital and it stays under Sky governance control, so the agent layer and the buffer behind it are the same equity counted twice. Never write "posts its own money".
      • osETH: the 5M SWISE operator bond is a condition of DAO approval. StakeWise documents no mechanism that seizes or converts it for holders, and no such contract exists in stakewise/v3-core. Never write "drawn on for slashing losses".
    • A governance framework is not a contract limit. Where copy cites a published parameter framework, state the bound the code actually enforces. sUSDe's adopted 1-7 day dynamic cooldown is a risk-committee operating rule; StakedUSDeV2 stores one cooldownDuration an Ethena multisig can set with no timelock, capped at MAX_COOLDOWN_DURATION = 90 days. Quote the framework, then the enforceable ceiling.

    Substance rules unchanged: strictly factual, no opinion/advice/superlatives, no cross-asset comparisons, no code mentions, no em-dashes, no claims that go stale (relative size, market-share, reserve dollar figures, point-in-time counts, dated framing), no percentage splits of backing composition (structural rates like fees, spreads, LTV thresholds and cooldown day counts are fine). A named stress episode may be cited only when verified and only for the structural lesson it teaches.

B.1 The canonical APY convention ​

Every trailing APY in the system is annualizeRatio (src/lib/data/apy.ts): the realised ratio of an on-chain compounding index between two blocks, annualised by actual elapsed time. Never average per-snapshot annualised rates. This is the same across every venue so /carries compares like-for-like.

B.2 Adding a new yield-bearing asset ​

  1. Register the on-chain rate source. Add the token to the relevant list in scripts/refreshers/token-yields.ts. Each entry is { address, symbol, kind, ... } where kind is the read shape: erc4626 (convertToAssets(1e18)), lido-wsteth (getStETHByWstETH), etherfi-weeth (getRate()), rocketpool-reth (getExchangeRate()), reUSD-onchain (NAVConsumer), mellow-oracle, veda-accountant, treehouse-teth, chainlink-feed (latestAnswer() on a redemption-rate feed, e.g. Huma PST's PST-USDC exchange-rate feed), erc4626-arbitrum (convertToAssets read from an Arbitrum hub deployment when the Ethereum token is a rate-less omnichain mirror, e.g. sUSDai). When the rate lives on an external contract (osETH, ezETH, PST) set rateSource. Use shareDecimals / rateDivisorPow10 for non-18-dec share tokens (e.g. 6-dec stable vaults). The refresher writes share_rate, supply_apy (trailing-24h, 4×6h windows), and apy_30d into token_yield_apy.
  2. Surface it on /asset-profiles. Set profile_ticker and issuer on the token's registry row in src/data/token-registry.ts and regenerate the migration seed (step 4 and B.2a are the same row and the same edit), plus a fetchMcap case in yield-token-assets.ts if the row should carry productive/underlying mcap and a BASE_YIELD_TOKENS entry for its rate. coveredAssets() is derived from those two columns (#810 Y1) — there is no profile(...) list to append to any more — and the refresher reads it, so that one row is what writes the entry into onchain_credit.assets. The same row is what lights up the holding's portfolio link: the /portfolio API resolves the profile href and the issuer onto the served row, so the browser gets them without importing a module that pulls in pg, and there is no second list to keep in step. Then add an ASSET_NARRATIVES entry for the ticker, and an ASSET_DENOMINATION entry in app/asset-profiles/page.tsx so the row lands in the right filter bucket (unlisted tickers default to USD, which is silent when it is wrong).
  3. Icons — the row needs two. The Ticker cell shows the token's own coin mark (what CoinGecko lists for the token): drop the official token image (64px, verbatim, never hand-drawn) into public/token-icons/ and register it in the shared registry src/components/icons/token-marks.tsx (IMAGE_MARK, keyed UPPER-CASE on the ticker; TokenIcon.tsx is just the Asset Profiles binding, and it falls back to the issuer mark until the token image is added). Resolve the file address-keyed where the source allows it, because tickers collide: the Morpho Blue API returns a per-address logoURI, which is how USD3 (3Jane) got its mark without picking up Reserve's same-ticker Web3 Dollar. The Issuer cell shows the issuer's protocol mark: add the glyph in src/components/assets/IssuerIcon.tsx (preferred: inline SVG component from the project's own authentic brand asset, same bar as the existing Lido/Spark/Fluid marks; never hand-draw one). A few raster fallbacks live in public/issuer-icons/ (falcon.png, renzo.jpg, sky.jpg, stakewise.png, 3jane.png). The icon registry keys off the issuer on the token's registry row, which is also the name /portfolio's Variable rate assets row prints in its Issuer cell. The same public/token-icons/ coin also feeds the /carries Position column via PositionCell.tsx (a separate resolver — see A.1); if the asset can be a carry leg, register it there too.
  4. Give it a portfolio token registry row. A profile without a onchain_credit.portfolio_tokens row is an asset the product explains and then cannot value in a wallet, and the weekly sync-portfolio-tokens.ts pages a WS8 alert for exactly that. Since #810 Y1 the row is written once, in src/data/token-registry.ts, and the numbered migration (scripts/sql/NNN-*.sql, additive and idempotent, lower-cased address, decimals read on-chain) is GENERATED from the seed group that names that file by tokenRegistrySeedValues(<group>); a drift test fails the build if the two disagree, in either direction (B.2a has the grouping rule and why a new row needs a new file). Set class = 'variable_rate', wallet_tracked = true, source = 'asset-profile', and the valuation and rate columns B.2a lists. Ship it in the SAME release as a rate path, which for a book row means step 1's refresher entry and, for anything held before that refresher first ran, rate_kind = 'getter' plus a rate_getter on the SAME row (the block-pinned read). There is no separate rate-getter map to edit any more: #810 piece E deleted JIT_RATE_GETTERS and the getters are compiled from the registry rows. A book row with no resolvable rate makes valuation skip the leg (M9) rather than store it, which is worse than the excluded-at-market-value treatment the leg had before the row existed: the position disappears from the product AND stops alerting. Where the honest answer is that the token belongs in no book at all, the row is still worth having with book NULL (a declared exclusion: same classification, but the token gets named on screen and the unknown-asset alert stops paging). The portfolio_tokens entry in database.md has the column semantics.
  5. Backfill history so the trailing returns (1M / YTD / 1Y) and the in-row yield-history chart have depth, since the daily refresher only writes the current snapshot forward.

B.2a Onboarding a new tracked asset (liquidity + pricing category) ​

ONBOARDING A TRACKED ASSET IS ONE REGISTRY ROW (#810 Y1). It used to be an edit in up to eight places — two basis registries, three mirror registries, the accounting-asset book map, the asset-profile list and the rate-getter map — each with its own membership rule, and the way that failed was silent: cbETH had an asset profile and no wallet-token row for four months, so the page linked to an asset the portfolio could not hold. All eight are projections of onchain_credit.portfolio_tokens now. To onboard an asset:

  • Add the row to src/data/token-registry.ts with every column decided (book, class, wallet_tracked, valuation, feed, feed_config, rate_kind, rate_getter, underlying_address / wrapper_address, unit, liquidity + liquidity_verified, mint_terms + redeem_terms + capacity_reader + terms_verified on a yield-bearing row, and profile_ticker + issuer if it gets a profile). loopable and loopable_verified are NOT decided here: the six-hourly job derives the flag and stamps the day it judged it, and a seeded value would be a second source of truth for a derived answer. Put it in a seed group, not in the file as a whole: the seed is grouped by the migration that writes it (TOKEN_REGISTRY_SEED_GROUPS, each entry naming its own scripts/sql/NNN-*.sql), because migrate.sh records a migration BY FILENAME and never re-runs it — a row appended to a migration that has already been applied reaches no existing database, and widening an applied file to hold it would leave every migrated box without the row while the drift test went green. So: add to the NEWEST group if its migration has not shipped yet, otherwise add a new group plus the new file it names. Then generate that file's VALUES block with tokenRegistrySeedValues(<that group>) and give it the same ON CONFLICT ... DO UPDATE SET list the other seeding migrations carry. The drift test checks every group against its own file, in both directions: an edit on either side fails, and so does a tuple hand-added after the generated block. Two cells are the one exception, and they are frozen rather than skipped: migration 099 wrote PST's and sUSDai's feed / feed_config on a pool-quote feed kind that migration 110 retired, so the current registry rows cannot express that text at all. token-registry.test.ts holds those two cells per address as literals and generates the other 21 columns of both tuples from the row as usual, so every column of every seeded row is still compared to the byte. Freeze a cell only when an APPLIED file holds a value the live vocabulary has no name for — never to quiet a drift test, which is a real disagreement between the code and a database.
  • Give it a ticker in src/lib/portfolio/symbols.ts if it has a book, or the holdings table renders it as hex beside a grey monogram — and symbols.test.ts fails the build, because the ticker map is required to COVER the book map.
  • Declaring a feed buys history. The standing six-hourly mirror sync auto-backfills any token with no token_price_sync_state row from the 2026-01-01 floor (#810 R6) on its first tick after the code deploys — not after the migration, since the sync reads the compiled seed. That is three 90-day Dune executions covering every such token on one CSV, and it is a real credit spend nobody is asked to approve, so decide it with the row rather than discovering it in the log.
  • Everything else follows: the sweep universe, the book map, the price-mirror set, the basis class, the rate getter and the profile link are all reads of that row.

ONBOARDING A FUND IS ONE FUND-REGISTRY ROW, AND IT DECIDES WHICH READER FINDS IT (#810 R1). A fund whose share rate is an ERC-4626 convertToAssets is read as a vault position; anything else is read by the wallet venue as a plain ERC-20 share balance, which means its token row is wallet_tracked = true. A build test fails a fund read by BOTH (the holding would count twice) or by NEITHER (a holder sees nothing), so the decision cannot be forgotten — but it has to be made.

  • Check the deposit path before assuming the transfer log is complete. The wallet venue sees a holding when shares reach the holder. A vault that mints straight to the depositor (Veda, Treehouse, every ERC-4626) is complete on that evidence. A vault that RESERVES shares in a queue and mints them on a later claim is not: both Lido Earn funds work that way, so a deposit in the claim queue appears when it is claimed. Record any such delay on the row's evidence and in M33 rather than discovering it from a support question.

Steps 1-5 above make an asset's RATE readable. These three make its PRICE trustworthy, and they belong BEFORE the asset is listed anywhere, not after a chart of it turns out to be a chart of a rounding artifact. A build-breaking test (src/lib/portfolio/mirror-coverage.test.ts) enforces the first two; the third is the one a person still has to do, which is exactly why it is written down.

  1. Classify its liquidity, with evidence and a date. Every registry row carries a liquidity (secondary | primary_buffer) and a liquidity_verified date. The coverage test fails if a secondary asset has no price-series source, if a primary_buffer asset still holds a slot in the raw price mirror, or if a composed row has no underlying to compose against. liquidity_verified is the day the evidence was gathered, not the day the row was typed.

  2. Verify the redemption mechanics at the contracts, not the docs. Who may redeem (permissionless, or a whitelist), how fast (instant, a cooldown, a queue), into what (an asset that is itself par to the book numeraire, or another wrapper), and against what capacity. That is what separates primary_buffer from secondary, and it is the same evidence the basisClass decision rests on, so do both in one sitting. Read asset(), convertToAssets(1e18) and previewRedeem(1e18) on the token itself; a previewRedeem below convertToAssets is a redemption fee, and the gap has to be a deliberate decision (see srUSDe's 2.5bps note in token-yields.ts).

  3. Declare the token's ROUTE, and let the measurement decide its category. Read the issuer's own contracts for how the token is minted and how it is redeemed, and put the answer on the row: mint_terms and redeem_terms (instant = atomic, permissionless and uncapped; capped = atomic but bounded; queued; gated; none), capacity_reader (erc4626, sky_savings, rocket_pool, or none when the route is a queue or a credential and there is no capacity to read), and terms_verified.

    Then decide the CATEGORY with the test in metrics.md, and state the evidence:

    The route and the marketThe row
    A real DEX market, and no atomic route at $5Mvaluation: "market" with its feed. Ordinary.
    $5M mints AND redeems atomically both waysvaluation: "composed" with its underlying_address, and the route evidence in composed_evidence. The price is held at the rate, so the rate is the cleaner reading of the same number.
    No real DEX marketvaluation: "composed" the same way, with the measurement in composed_evidence.
    A fund shareprimary_buffer, NO feed, and no test at all: a fund is redemption-priced by definition (R3).

    Do not guess the market half. Wait for the weekly measurement to answer it rather than declaring from an impression, and never read it off an aggregator quote: an aggregator routes through the token's own mint and redeem where that is atomic, which is exactly the route the test has to exclude, so it reports a deep market for an asset nobody trades. A row that disagrees with its measurements for fourteen consecutive days is reported by the six-hourly job, and applying the flip is a registry edit in a pull request.

The rationale, the thresholds and what each display state means are in metrics.md.

B.3 Backfilling an asset ​

Per-asset backfill scripts in scripts/backfill-*.ts loop every 6h snapshot from a fixed start through the latest aligned boundary, read the share rate at the historical block via archive eth_call, and upsert token_yield_apy (ON CONFLICT makes re-runs harmless). Examples: backfill-wsteth.ts, backfill-weeth.ts, backfill-reth.ts, backfill-susds.ts, backfill-ezeth-oseth.ts, backfill-reusd-onchain.ts. There are also span backfills (backfill-apy-24h.ts, backfill-apy-30d.ts, backfill-yield-history.ts) and a backfill-recent-gap.ts for repairing holes.

A plain ERC-4626 vault needs no new loop: scripts/lib/backfill-erc4626-rate.ts holds the sampling, and the per-token script is a config over it (address, symbol, start, label). backfill-sgho.ts is the shortest example, and the five scripts of the August 2026 USD yield batch are the fullest, since each carries the on-chain evidence for the start date it picked. Choosing that start is the whole job: read it too early and the vault was empty, which either reverts or publishes a flat 1.0 as weeks of manufactured 0%; read across a proxy implementation swap that re-priced the shares and a single block's accounting migration is published as a year's worth of yield.

bash
/opt/onchain-credit/scripts/run-cron.sh backfill-wsteth.ts
# DefiLlama-priced backfills: use the batchHistorical path, not per-ts loops
# (per-timestamp price calls 429 at scale).

After a backfill, re-run refresh-assets.ts (or wait for the 6h cron) to fold the new history into the assets table, then redeploy to re-prerender /asset-profiles (ISR trap).


C. Adding a money-market / curator vault ​

The /repo-lending page lists USDC / USDT / USDS / GHO supply rates plus the largest healthy Morpho Blue / Euler curator USD vaults. Membership is owned by scripts/sync-curator-vaults.ts (the money-market analogue of the Fluid sync), also ad-hoc, not cron.

It uses vaults.fyi for discovery only: lists every Morpho/Euler USD vault, keeps allowlisted curators (detectCurator: Steakhouse, Smokehouse, Gauntlet, Sentora, MEV Capital, Sky, native Euler) above a $100k TVL floor, dedups same-name deployments to the canonical (most-holders, tie-break TVL) vault and drops phantom/feeder listings, then diffs against the committed registry and prints NEW / GONE candidates. A human reviews the diff and re-runs with --write to rewrite the auto-generated registry src/data/curator-vaults.ts. The app never calls vaults.fyi at runtime: once a vault is in the registry, the curator-vault-state.ts refresher (in refresh-assets.ts) and the token-yields.ts rate read its rate/history/fees on-chain.

bash
VAULTS_FYI_API_KEY=... npx tsx scripts/sync-curator-vaults.ts          # report diff
VAULTS_FYI_API_KEY=... npx tsx scripts/sync-curator-vaults.ts --write   # rewrite registry
# On the box the key is in .env.local: scripts/run-cron.sh sync-curator-vaults.ts
# Rate-limited; VF_FIXTURE=<file.json> regenerates from a cached raw fetch.

Each registry entry carries rateDivisorPow10 = assetDecimals + 18 - shareDecimals so token-yields.ts can turn convertToAssets(1e18) into a human share rate regardless of share-token decimals. The MULTI-STRATEGY slice of that universe is no longer written here: it is derived from the fund registry (see C.1), so the funds the tab lists and the funds the portfolio can read are one list. Because curator-vaults.ts is a source file, the registry change ships through a normal push-to-main build; backfill the vault's history with backfill-curator-vaults.ts / backfill-usd-curator-vaults.ts, run refresh-assets.ts, then let the deploy re-prerender.

This registry is not where Morpho funds are listed any more. The /money-market-funds tab (section F) covers both vault generations under a rule-based listing with its own registry and its own narrative file (src/data/money-market-fund-narratives.ts), and Morpho's API has indexed Vaults V2 since 2026-08; the per-vault copy that used to live in curator-vault-narratives.ts moved there when the /repo-lending funds sub-view was removed. What remains here serves the /portfolio ERC-4626 universe and the assistant's curator-funds tool, and is not widened by that work.

C.1 Listing a multi-strategy fund ​

A fund on /multi-strategy-funds is a vault whose MANAGER runs a broad mandate, so unlike a curator vault it is a hand-made listing, not a sync. Everything below is one PR.

Read the chain before writing anything. name(), symbol(), decimals(), asset(), decimals() on the asset, convertToAssets(1e18), totalSupply(), totalAssets(), and whatever fee getters the platform exposes. Two numbers fall out of the decimals pair and BOTH must come from that read, never from a neighbouring registry line: shareDecimals (the supply and balanceOf divisor) and rateDivisorPow10 = assetDecimals + 18 − shareDecimals (the share-rate divisor). Check them by multiplying back: totalSupply / 10^shareDecimals × convertToAssets(1e18) / 10^rateDivisorPow10 must equal totalAssets() in whole units. A wrong divisor never throws, it publishes a fund size that is out by a power of ten (see the IPOR Fusion vault, whose share is 20-decimal over an 18-decimal asset).

  1. Registry. Add the fund to MULTI_STRATEGY_FUND_SEED in src/data/fund-registry.ts with kind: "multi_strategy", its asset, both decimals, its rateDivisorPow10, a block-pinned rateGetter, and a becameListedAt that is the date it first appears on the tab (with the commit in listedEvidence) — never now(). Regenerate the seed into a new scripts/sql/NNN-*.sql from multiStrategyFundSeedValues(); a drift test fails the build if the two disagree. That one row puts it in the 6h share-rate refresher, the portfolio's ERC-4626 reader (when its getter kind is erc4626; a fund priced by an oracle or an external accountant is read through the fund branch instead), the flow scan and the valuation path. If its manager is new, add an Erc4626Brand member, an ERC4626_BRAND_LABEL word and an ERC4626_BRAND_ICON entry. Do NOT write a money_market_manager row. That table is not a display table: its status column is the gate that decides whether a DISCOVERED Morpho vault lists without a human (086's propose-only rule), so an approved row for a house key like fluid or lido means the day Morpho's API attributes some vault to a curator id equal to that key, the vault lists with nobody having said yes — a listing decision made by a name collision. The display name a multi-strategy fund renders under comes from the fund registry's own manager field, which deliberately wins over that table for a multi_strategy row precisely because no row for these houses exists; scripts/sql/100-fund-registry-kind.sql states the reasoning at length and a build test keeps such an INSERT out of that migration. A house is approved one way only, and by hand: sync-money-market-funds.ts --approve-manager <key>.
  2. Marks. Add the manager's authentic brand SVG to src/components/icons/ProtocolIcons.tsx and register it in curator-marks.tsx (COMPONENT_MARK), StrategiesTable.tsx (MANAGER_ICON, STRATEGY_ICON) and platform-brand.tsx. Never redraw one; the coverage tests fail on a manager with no mark.
  3. Series. A getter in src/lib/data/strategies-table.ts (pass shareDecimals when it is not 18) plus the row, colour, narrative and statistics tower in src/app/multi-strategy-funds/page.tsx. A new manager is also a new StrategyManager member.
  4. Coverage. ONE token-registry row (src/data/token-registry.ts plus the generated migration seed), on the shape the other fund shares carry: its book, class: "variable_rate", walletTracked: false (the fund reader already emits the position, so sweeping the share as a bare balance double-counts it), valuation: "composed" with underlyingAddress = the fund's asset, composedEvidence saying why, and no feed unless the share genuinely trades — a fund share's value is its NAV per share and never a quote (#810 R3), and a listed fund is redemption-priced by definition and is never category-tested, so a feed on one buys a sync slot and nothing reads the bars. Add its ticker to symbols.ts. Nothing else: the book map, the mirror set, the composition set and the basis class are all reads of that row. It does not belong in BASIS_SERIES_TOKENS unless someone wants a stored basis history for it, which is why it needs no TOKEN_DECIMALS entry on /carries.
  5. History. A backfill-*.ts writing share_rate AND total_supply from the vault's inception, classified in BOUNDARY_RULE (scripts/refreshers/replay-anchor.test.ts). Dry-run it and check the first and last rows before it writes anything. It leaves apy_30d NULL for every row it writes, so run-cron.sh backfill-apy-30d.ts is the follow-up step: name it in the script header, in data-pipeline.md, and in the PR's server steps.
  6. Tests and docs. Decode tests against RECORDED raw values for the two divisors and their closure (scripts/refreshers/token-yields.test.ts), fixture rows in scripts/fixture/seed.sql, the e2e manager floor, the fund lists in architecture.md / metrics.md / data-pipeline.md / database.md, and the manager list in public/llms.txt (the agent-facing site index, which names the managers /multi-strategy-funds covers).

Editorial bar. Every fee, redemption term and inception date is read from the platform's own documentation or from an on-chain getter, and the PR says which. The strategy paragraph says what the fund is and what sits behind its rate; it never ranks the fund's risks for the reader, and the yield-source categories come from the closed vocabulary in FundBrief.tsx.


D. Portfolio account backfill and repair (WS5) ​

Every registered wallet gets a one-time archive replay so its chart is not born empty. It reaches back to that wallet's own history floor — the UTC midnight 30 days before the wallet was first added, raised to its first activity only when it held nothing curve-starting at the floor — and to nothing else. A wallet that already held a position there charts from the floor, with the position as an opening balance. EVERY build is ONE pass now: the portfolio appears in a minute or two and nothing is queued behind the run. (The two-pass build that used to carry the depth for a wallet with a deeper floor is gone; a wallet tracked before the rolling window shipped keeps its 2026-01-01 start, written onto its account row by migration 104, and a from-scratch replay of one is a single long pass — see the repair runbook below.) The replayed set is what the wallet holds at run time UNIONed with what the event ledger (plus, for Fluid, the resolver's own NFT list) shows it held earlier in the window, so a position closed INSIDE the window is replayed over its own lifespan rather than having its exit cash drawn as a deposit from nowhere. All of this is fully automated (no human step at signup) but has a manual repair path.

How it fires automatically. On a successful SIWE sign-in, /api/auth/verify upserts the account and calls enqueueBackfill(uid) — an insert-once queued row in portfolio_backfill_state (a re-login of an already-processed account is a no-op). The 6h portfolio cron (refresh-portfolio.ts) then processes at most 2queued accounts per tick (PORTFOLIO_BACKFILL_MAX, default 2), each in its own archive-routed child process, transitioning queued → running → done | empty. A queued/running wallet is excluded from live snapshotting, so backfill and live never write the same wallet's windows at once. The account's UI shows "History syncing" while status IN ('queued','running'); floor_ts is its "tracked since".

Manual repair (a missed cron run, a suspected gap, or forcing a re-replay (pass --fresh if the wallet has a coverage anchor, or the run patches the gap instead of replaying)). The queued job and the manual tool are the SAME script. It reads through the archive RPC (publicnode rejects archive eth_call), so set ETHEREUM_ARCHIVE_RPC_URL:

bash
# DESTRUCTIVE RE-DERIVATION (the FRESH path only; `--fresh` forces it): deletes the wallet's ENTIRE stored history (both
# tables) and re-lays the daily replay over the union of what the wallet holds now
# and what it held earlier in the window. Post-signup 6h rows thin to daily; a
# group closed BEFORE the window is pruned (one closed INSIDE it is replayed).
# The run covers THE WALLET'S OWN WINDOW in ONE pass, and takes no bound of its
# own: --days and BACKFILL_DAYS are gone and the CLI exits non-zero if you pass
# either (a bound plus a full-range delete amputated everything older than it).
# ONE PASS MEANS ONE TRANSACTION holding the global portfolio write lock for the
# whole grid — ~31 points for a wallet added under the rolling window, but one
# point per day since 2026-01-01 for a wallet tracked before it, which is ~4x
# that and climbing. Expect a slow tick and a queued 6h cron while it runs; the
# run logs `LONG single-pass replay: N grid points` past 120 so the log names its
# own cause. A wallet's window CANNOT be deepened after the fact, by any tool
# today (issue #861): a spine below the block the completeness certificate is
# asked about would certify a curve over a hole, and no tool sweeps and lowers one
# wallet's bare-token coverage so that block can move.
# IT CAN BE SHORTENED, ONCE, AND ONLY HERE: a wallet whose boundary block could
# not be resolved when it was added carries no floor pair and has been replaying
# from the bottom of the indexed history. This path resolves its real window
# (thirty days before it was added) and records it, so the repair legitimately
# re-cuts such a wallet's chart to that window. Check for a floor pair first if
# that wallet's deep history matters.
# A missed 6h tick needs NO repair (a chart gap only).
ETHEREUM_ARCHIVE_RPC_URL=<archive> DATABASE_URL=<...> \
  npx tsx scripts/backfill-portfolio-wallet.ts --uid 0x<wallet>

# Prefer the normal pipeline? Re-queue and the minutely drain picks it up:
psql -d creddit -c "INSERT INTO onchain_credit.portfolio_backfill_state (uid,status)
  VALUES ('0x<wallet>','queued') ON CONFLICT (uid) DO UPDATE SET status='queued',
  floor_ts=NULL, updated_at=now();"

The replay reuses the live snapshot code path (readAllPositionsSettled + buildSnapshotRows, verified byte-identical to a live snapshot at a shared block), so backfilled history is methodologically identical to live — restricted to the OPEN position groups, on a daily grid. Positions that predate the range enter as the opening balance of the first snapshot, never as a flow. Full mechanics: data-pipeline.md → Registration backfill (WS5).

Staging note. The nightly reseed truncates accounts + the three portfolio tables, so re-run scripts/ops/seed-portfolio-fixtures.ts after a reseed (it registers the fixture whales; the next cron tick backfills any that get queued).

D.1 Filling the account-collateral flag on history (issue #717) ​

portfolio_position_snapshots.collateral_enabled (migration 091) says whether the venue counted an Aave/SparkLend supply as collateral securing the account at the block that observation was taken at. It is what decides whether a supply travels with its account (see the account boundary). Rows written before 091 carry NULL, and the read path treats NULL as enabled - the conservative direction, and exactly how those rows always behaved. So the release is correct without this run, and the run only makes history sharper: the one thing it can change is that a supply the holder had switched off moves OUT of an account it never secured, for the spans it was off. It can never move a supply IN.

Prod (after release), on the owner's go. Never on staging - the nightly reseed drops the column with the rest of the dump, so a run there is thrown away.

bash
# 0. Migration 091 first (the gated manual prod step), then:
cd /opt/onchain-credit

# 1. DRY-RUN. Prints how many observations it can answer, per venue, and how many are a
#    supply the venue did not count as collateral. Writes nothing.
./scripts/run-cron.sh repair/backfill-collateral-flag.ts

# 2. Run it.
./scripts/run-cron.sh repair/backfill-collateral-flag.ts --execute

# 3. One wallet at a time, if a dry run turned up something worth looking at first.
./scripts/run-cron.sh repair/backfill-collateral-flag.ts --wallet=0x<wallet> --execute

Verification, in psql:

sql
-- Nothing left unanswered. A non-zero count with a clean exit means the reserves in
-- question left the registry; a non-zero count after a FAILED run means archive reads
-- to retry, and the run named every (venue, wallet, block) it could not answer.
SELECT venue, count(*) FILTER (WHERE collateral_enabled IS NULL) AS unanswered,
       count(*) FILTER (WHERE collateral_enabled IS FALSE)      AS not_collateral,
       count(*)                                                  AS supply_rows
  FROM onchain_credit.portfolio_position_snapshots
 WHERE chain_id = 1 AND venue IN ('aave','sparklend') AND position_key LIKE '%:supply'
 GROUP BY venue;

It is idempotent and safe to re-run: it selects only rows whose flag is NULL and re-checks that in the UPDATE, so a second pass writes nothing and a row a live read has already answered is never overwritten by the repair's older evidence. It removes no rows. It reads through the archive RPC, so ETHEREUM_ARCHIVE_RPC_URL must be set (run-cron.sh supplies the server's). A failed read leaves that observation NULL and the run exits non-zero listing every one, so a partial pass is visible rather than silent; re-running picks up the rest.

Do not run it while the ledger-truth campaign (#715) has its proof inputs frozen. The campaign compares stored history against a fixed corpus, and this repair changes which view a span of that history sits in. It runs after that campaign closes.


E. Verifying a UI change (end-to-end QA) ​

Every change with a visible or interactive effect is verified against a running browser and leaves behind a spec that re-runs the check. The rule, stated in AGENTS.md: a PR that changes a page, component, chart, table, filter or interaction adds or updates an e2e spec in the same PR. The step-by-step procedure an agent follows lives in the project skill .claude/skills/qa/SKILL.md (in-repo, so it also reaches Claude Code cloud sessions); this section documents the moving parts and where they live.

PiecePathWhat it is
Fixture DB builderscripts/fixture/build.shCreates the local Postgres the app reads during QA. Idempotent. Prints the DATABASE_URL on its last line
Fixture prerequisitesscripts/fixture/bootstrap.sqlThe four things the migration ledger assumes but never creates: the onchain_credit schema, the two roles (onchain_credit, onchain_credit_staging) and the fluid_vault_registry stub
Fixture rowsscripts/fixture/seed.sqlDeterministic seed data. Anything a spec asserts on specifically is seeded here
Fixture valuesscripts/fixture/seed-values.sql, generated by scripts/fixture/generate-values.tsEvery stored portfolio value, computed by the writers' own valuation from the facts seed.sql states. Applied by seed.sql itself; never edited by hand
Playwright configplaywright.config.tsProject + base-URL wiring
Specstests/e2e/*.spec.ts, tests/e2e/fixtures.tsOne spec file per surface; fixtures.ts exports the shared test
Route warm-uptests/e2e/global-setup.tsRequests every route a spec navigates to, once, before the first test, so no test pays a cold Turbopack compile mid-assertion. Pages no spec visits are deliberately not warmed. Skipped in E2E_BASE_URL mode
Browser installscripts/qa/ensure-browser.sh (npm run qa:browser)Idempotent Chromium install, no-ops when present

E.1 The fixture database ​

The app is read-only against Postgres and every page prerenders from it, so npm run build with no DATABASE_URL fails outright. QA therefore runs against a purpose-built local DB rather than staging: staging data is a nightly reseed that drifts, and asserting on it produces specs that fail for market reasons rather than code reasons.

bash
export DATABASE_URL=$(scripts/fixture/build.sh | tail -1)

build.sh creates the database, applies bootstrap.sql, replays scripts/sql/*.sql in filename order skipping every file marked -- DESTRUCTIVE, then applies seed.sql. Knobs: FIXTURE_PORT (default 55432) and FIXTURE_DB (default creddit_fixture). Re-running it is safe.

The portfolio's money columns are generated, never typed. seed.sql states facts only: what each portfolio reading read (quantity, venue index, block, time), what each movement moved, and the fixture world's series (hourly price bars, share-rate series, Pendle market state, and fixture.chain_rates, the rates a writer reads on chain at a block). Every value column (qty_underlying, value_market, value_redemption, a movement's marks and PT stamps, the pre-window PT fills) comes from seed-values.sql, which scripts/fixture/generate-values.ts writes by running the writers' own valuation over those facts, each row on the path its writer takes (the enrolment replay, the 6h tick, the live tip, the receipt marker). So a stored value is exactly what production would have stored for that row. After any change to seed.sql's portfolio rows or series, or to a writer's valuation:

bash
npx tsx scripts/fixture/generate-values.ts           # rebuild the facts, value them, rewrite seed-values.sql
npx tsx scripts/fixture/generate-values.ts --check   # the same, failing if the committed file differs
npx tsx scripts/ops/golden-portfolio.ts              # then regenerate the goldens

npm test fails when seed.sql has changed since seed-values.sql was generated (the value file records the seed's hash), and its golden gate re-runs the generator with --check on a fresh facts build, so a change to a writer's valuation that moves a stored value fails too until the file is regenerated. The generator refuses a fact no writer could produce (a reading the writer would skip, a book it would resolve differently, pre-window fills that do not explain their leg's opening). It builds its facts in a database of its own: with GOLDEN_FIXTURE_DB set that is <name>_facts, never the valued fixture itself.

Each value row also records the rate its writer took and where it took it (rate_raw, rate_source), read off the fixture's own facts: chain where fixture.chain_rates states the getter's answer at the row's block (or the rate is a PT's), series where the getter is unreadable and the writer fell back to the share-rate series. seed.sql fails the build on a label those facts contradict, and on a rate the fixture states twice with two values (a per-share rate that is not its wrapper's rate).

One deliberate difference from the ledger runner on the box (Deployment → Migrations): migrate.sh greps the whole file for the tag, so a migration that merely mentions -- DESTRUCTIVE in its prose is silently skipped on the server. build.sh honors the tag on line 1 only and prints a loud warning for a tagged file found anywhere below it. Keep the tag on line 1 and the two agree; put it elsewhere and the fixture applies a file the server will not, which is the direction that lets a green fixture hide a migration staging never ran.

A new migration is expected to replay clean into a virgin fixture. If it does not, the fixture is the early warning, not the problem: the same file is about to run against creddit_staging on the next staging deploy.

The seeded row counts are part of the contract, not incidental: the specs assert a floor on them (requireRows in tests/e2e/fixtures.ts) rather than skipping when a screener comes up empty, because an empty screener against a known-good database is the exact failure this suite exists to catch. Change what seed.sql seeds and the matching SEEDED_ROWS in the spec changes with it.

A seeded count is a FIXTURE claim, and only the fixture can be held to it. The same spec files run twice: pre-merge against the fixture, and again against staging in the post-deploy smoke (E.5), where the dataset is a live registry this repo does not control. So SEEDED_ROWS belongs inside a FIXTURE_MODE branch, and what runs in both places asserts the invariant instead — one cell per rendered row, a filter's two halves partitioning the table, a chart's labels not colliding — with the row count taken from requireRows's return value rather than from the constant. The same rule covers data SHAPE, not just size: "some fund is closed to deposits" and "this chart reaches September 2025" are facts about seed.sql, not about the app, and a live registry may legitimately hold neither. A spec that fails on staging for holding different data is not reporting a regression; it is spending the smoke's one signal, which is the whole reason the job exists.

A signed-in describe must stand down when the server is not the fixture's. Signed-in cases reach the app with a cookie the harness signs itself, against seeded history; against a real deployment the server rejects that cookie (401, which is the signature working) and holds none of those positions anyway. Without a guard they do not fail fast: each burns its own dashboard timeout, which is how one unguarded file buries a whole smoke run. requireSeededSession (tests/e2e/fixtures.ts) is the shared form — call it once at the top of the describe, passing the derived test object where the spec drives one (test.extend({ authedAddress })). Several specs still carry an inline guard of their own, and each is WEAKER than the helper rather than merely older: they skip unconditionally (so a broken seeded session reads as a clean run against the fixture), none checks all three conditions (four stop at two, and three check only that the session verifies), and some probe /api/portfolio/positions rather than the summary. A new spec calls the helper; an existing one is worth converting whenever it is touched. Note what the helper does against the FIXTURE: it asserts rather than skips, for the reason above — a seeded session that has stopped working is a regression in the app or the seed, and skipping would report it as a clean run.

The fixture also holds one signed-in portfolio. seed.sql seeds an account for the wallet the authed fixture signs in as (e2eWallet() in tests/e2e/env.ts), its self wallet link, a settled backfill and a six-month snapshot + flow history. Every ledger row it seeds carries both quantity columns (qty_delta, the signed change in the leg's own quantity, and to_balance, what the leg held afterwards), filled by one statement at the end of seed.sql rather than typed on each row: a row missing them is withhold W6 unanchored-leg, whose scope is the whole leg for the whole history, so one null column zeroes a position's entire return line. And no row that OPENS a leg sits on that leg's own first reading block: a movement belongs to the interval (previous read, this read], which for a receipt on a reading's own block is the interval ending at that reading — correct for every receipt but the one that opens the leg, which the reader drops entirely (#783). Dropped, it stops being a capital move: the chart loses a flag it should draw, or draws one for a rotation that moved nothing. Ordinary on-grid rows are left on-grid deliberately (twenty-six of them are), because moving them would state a rule the engine does not have. The build fails loudly if either rule is broken — two RAISE EXCEPTION guards at the end of seed.sql, one per rule. That is what makes /portfolio signed in deterministic: the portfolio GET routes serve stored portfolio_position_snapshots + portfolio_flow_events_v2 and never trigger the live RPC pipeline themselves (the GET routes read the stored ledger only), so the dashboard renders entirely from Postgres. The seeded position set is chosen to exercise the whole M22 classification on one screen: an Aave account that becomes financed partway through its history, a Fluid NFT financed against a debt in another denomination beside a debt-free sibling NFT of the same vault, a Morpho market holding both a financed carry and its own pure lend, and a Fluid vault side of two base assets that no view charts. The counts, not the figures, are what tests/e2e/portfolio.spec.ts asserts. The market that wallet's positions sit in is also registered as a repo market (fixture-wsteth-usdc), so the portfolio's pure lend resolves to a market the app has heard of: that is what makes the Repo lending row's collateral and utilization columns, and its deep link back to /repo-lending, assertable end to end rather than permanently dashed. It is one row in spec_morpho, and that temp table feeds THREE tables, so the effect is wider than the portfolio row and worth knowing before changing it: morpho_market_registry (an extra market on /repo-lending's isolated tab, tracks = repo,carry), market_collateral_exposure (its single isolated-market slice), and morpho_market_apy (a rate series market …7001 previously had none of, so the portfolio's lend row now carries a quoted 24h APY that also reaches the hero rail's blended Net APY and projected daily income). A fifth isolated market (fixture-weeth-usdc, tracks = repo only) is deliberately distressed — its rate series carries a negative trailing window — so the chart's below-zero rendering is assertable from a browser. A sixth (fixture-susde-usdc-2) shares both its deposit asset and its collateral TICKER with fixture-susde-usdc while being collateralised by a different token: the pair whose two books the exposure rows could not both hold until their key carried the collateral address (migration 079, database.md), so a reader that merged them, or a writer that took one row's ticker as licence to clear the other's, fails in a browser rather than only in a unit test. The seed also models a Fluid book with an Unattributed remainder (coverage below 100%) and a slice with no resolvable capacity, the three degraded shapes the Underwritten-capital panel's honesty states need. One exception to the seed's time-pinning: the current-state tables the freshness gates read (market_collateral_exposure, market_risk_current, and the sofr_rates tail) stamp relative to the wall clock at build time — a pinned stamp would age out of the 24h exposure window and blank the panel, the risk tier and the Vs SOFR column. The corollary: a fixture database goes stale a day after it is built, so rebuild it rather than reusing yesterday's. A second tracked wallet holds one financed Aave account, so selecting it alone is a denomination view that earned and holds nothing today beside two that never held anything, the pair of states the dashboard has to tell apart. playwright.config.ts additionally points every RPC URL at a closed port, so a machine with egress cannot have the suite read mainnet while asserting against the fixture.

Two further wallets sit on accounts of their own, each seeded for one thing the main wallet cannot show, and a spec that wants either signs in as it explicitly (test.extend({ authedAddress })). The dislocated wallet holds a position whose market value drifts away from what it redeems for and then takes a one-tick write-down, so the chart's two lines end on opposite signs — against the main wallet they would render exactly on top of each other and a build that plotted one series twice would pass. The liquidation wallet holds the two seizure shapes the 2026-08 prod validation campaign found defects in: collateral financed in another denomination, seized once (the chart must state what the event COST, not the collateral taken), and a position that is deposited into, seized, and withdrawn from in that order (a seizure used to disappear precisely because its owner had touched the position). It also carries one transfer far below the marker floor, so "a flag on the chart is a claim that capital moved" is assertable, together with its companion: the sub-floor row is still served by the events ledger. Both wallets carry their own account uid and their own position keys, so neither moves a figure or a count any other spec asserts.

And three more, on the same terms (own account uid, own position keys, no membership of anybody else's account). The plain-ETH wallet holds 12.5 ETH and nothing else, so its total return is exactly zero at every point while its book value is real — the shape the chart's y-axis span floor exists for. The deep-history wallet holds the two shapes the 2026-08 deep-history build is about: a position ENTERED before the window and REDEEMED inside it, with both sides of the redemption on one transaction, so the served chart draws no capital flag and what the portfolio is worth runs continuous across it (seed only the cash side and the phantom deposit comes back, which is what makes the spec able to fail); and a Fluid position financed ACROSS denominations whose seizure states its cost on the series running in its own currency and withholds it on the other. The still-syncing wallet is settled from a shallow floor and queued — the one state no other fixture wallet has, syncing AND already drawable, which is what the build gate has to keep looking like a portfolio rather than a building state. (It was seeded for the tiered build, which is gone; what it holds now is an ordinary re-queue, and it is what pins that a background pass never takes a drawn portfolio off the screen.) Between them they are what tests/e2e/portfolio.spec.ts asserts the deep-history behaviour on; the whole write path that produces those rows is out of reach of an offline fixture and is covered by the unit suite instead.

An eighth wallet sits the skew-band exam, on the same terms again (own account uid, own transaction hash, no membership of anybody else's account). It holds cash and nothing else: 40,000.00 USDC, plus a 12,000.00 USDC deposit stamped ten minutes past a window label and two hundred blocks below that window's own read. Cash earns nothing, so every step in that wallet's return series is a placement defect rather than a market move, which is what makes "a movement is counted in the interval its block falls in" assertable from a browser rather than only in a unit test. Its timestamp and its block are set from separate expressions on purpose: a row that derived one from the other could not tell the two conventions apart, which is exactly how this class of defect hides.

A ninth wallet holds a history with value that left what the books count, on the same terms again. Every other wallet is a portfolio whose figures all add up; this one is the opposite exam, because the departures the coverage report records are invisible by construction — no figure is wrong, so nothing looks wrong. It carries four of the five shapes: a wrapper rotation that returned more than it took, a position bought with an asset the product does not price, the same in reverse on the way out, and a leg the books hold and keep out of every return line. Every one of them needs a row kind that can say "this went into a wrapper and came back changed", which is part of why the ledger has twelve of them. The rotation's two residuals are deliberately different numbers (the exact quantity, and the day-weighted value across the episode), so a consumer that carried the wrong one has a wrong string to be caught on. What the wallet proves changed on 2026-09-02: the /portfolio band that rendered these notes was removed by product decision and the spec that drove it went too, so the wallet now backs the coverage route, the reconciler's report, and the one browser assertion that survives the removal (coverage-not-shown): the route still returns this wallet's departures AND the page renders none of them. Both halves matter, because "no band" is also true of a wallet with nothing to report, and a spec that cannot tell the two apart is not a spec.

Two details of that wallet are load-bearing rather than incidental. The exit's consideration has a registry row with no book, which is the shape every unbookable asset on the live registry has — all of its rows are active, so a perimeter that tested the lifecycle flag instead of the book would count the declared exclusions as covered and this wallet's exit note would vanish with the rest of the report still reading complete. And the account holds a second, empty wallet, so "a wallet with nothing outside coverage reports nothing" can be asserted on a wallet the account really tracks: asking about somebody else's wallet gets a refusal, and a refusal proves nothing about an empty report.

A tenth wallet carries every one of the ledger's twelve row kinds, and five link GROUPS, which are the shapes the retired feed drew as movements of capital nobody made: a wrapper round trip over three transactions and fifty days, a liquidation whose seizure and write-off are one event, a transfer to another of the holder's own wallets, and TWO redemptions — one instructed and paid a fortnight later, one instructed and still in the queue. The pair is deliberate: an escrow records a pair of receipts each time it moves, so both groups carry an arrival from the instant they are instructed and only the leg those arrivals sit on separates a redemption that has been paid from one that has not. A fixture with only one of them cannot tell a feed that reads the leg from a feed that reads the kind. It also carries the three records the Activity face holds back by default — a fee riding with the supply that paid it, a fee on its own, and an unsolicited dust transfer — so "held back" and "asked back in" are both exercisable. On the same isolation terms as the ninth.

An eleventh wallet holds the two REBASING tokens, on the same isolation terms again, and it is the only fixture account that does: stETH and eETH are counted internally in shares (M5.3) while the screen states the token balance, and the two figures differ by about 20%, so every failure mode on that surface is a plausible number under a correct ticker. Its two stETH transfers move exactly the SAME share amount at blocks five months apart, so they print two DIFFERENT stETH amounts — which is the balance growing while it is held, and is a sentence a page printing share counts cannot produce (it would print the same figure twice). The seed asserts that divergence itself, so a fixture whose two candidate figures had coincided would fail the build rather than passing whichever the code printed. It also carries a cbETH balance: a variable-rate wrapper that is NOT share-accounted, so its quantity is the control for the conversion, and the only COVERED asset in the section — its ticker is a link to the asset profile while stETH's and eETH's are plain text, which is both branches of that cell on one wallet.

A twelfth account holds the SAME position in two wallets, which no other fixture account does: every other one is a single wallet, or a pair whose holdings are disjoint by construction. It is what the All view's cross-wallet merge is exercised on — two labelled wallets ("Desk one", "Desk two") each holding a cbETH balance and nothing else, with unequal quantities distinct from every other in the file, so a fold that dropped a contributor or counted one twice lands on a number the fixture cannot otherwise produce. It is an account of its own rather than a leg added to the main pair's second wallet, and that is the isolation rule earning its keep rather than caution: that wallet is load-bearing for two counts other specs assert — "its denomination views hold nothing today" and "it holds nothing the books cannot rate" — and a shared holding breaks one or the other whichever band it lands in.

Choosing its address was the part that went wrong first, and the lesson is worth the sentence. The fixture is one shared database and every wallet block opens by deleting its own rows, so an address that another spec already asserts against is not a fresh wallet — it is a wipe. The first draft of this block reused the eighth wallet's address and turned a spec about a completely different surface red. Grep tests/e2e for an address before seeding a new wallet under it.

Those wallets' specs need a signed-in fixture session and skip themselves when they cannot get one, so a green summary line is not evidence they ran. Since it is the only browser-side exercise the block-bucketing change gets, a QA pass over that area reads the run's skipped count and names the specs as having executed, rather than reporting the suite as passing.

Running as root. initdb and pg_ctl both refuse to run as uid 0, which is the normal case in a cloud session on an image that ships Postgres without starting it. build.sh handles it: as root it chowns FIXTURE_PGDATA to the postgres OS user and runs both through runuser (or su). If that user does not exist the script says so and stops, rather than dying inside initdb.

E.2 Running the suite ​

bash
npm run qa:browser    # once per machine; no-ops afterwards
npm run e2e           # headless, ALL projects
npm run e2e:ui        # headed, for watching a failure

A QA pass does not run all of them, and that is deliberate. ci.yml runs zoom-sweep and narrow-900 whole, plus the reduced-motion tests tagged @reduced-motion, pre-merge on parallel runners against a fixture built the same way from the same repo. Running those again by hand was the largest single block of wall clock in a QA pass and could not tell anyone anything the CI run would not. So the pass runs the complement:

bash
npm run e2e -- --project=desktop-1360    # the one project no pre-merge job runs

plus --project=laptop-1140 when the change is width-sensitive, and --project=zoom-sweep when it adds a route or a UI state, so the surface registry gate (E.6) fails locally rather than a CI round trip later. A bare npm run e2e is still correct in one case: when CI's e2e jobs could not run on the diff at all, whether because their changed-files gate read it as touching no app file or because of the no-runner capacity state described in E.5. Nothing else covered those projects there.

A draft PR is not that case. The browser jobs have not run because they are waiting to be asked (E.5), and the remedy is to mark the PR ready for review and let CI run its half — which is the step before this pass, not a reason to spend twenty-five minutes reproducing it by hand.

The untagged part of the reduced-motion project is the one thing neither half runs any more, by decision. Those cases asserted, under the preference, the same structure they assert at the same 1360 width with motion on, and that width is covered twice: by the local desktop-1360 run here and by the post-deploy smoke. src/ has five surfaces that branch on the preference in JS rather than in CSS, and the tagged tests are what assert three of them: the landing hero's typewriter, scrollToTop's instant-versus-smooth choice at both of its callers, and the portfolio chart's draw-in latch. A fourth, the building chart's static ellipsis, is asserted by a spec that calls emulateMedia itself, so it runs in every project and needs no tag. The fifth, the home carousel's transitions, has no assertion in any project and had none before the trim either. A spec that starts depending on the preference has to carry the tag or CI stops seeing it; the project itself is whole in playwright.config.ts, so --project=reduced-motion locally still means all of it.

Because the two halves are now complementary rather than redundant, CI green on the final commit is part of the QA pass, not a separate courtesy. A green local run against a commit CI has not judged leaves the sweep, the narrow width and every reduced-motion branch unrun — and so does a PR still sitting in draft, since that is what CI's browser jobs wait for (E.5).

Every spec runs in every width project, and there is no second run. There was one until the portfolio cutover: a ledger-v2 project on its own port with its own server, because which flow ledger answered a request used to be read from the SERVER's environment and a browser cannot set a server variable. There is one ledger now, so there is one server, one baseline, and one testIgnore pattern (the zoom sweep's, which the width projects skip because the sweep drives its own viewport). A spec added to tests/e2e/ is picked up by the width projects with no registration step.

npm test (unit tests, one long list of files in package.json) stays separate from the e2e suite and needs no browser, but it is no longer database-free. Six files in it build their own fixture database on every run: the golden gate (Architecture, Golden portfolio harness), the S2 value-parity gate (scripts/ops/value-parity.test.ts), the ledger worker's job-vs-tick parity test (scripts/worker/sweep-parity.test.ts, Data pipeline, the ledger worker) and its audit suite (scripts/worker/audit-job.test.ts), and the two release backfills' suites (scripts/ops/backfill-rate-facts.test.ts, scripts/ops/backfill-opening-rows.test.ts). They are package.json's test:fixture list, and CI runs them as a job of their own (E.5, The unit tests run as two jobs). Locally npm test is still the one command and runs both halves together. So npm test:

  • needs the Postgres 16 binaries, as the e2e suite does (brew install postgresql@16 on a Mac; the CI runner image ships them);
  • starts a server on 127.0.0.1:55432 through scripts/fixture/build.sh, or reuses the one already listening there, and refuses one that is not Postgres 16 or does not collate as C. The six files run in parallel, and on a machine with no server yet (every CI runner) each would try to create and start the same cluster; build.sh makes the check-and-start one step under a lock directory (/tmp/creddit-fixture-start-<port>.lock), so the others wait and then reuse the first one's server. A lock directory more than two minutes old (its builder was killed) is removed on sight rather than waited out, so one dead build never costs every later build two minutes;
  • builds and drops four databases of about 80 to 130 MB for the gate: the fixture (creddit_golden_<pid>, or the name GOLDEN_FIXTURE_DB gives it, kept), a copy of it read under a perturbed query plan (…_perturbed), a copy without its pin that the comparator path's subtests capture as another database (…_unpinned), and the value check's facts build (<GOLDEN_FIXTURE_DB>_facts, kept, or a creddit_golden_<pid> of its own process); and for the parity test one fixture (creddit_s3parity_<pid>, a name of its own whatever GOLDEN_FIXTURE_DB says) plus a short-lived copy per scenario (the parity gate's two passes; the worker's stale-job, settle-line and candidate-read cases; the job-floor cases, each beside a control copy the tick alone derives; the standing-failure close, the queue's statements and the replay-then-page-load basis case; the whole-window guards of a rederive job; the stale-merge fence's cases, each order run fenced, with the fence off and beside a control copy with no job; and the worker soak, a live copy and a tick-only control per seed for three seeds of thirty simulated ingester cycles each); and one fixture each for the value-parity census (creddit_parity_<pid>), the audit suite (creddit_s4audit_<pid>) and the two backfill suites (creddit_ratefacts_<pid>, creddit_openings_<pid>), all named per process so parallel runs never drop each other's. A run that is killed leaves its databases behind, so every golden fixture build (buildGoldenFixture, which the gate, the parity test, the rate-facts test and the fixture tools build through; not a plain scripts/fixture/build.sh build such as e2e's) first drops the per-process databases (creddit_<family>_<pid>[_<suffix>]) whose process no longer exists and that nobody is connected to, and says which on stderr; a named fixture (creddit_fixture_*, FIXTURE_DB, GOLDEN_FIXTURE_DB) is never touched. The name is all it goes by, so a database made by hand on the fixture server takes a name under creddit_fixture_* or outside the creddit_<word>_<digits> shape: creddit_pr_960 reads as process 960's and is dropped once it is idle and no process 960 runs;
  • takes about a minute and a half longer than it did without the gate (the soak is about fifteen seconds of it). The gate is bounded: it fails after ten minutes on CI (where it normally takes about five, in fixture-suites) and after an hour elsewhere, killing every process it started, so a hung child cannot hold the run for ever. A machine running several suites at once on one fixture server has taken it to 23 minutes, which is why the local bound is wider; GOLDEN_GATE_TIMEOUT_MS sets it outright.

A change that moves a served /portfolio number regenerates tests/golden in the same PR (npx tsx scripts/ops/golden-portfolio.ts), and the golden diff is the statement of what moved.

On a network-restricted machine (Claude Code cloud sessions). Playwright downloads browsers from cdn.playwright.dev, falling back to playwright.download.prss.microsoft.com. Neither host is on the default cloud "Trusted network" allowlist (npm, GitHub and mcr.microsoft.com are), so npm run qa:browser is otherwise the first hard stop of the whole loop, on a step this page describes as a no-op. ensure-browser.sh detects the refused download and prints these same two options; either one is enough.

  1. Allowlist and cache. Add both hosts to the sandbox network allowlist in the Claude Code UI, and put bash scripts/qa/ensure-browser.sh in the UI-configured environment setup script so the browser lands in the cached image. A repo SessionStart hook is not a substitute: hooks are not snapshotted, so every session would re-download ~150MB.

  2. Playwright's own image, which needs no allowlist change at all (mcr.microsoft.com is trusted by default and Docker is preinstalled). It ships the browsers and their system libraries together, so it replaces both halves of ensure-browser.sh:

    bash
    docker run --rm --network host -v "$PWD":/w -w /w \
      mcr.microsoft.com/playwright:v<@playwright/test version>-noble \
      npx playwright test

The system-library half of the install (Linux only, apt-get) is an attempt and never a requirement: when the Ubuntu archives are unreachable, or there is no sudo, the script warns, downloads the browser anyway, and lets the first Chromium launch be the thing that reports a genuinely missing shared library.

Only that half is ever elevated. Playwright resolves its browser registry from $HOME at the moment the command runs, and sudo rewrites HOME to the target user's, so a download run under sudo lands in root's cache while the suite looks in the caller's: every spec then fails on a missing executable and any CI cache keyed on ~/.cache/ms-playwright stays empty. On a non-root machine the script therefore elevates the apt-get step alone (Playwright's install-deps does that itself) and downloads the browser as the invoking user.

E.3 What a surface check covers ​

For each user-visible surface the change touches, at 1360, 1140 and 900 wide:

  1. It renders its real content, not a skeleton or a plausible-looking empty state.
  2. The console is clean: no errors, no hydration mismatch, no failed request.
  3. No horizontal overflow on the page body (scrollWidth === clientWidth on documentElement). Wide tables and charts scroll inside their own container. The v0.19.0 /carries regression, a fixed-px grid that forced the whole page sideways, shipped because cells were checked in isolation instead of the page.
  4. Interaction is exercised after React has hydrated. Screener rows are in the SSR HTML, so clicking a <summary> toggles <details> natively while onToggle never fires: the row expands and a naive check passes on a feature that is not wired up. Gate on a React fiber key on the element first.
  5. Reduced motion, where the change animates or scrolls. The preference is read in JS as well as CSS (src/lib/scroll.ts, the portfolio charts), so both paths need exercising.
  6. Both auth states where the surface differs. /portfolio in particular is a prerendered public shell whose dashboard renders entirely client-side after the session probe, so it is never present in the SSR HTML. Specs mint a signed-in cookie directly (SESSION_COOKIE and cookieValueFor() from src/lib/auth/session.ts) instead of driving the SIWE dance.

Two standing rules for the assertions themselves: never assert an exact market number (assert structure, counts, invariants, ordering, direction), and never verify by grepping server HTML (client components ship their copy in the JS bundle, so a changed label is absent from the SSR HTML both before and after).

E.4 Driving the browser by hand ​

Exploration before a spec exists, and any interactive check an agent runs, goes through the @playwright/cli command-line client (npm i -g @playwright/cli, then playwright-cli --help). CLI invocations keep large tool schemas and accessibility trees out of the model's context, so the same session costs far fewer tokens than the equivalent MCP server. playwright-cli install --skills installs its own helper skills locally; those do not reach cloud sessions, so the procedure assumes discovery through --help.

Note that the local access gate (NEXT_PUBLIC_ACCESS_CODE, see Deployment → Access gate) is compiled in at build time. Leave it unset for QA builds, or seed the creddit_access_v2 localStorage flag before loading a page.

E.5 CI ​

  • The unit tests run as two jobs, on every push. check runs both typechecks and npm run test:unit: every file npm test lists except the six fixture suites (E.2). fixture-suites runs npm run test:fixture, those six, on a runner of its own: each builds its fixture database through scripts/fixture/build.sh from the Postgres 16 binaries the runner image ships (the e2e jobs' own PATH line, no service container). Each job has its own time limit, sized from measured runs with room to spare: on the split's first run (36216742950) check took 6 minutes (limit 20) and fixture-suites 13 (limit 30), the runner's two cores running the six suites one file at a time, the golden gate five minutes of it. Before the split one job ran both halves and was cancelled at its 15-minute limit with a sixth of the tests reported (PR #961, run 36214004750), which read like a failure of whatever test was running. scripts/ci/test-halves.mjs subtracts the test:fixture list from npm test's, so a new unit test is added to npm test's list only, and a new fixture suite to both; scripts/test-manifest.test.ts fails when the halves stop partitioning the list or a suite that builds a fixture database (buildGoldenFixture( or buildParityFixture() is outside test:fixture. Locally npm test stays the one entry point and runs both halves.
  • On a PR: a non-blocking job in ci.yml nags when a PR touches src/app or src/components without touching tests/e2e. Branch protection is advisory on this free-plan repo, so like the rest of CI it reports and cannot block; the reviewer honors it.
  • Only once the PR is ready for review. Both browser jobs below carry github.event.pull_request.draft == false, and the workflow's pull_request trigger lists ready_for_review so that promoting a draft is itself an event they fire on. Feature PRs open as drafts here (AGENTS.md, "Shipping"): during the review rounds a PR pays only the two unit-test jobs and the coverage nag, and the browser half starts when the review has converged and the PR is marked ready, then runs on every push after that. The reason is cost, and it is not marginal: those two jobs are about four fifths of this repo's Actions minutes, a PR takes three or four pushes to converge, and the free plan allows 2,000 minutes a month. A draft PR therefore has no browser verdict at all, which is why marking it ready comes before the QA pass rather than after it.
  • On a PR, when the diff can move a pixel: the e2e-zoom job of ci.yml builds the fixture database on the runner and runs the zoom sweep (E.6) before merge. It skips itself on diffs that touch no layout-relevant file: the gate matches app source, the e2e suite, playwright.config.ts, next.config.ts, postcss.config.mjs, public/, a dependency manifest and the fixture, so what skips is docs, crons, SQL and workflow edits. A red X here is a real layout defect or an unregistered route, not a nag; like every check on this repo it cannot block, and the reviewer honors it.
  • On a PR, when the diff can move the app at all: the e2e-interaction job of ci.yml runs the interaction specs on the same fixture, one matrix leg per project: narrow-900 (below the lg: breakpoint, where the shell swaps its sidebar for the mobile nav, and below the sweep's own narrowest width) and reduced-motion, the same specs with prefers-reduced-motion set. That second leg passes --grep @reduced-motion, so it runs only the tests that cover the surfaces branching on the preference in JS rather than in CSS; E.2 says what that gives up and why the project is still whole locally. The grep is carried as matrix data rather than set on the project, so a local --project=reduced-motion is unaffected. Same skip gate as the sweep. laptop-1140 runs in no CI job by decision — 1360 and 900 bracket the two shells and the sweep already covers 1140's layout — so run it by hand (npx playwright test --project=laptop-1140) when a change is width-sensitive.
  • Which half runs where. These two jobs own zoom-sweep, narrow-900 and the tagged reduced-motion tests outright: the QA pass does not re-run them locally (E.2), so their result on the PR's final commit is the only result they have. That makes a red X here, or a run that never happened, a gap in the QA pass rather than a duplicate opinion about something already checked by hand. The complement, desktop-1360, runs in no pre-merge job and is the local half; it does run post-deploy against staging's real data, but that is after the merge, which is why it stays local rather than being left to the smoke.
  • When a job did not run at all, which is a different state from a red X and reads the same on the PR page: every job of a run failing in a few seconds with no steps and no runner assigned is a GitHub Actions capacity or billing state on the account, not a verdict on the diff. Nothing was checked, so the sweep is run locally as part of that PR's QA pass and the result recorded there. Check with gh api repos/:owner/:repo/actions/runs/<id>/jobs before reading a run as a result either way.
  • After a staging deploy, when the push touched something it could see: the smoke job of .github/workflows/deploy-staging.yml runs the suite against the build that deploy just put on the box, reached through an SSH tunnel to its app port rather than through staging.creddit.xyz (NGINX basic auth sits in front of the hostname and holds only a one-way hash of the password). Report-only. See Deployment → Post-deploy smoke. Two projects: desktop-1360 and zoom-sweep. The sweep runs here as well as pre-merge on purpose, so the same layout checks repeat against staging's real dataset, whose long names and row counts the fixture cannot anticipate. A smoke-gate job in the same workflow compares the pushed range and stands the smoke down only when it carries none of what the pre-merge gates match — app source, the e2e suite, the Playwright, Next and PostCSS configs, public/ assets, a dependency manifest — i.e. when it is docs, crons, SQL or workflow edits alone. Its pattern is deliberately never narrower than ci.yml's, bar scripts/fixture/ which it does not read, because a diff the pre-merge gates wave through has this job as its only browser verdict. Anything uncertain smokes: a manual dispatch, a missing or unusable before, a truncated file list, or a gate job that failed to answer at all. A stood-down smoke is a skipped job with its reason in the run summary, never a red one.

E.6 The zoom sweep ​

Browser zoom does not get its own rendering path: zoom at Z% in a window W CSS-pixels wide lays the page out at an effective viewport of W/Z, then scales the result for display. That resized viewport shifts which media-query tier applies, including the app shell's own zoom tiers (--shell-zoom in globals.css), which is where zoom-only layout bugs live: an element that fits at 100% collides at 125% because 125% lands the layout in a different tier.

The sweep (tests/e2e/zoom-sweep.spec.ts, Playwright project zoom-sweep) therefore checks layout at every effective width that browser zoom between 67% and 150% produces on a 1440-class laptop and a 1080p display, plus both sides of every shell zoom-tier boundary — about twenty-five widths (tests/e2e/zoom.ts, which reads the tier boundaries out of globals.css so the two cannot drift). At each width, each registered surface state runs the layout gauntlet (tests/e2e/layout-gauntlet.ts):

  • text collisions — two unrelated pieces of text painted over each other;
  • clipped text — text amputated by an overflow-hidden box with no ellipsis;
  • document overflow — the page body scrolling sideways;
  • scroller overflow — a table scrolling sideways inside its own container at desktop widths (same budget as expectFitsWithoutSideScroll).

Coverage grows with the app by construction. Surfaces and their UI states (an expanded row, an open filter popup, the managers dialog, signed-in /portfolio) are registered in tests/e2e/surfaces.ts. The sweep diffs that registry against src/app/**/page.tsx at run time and fails on any unregistered route, so a new page cannot ship unswept; a new state still needs a registry entry, which the CI coverage nag points at. A finding that is deliberate design gets an entry in DEFAULT_LAYOUT_ALLOW (tests/e2e/layout-gauntlet.ts) with a justification, the same contract as the console allow-list.

Run it locally like any other part of the suite:

bash
export DATABASE_URL=$(scripts/fixture/build.sh | tail -1)
npx playwright test --project=zoom-sweep

A failure names every bad width with its zoom provenance ("1536px, 125% zoom of a 1920px display") and attaches a screenshot per failing width, so the read is which tier broke, not just that something did.


F. Listing a money market fund ​

/money-market-funds lists open-ended, single-asset, lend-only Morpho funds on Ethereum mainnet, both MetaMorpho and Vaults V2. Membership is a RULE, not an allowlist: scripts/sync-money-market-funds.ts walks every Morpho vault, scores each against one criteria set, and publishes anything that passes under a manager a human has approved. The only manual decision is the MANAGER, taken once.

bash
scripts/run-cron.sh sync-money-market-funds.ts --dry-run        # report only, no writes
scripts/run-cron.sh sync-money-market-funds.ts                  # persist + report
scripts/run-cron.sh sync-money-market-funds.ts --approve-manager <manager-key>
scripts/run-cron.sh sync-money-market-funds.ts --reject-manager  <manager-key>
scripts/run-cron.sh sync-money-market-funds.ts --bind-manager <fund-slug> <manager-key>
scripts/run-cron.sh sync-money-market-funds.ts --reject-fund  <fund-slug> "<reason>"
scripts/run-cron.sh sync-money-market-funds.ts --relist-fund  <fund-slug>
scripts/run-cron.sh sync-money-market-funds.ts --allow-shrink   # one run: sweep a genuinely smaller universe

Every admin flag short-circuits BEFORE discovery and prints what changed, so an approval takes a second rather than a run. --dry-run beside an admin flag prints what it WOULD change and writes nothing, and a flag typed without its value exits rather than falling through into a full discovery and persist. It runs daily and can be run on demand.

F.1 The nine gates ​

Evaluated in order; the FIRST failure is the recorded reason, so a fund that is both too small and too young reports the size, which is the thing that would change first. The per-gate outcome is persisted so the report can explain a decision after the fact.

#GateRuleFails to
1Deposit assetdollar, ether or bitcoin based, the same three the portfolio reasons inineligible
2Sizeat least $2.5M to enter; a listed fund only leaves below $2.0M for a sustained weekineligible / delisted
3Track recordat least 90 days of live history, from the fund's own inception OR from share-rate readings we already holdineligible
4Readabilityfee, allocation, exit liquidity, owner, curator and notice period all read on chainineligible
5Fee shapeno fee charged on a deposit or a withdrawal. The fee LEVEL is not a criterion: 0% performance and 0% management is as listable as 20%, and a fee charged only on what the fund earns or on what it holds never rejects a fund. No Morpho vault of either generation charges to get in or out, so this has never fired; it is asserted so a future vault kind that does cannot slip onto a page whose fee column has no place for itineligible
6Shapeopen-ended, single-asset, lend-only. MetaMorpho always qualifies; a Vaults V2 fund must hold only direct-lending or nested-fund routesineligible
7Managerresolved AND approvedproposed
8Manager confirmedattributed by Morpho's own curator record or by a human, NOT by a match against the fund's own nameproposed
9Parthe deposit token trades within 5% of one unit of its own denomination to HOLD a listing, within 3% to gain one. An unreadable price never removes a fundineligible / delisted

A fund clearing 1-6 with an unapproved or unresolved manager is proposed and does not render. Nothing auto-lists under a new manager; that is the entire point of the approval flow.

delisted is a one-run status, and that is by design. It records the transition: a fund that was on the page and no longer clears a gate. On the NEXT run the prior status is delisted rather than listed, so the entry band applies and the fund settles into ineligible like anything else that does not qualify. Nothing is lost, because the OFF PAR and EXCLUDED BY POLICY blocks name it with its reason on every run; but an operator scanning for a DELISTED line has one run to catch it, so the reason is worth reading out of the report rather than out of the status column.

Gate 9 moves on a slower clock than the page does. It runs in the daily sync, while the fund refresher rewrites prices and sizes every six hours, so a deposit token that moves far from its unit can sit on the tab for up to a day before the rule sees it. The drawer and the size tooltip carry the live price and distance the whole time, so nothing is hidden while the gate waits; what is deferred is the listing decision, not the disclosure.

Gate 8 closes the other half of the same door. A name rule reads a string the vault's deployer chose, and the rest of the bar is $2.5M held for ninety days, so a match alone would publish an approved house's name and mark against a fund that may have nothing to do with them. Eight of the funds listed on day one arrive that way, all of them correct and none of them verified by anything but the name; they are a one-off --bind-manager each, which is exactly what that flag is for. Attribution from Morpho's own curator record needs no confirmation: it is the curator's claim on the vault rather than our guess about it.

Gate 9 is about what the columns MEAN. It matters because every return this page publishes is a ratio of share price measured IN the deposit token: a fund whose token has fallen 27% against what it redeems for publishes a yield in a unit that is not the unit the column claims, in the same right-edge column as a fund at par. The size column already converts and therefore knew; nothing else on the row did.

Par is the deposit token's OWN redemption value, and there are four answers. Reading it off the ticker (anything containing "USD" is worth a dollar) is what this gate used to do, and it made every yield-bearing deposit token structurally unlistable: a wstETH fund at 1.2043 ether per token recorded a 20.4% break that never happened. So the question the rule asks is how many units of the fund's own denomination one deposit token redeems for, and it answers from the tracked-asset registries the rest of the product already values wrappers with:

basispar isexample
parone unit, by designUSDC, USDS, PYUSD, stETH against ether
redemption_ratethe token's own tracked on-chain rate, from token_yield_apy.share_rate, no older than a weekwstETH → stETH → ether, sUSDe → USDe → dollar, sUSDS → USDS → dollar
referenceone, by construction: the token IS the price reference the denomination is quoted inWETH in an ether fund, WBTC in a bitcoin fund
unmeasurednothing, so the gate abstains and flags the row. Only a token with NO tracked path at all takes the backstop belowa synthetic dollar in none of the registries; a wrapper whose rate has not been published for over a week

An unmeasured par is a flag, not a verdict, with one narrow backstop. Not knowing what a token redeems for is not evidence that it broke, so the gate abstains, the fund is judged on its other merits, and the row carries a par unmeasured flag saying the deposit token's redemption value is not one we track and that the return beside it is earned in that token. The same applies to a wrapper whose rate we hold but which has stopped being published: past a week the rate is not used and the gate abstains rather than judging against a stale one.

Two different situations reach unmeasured and only one of them is judged. Either the repo tracks no redemption path for the token at all, or it tracks one and could not use it this run: no rate recorded yet, the last one over a week old, or the tracked record filed under a different unit from the column the fund sits in. In the first case we do not know whether the token accrues, so its market price against one nominal unit is the only floor available. In the second we already know it accrues, which makes that same comparison meaningless before it is taken: a wstETH fund sitting exactly on its redemption rate reads 20.4% "above ether". So the backstop applies to untracked tokens only. A wrapper whose rate feed has a gap keeps its listing, carries the flag, and its note names the stale rate; a gap in an unrelated feed can never delist a sound fund with a reason that reads as a market fact.

The backstop is the untracked token's MARKET price against one nominal unit of the column it sits in. A fund taking deposits in a token trading at $0.73 cannot sit in the dollar column beside true dollar funds while we work out what it redeems for, whatever that turns out to be: the yield beside it is earned in that token, and no accrual explains a discount of that size. So an untracked token outside the same band against one nominal unit is held out, on the same hysteresis (5% to hold a listing, 3% to earn one), and the recorded reason states the two facts separately: deposit asset msUSD market price 26.6% below the dollar; redemption value not tracked. No reason string anywhere asserts a distance from a par nobody established, or names a peg. A price we could not READ still never removes a fund.

It carries the same hysteresis as the size gate, for the same reason: a listed fund holds out to 5%, anything else has to come inside 3%, so a token hovering at the edge cannot flip a row on and off the tab day after day. A price we could not READ is not an observation and never removes a fund, exactly as with size, and the price itself is quoted under the strict bounds (a 0.9 confidence floor and a six-hour staleness bound) that the rest of the product publishes marks under: a thin or hours-old quote is refused and read as no price, which keeps the fund and its last good size. And it runs LAST on purpose: it is the only gate that can flip on a price tick, so a fund that also fails a structural gate reports that structural reason instead. The report prints an OFF PAR block naming every fund the gate is currently holding out and how far from its redemption value (or, for an untracked token, from one nominal unit) each one sits, a PAR UNMEASURED block naming the ones it declined to judge and listed anyway, and the drawer header carries the current price and deviation for EVERY fund it measured, at par or not, beside the size tooltip that says the same thing.

The size gate has a memory. A fund enters at $2.5M and only leaves below $2.0M, so a quiet week of redemptions near the floor cannot flip a row in and out on consecutive days. It also has a clock: the fund has to sit under the exit floor for a sustained week, not merely be under it the moment we happened to look. A size that could not be READ is not an observation and neither starts nor clears that clock, so one broken read can never remove a fund from the page.

Discovery is the API's job; the decision is the chain's. Enumerating every vault of both generations is only practical through Morpho's API, so that is what it is used for. Anything at or above a review floor set BELOW the listing floor then gets a chain read, and the size, fee, roles and notice period the gates judge come from there. Nothing is ever excluded on a number nobody verified.

F.2 Resolving a fund's manager ​

Three sources, in order, and the rule never invents one.

  1. The API's curator attribution. The key is Morpho's own Curator.id, not a slug of the display name: four live curators publish an id that differs from their name, and deriving the key from the name would mint a second, unapproved manager the day one of them rebrands.
  2. A name pattern, and only when it lands on an ALREADY-APPROVED manager, so a pattern can never create a house. It recovers the co-branded funds the API leaves unattributed (Trezor and Safe Steakhouse funds, two Gauntlet funds, two Yearn funds) and captures the co-brand behind the row's brand mark. It proposes; it never lists (gate 8), and the report gives those funds their own BY NAME bucket with the --bind-manager line already written out.
  3. A human binding, --bind-manager <fund-slug> <manager-key>.

Unresolved means manager_key IS NULL, status proposed, and a line in the report's UNNAMED bucket. Since a fund with no manager never lists, the promise that a fund never renders with a blank core column holds for the manager too. The manager no longer renders as a second line under the fund name (most funds carry it in the name); the brand mark beside the name and the MANAGER filter carry it.

F.3 The report ​

Three groups, the same shape as the carry coverage report.

=========== MONEY MARKET FUNDS COVERAGE REPORT ===========
Floors: TVL >= $2.5M to list | under $2.0M for 7 sustained days to delist | >= 90d track record
Size, fee, roles and timelock are confirmed on chain; the API is discovery only.

---- LISTED / PROPOSED ----
  LISTED    Steakhouse USDC       V1  USDC   $74.1M  Steakhouse Financial
  PROPOSED  Galaxy USDC Quality   V2  USDC   $24.3M  galaxy  (manager galaxy is not approved)
---- EXCLUDED BY POLICY ----
  Steakhouse Prime EURCV         V2  EURCV  $127.9M  deposit asset EURCV is not USD, ETH or BTC based
---- NEEDS A DECISION ----
  MANAGER  galaxy      3 fund(s)   $27.5M best  ->  --approve-manager galaxy
  UNNAMED  Adpend USDC V1 $164.3M               ->  --bind-manager adpend-usdc-555558 <key>
  BY NAME  Gauntlet USDT Core V2 $4.6M  reads as gauntlet  ->  --bind-manager gauntlet-usdt-core-110005 gauntlet
==========================================================

The decision queue is collapsed PER MANAGER, not per fund: one approval unlocks every fund that house runs, which is the unit the decision is actually taken in. A fund with no readable manager is its own line, because it needs a binding rather than an approval, and so is a fund whose manager was read off its own name, because the question there is whether this particular vault is really theirs.

A degraded discovery run removes nothing. The sweep that marks a vault gone fires only on a run whose walks proved themselves, per generation, against the endpoint's own reported total and against how many LIVE rows the registry already carried. Both walks stop on an empty page by design, so a resolver answering 200 with zero rows produces a short list and no error at all, and the sweep would then read an outage as a set of closures. Anything the factory registry knows about that the API did not return this run is carried forward rather than swept. A fund that really is gone is picked up by the next healthy run.

Three different conditions can withhold the sweep and the run names which one fired, because they call for different actions:

conditionwhat it meanswhat to do
the endpoint reported no totalthere is nothing to check the walk againstre-run when the endpoint recovers
the walk came back short of that totalthe endpoint is degradedre-run when it recovers
the walk was complete but the registry carries more live rowsthe universe may genuinely have shrunkre-run once with --allow-shrink if the smaller set is real

The third is the one with a trap in it. Nothing in a single run can tell a genuine prune apart from a resolver that quietly dropped rows, so it withholds the sweep, and because the only thing that would lower the live-row count is the sweep it is withholding, it keeps withholding it. --allow-shrink suspends that check for ONE run. It never suspends the endpoint-total check: the flag asserts that the fund set is smaller, not that a truncated walk is acceptable. Rows already marked gone are outside the comparison, so an ordinary prune that fits inside the 2% tolerance needs no flag at all.

F.4 After an approval ​

Nothing else is manual. The refresher, the share-rate snapshotter and the history backfill all read their work-list from the registry, so an approval changes what they cover with no deploy:

  1. sync-money-market-funds.ts to apply the approval to each fund's status.
  2. refresh-assets.ts for the first state and allocation read (also the tick at which the share-rate snapshotter picks the fund up).
  3. backfill-money-market-funds.ts 365 for the fund's return history, or its chart opens empty and its trailing returns read null. See data-pipeline → Money market fund history.

The one thing that IS a code change is the manager's mark: the marks are mirrored into public/curator-logos/ rather than hotlinked, so a newly approved house shows the shared initial tile until someone adds it.

Deploy and prerender (the ISR trap) ​

.github/workflows/deploy.yml ("Deploy to production") triggers on every push to main. A GitHub runner SSHes to root@dexhq.io (pinned host key, key from the DEPLOY_SSH_KEY secret) and runs, in /opt/onchain-credit:

git fetch (https + ephemeral GITHUB_TOKEN) && git reset --hard FETCH_HEAD
npm ci
npm run build
pm2 restart onchain-credit --update-env
bash scripts/ops/restart-ingester.sh   # the event ingester, then the ledger worker; leaves a stopped one alone

It is code only: no DB migrations, backfills, or refreshers run; those are the manual server steps documented above. On build failure it rolls back to the previous commit and rebuilds. The concurrency: deploy-production group serialises deploys. .env.local and node_modules are gitignored, so git reset --hard preserves secrets and deps.

The trap: pages are Next.js App Router with per-page ISR (revalidate 1800 or 3600), prerendered at build time. If a refresher or registry sync runs after a deploy build, the prerendered HTML is stale until the revalidate window elapses. So the canonical order for any data-driven change is:

  1. Land the data (run the sync / backfill / refresher on the box).
  2. Then trigger a deploy (push to main, or re-run the existing deploy) so the build re-prerenders against the fresh data.

Migrations, when a change needs one, are manual: the DDL lives in scripts/sql/001..043-*.sql; apply them with the ledger runner scripts/ops/migrate.sh as the postgres owner against the creddit DB before the dependent code deploys (see Deployment → Migrations).

Never deploy to production without explicit permission. New features ship via feature branch → PR → review → merge to main; deploy auto-runs on merge.

Private documentation. creddit.xyz