Skip to content

built Built. This is a decision record, not documentation.

What is still current: The per-line mark bridge is live in the engine and documented as M21 in Metrics: a line with no mark at a reading is bridged from its own last valued point, never from another line's. The incident figures are history.

Landed: PR #802 (v0.56.0)

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

Mark-gap per-line bridge — implementation plan ​

Written 2026-09-05. Branch fix/mark-gap-per-line-bridge off origin/staging (v0.56.0, 7aa0c8e5). Target: PR against staging.

0. The incident, measured ​

Wallet 0xddcd0d178587cf11529067ada8189abecd39cdd1 (tracked under account 0x46a2…b1f2), USD book, one carry: PST collateral (morpho:market:0x002278ea…:collateral, accounting asset 0x22ae3d9a738471f405169af055d31c687087d4c7, Huma PayFi Strategy Token) against USDC debt.

Served on prod 2026-09-04 (GET /api/portfolio/history?book=USD&bucket=1d&wallet=0xddcd…):

linetip value
accrual+51,933
total return+37,158
gap−14,775

Per-position (/api/portfolio/positions): PST yieldRedemption 101,377 vs yieldMarket 86,915 (−14,462); USDC debt −203; idle USDC −110. The PST leg is the whole gap.

Root cause. The PST market-price mirror (token_price_bars, source dune:prices.hour) has NO bars from 2026-07-10 09:00 to 2026-07-14 13:00 UTC (101 hours). The stored snapshots at 2026-07-13 00:00 and 2026-07-14 00:00 therefore carry value_market = NULL with value_redemption present and qty_underlying = 5,902,588.836183 (unchanged; no receipts in the stretch). The market unit price was 1.114536 at 07-12 and 1.117143 at 07-15: the step across the hole is worth

5,902,588.836183 × (1.117143 − 1.114536) = 15,388.9

The engine (v2, src/lib/portfolio/v2/engine.ts) books the three intervals 07-12→13, 13→14, 14→15 as W2 unpriced-input on the total-return line: 0 each, no suspension, no bridge. The step is dropped, not deferred. Empirically: the served chart gap minus the PST market-vs-redemption wedge sits at ≈ −$600 from 11 Jun to 12 Jul, steps to ≈ −$16,000 on 15 Jul, and stays there.

Endpoint check (Fred's formula, V_end − V_start − Σ receipts, all in one mark, from stored rows):

basisendpoint formulaengine serves
redemption (accrual)101,377101,377
market (total return)102,30586,915

The accrual line reproduces the formula exactly; the total-return line is short by exactly the hole.

Prod blast radius (census 2026-09-05, non-PT, non-EXCLUDED, qty>0, dark endpoint with a valued point on that line both before and after): exactly two legs —

walletlegline darkdark rowsfirstlasteffect of the fix
0xddcd0d17…PST collateralmarket22026-07-132026-07-14+15,389 on total return from 15 Jul onward
0x5d09098f…wallet:token:0xdc035d45… (USDS)market72026-07-222026-08-11≈ $0 (leg worth ~$51)

No redemption-line dark endpoints exist outside PT legs (which are never valued on the accrual line and are NOT touched by this plan — see §2.6).

Staging holds no rows for 0xddcd… (0 snapshots, 0 receipts, no backfill state) and staging is never backfilled by hand (nightly reseed). Acceptance is therefore an offline, prod-anchored replay (§5.3), not a staging screenshot.

1. What changes, in one sentence ​

A present spine row whose mark is null in ONE line is that line's read gap: the line withholds (W2, as today) for every interval that touches the dark endpoint, and bridges on that line from the last grid point at which that line WAS valued when the next valued point arrives — under the same bridge floor, the same span keying, and the same bridge guard W1 already uses. Nothing else about W2 changes: a receipt amount null in a line's mark still withholds that line for the span with no bridge, and the uncorroborated bridge guard (#766) still declines.

This is W1's rule applied per line. M29 already says W1 = "0, suspend, carry the equity into the time-weighted base, bridge on return". The barrier paragraph in M29 ("a withheld window is a barrier") is about the withholds whose window is unadjudicated (W3/W4/W7/W8/W9 — they raise the bridge floor via withheldZero). A null endpoint mark is adjudicated: occupancy is ledger truth, quantity is known, every receipt in the span is priced (or the receipt rule withholds). W2-endpoint never raised the floor and must not start to.

2. Engine changes (src/lib/portfolio/v2/engine.ts) ​

Read the whole file header, §6 (state table, ~line 1005), LegRunState (~1300), bookInterval (~1620–2290), withheldZero (~1704), the W1 branch (~1883–2010), the bridge span (~2012–2050), the W2 per-line block (~2051–2110), the bridge guard (~2112–2215) BEFORE editing.

2.1 Per-line last-valued index ​

Add to LegRunState:

ts
/** the last grid point at which the spine held a row for this leg WITH a value in that line's mark */
lastValuedIdx: Record<Line, number | null>;

Initialise { accrual: null, totalReturn: null }. Maintain it in the interval loop (the caller of bookInterval, ~line 2280), after each interval returns, from the CLOSE row, and once for point 0 before the loop:

ts
for (const line of LINES) if (markValue(leg.spine[k], line) !== null) state.lastValuedIdx[line] = k;

Do NOT maintain it inside bookInterval's many return paths. It is a fact about the spine, like lastReadIndex, and it is updated regardless of what the interval booked. Reachability against the bridge floor is checked at USE time (idx >= state.bridgeFloorIdx), exactly as reachable() does for W1.

2.2 Per-line span open in the W2 block ​

Today the W2 block computes one spanOpenIdx / spanOpenRow / spanReceipts / spanOpenOcc / spanIndexAtOpen for both lines (from the W1 bridge decision) and then, per line, emits a W2 when the open endpoint's mark is null.

Change: per line L, decide the span open for that line:

spanOpenIdx_L    = spanOpenIdx                       // the leg-level one (W1 anchor or openIdx)
openValue_L      = markValue(spine[spanOpenIdx_L], L)
if (needOpen && spine[spanOpenIdx_L]?.present && openValue_L === null) {
  const a = state.lastValuedIdx[L];
  if (a !== null && a >= state.bridgeFloorIdx && a < spanOpenIdx_L) {
    spanOpenIdx_L = a;                               // the per-line anchor
    openValue_L   = markValue(spine[a], L);          // non-null by construction
  } else {
    emit W2 (evidence `endpoint:<mark>-null`, block = points[spanOpenIdx].blockNumber, value = other mark)
    withheld = true;                                 // stranded: today's behaviour
  }
}
spanReceipts_L   = spanOpenIdx_L === spanOpenIdx ? spanReceipts : receiptsInSpan(leg, points, spanOpenIdx_L, closeIdx)
spanOpenOcc_L    = spanOpenIdx_L === spanOpenIdx ? spanOpenOcc  : occupancyAt(leg, points, spanOpenIdx_L)
spanIndexAtOpen_L= spanOpenIdx_L === spanOpenIdx ? spanIndexAtOpen : spanIndexAtPoint(spanOpenIdx_L)

Then, unchanged in order: the close-endpoint null check (emit W2 endpoint:<mark>-null at closeIdx, withheld — this is what keeps every dark interval claimed), the receipt-null check over spanReceipts_L (emit W2 mark:<mark>-null, withheld), then walk({... valueOpen: openValue_L, valueClose, occupiedOpen: spanOpenOcc_L.occupied, receipts: spanReceipts_L, spanIndexAtOpen: spanIndexAtOpen_L }).

Notes that are load-bearing:

  • The per-line anchor applies in EVERY leg state whose span open is present-but-null on that line: LIVE (the PST shape), EXIT (a close whose open endpoint was dark: the line books A_exit − V_L(anchor) − Σ, which is the per-line analogue of EXIT ACROSS A GAP), READ_GAP_HEALED and EXIT_ACROSS_GAP (the W1 anchor row itself has a null mark on this line — today that strands the line with a W2 at the open; now it reaches back to the line's own last valued point). Do not special-case by state name; key it off openValue_L === null as above.
  • a < spanOpenIdx_L guarantees the per-line anchor only ever moves the span open EARLIER. The zero anchor case (suspendOpenEmpty, ledger says empty at the open) has needOpen === false and never enters this branch.
  • When the leg-level W1 bridge is active AND the line's anchor is earlier than the W1 anchor, the line's span is the longer one. Its receipts must be receiptsInSpan(anchor_L, closeIdx) — never the leg-level list — or the difference books as return (the same "span's receipts are a function of the span" rule the W1 bridge states at ~line 2018).
  • Do NOT add per-line suspension state. The anchor lookup IS the suspension: a dark interval withholds and leaves lastValuedIdx[L] where it was; the first valued close on L reaches back to it. That keeps the per-leg state machine (classifyState) untouched.
  • Order of decisions is unchanged: ghost → W1 → W2 (per line) → the law. W2-endpoint must still NEVER call withheldZero (it does not today). Pin this: a per-line dark interval must not raise bridgeFloorIdx (§4 test T7).

2.3 spanKeysTouched widening ​

The invariant check at the end of bookInterval (~line 2246) throws if a walk books into a span key the interval does not report touching. The W1 bridge widens spanKeysTouched down to spanIndexAtOpen (~line 2046). A per-line anchor can sit earlier, so after the per-line spans are decided, widen down to min(spanIndexAtOpen_L over lines). Keep it as a [low..high] range (see the comment at ~2038 for why).

2.4 Bridge guard, per line ​

bridging (~line 2176) is leg-level. Make it per line: bridging_L = spanOpenIdx_L < openIdx, and gate on spanReceipts_L.length > 0. The condition "this line books a bridge that carries receipts while the OTHER line is withheld on this interval" is unchanged. Consequence worth stating in the PR: a PT leg (accrual never valued) whose total-return line has a dark endpoint will bridge only across a receipt-free stretch; with receipts the guard declines exactly as it does for W1 bridges today (#766).

2.5 Carried equity, per line ​

LegIntervalBooking.carriedEquity is one number used by BOTH TWR bases (attribution.ts buildCurve, ~line 323 and the two twrBase calls). W1 sets it on dark intervals (via zeroBooking(equity)) and null on the healing interval. For a per-line dark interval the market book value already EXCLUDES the leg (its market value is null) while the accrual book value INCLUDES it, so one number cannot serve both bases.

Add to LegIntervalBooking:

ts
/** equity carried into ONE line's time-weighted base while that line alone is dark (per-line read gap) */
carriedEquityByLine: Record<Line, number | null>;

Set it on every interval whose line L is withheld for an endpoint-null with a reachable lastValuedIdx[L]: markValue(spine[lastValuedIdx[L]], L) (the leg's last value on that line). null otherwise, and null on the healing interval (mirror W1: its heal carries nothing). Keep carriedEquity exactly as it is for W1.

In attribution.ts buildCurve: compute carriedAccrual and carriedReturn separately — carried_L += legSign × (booking.carriedEquity ?? booking.carriedEquityByLine[L] ?? 0) — and pass each to its own twrBase. V2CurvePoint.suspendedEquity keeps its meaning (the W1 figure, both lines); document that. legPerformance needs no change: it reads subIntervals[line][k].openValue, which on a per-line bridge is the anchor's value.

Grep every other consumer of carriedEquity / suspendedEquity (reconcile/, ledger-v2-api.ts, aggregate, API shapes) and keep them compiling and semantically unchanged.

2.6 What must be byte-identical afterwards ​

  • PT legs (accrual mark null on every row, M12): lastValuedIdx.accrual stays null forever → no anchor → every accrual interval withholds exactly as today, and the bridge guard behaves exactly as today.
  • A leg never valued on a line before its first dark point: no anchor → today's behaviour.
  • A line dark at the tip (mirror lag, #461): the tip interval withholds on that line, no bridge (no later point), no new alarm; the next replay with a priced tip books normally.
  • A barrier (withheldZero: W3/W4/W7/W8/W9) inside the dark stretch: bridgeFloorIdx rises past the anchor → stranded → W2 at the open, book 0, resume from the next valued point (W1's stranded semantics).
  • Receipt unpriced on line L inside the stretch: W2 mark:<mark>-null, line withheld, no bridge.
  • No new anomaly kind, no new raised entries. W2 rows keep shape "W2 unpriced-input", scope leg-interval-line, line set, evidence strings unchanged.

3. Reconciler (src/lib/portfolio/reconcile/) ​

recompute.ts already builds windows PER LINE from the points valued in that line's mark (endpointValue → reason "unpriced" → not an anchor; bridged: closeIndex − openIndex > 1). So the reconciler's total-return window over the PST hole is already 07-12 → 07-15, and today the engine's W2 rows on all three intervals put it in bucket 2 ("withheld", claimCovers is ANY-overlap). After the change the two dark intervals still carry W2 rows, so the window still lands in bucket 2 — no identity row, no page, no unshaped-decline. Verify this with a conditions.test.ts case (§4 T11).

One improvement, in the same PR: conditions.ts ~line 613 runs the bridge cross-check (diagnostic, never paged) only when shapes.every(s => s === "W1 unread-endpoint"). Extend it to windows whose claims are all W1 and/or W2 rows, so a per-line bridge's booked figure is cross-checked against the reconciler's own subtraction the same way a W1 bridge is. Update the comment on Evaluation.bridgeChecks.

4. Tests (all must be added to the explicit file list in package.json "test" if in a NEW file) ​

Adjust:

  • v2/engine.test.ts "W2 is per LINE and symmetric": spine [1000/990, null/1001, 1020/1012]. Now: ONE W2 row (interval 0, close-null, line totalReturn, value 1001); interval 0 TR = 0, accrual = 11; interval 1 TR = 1020 − 1000 = 20 (bridged), accrual = 11; interval 1 withheldLines = []; carriedEquityByLine.totalReturn = 1000 on interval 0, null on interval 1.
  • v2/scenarios/block-b.test.ts CV-7: interval HIT withholds TR (W2, value = other mark); interval HIT+1 BOOKS TR = Q × (mkt[HIT+2] − mkt[HIT]); accrual books on both. Update the matrix note.

Add (scenario cells in v2/scenarios/, registered in v2/fixtures/matrix.ts with status: "implemented" in the same diff — matrix.test.ts enforces both directions; use the allocator for magnitudes and assert the withhold ledger + anomaly count as the block-B cells do):

  • T1 CV-7b — two consecutive dark points on one line (the PST shape), no receipts: the line books the whole stretch on the heal interval; exactly two W2 rows (one per dark interval, close-null); the other line books every interval; bridgeFloorIdx untouched; sum over the stretch on the dark line equals its endpoint subtraction to 1e-9 relative.
  • T2 CV-7c — same, with ONE priced receipt (deposit) inside the stretch: bridged figure = close − anchor − receipt on that line; the other line's sum over the stretch equals its own endpoint subtraction; no guard firing (the other line is not withheld on the heal interval).
  • T3 — a receipt inside the stretch unpriced on the dark line: heal interval withholds that line (W2 mark:…-null), books 0, no bridge; accrual books.
  • T4 — dark through the tip: the tip interval withholds the line; no bridge; raised unchanged; no coverage/read-gap anomaly.
  • T5 — never valued before the first dark point: byte-identical bookings before/after (snapshot the result object with the fix switched off is not possible, so assert the explicit expected values).
  • T6 — PT leg (redemption null everywhere) with a market dark endpoint and receipts in the stretch: the guard declines exactly as the existing "the guard" test does; with NO receipts it bridges the market line and the accrual line withholds every interval as today.
  • T7 — a W7 uncertified interval inside the dark stretch: stranded; W2 at the open of the heal interval; the leg resumes from the next valued point; assert the floor semantics.
  • T8 — occupancy close AND reopen inside the dark stretch (withdraw-to-zero, then deposit): the bridged walk is keyed from spanIndexAtPoint(anchor), spanKeysTouched is widened, the invariant check does not throw, and the two series book the close's residual and the reopen's growth respectively.
  • T9 — W1 whose anchor row is null on ONE line (row present at lastReadIndex with valueMarket: null, then absent, then present+valued): the accrual line bridges from the W1 anchor; the total-return line bridges from ITS last valued point (earlier), with the longer span's receipts.
  • T10 — attribution.ts buildCurve: a two-leg book where one leg is market-dark for one interval; the total-return TWR base for that interval includes the dark leg's carried market value; the accrual base is unchanged; suspendedEquity on the point is unchanged (0, no W1).
  • T11 — reconcile/conditions.test.ts: a per-line bridged window (mirror the existing "a READ GAP" test at ~line 174): window bucket = "withheld" with shapes all W2; NO identity row; NO unshapedDeclines; bridge cross-check quiet when the engine's bridge equals the recomputed window and FIRES (diagnostic) when they disagree.
  • T12 INC-PST-2026-07 — prod-anchored cell: export the PST collateral leg's stored rows (spine rows 2026-06-11 → 2026-09-04 with qty_underlying, value_market, value_redemption, block_number; and its portfolio_flow_events_v2 receipts) from prod with a READ-ONLY psql over SSH (ssh -i ~/.ssh/hetzner_ed25519 root@dexhq.io "cd /opt/onchain-credit && set -a && source .env.local && set +a && psql \"$DATABASE_URL\" -At -F'|' -c '<SELECT …>'"), embed as a fixture beside the cell, and assert: (a) total return over intervals 07-12→07-15 = 15,388.9 ± 0.5; (b) total return over the whole replay equals the endpoint formula computed from the same fixture rows to 1e-9 relative; (c) accrual over the whole replay is unchanged (101,377.2 ± 0.5); (d) exactly two W2 rows on the total-return line. Never write to prod. Do not commit any wallet label; the address is already public on chain.

Every new cell must assert its withhold ledger (fixtures/withholds.ts) and pass assertMagnitudeUniqueness / mutation checks like its neighbours.

5. Verification ​

5.1 Toolchain (worktree gotchas that have bitten before) ​

  • Node 20: export PATH=/usr/local/opt/node@20/bin:$PATH (default node is v11).
  • This worktree already has a REAL node_modules (clonefile copy) with the nested self-symlink removed and .next deleted. If Turbopack/tsx reports "Module not found" for packages that are clearly present, run npm ci.
  • npm run typecheck && npm run typecheck:scripts && npm test — the unit suite is an explicit file list in package.json; a new test file that is not listed does not run.
  • Docs: find the vitepress build script in package.json (or docs/) and run it — it is the dead-link check. Docs ship in the same PR.
  • e2e: npm run e2e on isolated ports. Read docs/ and playwright.config.* for FIXTURE_PORT / E2E_PORT; another worktree's dev server or fixture Postgres on the default ports will make the run starve or share state. If the fixture DB is >24h stale the suite mass-fails for that reason alone — regenerate it per the harness docs before concluding anything.

5.2 Expected restatement (put this in the PR body, it is user-visible) ​

Served history recomputes on read; there is no migration, no stored-row change, no cron change and no prod data step. On prod exactly two wallets restate: 0xddcd… total-return line +15,389 from 15 Jul 2026 onward (tip ≈ +52.5k against accrual ≈ +51.9k as of 2026-09-04, so the two lines now sit ≈ $600 apart, which is the borrowed-USDC and idle-cash wedge); 0x5d09… by ≈ $0. All other wallets are byte-identical (the census in §0 is the proof; re-run it at PR time and paste it).

5.3 Acceptance ​

  • All of §4 green; T12 numbers as stated.
  • Full unit suite, typecheck, docs build, e2e green (or every red matched to a standing pre-existing red by name — see the staging smoke notes in docs/).
  • Re-run the §0 census query at PR time and paste it into the PR.

6. Docs (docs/metrics.md, same PR) ​

  • M29 withhold table, W2 row: split into the two cases. Endpoint-null → "0 on that line for the interval, the other line books normally; the line bridges from its last valued point on the next valued one under W1's floor and guard". Receipt-null / bridge-guard → unchanged wording ("dropped, not deferred").
  • M29 "A withheld window is a barrier" paragraph: name the shapes it applies to (W3, W4, W7, W8, W9 — the ones that raise the floor) and state explicitly that W1 and W2-endpoint are the two withholds a later span may reach over, because their windows are adjudicated.
  • M29 state table: add a row "LIVE, one line unpriced at an endpoint → that line: W2 + per-line bridge".
  • engine.ts header and the SpineReading doc comment ("Collapsing the two would make an unpriced endpoint look like an unread one: same booked number, completely different alarm") → the two keep different ROWS and alarms; they now share the BOOKING rule.
  • reconcile/conditions.ts bridgeChecks comment.
  • Add this plan's link to wherever docs/plans/ files are indexed (check docs/.vitepress/config.*); if plans are not indexed, leave it unlinked (the M21 section links plans by path, so the path form /plans/mark-gap-per-line-bridge-plan must resolve in the docs build).

7. Out of scope (file as follow-up issues, do not build) ​

  • PST price feed quality. PST rides the shared dune:prices.hour feed and refreshed to a new value only 90 times across 1,829 hourly bars (11 Jun → 4 Sep), one value held for 168 consecutive hours. sUSDe was moved to its own dex.trades-derived route (DEX_RATIO_SOURCES in src/lib/data/dune.ts) for the same class of problem. Whether PST's Fluid PST/USDC pool trades often enough to support a dedicated route is a Dune-credit question for Fred. Open an issue titled "PST secondary-market price: shared hourly feed is stale for days; evaluate a dedicated dex-ratio route" with the numbers above.
  • Any change to how the mirror fills holes (carrying a last price forward at WRITE time). The engine fix makes the served number correct regardless; the mirror stays honest about what it has.

8. PR ​

  • Open a GitHub issue first (gh issue create) titled "Total return drops a price step across a present-but-unpriced snapshot (PST, 0xddcd…, −$15.4k)"; reference it from the PR with Closes #….
  • PR title: fix(engine): bridge a per-line mark gap from the line's last valued point (W2 endpoint).
  • PR body: what/why in product terms first (the two lines, the July hole, the $15.4k), then the rule change, the §0 numbers, the restatement note (§5.2), "server steps: none", the census table, the test list, and the follow-up issues. Footer:
🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_017XBkQ2RK6t8KyNAUmhN5sB
  • Commit trailer on every commit:
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XBkQ2RK6t8KyNAUmhN5sB
  • Do NOT merge. Do NOT deploy. Do NOT touch prod or staging databases except the read-only export in T12.

Private documentation. creddit.xyz