Underwriting — Visual System Map
Status: Visual companion tounderwriting-uiux-rework-breakdown.md. Captured 2026-06-08 — this is the pre-rework audit snapshot; treat diagrams as historical where superseded.
Updated 2026-06-11 — what has changed since capture (seeunderwriting-rework-plan.mdfor detail): Phases 0–3 are merged. The typedcardStatus/termLoanStatus/declineStatuscodes are routing-authoritative; the UI surfaces below (NextActionCard,FundingSummaryCard,OperatorUnderwritingQueue,ProcessingUnderwritingHero) now readderiveWorkflowState(lib/underwriting/workflowState.ts) instead of string-matching (#929/#930). The duplicate paydowns strings are collapsed at emission (#967).fundingWorkflowPathis now persisted server-side onunderwritingResultsand consumed byuseContactData+lib/chat/tool-handlers.tswith client fallback (#969) — prod backfill still pending.reconcileSummaryRecommendation/summaryRecommendationare deleted (#970). LegacyworkflowPath/legacyFundingPathcolumns andnormalize*canonicalizers still exist pending Phase 5. Purpose: See every system/process in the underwriting pipeline, what each one emits, concrete worked examples of how they combine, and exactly where the code lives. How to read: the Mermaid diagrams render in Cursor’s Markdown preview (open this file, ⇧⌘V). Code references usepath:line.
Mental model. Underwriting is one pure function (runUnderwriting) that runs a sequence of independent evaluators, each answering one question and emitting its own label. A router (deriveWorkflow) then tries to fold those labels into one structuredworkflowobject. The labels are also kept (legacy), so the same file ends up described by 5 vocabularies at once. The UI then re-parses those labels with string matching. That fan-out is the thing the rework collapses.
1. The whole pipeline at a glance
Inside runUnderwriting — the evaluator sequence
🔶 Two routers run on every file.deriveWorkflow()(the structured model) anddetermineFundingCategories()(the parallel category engine) both classify the same file. The oldreconcileSummaryRecommendation()persistence shim was deleted 2026-06-11 (PR #970) once everysummaryRecommendationconsumer was gone — routing authority lives inworkflow, andfundingCategoriesis presentation-only.
2. The systems/processes — one card each
Each “system” is an independent process with its own question, code home, and output label.System 1 — Cards / Revolving evaluator
| Question | Does this file qualify for 0% card stacking, and under what conditions? |
| Code | convex/lib/cardUnderwriting.ts:149 (evaluateCards) |
| Emits | reportStatus (headline string), declineReason, creditRepairFlag, card nextActions |
| Shape | ~370-line nested if/else; the most complex branch in the engine |
| Possible outputs | Qualified · Qualified w/ Paydowns · Qualified w/Paydowns (no space) · Qualified w/ Inq Removal · Qualified w/Paydowns/Inq Removal · Qualified - Pending Seasoning · Doesn't Qualify (+ a declineReason) · Manual Review - Borderline Score · Manual Review - Late Payments Pattern · Manual Review - Complex Profile · Credit Not Found |
System 2 — Term Loan evaluator
| Question | Does this person qualify for a personal term loan (income + DTI + installment history)? |
| Code | convex/lib/termLoanUnderwriting.ts:66 (evaluateTermLoan) |
| Emits | tlDecision string, tl_* next actions |
| Shape | early-return guard ladder → DTI-band tree |
| Possible outputs | 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 |
System 3 — Established (MCA / SBA) gates
| Question | Is the business eligible for MCA and/or SBA products? |
| Code | convex/lib/establishedUnderwriting.ts:32 (evaluateEstablished) |
| Emits | mcaEligible: boolean, sbaEligible: boolean |
| Rules | MCA: FICO ≥ FICO_MCA + revenue ≥ MIN_REVENUE_MCA + TIB ≥ 6mo. SBA: FICO ≥ FICO_SBA + TIB = “2 years +” + revenue ≥ MIN_REVENUE_SBA |
System 4 — Credit Repair flag (orthogonal)
| Question | Would credit repair meaningfully improve this file’s outcome? |
| Code | creditRepairFlag.ts (buildCreditRepairFlag), set inside evaluateCards (5 branches) + a post-pass in runUnderwriting.ts:1072 (resolveCreditRepairPostPass) |
| Emits | creditRepairFlag ({severity, items[], timeframe, note}) → stored as creditRepairRecommended + creditRepairFlag on the row (schema.ts:250-251); also a credit_repair/credit_repair_required next action |
| Note | This is NOT a GHL tag — it’s a flag on the underwriting doc + a recommendation card. It is orthogonal to the funding decision (a file can be Qualified w/ Paydowns AND carry a credit-repair flag). |
System 5 — Workflow router (the target model)
| Question | Given all the above labels, what workflow does this file belong in? |
| Code | convex/lib/deriveWorkflow.ts:490 (deriveWorkflow) |
| Emits | workflow object + legacyFundingPath string + systemTwoNextAction |
workflow shape | { primaryPaths[], overlays[], declineReason?, parkedReason?, parkedCallbackDate? } or null (pre-workflow) |
| primaryPaths | card_funding · term_loan · established_funding · parked · decline |
| overlays | paydowns_required · inq_removal_required |
| parkedReason | pending_seasoning · recent_credit_activity · thin_file |
| declineReason | repair_referral · no_fit (only these two are emitted; the other 3 enum values are dead) |
System 6 — Funding Categories (parallel classifier)
| Question | Which marketing/funding “category” (1, 1a, 2, 3, 4…) does this file present as, and what’s the success probability? |
| Code | convex/lib/fundingCategories.ts (determineFundingCategories) |
| Emits | category codes, primaryTrack (presentation fields for chat/UI) |
| Tension | Runs independently of Systems 1–5; presentation-only since Phase 2 — routing authority is workflow, not category codes |
3. The Cards decision tree (System 1, visualized)
This is the single most important — and most complex — branch. Source:cardUnderwriting.ts:240-609. Simplified to the decision spine:
The decline-reason ladder (cardUnderwriting.ts:306-327)
When the hard-DQ gate fires, the reason is the first match, strongest signal first:
4. How labels become a workflow (System 5 mapping)
deriveWorkflow() reads the evaluator labels and maps them. Order is load-bearing.
⚠️ Confirmed drift. The two card decline reasons"Recent severe late payment history requires credit repair"and"Collections with recent late payment history requires credit repair"(cardUnderwriting.ts:383,405) are not inREPAIR_REFERRAL_DECLINE_REASONS(deriveWorkflow.ts:137-143). Those files fall through todecline / no_fitinstead ofrepair_referral— the credit-repair routing is silently lost for exactly the files that need it.
Status → workflow quick-reference
| reportStatus / situation | primaryPaths | overlay | parked/decline reason |
|---|---|---|---|
Qualified | [card_funding] | — | — |
Qualified w/ Paydowns | [card_funding] | paydowns_required | — |
Qualified w/ Inq Removal | [card_funding] | inq_removal_required | — |
Qualified + TL Qualified | [term_loan, card_funding] | — | — |
Qualified - Pending Seasoning | [parked] | — | pending_seasoning |
Doesn't Qualify + Too Many Newly Opened | [parked] | — | recent_credit_activity |
Doesn't Qualify + Lack of History/Thin | [parked] | — | thin_file |
Doesn't Qualify + BK/Severe Lates/Low Score | [decline] | — | repair_referral |
Doesn't Qualify + Card limits too low | [decline] | — | no_fit |
Credit Not Found / Manual Review - * | null | — | (operator acts first) |
| MCA/SBA eligible (alone) | [established_funding] | — | — |
5. Worked end-to-end examples
Each example shows: the file → what every system emits → what gets stored → what the UI renders. This is the “System y says X and System x adds Y” view you asked for.Example A — “Qualified w/ Paydowns for cards, credit-repair flagged, term loan also qualifies”
Input: FICO 690, util 55% (over-utilized but under threshold), 1 old 30-day late within 24mo, income $120k, DTI 38%, 1 open installment, business: no revenue data. What the UI does today (/contact):
canStartFundingWorkflow= true (matches'Qualified w/ Paydowns'literal,useContactData.ts:694-704).FundingRecommendationsrenders thepaydown_requiredcard; the co-firingcredit_repairadvisory is merged into the primary card (SOFT_MERGE_PRIMARIES,FundingRecommendations.tsx:723).getCardsStatus→ “Qualified w/ Paydowns” pill;getTermLoanStatus→ “Qualified” pill (useContactActions.ts:1031-1083).- ⚠️ The UI does not read
workflow.overlaysor theterm_loan → card_fundingsequencing — it reconstructs the path client-side from the strings +selectedFundingTypes.
Example B — “Recent bankruptcy → decline, but term loan still qualifies” (cross-track non-exclusivity)
Input: FICO 700, BK filed 30 months ago (recent), income $130k, DTI 35%, clean installment history. Why it matters for the UI: the structuredworkflow says decline, but the raw tlDecision string says qualified. A UI that reads workflow and a UI that reads tlDecision would show opposite things. (Decision log entry 2026-04-27 in funding-workflow-paths.md documents this is intentional, not a bug.)
Example C — “Pending Seasoning → parked” (qualified-but-wait)
Input: FICO 760, 2 revolving accounts, avg age 7 months, no derogs, no lates.Example D — “Thin file” vs “Manual Review” (two different parked/null outcomes)
Input 1 (thin): 1 revolving account, 14 months old, score 710, no derogs → cards falls toDoesn't Qualify / Lack of Credit History → parked / thin_file (callback +6mo).
Input 2 (complex): score 672, mixed recent lates + high util + 3 new cards, none individually blocking → cards Manual Review - Complex Profile → workflow = null (operator must revise/override before any workflow applies).
6. Where every output lives, and who reads it
Field → consumer table
| Output | Stored as | Read by (file:line) | Notes |
|---|---|---|---|
reportStatus | underwritingDecision column | useContactData.ts:693, useContactActions.ts:1031, NextActionCard:193, FundingSummaryCard:213, OperatorUnderwritingQueue:116, ProcessingHero:28 | 6 different string matchers |
tlDecision | termLoanDecision column | useContactActions.ts:1056-1083 | regex strip + startsWith |
workflow | workflow object column (schema.ts:323) | only useContactData.ts:645-649, 728-732 | mostly unread |
workflow.parkedReason | inside workflow | FundingSummaryCard:244, NextActionCard:165, SendParkedEmailDialog:70 | parked email template select |
workflowPath | workflowPath column | useContactData.ts:662-668 → FundingWorkflow | set at approve time, not by deriveWorkflow |
legacyFundingPath | inside fullResults | buildUnderwritingTrace, aiUnderwriting.ts:121 | trace narration (stale for parked) |
nextActions[] | nextActions column | FundingRecommendations.tsx | action-type dispatcher |
creditRepairFlag | creditRepairFlag + creditRepairRecommended | AddressTheseFirstCard, recommendation merge | orthogonal flag, not a tag |
7. The dual-system overlay (why the rework exists)
The same file is described 5 ways simultaneously. The UI re-derives meaning from whichever it happens to trust per surface: The rework’s thesis (green path): makeworkflow (V5) authoritative, render everything off it, and delete V1–V4 string coupling. The structured model already exists; the UI just doesn’t trust it yet. See underwriting-uiux-rework-breakdown.md §8 for the sequencing.
8. Code-location index (jump table)
| What | Where |
|---|---|
| HTTP entry + adapter | convex/underwriting.ts:400 / :423 |
| Pure orchestrator | convex/lib/runUnderwriting.ts:318 |
| Threshold resolution | convex/lib/resolveThresholds.ts:117 |
| Tradeline parsing | convex/lib/tradelineParser.ts:140 |
| Credit metrics / multipliers | convex/lib/creditMetrics.ts:195 |
| Cards evaluator | convex/lib/cardUnderwriting.ts:149 |
| Term loan evaluator | convex/lib/termLoanUnderwriting.ts:66 |
| Established (MCA/SBA) gates | convex/lib/establishedUnderwriting.ts:32 |
| Credit repair flag | convex/lib/creditRepairFlag.ts + runUnderwriting.ts:1072 |
| Workflow router | convex/lib/deriveWorkflow.ts:490 |
| Parallel category engine | convex/lib/fundingCategories.ts |
| Summary reconciliation | convex/lib/runUnderwriting.ts:282 |
| Trace narration | convex/lib/buildUnderwritingTrace.ts:341 |
| Persistence | convex/underwriting.ts:862 (storeResults) |
| GHL field sync | convex/underwriting.ts:272 (syncDecisionToGhl) |
| Operator mutations | convex/underwriting.ts:1756+ (approve/park/revise/decline/queue) |
| Result schema | convex/schema.ts:240+ (underwritingResults) |
| Public result type | lib/types/underwriting-result.ts:177 |
| Thresholds + multiplier config | lib/types/funding-categories.ts |
| UI: contact data hook | components/contact/hooks/useContactData.ts (852) |
| UI: contact actions hook | components/contact/hooks/useContactActions.ts (1925) |
| UI: next-action hero | components/contact/NextActionCard.tsx |
| UI: recommendation cards | components/funding/FundingRecommendations.tsx |
| UI: workflow checklist | components/funding/FundingWorkflow.tsx |
| UI: summary + product rows | components/funding/FundingSummaryCard.tsx / components/contact/FundingSummaryCard.tsx |
| UI: operator queue | components/funding/OperatorUnderwritingQueue.tsx |
| UI: processing (read-only) | components/processing/ProcessingUnderwritingHero.tsx |
| Review console | app/admin/internal/underwriting-review/page.tsx + _components/* |
| Console replay engine | convex/lib/underwritingReviewReplay.ts |
9. Cross-references
docs/design/underwriting-uiux-rework-breakdown.md— the prose audit + prioritized findings + rework sequencing.docs/funding-workflow-paths.md— the intended products+overlays design model and full Decisions Log.docs/adr/0001-underwriting-whatif-override-seam.md— the multiplier-override seam used by the review console.

