Underwriting Process Replay — Design
2026-05-29 · Repo:ghl-leadbuilt-marketplace-app-fundingmachine · Surface: Underwriting Review Console (app/admin/internal/underwriting-review)
Summary
Upgrade the console’s static Multiplier replay card into an interactive, animated Process Replay: a scrubbable, “slowed down” playback of how an underwriting decision is assembled stage-by-stage, with a persistent roster of every account that lights up to show which accounts drove each stage. The playback is tweak-aware — it animates over the decision produced by the current Tweak-panel knob settings — so an operator can watch a knob change ripple through individual accounts and rebuild the funding multiplier. This is a purely client-side, presentation-layer feature. It introduces no change torunUnderwriting’s output, no RULESET_CODE_VERSION bump, and no golden-snapshot churn.
Goals
- Let an operator watch the decision build: each multiplier stage in sequence, the running product compounding, the funding range resolving.
- Show, per stage, the specific accounts that drove it (e.g. “Derog ×0.80 ← ONEMAIN −5pt, SYNCB/LOWES −3pt”).
- Let an operator follow a single account’s journey across all stages (click an account → cross-highlight every stage it touched).
- React live to calibration tweaks: change a knob, the playback re-derives and you see the delta at the stage you’re on.
Non-goals (YAGNI — explicitly out of scope)
- Batch-wide simultaneous playback (multiple reports animating at once).
- GIF / video export of the animation.
- Persisting playback position across sessions.
- Any server-side or engine-trace change (e.g. enriching
UnderwritingTracewith per-account arrays). - Side-by-side comparison of two different tweak states.
Background: what already exists
convex/lib/underwritingReviewReplay.ts→buildStageTimeline(decision)produces the ordered multiplier stages (base → score → newCards → derog → thinFile → latePayment → unsecuredRecent → oldBankruptcy → clamp → range) with each stage’smultiplier, compoundingrunningProduct,band,reason, andgate. It is a pure presentation transform over the decision’s explainability trace.app/admin/internal/underwriting-review/_components/ReplayStepper.tsxrenders that timeline statically.ReportDetail(inpage.tsx) reconstructs the baselineUnderwritingInput(lib/underwriting/reconstructInput.ts), runsrunUnderwritingwith no tweaks, and renders the static stepper.- The Tweak panel manages
knobChangesviauseReviewSession;applyKnobChanges(lib/underwriting/knobRegistry.ts) converts them into the{ thresholds, multiplierOverrides }pair the batch diff re-runs with. - Per-account scoring already exists as pure modules:
convex/lib/derogScoring.tsreturnsDerogDetail[](creditor, type, weighted points);convex/lib/lateScoring.tsreturnsLateDetail[](creditor, 30/60/90 instances).
Architecture
All client-side. Data flow for the selected report:Tweak-aware integration
ReportDetail is extended to apply the current session knobChanges to the selected report’s reconstructed input before running the engine, reusing the exact applyKnobChanges path the batch diff uses. This guarantees the replay and the batch-diff numbers can never disagree. On knob change, the replay re-derives and holds the current stage index (clamped) so the operator sees how the knob moved the stage they are viewing.
buildProcessReplay — the new pure transform
Lives beside buildStageTimeline (no ctx, no I/O, no React; unit-tested directly). Returns:
stages: ProcessStage[]— the existingStageshape plus, for account-driven stages, the list oftouchedAccountIdsand each account’s effect for that stage.accounts: ReplayAccount[]— the full roster: every tradeline + public record, each with a stableaccountIdentifier, display label (creditorName), type, and a key figure.
Per-stage attribution sources
| Stage | Account-driven? | Source |
|---|---|---|
| base | no | portfolio scalar (file strength / time-in-business) |
| score | no | single FICO number |
| newCards | yes | reconstructed: revolving accounts opened within 12 months |
| derog | yes | authoritative: derogScoring.calculateDerogScore → DerogDetail[] |
| thinFile | yes | reconstructed: counted revolving accounts, tagged strong/weak |
| latePayment | yes | authoritative: lateScoring → LateDetail[] |
| unsecuredRecent | yes | reconstructed: unsecured installment accounts in recency bands |
| oldBankruptcy | yes | reconstructed: public records filed > 36 months ago |
| clamp | no | floor/ceiling math |
| range | no | dollar conversion |
Correctness: guarding against attribution drift
The reconstructed stages (newCards, thinFile, unsecuredRecent, oldBankruptcy) mirror engine classification client-side and could silently drift from the engine. Three mitigations:- One source of truth. Reuse the engine’s pure classification/parsing helpers (
creditMetricsexports,derogScoring,lateScoring) — never reimplement the rules. The replay imports them so it moves with the engine. The same parsed-tradeline preprocessing the engine uses feeds the scoring modules. - Reconciliation tests (the primary guard). Every reconstructed list must reconcile to an aggregate the engine already publishes on the decision: reconstructed new-card count
===decision.newCardsLast12Months; counted revolvers===filteredRevolvingCount; derog weighted points → multiplier===decision.derogMultiplier; etc. Any drift is a CI failure, not a wrong number on screen. - Fail-safe at runtime. If a stage cannot reconcile for a given report, render that stage’s aggregate reason without account chips rather than show attribution that might be wrong.
UX
- Transport controls: play / pause, step forward / back, restart, draggable scrub bar, speed selector (0.5× / 1× / 2×). Default pace deliberately gentle (~1–1.5s per stage).
- Per-step animation: the current stage node highlights and its multiplier folds into the running product; in the roster, the accounts that stage touched light up with their effect, the rest dim. Account-less stages dwell on the scalar with the roster all-dim (so “no accounts here” reads clearly).
- Two-way interaction: click a stage to jump to it; click an account to cross-highlight every stage it participated in (the single-account journey view).
- Accessibility: respect
prefers-reduced-motion(no auto-animation; controls become pure stepping). Timeline stays keyboard-navigable. Matches the console’s existing a11y posture.
Components & files
convex/lib/underwritingReviewReplay.ts— addbuildProcessReplay(andProcessStage/ReplayAccounttypes) alongsidebuildStageTimeline. Pure.app/admin/internal/underwriting-review/_components/ProcessReplay.tsx— NEW interactive component (timeline + roster + transport). Replaces the staticReplayStepperusage inside the Multiplier-replay card.ProcessReplayrenders its own reduced-motion mode (a non-animated, fully-stepped view of the same timeline + roster), so the standaloneReplaySteppercomponent is removed and its presentation folded intoProcessReplay.app/admin/internal/underwriting-review/page.tsx—ReportDetailappliesknobChangesto the selected report’s input (tweak-aware) and renders<ProcessReplay>.- Tests:
tests/underwritingReviewReplay.test.ts(or a sibling) — reconciliation assertions for each reconstructed stage against the decision’s published aggregates, plus structural assertions on the roster.
Testing
- Unit-test
buildProcessReplaydirectly with fixtures (the same pattern asbuildStageTimeline). - Reconciliation tests as described above are the load-bearing correctness guard.
- CI gates unchanged:
bun run lint,bunx tsc --noEmit,bun test.
Risks & mitigations
- Attribution drift (primary risk) — mitigated by reuse-not-reimplement + reconciliation tests + runtime fail-safe (above).
- Tradeline parsing reuse — the replay must feed the scoring modules the same parsed-tradeline shape the engine builds; reuse the engine’s preprocessing rather than parsing
reportDataindependently. - Performance — re-deriving on every knob change is cheap (one
runUnderwriting+ one pure transform per selected report), matching the cost the batch diff already pays.

