Skip to main content

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 the fundingPath.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, structured workflow object (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 making workflow load-bearing and deleting the string coupling.

1. The three layers

LayerWhereRoleRework relevance
Decisioning engineconvex/lib/* + convex/underwriting.tsPure scoring + routing; persistence; operator mutationsSource of the data the UI renders; needs the dual-system collapsed
Operator UI/UXcomponents/funding/*, components/contact/*, components/processing/* + contact hooksSurfaces decisions, drives approve/revise/park + the workflow checklistPrimary rework target
Review consoleapp/admin/internal/underwriting-review/* + lib/underwriting/*Internal replay/tweak/calibration workbenchSecondary; shares the engine but assembles input separately

2. End-to-end data flow (one credit report)

  1. HTTP ingressanalyzeCreditReport httpAction (convex/underwriting.ts:400) → analyzeCreditReportImpl (:423). This adapter is the only impure boundary: validates body, normalizes SSN/state, loads operator customValues, resolves thresholds, sources creditData from CRS (fresh pull) or the stored creditReportRequests row (the useStoredReportData re-run path).
  2. Threshold resolutionresolveThresholds({ body, customValues }) (convex/lib/resolveThresholds.ts:117) folds operator overrides into ~28 typed thresholds. buildRulesetVersion pins the run to <codeVersion>+<thresholdHash>.
  3. Pure orchestrationrunUnderwriting(input) (convex/lib/runUnderwriting.ts:318), deterministic and side-effect-free:
    • parseTradelinessplitBankruptciescomputeCreditMetrics (7 multipliers, ranges, paydowns, gates)
    • derog/late reconciliation via Math.max(parser, authoritative) (runUnderwriting.ts:627)
    • evaluateEstablishedevaluateTermLoanevaluateCards → income validation → applyHardStopFlagsgetThinFileStrategy
    • deriveWorkflow(...){ legacyFundingPath, workflow, systemTwoNextAction }
    • determineFundingCategories (the parallel category engine) → credit-repair post-pass → reconcileSummaryRecommendationbuildUnderwritingTracemergeNextActionsbuildResults
  4. PersistencestoreResults internalMutation (convex/underwriting.ts:862) writes the underwritingResults row: decision strings as columns, fullResults as v.any(), the typed workflow object, and nextActions. De-dupes by (reportId, locationId); schedules aggregate rebuild + health-check + automation fan-out.
  5. Side-effectssyncDecisionToGhl (:272) pushes to GHL custom fields (best-effort). Operators later act via mutations (approveUnderwriting, parkUnderwriting, reviseUnderwriting, …).
Key types: 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 plus nextActions 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) and w/ Paydowns (with space) both ship. deriveWorkflowPathFromDecision (underwriting.ts:1736) and canStartFundingWorkflow (useContactData.ts:694-704) each enumerate both spellings. ReviseUnderwritingDialog.tsx:43-58 has a normalize* 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 | decline
  • overlays[].id: paydowns_required | inq_removal_required (each appliesTo: 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_referral and no_fit are ever emitted. insufficient_income, duplicate_file, other are dead enum members (no producer).

Legacy path strings (still consumed)

  • legacyFundingPath (in fullResults): prime | near_prime | seasoning | mca_nuclear | credit_repair | thin_file — consumed by buildUnderwritingTrace rationale + aiUnderwriting prompts.
  • workflowPath (schema column, schema.ts:307): prime | paydowns | inq_removal | seasoning — set at approve time by deriveWorkflowPathFromDecision (underwriting.ts:1731), which never returns seasoning. Drives FundingWorkflow.tsx templates.

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. The deriveWorkflow.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/effectiveCardsDecisioncanStartFundingWorkflow + the whole Creative workflow gate (useContactData.ts:693-704)
  • workflowPathFundingWorkflow templates (useContactData.ts:662-668)
  • legacyFundingPath → trace rationale + AI prompts
  • nextActions[]FundingRecommendations cards (with HARD_SUPPRESSOR_PRECEDENCE)
  • mcaPossible/sbaPossible “Yes”/“No” strings → established fallback

System B — workflow object (only partially live)

  • Read today (3 narrow spots): workflow.primaryPaths for isParkedRouting (useContactData.ts:645-649) and hasEstablishedRouting (:728-732); workflow.parkedReason + parkedCallbackDate for parked banners + email template selection (FundingSummaryCard, NextActionCard, SendParkedEmailDialog, ContactOverviewTab).
  • NOT read: workflow.overlays (paydowns/inq-removal inferred from reportStatus instead), workflow.declineReason, and primaryPaths ordering / 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 using reportStatus.indexOf("Qualified"); carries TODO(Phase 4) admitting it (:279).
  • deriveLegacyFundingPath (deriveWorkflow.ts:190) — re-derives a path from strings evaluateCards already produced.
  • deriveSystemTwoNextAction — suppresses duplicate actions by scanning nextActions.
Each is a place a string rename or stray space silently misroutes. Confirmed live drift: the two "...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:
  1. FundingWorkflow.tsx (469) — static template lookup. Five hard-coded step arrays indexed by a 5-value WorkflowPathValue (:178-184); getCurrentWorkflowStep returns first incomplete step. No decision-string logic itself (path + completed-set driven).
  2. FundingRecommendations.tsx (903) — action-type dispatcher. ACTION_CONFIG keyed by nextActions[].action (:95-576); HARD_SUPPRESSOR_PRECEDENCE (:714) lets credit_repair_required/complex_review/starter_step render alone; caution-state swap (:784-829) mutates prime_approval in place into a warning card when a credit-repair flag co-fires (feature logic leaking into a generic renderer).
  3. NextActionCard.tsx (493)getNextAction is 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 to getCurrentWorkflowStep (mirroring FundingWorkflow). StickyDecisionBar is a thin 4th renderer over the same booleans.

Surface inventory (operator-facing)

  • /contact (app/contact/page.tsxContactPageContent, 1202) — the underwriting decision surface (approve/revise/park + workflow). Partner/operator-facing, GHL iframe.
  • /admin/internal/contact — same ContactPageContent with isAdmin (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 via ProcessingUnderwritingHero, but its action card runs a separate processingSubmission.status state machine. Shares no workflow code with /contact.
  • /dashboardOperatorUnderwritingQueue (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-704canStartFundingWorkflow, 7 literal Qualified* variants (spaced + unspaced).
  • useContactActions.ts:1031-1054getCardsStatus (includes('Qualified'), includes('Paydown'), …).
  • useContactActions.ts:1056-1083getTermLoanStatus (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-145shortDecision (9 literals incl. "Doesnt Qualify" typo-tolerance) + decisionBadgeColor.
  • ProcessingUnderwritingHero.tsx:28-53getDecisionTone (a 4th, lowercased dialect).
  • ReviseUnderwritingDialog.tsx:43-58normalizeCardsDecision/normalizeTermLoanDecision (the unshared canonicalizer).
decisionBadgeColor (queue, case-sensitive startsWith) and getDecisionTone (hero, lowercased includes) 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): fundingWorkflowPath is recomputed in useContactData.ts:684-691 (because parkUnderwriting/approveEstablishedFunding deliberately don’t set the legacy workflowPath). “Which workflow am I in” is thus a client reconstruction over server state.
  • Ephemeral client UI state (lost on reload): awaitingUnderwriting (7s/12s setTimeout polling) and activeTrack (dual-track switcher).
  • Workflow step transitions: handleWorkflowActionClick is a giant switch(stepId) (useContactActions.ts:1768-1856), but routing is split across two filesContactPageContent.tsx:429-450 intercepts 2 of ~12 step ids before delegating. The applicant→client gate is double-encoded (useContactActions.ts:1722-1735 and :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.
  • reviseUnderwriting mutation (underwriting.ts:1756) — operator override; does not re-run the engine, patches operatorOverrides strings.
  • app/api/credit-report/re-underwrite/route.tsreUnderwriteCreditReport (creditReports.ts:2707) — the only true re-run-and-persist path; re-hydrates inputs, calls analyzeCreditReportImpl with useStoredReportData: true, persists via storeResults.

7. Quality findings that will impede the rework (prioritized)

P1 — structural blockers

  1. Three parallel classification engines kept in sync by handevaluateCards/evaluateTermLoan (strings) + deriveWorkflow (object) + determineFundingCategories (codes). The UI reads from all three because none is authoritative. This is the single biggest obstacle.
  2. 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.
  3. 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 (useContactData 852 + useContactSideEffects 629 + this 1,925).
  4. 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; duplicates ContactPageContent and shares nothing.
  5. The controller “prop bag”useContactPageController returns one ~150-field flat object that the composer warns “MUST stay byte-for-byte compatible.” Anti-abstraction: couples every consumer to one surface.
  6. 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

  1. 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*).
  2. Brittle substring routing in deriveWorkflow (.includes("Credit Repair"|"Seasoning"|"Paydown"|"Inq Removal")) with the confirmed REPAIR_REFERRAL_DECLINE_REASONS drift.
  3. evaluateCards (~370-line nested if/else) with the same credit-repair emission block copy-pasted 5× (cardUnderwriting.ts:384-503).
  4. body: any leaks untyped form data into the pure core (runUnderwriting.ts:84; read for incomeType, monthlyBankDeposits, businessDeposits, fallback dti).
  5. Console input drift — three separate UnderwritingInput assembly 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.
  6. 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

  1. Dead enum members: workflow.declineReason (insufficient_income/duplicate_file/other), workflowPath seasoning, detectChargeCards, THIN_MULT_CONFIG ternary returning 0.5 either way (creditMetrics.ts:367).
  2. buildPathRationale (buildUnderwritingTrace.ts:341-377) narrates the legacy fundingPath and knowingly mis-describes parked/recent_credit_activity as “Credit repair path.”
  3. Magic DTI/score numbers inline in evaluateTermLoan (not sourced from FUNDING_THRESHOLDS).

The data already exists in the right shape (workflow); the UI just doesn’t trust it. Highest-leverage moves, roughly in order:
  1. Make workflow the single authoritative routing model. Have evaluateCards/evaluateTermLoan return typed verdicts that feed deriveWorkflow directly; route ALL UI rendering off workflow.{primaryPaths, overlays, parkedReason, declineReason}. This deletes reconcileSummaryRecommendation, deriveLegacyFundingPath, deriveWorkflowPathFromDecision, the 6 string matchers, and the spaced/unspaced duplication.
  2. 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.
  3. One typed WorkflowState derivation, ideally server-side on the doc — eliminates the client-side fundingWorkflowPath reconstruction; NextActionCard/StickyDecisionBar/FundingSummaryCard/FundingWorkflow render from one object; collapses decisionBadgeColor/getDecisionTone/shortDecision into one tone function.
  4. Replace NextActionCard.getNextAction’s 12-branch waterfall with a dispatch keyed off workflow state (it already delegates post-approval).
  5. Break useContactActions along 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 declarative stepId → handler/tab table.
  6. Decompose convex/underwriting.ts into adapter/store/operator-workflow/queue modules (the operator mutations are what the UI rework touches most).
  7. Engine hygiene (enabling): extract the 5× credit-repair emission into one helper; hoist the four body: any reads into typed UnderwritingInput fields; update trace narration to the new taxonomy.
  8. Console (when convenient): a single shared buildUnderwritingInput contract for production/tests/console; emit per-account attribution from runUnderwriting’s trace so the replay reads instead of mirrors; extract ReportDetail/chrome out of the 1,017-line page.

9. Cross-references

  • docs/funding-workflow-paths.md — the products+overlays design model and the full Decisions Log (the intended workflow semantics).
  • docs/adr/0001-underwriting-whatif-override-seam.md — the multiplier-override seam.
  • Audit agents: engine 8ca3d6df, UI/UX 642fea29, console fd645e1a.