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_creditschema; refreshers and ad-hoc syncs are the only writers. On-chain reads go throughsrc/lib/data/rpc.ts. - Code ships automatically; data does not.
.github/workflows/deploy.ymlredeploys the app on every push tomain, 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
| Production | Staging | |
|---|---|---|
| Path on box | /opt/onchain-credit | /opt/onchain-credit-staging |
| pm2 process | onchain-credit (:3001) | onchain-credit-staging (:3002) |
| DB | creddit (role onchain_credit) | creddit_staging (role onchain_credit_staging) |
| nginx / URL | https://creddit.xyz (Cloudflare in front) | https://staging.creddit.xyz (basic-auth .htpasswd-staging, noindex) |
| Git branch | main | staging |
| Deploy | CI 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. Other pm2 processes on the box: rindexer, dexhq, creddit-indexer.
Develop on staging, promote to prod. Feature branch → PR into staging → 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/<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). Full behavior: Deployment → running a cron by hand.
| Schedule | Job | Purpose |
|---|---|---|
0 */6 * * * | refresh-assets.ts | 6h grid: Fluid LL/DEX, token yields, basis, Aave/Spark/Morpho reserves, assets table, curator state |
15 */6 * * * | refresh-vault-capacity.ts | borrow/supply caps + headroom |
30 */6 * * * | refresh-collateral-exposure.ts | market collateral exposure |
45 */6 * * * | refresh-lending-positions.ts | position-attributed underwritten capital |
50 */6 * * * | refresh-portfolio.ts | read-only portfolio spine + flow ledger (WS4) |
* * * * * | drain-portfolio-backfills.ts | registration-backfill queue drain (WS5): FIFO 1-at-a-time, open-only daily replay, ~1 min signup-to-history. A failed child retries once then parks; every failure pages (exit 2) and names the wallet |
*/10 * * * * | refresh-portfolio-discovery.ts | discovery 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.ts | exhaustive Morpho Blue market universe (T4 §3.5), cursor-driven CreateMarket scan |
40 3 * * * | refresh-metamorpho-factory.ts | exhaustive MetaMorpho vault universe (T5 §3.5), cursor-driven CreateMetaMorpho scan over both factories |
30 4 * * 0 | refresh-portfolio-reconcile.ts | weekly (Sun) discovery reconciliation safety net (T5 §3.6), capped PORTFOLIO_RECONCILE_MAX (default 50) |
30 4 * * 1 | sync-portfolio-tokens.ts | weekly (Mon) portfolio token-registry sync (T6). Propose + alert only — never writes without --approve |
30 3 * * 1 | refresh-vault-risk.ts | weekly LTV / liquidation thresholds |
0 13 * * 1-5 | refresh-sofr.ts | weekday SOFR from NY Fed |
15 2 * * * | ops/backup-creddit.sh | daily prod DB backup |
0 3 * * * | ops/reseed-staging.sh | daily 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.tsandsync-curator-vaults.tsare 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.tsIS 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.
| Class | Defined where | Key shape | Added by |
|---|---|---|---|
| Curated Aave v3 / SparkLend e-mode | hand-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 key | editing source |
| Auto-discovered Fluid vaults | onchain_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), never proposes one with under 14 days of runway, classifies matured ones matured, 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. Maturity delisting is layered: the reader hard-filters at read time, the 6h pendle refresher flips active -> matured, and discovery re-classifies.
Floors: Fluid vault TVL ≥ $100k; Aave/Spark enterable size ≥ $1M, 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. 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-ghoRun 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:
- Run
scripts/run-cron.sh sync-carries.ts(manual; it is not on a cron). A new qualifying pair is written tocarry_registryasproposed. - Review the report's PROPOSED group, then
sync-carries.ts --approve <key>(e.g.aave-oseth-weth). Status flips toactive. - 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_apyadapter for its yield (see Section B), and - a
CollateralKind+COLLATERAL_PROFILE+COLLATERAL_LABEL_TO_KINDentry insrc/lib/data/oracles.tsso 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 thepublic/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.tsxlocks that every active-carry token resolves to a specific mark. The mergedCOLLATERAL → DEBTcolumn renders each row ascollateral → 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 amberSMART COLLATERAL/SMART DEBTcaption 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-srUSDeover22 OCT 2026). After that the next sync surfaces its pairs asproposedfor 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:
| Status | Meaning |
|---|---|
active | TVL ≥ floor, supported, delta-neutral same-single-class → shown |
directional | net price exposure (cross-class or mixed-class LP) → held off |
reward_dependent | clean carry but return leans on a live Merkl incentive → held off |
reward_token_external | collateral's real yield is an off-chain reward we don't model (USDe points, USDtb), held out on the collateral side |
wound_down | borrowing disabled on-chain (Fluid collapses borrowLimit to dust below minimumBorrowing) |
below_floor | TVL < $100k → dropped |
blocked_adapter | a leg token's intrinsic yield isn't modelled yet |
blocked_pool | a smart-col/debt DEX pool isn't snapshotted (for a cross-pool T4, BOTH pools must be) |
blocked_review | unrecognised 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) |
gone | was 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.
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:
src/lib/data/oracles.tsCOLLATERAL_LABEL_TO_KINDmust map the vault's collateral label (e.g."reUSD/USDT") to aCollateralKind. If it doesn't,fluidMetaFromRegistry()returnsnulland the panel is omitted rather than shown wrong. New collateral kinds also need aCOLLATERAL_PROFILEentry (layers 2/3: how the rate is read, what it protects, what it does not) and, for any new token, aTOKEN_INFOentry (valuation + issuer risk) used by the generated pricing prose.src/lib/data/fluid-oracle-copy.tsFLUID_VAULT_COPYholds the curated per-vault liquidation narrative, keyed by`${collateral_label}|${debt_label}`exactly as the registry stores the labels (so vaults #6 and #16, bothweETH|wstETH, share one entry).fluidVaultCopy(colLabel, debtLabel)returnsnullfor 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 isFAULT_SINGLEfor single-leg vaults andFAULT_LPfor 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-usdtisredemption(both legs ride the capped USDT/USD feed, so they cancel);aave-susde-usdcismarket-linked(collateral on USDT/USD, debt on USDC/USD, leaving a USDT-vs-USDC cross). Set thepricingModeoverride on theSTRATEGY_ORACLEentry accordingly;resolvePricingMode()falls back to the methodology default otherwise.
A.4 Risk params and capacity coverage
- Risk params (
max_ltv,liquidation_threshold) come fromonchain_credit.vault_risk_params, populated weekly byrefresh-vault-risk.tsand read bygetVaultRiskParams()(src/lib/data/vault-risk.ts). A missing key (refresher hasn't seen the strategy yet) degrades to the hard-codedvaultLTVin the registry / strategy core; the page does not 500. - Capacity is registry-driven for Fluid (issue #81): the
vault-capacity.tsrefresher iterates everystatus='active'Fluidcarry_registryvault, derives the full per-vault config on-chain fromgetVaultEntireData.constantVariables(type viaisSmartCol/isSmartDebt, DEX pools, leg tokens, decimals), then reads live limits. Fluid capacity rows are keyed by lowercased vault address (matchingstrategy.vaultAddress); Aave/Spark rows use semantic keys. Orphaned Fluid rows (vault leftactive) 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:
# 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:30The 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:
| Gate | Source | Why |
|---|---|---|
| Ethereum mainnet, Morpho Blue singleton | chain | scope |
| AdaptiveCurve IRM + non-idle (collateral ≠ 0) | chain | we only model this IRM; idle markets are unmodellable |
Morpho-listed (listed = true) | API (veto) | Morpho's own curation; drops fake/unlisted books |
| No RED API warning | API (veto) | catches oracle-derivation / custom red flags |
| Bad debt < 0.1% of supplied USD | API (veto) | drained/impaired books |
| Utilization ≤ 99% | chain | a ~100%-util book is drained: lenders can't exit, "borrow APY" is fiction |
| Decomposable oracle | chain | the 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 MetaMorpho vaults | API (veto) | the "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 |
Track gates + floors:
- repo: loan ∈ {USDC, USDT, USDS, GHO} (
REPO_LOAN_ASSETSinscripts/morpho-rule.ts, asserted equal to the page'sMONEY_MARKET_ASSETStabs), 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 (assetClassfromcarry-criteria.ts;OTHERis never delta-neutral →directional), collateral yield is modellable (token_yield_apyrow, or a PT inpendle_markets; elseblocked_adapter), collateral is not an external-reward token (USDe/USDtb →reward_token_external), and free loan liquidity ≥ $1M (Morpho has no supply/borrow caps, so free liquidity= (supply − borrow) × priceis 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 carry); 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>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.
Numbers.
src/lib/data/assets-table.tsgetAssetsFromDb()readsonchain_credit.assets(trailing APY, 1M/YTD/1Y returns, productive vs underlying mcap). That table is written daily by theyield-token-assets.tsrefresher (part ofrefresh-assets.ts), which derives every figure from the per-snapshotonchain_credit.token_yield_apyhistory.current_apyis the latestapy_30d(trailing-30d), so the table value equals the yield-history chart's latest point.Narrative.
src/data/asset-narratives.tsASSET_NARRATIVESholds 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, an optional numberedwaterfallstack graphic on the section, then the residual risk set), and "Redemptions and liquidity" (redemption path, cooldowns/queues/gates with structural parameters, stress behavior). Paragraphs support inline[text](url)links so a claim can point at its live evidence (a dashboard, an on-chain balance). The per-assetfactsgrid is retired: every memo is headline, chart, three sections.Voice (locked in;
PSTis 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. Not a dense noun-phrase opener. - 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 innote. - 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 instakewise/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;StakedUSDeV2stores onecooldownDurationan Ethena multisig can set with no timelock, capped atMAX_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.
- The lede opens
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
- 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, ... }wherekindis 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(convertToAssetsread 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) setrateSource. UseshareDecimals/rateDivisorPow10for non-18-dec share tokens (e.g. 6-dec stable vaults). The refresher writesshare_rate,supply_apy(trailing-24h, 4×6h windows), andapy_30dintotoken_yield_apy. - Surface it on
/asset-profiles. Add the asset to theyield-token-assets.tsregistry (itsBASE_YIELD_TOKENS/ per-asset meta) so a row is written intoonchain_credit.assetswith productive/underlying mcap. Then add anASSET_NARRATIVESentry for the ticker. - 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 insrc/components/assets/TokenIcon.tsx(keys off the ticker; falls back to the issuer mark until the token image is added). The Issuer cell shows the issuer's protocol mark: add the glyph insrc/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 inpublic/issuer-icons/(falcon.png,renzo.jpg,sky.jpg,stakewise.png). The icon registry keys off the issuer name used inyield-token-assets.ts. The samepublic/token-icons/coin also feeds the/carriesPosition column viaPositionCell.tsx(a separate resolver — see A.1); if the asset can be a carry leg, register it there too. - 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.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.
/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.
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. Per-vault narrative copy lives in src/data/curator-vault-narratives.ts. 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.
Known gap. Morpho V2 vaults are not indexed by the Morpho V1 API used for fee/allocation data (issue #68), so V2 curator vaults stay unsurfaced for now.
D. Portfolio account backfill and repair (WS5)
Every registered wallet gets a one-time 90-day archive replay so its chart is not born empty. 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:
# DESTRUCTIVE RE-DERIVATION (the FRESH path only; `--fresh` forces it): deletes the wallet's ENTIRE stored history (both
# tables) and re-lays the open-only daily replay. Post-signup 6h rows thin to
# daily; groups closed since the last run are pruned. NEVER pass a small --days
# on a wallet with real history — the full-range delete still runs, so --days 7
# amputates everything older. (--days is for acceptance runs on throwaway
# accounts only.) A missed 6h tick needs NO repair (it is just a chart gap).
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-runscripts/ops/seed-portfolio-fixtures.tsafter a reseed (it registers the fixture whales; the next cron tick backfills any that get queued).
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.
| Piece | Path | What it is |
|---|---|---|
| Fixture DB builder | scripts/fixture/build.sh | Creates the local Postgres the app reads during QA. Idempotent. Prints the DATABASE_URL on its last line |
| Fixture prerequisites | scripts/fixture/bootstrap.sql | The 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 rows | scripts/fixture/seed.sql | Deterministic seed data. Anything a spec asserts on specifically is seeded here |
| Playwright config | playwright.config.ts | Project + base-URL wiring |
| Specs | tests/e2e/*.spec.ts, tests/e2e/fixtures.ts | One spec file per surface; fixtures.ts exports the shared test |
| Route warm-up | tests/e2e/global-setup.ts | Requests 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 install | scripts/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.
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.
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.
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. That is what makes /portfolio signed in deterministic: the portfolio GET routes serve stored portfolio_position_snapshots + portfolio_flow_events and never trigger the live RPC pipeline themselves (loadLiveRows, src/lib/portfolio/api-data.ts), 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. 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.
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
npm run qa:browser # once per machine; no-ops afterwards
npm run e2e # headless
npm run e2e:ui # headed, for watching a failurenpm test (unit tests, one long list of files in package.json) is unchanged and stays separate: it is fast and hermetic, the e2e suite needs a database and a browser.
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.
Allowlist and cache. Add both hosts to the sandbox network allowlist in the Claude Code UI, and put
bash scripts/qa/ensure-browser.shin the UI-configured environment setup script so the browser lands in the cached image. A repoSessionStarthook is not a substitute: hooks are not snapshotted, so every session would re-download ~150MB.Playwright's own image, which needs no allowlist change at all (
mcr.microsoft.comis trusted by default and Docker is preinstalled). It ships the browsers and their system libraries together, so it replaces both halves ofensure-browser.sh:bashdocker 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:
- It renders its real content, not a skeleton or a plausible-looking empty state.
- The console is clean: no errors, no hydration mismatch, no failed request.
- No horizontal overflow on the page body (
scrollWidth === clientWidthondocumentElement). Wide tables and charts scroll inside their own container. The v0.19.0/carriesregression, a fixed-px grid that forced the whole page sideways, shipped because cells were checked in isolation instead of the page. - Interaction is exercised after React has hydrated. Screener rows are in the SSR HTML, so clicking a
<summary>toggles<details>natively whileonTogglenever 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. - 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. - Both auth states where the surface differs.
/portfolioin 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_COOKIEandcookieValueFor()fromsrc/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
- On a PR: a non-blocking job in
ci.ymlnags when a PR touchessrc/apporsrc/componentswithout touchingtests/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. - After a staging deploy: the
smokejob of.github/workflows/deploy-staging.ymlruns the suite against the build that deploy just put on the box, reached through an SSH tunnel to its app port rather than throughstaging.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.
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-envIt 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:
- Land the data (run the sync / backfill / refresher on the box).
- 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.