> ## Documentation Index
> Fetch the complete documentation index at: https://docs.myfundingmachine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Underwriting uiux rework breakdown

# 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

| 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)

1. **HTTP ingress** — `analyzeCreditReport` 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 resolution** — `resolveThresholds({ body, customValues })` (`convex/lib/resolveThresholds.ts:117`) folds operator overrides into \~28 typed thresholds. `buildRulesetVersion` pins the run to `<codeVersion>+<thresholdHash>`.
3. **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` → `getThinFileStrategy`
   * `deriveWorkflow(...)` → `{ legacyFundingPath, workflow, systemTwoNextAction }`
   * `determineFundingCategories` (the **parallel** category engine) → credit-repair post-pass → `reconcileSummaryRecommendation` → `buildUnderwritingTrace` → `mergeNextActions` → `buildResults`
4. **Persistence** — `storeResults` 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-effects** — `syncDecisionToGhl` (`: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`/`effectiveCardsDecision` → `canStartFundingWorkflow` + the whole Creative workflow gate (`useContactData.ts:693-704`)
* `workflowPath` → `FundingWorkflow` 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.tsx` → `ContactPageContent`, 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`.
* **`/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 literal `Qualified*` 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-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 files** — `ContactPageContent.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.ts` → `reUnderwriteCreditReport` (`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 hand** — `evaluateCards`/`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

7. **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*`).
8. **Brittle substring routing** in `deriveWorkflow` (`.includes("Credit Repair"|"Seasoning"|"Paydown"|"Inq Removal")`) with the confirmed `REPAIR_REFERRAL_DECLINE_REASONS` drift.
9. **`evaluateCards` (\~370-line nested if/else)** with the same credit-repair emission block **copy-pasted 5×** (`cardUnderwriting.ts:384-503`).
10. **`body: any` leaks untyped form data into the pure core** (`runUnderwriting.ts:84`; read for `incomeType`, `monthlyBankDeposits`, `businessDeposits`, fallback `dti`).
11. **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.
12. **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

13. 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`).
14. `buildPathRationale` (`buildUnderwritingTrace.ts:341-377`) narrates the legacy `fundingPath` and knowingly mis-describes `parked/recent_credit_activity` as "Credit repair path."
15. Magic DTI/score numbers inline in `evaluateTermLoan` (not sourced from `FUNDING_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:

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`](8ca3d6df-9406-4ab7-b585-b6b36440ff28), UI/UX [`642fea29`](642fea29-d078-45b1-85e2-c1e9568e1e44), console [`fd645e1a`](fd645e1a-5664-44c4-97d0-516cee64cc73).
