Underwriting Subsystem — Breakdown for the UI/UX Rework
Status: Working reference. Captured 2026-06-08 ahead of re-working how underwriting works in the UI/UX. Method: Three parallel thermo-nuclear code-quality audits (decisioning engine, operator UI/UX, review console), cross-checked against the source. File sizes and thefundingPath.ts deletion were verified directly.
Why this doc exists: the underwriting surface is large and context-heavy. This is the durable map so the rework doesn’t have to re-derive it.
The one-sentence problem. The engine already emits a clean, structuredworkflowobject (convex/lib/deriveWorkflow.ts), but the UI almost entirely ignores it and instead steers the whole post-underwriting experience by free-text string-matching on ~20 human-readable decision strings, re-parsed by 6+ non-identical matchers across 8+ surfaces. The rework’s center of gravity is makingworkflowload-bearing and deleting the string coupling.
1. The three layers
| Layer | Where | Role | Rework relevance |
|---|---|---|---|
| Decisioning engine | convex/lib/* + convex/underwriting.ts | Pure scoring + routing; persistence; operator mutations | Source of the data the UI renders; needs the dual-system collapsed |
| Operator UI/UX | components/funding/*, components/contact/*, components/processing/* + contact hooks | Surfaces decisions, drives approve/revise/park + the workflow checklist | Primary rework target |
| Review console | app/admin/internal/underwriting-review/* + lib/underwriting/* | Internal replay/tweak/calibration workbench | Secondary; shares the engine but assembles input separately |
2. End-to-end data flow (one credit report)
- HTTP ingress —
analyzeCreditReporthttpAction (convex/underwriting.ts:400) →analyzeCreditReportImpl(:423). This adapter is the only impure boundary: validates body, normalizes SSN/state, loads operatorcustomValues, resolves thresholds, sourcescreditDatafrom CRS (fresh pull) or the storedcreditReportRequestsrow (theuseStoredReportDatare-run path). - Threshold resolution —
resolveThresholds({ body, customValues })(convex/lib/resolveThresholds.ts:117) folds operator overrides into ~28 typed thresholds.buildRulesetVersionpins the run to<codeVersion>+<thresholdHash>. - Pure orchestration —
runUnderwriting(input)(convex/lib/runUnderwriting.ts:318), deterministic and side-effect-free:parseTradelines→splitBankruptcies→computeCreditMetrics(7 multipliers, ranges, paydowns, gates)- derog/late reconciliation via
Math.max(parser, authoritative)(runUnderwriting.ts:627) evaluateEstablished→evaluateTermLoan→evaluateCards→ income validation →applyHardStopFlags→getThinFileStrategyderiveWorkflow(...)→{ legacyFundingPath, workflow, systemTwoNextAction }determineFundingCategories(the parallel category engine) → credit-repair post-pass →reconcileSummaryRecommendation→buildUnderwritingTrace→mergeNextActions→buildResults
- Persistence —
storeResultsinternalMutation (convex/underwriting.ts:862) writes theunderwritingResultsrow: decision strings as columns,fullResultsasv.any(), the typedworkflowobject, andnextActions. De-dupes by(reportId, locationId); schedules aggregate rebuild + health-check + automation fan-out. - Side-effects —
syncDecisionToGhl(:272) pushes to GHL custom fields (best-effort). Operators later act via mutations (approveUnderwriting,parkUnderwriting,reviseUnderwriting, …).
UnderwritingInput (runUnderwriting.ts:61), UnderwritingDecision = ReturnType<typeof runUnderwriting>, the persisted UnderwritingResult (lib/types/underwriting-result.ts:177), and Workflow (deriveWorkflow.ts:59).
3. The decision taxonomy (the UI must render all of these)
This is the contract the UI consumes. There are five overlapping classification vocabularies plusnextActions and the parallel fundingCategories — the core UX problem.
reportStatus (headline card decision string)
Credit Not Found · Qualified · Qualified w/Paydowns (no space) · Qualified w/Paydowns/Inq Removal (no space) · Qualified w/ Paydowns (with space) · Qualified w/ Inq Removal · Qualified - Pending Seasoning · Doesn't Qualify · Manual Review - Borderline Score · Manual Review - Late Payments Pattern · Manual Review - Complex Profile
⚠️ Casing hazard is real and load-bearing.w/Paydowns(no space) andw/ Paydowns(with space) both ship.deriveWorkflowPathFromDecision(underwriting.ts:1736) andcanStartFundingWorkflow(useContactData.ts:694-704) each enumerate both spellings.ReviseUnderwritingDialog.tsx:43-58has anormalize*canonicalizer the rest of the app does not share.
tlDecision (term-loan decision string)
TL Qualified · TL Qualified - Higher DTI · TL Qualified w/ Inq Removal · 11× TL Manual Review - * · 7× TL Declined - * · Enter Personal Income for TL Decision · NA (set when reportStatus === "Credit Not Found", runUnderwriting.ts:869).
declineReason (card decline ladder, cardUnderwriting.ts)
No credit file found · Paydowns exceed preapproval · Recent Bankruptcy on File · Recent Severe Late Payment History · Credit Score Too Low · Too Many Collections/Charge-Offs · Too Many Newly Opened Accounts · Current card limits are too low · Too Many Late Payments · Lack of Credit History · Thin credit file (insufficient accounts) · Recent severe late payment history requires credit repair (lowercase, :383) · Collections with recent late payment history requires credit repair (:405)
workflow object (deriveWorkflow.ts) — the target model
primaryPaths:card_funding|term_loan|established_funding|parked|declineoverlays[].id:paydowns_required|inq_removal_required(eachappliesTo: card_funding | term_loan)parkedReason:pending_seasoning|recent_credit_activity|thin_file(+parkedCallbackDate)declineReason:repair_referral|no_fit|insufficient_income|duplicate_file|other- ⚠️ Only
repair_referralandno_fitare ever emitted.insufficient_income,duplicate_file,otherare dead enum members (no producer).
- ⚠️ Only
Legacy path strings (still consumed)
legacyFundingPath(infullResults):prime|near_prime|seasoning|mca_nuclear|credit_repair|thin_file— consumed bybuildUnderwritingTracerationale +aiUnderwritingprompts.workflowPath(schema column,schema.ts:307):prime|paydowns|inq_removal|seasoning— set at approve time byderiveWorkflowPathFromDecision(underwriting.ts:1731), which never returnsseasoning. DrivesFundingWorkflow.tsxtemplates.
nextActions[].action types (rendered by FundingRecommendations)
credit_repair · credit_repair_required · income_verification_required · starter_step · wait_for_seasoning · approved_aged_lates · borderline_score_approved · paydown_required · paydown_strategy · dispute_inquiries · complex_review · prime_approval · mca_option · tl_approved_low_income · tl_approved_seasoned_unsecured · tl_approved_moderate_dti
Funding category codes (funding-categories.ts:34)
1 · 1a · 1b · 2 · 2a · 2b · 3 · 4 · none — each with confidence, success probability, and a step sequence.
4. The dual-system tension (the heart of the rework)
Two generations of routing are emitted on every run, plus a third orthogonal category engine. ThederiveWorkflow.ts:9-12 header still claims workflow is “emitted but inert” — this is now stale; the UI reads part of it.
System A — legacy strings + nextActions (what the UI mostly reads)
reportStatus/effectiveCardsDecision→canStartFundingWorkflow+ the whole Creative workflow gate (useContactData.ts:693-704)workflowPath→FundingWorkflowtemplates (useContactData.ts:662-668)legacyFundingPath→ trace rationale + AI promptsnextActions[]→FundingRecommendationscards (withHARD_SUPPRESSOR_PRECEDENCE)mcaPossible/sbaPossible“Yes”/“No” strings → established fallback
System B — workflow object (only partially live)
- Read today (3 narrow spots):
workflow.primaryPathsforisParkedRouting(useContactData.ts:645-649) andhasEstablishedRouting(:728-732);workflow.parkedReason+parkedCallbackDatefor parked banners + email template selection (FundingSummaryCard,NextActionCard,SendParkedEmailDialog,ContactOverviewTab). - NOT read:
workflow.overlays(paydowns/inq-removal inferred fromreportStatusinstead),workflow.declineReason, andprimaryPathsordering / term-loan→card sequencing.
The reconciliation tax
Because both systems derive independently from the same strings, the engine keeps them consistent with substring reconciliation:reconcileSummaryRecommendation(runUnderwriting.ts:282-312) — overrides the category engine’s summary usingreportStatus.indexOf("Qualified"); carriesTODO(Phase 4)admitting it (:279).deriveLegacyFundingPath(deriveWorkflow.ts:190) — re-derives a path from stringsevaluateCardsalready produced.deriveSystemTwoNextAction— suppresses duplicate actions by scanningnextActions.
"...requires credit repair" decline reasons (cardUnderwriting.ts:383,405) are not in REPAIR_REFERRAL_DECLINE_REASONS (deriveWorkflow.ts:137-152), so those files route to no_fit instead of repair_referral.
5. How the UI renders today
Three independent renderers each re-derive “what to show” from the same data:FundingWorkflow.tsx(469) — static template lookup. Five hard-coded step arrays indexed by a 5-valueWorkflowPathValue(:178-184);getCurrentWorkflowStepreturns first incomplete step. No decision-string logic itself (path + completed-set driven).FundingRecommendations.tsx(903) — action-type dispatcher.ACTION_CONFIGkeyed bynextActions[].action(:95-576);HARD_SUPPRESSOR_PRECEDENCE(:714) letscredit_repair_required/complex_review/starter_steprender alone; caution-state swap (:784-829) mutatesprime_approvalin place into a warning card when a credit-repair flag co-fires (feature logic leaking into a generic renderer).NextActionCard.tsx(493) —getNextActionis a 12-branch if-waterfall (:99-411) mixing booleans and decision strings to pick one hero CTA; ordering is load-bearing; post-approval it delegates togetCurrentWorkflowStep(mirroringFundingWorkflow).StickyDecisionBaris a thin 4th renderer over the same booleans.
Surface inventory (operator-facing)
/contact(app/contact/page.tsx→ContactPageContent, 1202) — the underwriting decision surface (approve/revise/park + workflow). Partner/operator-facing, GHL iframe./admin/internal/contact— sameContactPageContentwithisAdmin(currently no rendering branches on it)./admin/internal/processing(page.tsx, 1575) — a separate god-component (InternalContactContent) that does NOT reuse the controller; read-only underwriting viaProcessingUnderwritingHero, but its action card runs a separateprocessingSubmission.statusstate machine. Shares no workflow code with/contact./dashboard—OperatorUnderwritingQueue(299).
Decision-string coupling map (what the rework must replace)
The same conceptual decision is parsed by ≥6 non-identical matchers plus a 7th canonicalizer:useContactData.ts:694-704—canStartFundingWorkflow, 7 literalQualified*variants (spaced + unspaced).useContactActions.ts:1031-1054—getCardsStatus(includes('Qualified'),includes('Paydown'), …).useContactActions.ts:1056-1083—getTermLoanStatus(startsWith('TL Qualified'), regex strip, …).NextActionCard.tsx:193,237,253—=== 'Credit Not Found' / "Doesn't Qualify"; parked-reason copy matched twice (:165-171,:378-384).FundingSummaryCard.tsx:213-216,231,668-706— decline banner +mca/sbaPossible === 'Yes'.OperatorUnderwritingQueue.tsx:116-145—shortDecision(9 literals incl."Doesnt Qualify"typo-tolerance) +decisionBadgeColor.ProcessingUnderwritingHero.tsx:28-53—getDecisionTone(a 4th, lowercased dialect).ReviseUnderwritingDialog.tsx:43-58—normalizeCardsDecision/normalizeTermLoanDecision(the unshared canonicalizer).
decisionBadgeColor(queue, case-sensitivestartsWith) andgetDecisionTone(hero, lowercasedincludes) classify the same strings into colors with different rules — they can disagree.
State & control flow
- Server (source of truth):
underwritingDoc.{operatorApprovalStatus, selectedFundingTypes, completedWorkflowSteps, operatorParked*}. - Client-derived (not persisted):
fundingWorkflowPathis recomputed inuseContactData.ts:684-691(becauseparkUnderwriting/approveEstablishedFundingdeliberately don’t set the legacyworkflowPath). “Which workflow am I in” is thus a client reconstruction over server state. - Ephemeral client UI state (lost on reload):
awaitingUnderwriting(7s/12ssetTimeoutpolling) andactiveTrack(dual-track switcher). - Workflow step transitions:
handleWorkflowActionClickis a giantswitch(stepId)(useContactActions.ts:1768-1856), but routing is split across two files —ContactPageContent.tsx:429-450intercepts 2 of ~12 step ids before delegating. The applicant→client gate is double-encoded (useContactActions.ts:1722-1735and:1775-1787).
6. The review console (secondary) + revise/re-underwrite
The console (app/admin/internal/underwriting-review) is an ephemeral, browser-side replay/tweak/calibration workbench. Nothing it computes is persisted to the engine; it persists only durable labels/flags/calibration proposals, and the actual engine change happens by pasting a generated Cursor prompt.
The override seam (ADR-0001): runUnderwriting/computeCreditMetrics accept an optional MultiplierOverrides defaulting to live constants, so a no-knob run is byte-identical (no ruleset bump). knobRegistry.ts is the declarative catalogue (each knob carries appliesTo + dotted path + the file/symbol where the default lives); batchEval.applyKnobsToInput is the single place that threads {thresholds, multiplierOverrides} onto an input. The persona guardrail re-runs 12 golden personas and compares the full serialized decision.results before commit.
Three differently-named operations (clarification — they are NOT variants):
app/api/credit-report/underwriting/route.ts— a READ (lists persisted results for a location). Misleadingly named.reviseUnderwritingmutation (underwriting.ts:1756) — operator override; does not re-run the engine, patchesoperatorOverridesstrings.app/api/credit-report/re-underwrite/route.ts→reUnderwriteCreditReport(creditReports.ts:2707) — the only true re-run-and-persist path; re-hydrates inputs, callsanalyzeCreditReportImplwithuseStoredReportData: true, persists viastoreResults.
7. Quality findings that will impede the rework (prioritized)
P1 — structural blockers
- Three parallel classification engines kept in sync by hand —
evaluateCards/evaluateTermLoan(strings) +deriveWorkflow(object) +determineFundingCategories(codes). The UI reads from all three because none is authoritative. This is the single biggest obstacle. convex/underwriting.ts= 2,894 lines — HTTP adapter + orchestration glue + persistence + CRS bureau I/O + GHL sync + ~18 operator mutations/queries. Should split:underwritingHttp/underwritingStore/operatorWorkflow/operatorQueue.useContactActions.ts= 1,925 lines — credit getters + re-underwrite + GHL writes + opportunity-stage transitions + every approve/revise/park/workflow handler + the decision-string parsers. The earlier “decomposition” re-bagged the god-hook rather than breaking it up (useContactData852 +useContactSideEffects629 + this 1,925).app/admin/internal/processing/page.tsx= 1,575 lines — inlines a 90-line contact-data merge + two near-identical ~85-line submission payload builders (copy-pasted) + all five tabs; duplicatesContactPageContentand shares nothing.- The controller “prop bag” —
useContactPageControllerreturns one ~150-field flat object that the composer warns “MUST stay byte-for-byte compatible.” Anti-abstraction: couples every consumer to one surface. - Other >1k-line files in the blast radius:
EstablishedWorkflowDialog.tsx(1,701),FundingPlanTable.tsx(1,252),underwriting-review/page.tsx(1,017).
P2 — coupling & boundary
- Decision strings are an untyped cross-boundary contract — no shared union/parser; 6 consumers each guess the format (hence spaced/unspaced duplication + the unshared
normalize*). - Brittle substring routing in
deriveWorkflow(.includes("Credit Repair"|"Seasoning"|"Paydown"|"Inq Removal")) with the confirmedREPAIR_REFERRAL_DECLINE_REASONSdrift. evaluateCards(~370-line nested if/else) with the same credit-repair emission block copy-pasted 5× (cardUnderwriting.ts:384-503).body: anyleaks untyped form data into the pure core (runUnderwriting.ts:84; read forincomeType,monthlyBankDeposits,businessDeposits, fallbackdti).- Console input drift — three separate
UnderwritingInputassembly recipes (production adapter, test fixture,reconstructInput.ts); the console’s is provably lossy (incomeType/deposits unrecoverable), and the “Reconciled / Differs” badge exists to paper over it. - Replay mirrors engine internals by copy (
underwritingReviewReplay.ts) —MULT_UNSEC_*, banding predicates, new-card/BK logic copied verbatim; drift silently degrades to “attribution unavailable.”
P3 — dead / inconsistent / stale
- Dead enum members:
workflow.declineReason(insufficient_income/duplicate_file/other),workflowPathseasoning,detectChargeCards,THIN_MULT_CONFIGternary returning0.5either way (creditMetrics.ts:367). buildPathRationale(buildUnderwritingTrace.ts:341-377) narrates the legacyfundingPathand knowingly mis-describesparked/recent_credit_activityas “Credit repair path.”- Magic DTI/score numbers inline in
evaluateTermLoan(not sourced fromFUNDING_THRESHOLDS).
8. Recommended sequencing for the rework (“code-judo” first)
The data already exists in the right shape (workflow); the UI just doesn’t trust it. Highest-leverage moves, roughly in order:
- Make
workflowthe single authoritative routing model. HaveevaluateCards/evaluateTermLoanreturn typed verdicts that feedderiveWorkflowdirectly; route ALL UI rendering offworkflow.{primaryPaths, overlays, parkedReason, declineReason}. This deletesreconcileSummaryRecommendation,deriveLegacyFundingPath,deriveWorkflowPathFromDecision, the 6 string matchers, and the spaced/unspaced duplication. - Split the decision string into
{ code: enum, label: string }— typed machine value + separate display-label map. Kills the casing hazard and lets the UI restyle copy without.includes()parsing. - One typed
WorkflowStatederivation, ideally server-side on the doc — eliminates the client-sidefundingWorkflowPathreconstruction;NextActionCard/StickyDecisionBar/FundingSummaryCard/FundingWorkflowrender from one object; collapsesdecisionBadgeColor/getDecisionTone/shortDecisioninto one tone function. - Replace
NextActionCard.getNextAction’s 12-branch waterfall with a dispatch keyed off workflow state (it already delegates post-approval). - Break
useContactActionsalong seams (credit getters / GHL writes / workflow handlers) and lift the decision parsers into the shared model; collapse the double-encoded client-status gate; unify the split step→action routing into one declarativestepId → handler/tabtable. - Decompose
convex/underwriting.tsinto adapter/store/operator-workflow/queue modules (the operator mutations are what the UI rework touches most). - Engine hygiene (enabling): extract the 5× credit-repair emission into one helper; hoist the four
body: anyreads into typedUnderwritingInputfields; update trace narration to the new taxonomy. - Console (when convenient): a single shared
buildUnderwritingInputcontract for production/tests/console; emit per-account attribution fromrunUnderwriting’s trace so the replay reads instead of mirrors; extractReportDetail/chrome out of the 1,017-line page.

