Skip to main content

Underwriting Simplification + UI/UX Rework — Execution Plan (live)

Companion docs: underwriting-system-map.md (visual map + code-location jump table) and underwriting-uiux-rework-breakdown.md (prose audit). This file is the live execution doc — update the status as phases land.

Approach (locked)

  • Behavior: consolidate where it clearly simplifies; each change reviewed via golden-snapshot diff.
  • Sequencing: engine-first, additive (strangler) → UI migration per surface → delete legacy. Not product vertical slices. Each phase is its own branch/PR.
  • fundingCategoriespresentation-only (still stored; its routing authority is removed). reconcileSummaryRecommendation is ✅ DELETED (2026-06-11) along with the summaryRecommendation results field it produced — every consumer of the headline was confirmed gone (AI prompt feed removed in Phase 2; chat tools read presentation fields only; the sole /underwriting/analyze caller discards the response body; no UI/analytics reader). The field was removed outright rather than surfacing the category engine’s verbatim headline, eliminating the contradiction risk that made the original 3507a396 deletion premature.
  • Card-type captured at approval (cardFundingCategory, typed) → pre-fills the 7F submit dialog.
  • Execution method: subagent-driven-development — fresh implementer subagent per task, then two-stage review (spec compliance → code quality) before each task is marked done.

Status at a glance

PhaseStatusBranch / PR
0 — Golden-snapshot safety net✅ DONE (merged)PR #912 (feat/underwriting-typed-model-foundation)
1 — Workflow authoritative (typed status, additive)✅ DONE (merged)same branch/PR #912
2 — Engine consolidation✅ DONE (merged)PR #918 (feat/underwriting-phase2-engine-consolidation)
3 — UI migration (surface slices)✅ DONE (merged 2026-06-10/11)PRs #929, #930, #951, #967, #969, #970
3-cardtype — Card-type at approval⏳ pending — next up
4 — Decompose god-files⏳ pending
5 — Delete legacy⏳ pending (unblocked once prod backfill runs — see Phase 5)
6 — Align periphery⏳ pending
PR #912 (feat/underwriting-typed-model-foundation) bundled Phase 0 + Phase 1 (foundation + engine typed-model); PR #918 (feat/underwriting-phase2-engine-consolidation) is Phase 2 (engine consolidation). Phase 3 landed as six PRs (see its section). All merged to main. All commits authored Brock Jeppesen <brock@7figures.com>. Full suite green, tsc/lint clean. ⚠️ OUTSTANDING OPERATIONAL STEP (from PR #969): run the production backfill so pre-existing underwritingResults rows get the new persisted fundingWorkflowPath field — bunx convex run oneOffMigrations:backfillFundingWorkflowPath --prod (idempotent, paginated; rerun until it reports hasMore: false). This is a prerequisite for Phase 5’s workflowPath column deletion.

Phase 0 — Safety net ✅ (commits on PR #912)

  • 201323a5 — golden snapshots now pin top-level decision strings (reportStatus/tlDecision/declineReason) and the structured workflow object across all 12 personas (the existing suite only pinned decision.results). This is the review gate every later consolidation diffs against. Only the non-deterministic workflow.parkedCallbackDate is stripped (live-Date epoch millis).
  • 45e98b0c — committed the companion design docs.

Phase 1 — Workflow authoritative (ADDITIVE) ✅ (commits on PR #912)

Design decision (locked): the typed status code is the routing authority; the legacy decision strings become display labels. (Chosen over a central string→code classifier, which would leave strings authoritative.)
  • 978c2cbebug fix (was the in-flight bug): card credit-repair declines emit declineReason strings ("Recent severe late payment history requires credit repair" / "Collections with recent late payment history requires credit repair", cardUnderwriting.ts:383/405) that were missing from REPAIR_REFERRAL_DECLINE_REASONS (deriveWorkflow.ts), so they fell through to decline/no_fit instead of decline/repair_referral. Added the exact strings + regression test. (Interim string-match; superseded by typed verdicts.)
  • 1b1b0b3ctyped verdicts: new pure module convex/lib/underwritingStatus.ts with CardStatusCode / TermLoanStatusCode unions + CardStatus/TermLoanStatus {code,label}. evaluateCards / evaluateTermLoan now emit status: {code,label} alongside the untouched legacy reportStatus/tlDecision. Card code derived via an exhaustive classifyCardStatus switch in the evaluator (throws on unmapped string); TL via a finalize() single-exit.
  • 650ee357workflow authoritative: deriveWorkflow’s workflow-object derivation routes off the typed codes instead of reportStatus/tlDecision string-matching. Typed cardStatus/termLoanStatus threaded through runUnderwritingbuildResults → persisted result (+ lib/types/underwriting-result.ts). Credit-not-found override sets termLoanStatus = {code:"tl_not_applicable", label:"NA"} in lockstep with tlDecision="NA".
  • 1e6c9c0bcleanup: single-sourced the code unions (result contract imports them from the pure module; no duplicate declarations) and extracted 5 exported exhaustive-switch predicates in underwritingStatus.ts: isCardsQualified, cardsRequirePaydowns, cardsRequireInqRemoval, isTlQualified, tlRequiresInqRemoval. deriveWorkflow now calls these. Reuse these in Phases 3 & 5.
Equivalence proof: the workflow golden snapshots are byte-identical — typed code-routing ≡ old string-routing. Only additive growth is the new cardStatus/termLoanStatus fields (in both underwriting.test.ts.snap and realFileGoldens.test.ts.snap). Intentionally left untouched in Phase 1 (deferred to later phases): deriveLegacyFundingPath, deriveSystemTwoNextAction, the legacyFundingPath/fundingPath string, and all decline-reason string-matching (REPAIR_REFERRAL_DECLINE_REASONS, PARKED_*). Decline reasons get typed in Phase 2; the legacy helpers get deleted in Phase 5.

Typed status taxonomy (for downstream phases)

CardStatusCode = credit_not_found | qualified | qualified_paydowns
  | qualified_inq_removal | qualified_paydowns_inq_removal
  | pending_seasoning | manual_review | doesnt_qualify
TermLoanStatusCode = tl_qualified | tl_qualified_inq_removal
  | tl_manual_review | tl_declined | tl_needs_income | tl_not_applicable
Note: qualified_paydowns already unifies the duplicate strings "Qualified w/ Paydowns" and "Qualified w/Paydowns" at the code level — Phase 2 collapses the strings. tl_not_applicable is only produced by runUnderwriting (credit-not-found), never by the TL evaluator.

Phase 2 — Engine consolidation ✅ DONE

Branch feat/underwriting-phase2-engine-consolidationPR #918 (merged). Originally stacked on the Phase-1 foundation branch; rebased onto main after PR #912 merged. Each task ran as a fresh implementer subagent → two-stage review (spec compliance → code quality), then a final Greptile bot review (commit fbadba91, below). Full suite green, tsc/lint clean; workflow golden snapshots byte-identical for every refactor, with the one deliberate delta below.
  • c217880a — Dedupe the 5× copy-pasted credit-repair emission in cardUnderwriting.ts → one buildCreditRepairNextAction(flag, includeMostRecentLate) helper (preserves exact details key order; 3 hard-decline sites pass true, 2 soft-approval sites pass false). Snapshots byte-identical.
  • d8344be8 — Type the decline reasons into {code,label} (the deferred Phase-1 piece): added CardDeclineReasonCode (14-member union) + CardDeclineReason + 3 exhaustive predicates (isRepairReferralDecline/isParkedThinFileDecline/isParkedRecentActivityDecline) in underwritingStatus.ts; cardUnderwriting.classifyCardDeclineReason maps the 14 legacy strings and emits declineStatus; deriveWorkflow routes off the typed code via the predicates and the 3 ReadonlySet<string> decline sets are deleted. Snapshots byte-identical.
  • 3507a396 + 79195490, then partly reverted by fbadba91, then deleted for real (PR #970, 2026-06-11) — Demote fundingCategories to presentation-only (its routing authority is removed). 3507a396 initially deleted reconcileSummaryRecommendation and surfaced fundingCategoriesResult.summaryRecommendation verbatim; 79195490 removed summaryRecommendation from the AI prompt feed (aiUnderwriting.ts + aiPrompts.ts). ⚠️ Correction: deleting reconcile was premature — for 3 personas (Alex Adams, Charlie Clark, Grace Garcia) fundingCategories reports doesNotQualify while the engine qualifies / is pending seasoning, so the verbatim headline contradicted the engine decision in the persisted result + any direct API/UI consumer (not just the AI, which 79195490 had already shielded). The Greptile review flagged this; fbadba91 restored reconcileSummaryRecommendation as a persistence-only compat shim until every consumer was gone. PR #970 deleted the shim and field outright once the AI prompt feed, API caller, and all UI/analytics readers were confirmed absent — no headline → no contradiction. AI chat tools consumer (tool-handlers.ts) reads only presentation fields — unaffected throughout.
  • 12839130 — Type the body: any (runUnderwriting.ts): added exported UnderwritingRequestBody interface (4 consumed fields incomeType/monthlyBankDeposits/businessDeposits/dti + nested data? mirror for the existing .data fallbacks + [key: string]: unknown index signature so the wider raw bodies in tests/webhook/console still type-check). resolveDti param narrowed to Pick<…, "dti"|"data">. Behavior-neutral; full input-contract unification deferred to Phase 6.
  • ff5982e5 — Drop the one genuinely-dead 0.5 : 0.5 ternary (creditMetrics.ts, both branches returned 0.5) → direct assignment + removed the now-unused THIN_MULT_CONFIG import; de-staled a deriveWorkflow comment (doesnt_qualify now keys off the typed decline code, not the declineReason string).
  • DEFERRED to Phase 3 → done in PR #967 — Collapse duplicate status strings ("Qualified w/Paydowns""Qualified w/ Paydowns"). Engine emits only the spaced form now; readers stay tolerant of persisted legacy no-space values until Phase 5.
  • ⏭️ DEFERRED to Phase 3-cardtype — Tighten processingSubmissions.fundingCategory to the typed union (folds into the card-type-at-approval feature, as the original plan noted).
  • ⚠️ NOT DEAD (plan inventory was wrong; left intact): workflow.declineReason values insufficient_income/duplicate_file/other are persisted-schema validator members + forward-designed for operator/automation declines (workflowValidator.ts, automationOutcome.ts, tested in automationOutcome.test.ts). workflowPath seasoning is a persisted-schema validator member + in seed data (removal = schema migration). detectChargeCards (preprocessor.ts) is production-dead but has an active test suite. None safe to remove without schema/test work — out of scope for this consolidation phase.
OPEN QUESTION — RESOLVED (investigated 2026-06-08): fundingCategories is consumed by the AI chat tools (lib/chat/tool-handlers.ts:148). It reads only presentation fields (categoryName, track, confidence, successProbability, warnings) — NOT summaryRecommendation and NOT any routing authority. ✅ Demoted to presentation-only; its routing authority is removed and presentation fields are intact. (reconcileSummaryRecommendation and summaryRecommendation are ✅ DELETED as of 2026-06-11 — see intro bullet.)

Phase 3 — UI migration (surface slices) ✅ DONE (merged 2026-06-10/11)

Landed across six PRs, all merged to main:
  • PR #929 — multi-product funding workflow start (term-loan-only path). Added the shared derivation in lib/underwriting/workflowState.ts (deriveWorkflowState, reusing the exported predicates from underwritingStatus.ts) and migrated NextActionCard. Schema gained the term_loan literal (Convex deployed).
  • PR #930 — migrated FundingSummaryCard, OperatorUnderwritingQueue, and ProcessingUnderwritingHero onto deriveWorkflowState. The operator queue’s backend bucketing (getOperatorQueue in convex/underwriting.ts) routes off typed codes.
  • PR #951 — thermo-audit carry-overs F2, F3, F5 (F1 below, F4 was fbadba91): card evaluator now emits declineStatus {code,label} directly at each decline branch (string→code round-trip gone); body narrowed at the HTTP boundary (parsed once in analyzeCreditReportImpl into typed UnderwritingInput fields — UnderwritingRequestBody index-signature interface deleted); test harness reuses the hoisted production classifyCardStatus/classifyTermLoanStatus instead of re-implementing them.
  • PR #967 — collapsed the duplicate paydowns decision strings ("Qualified w/Paydowns""Qualified w/ Paydowns", the Phase-2 deferral). Engine now emits only the spaced canonical form; classifyCardStatus and ReviseUnderwritingDialog remain tolerant of the legacy no-space variants (persisted history + Zoho) — that tolerance is Phase-5 cleanup, not deletable now.
  • PR #969persisted the workflow path server-side: new optional fundingWorkflowPath field on underwritingResults (union incl. creative/established/parked/…), computed via resolveFundingWorkflowPath (lib/underwriting/workflowState.ts) and written by approveUnderwriting, approveEstablishedFunding, updateSelectedFundingTypes, revokeApproval, parkUnderwriting, declineAndCloseFile. useContactData and lib/chat/tool-handlers.ts read the persisted field first, falling back to client-side resolution (uw.fundingWorkflowPath ?? resolveFundingWorkflowPath(uw)) — the tool-handlers workflowPath consumer is migrated, unblocking Phase 5. Semantics note (reviewed + accepted bot tightening): resolveFundingWorkflowPath only returns creative/established when operatorApprovalStatus is approved or parked — pre-approval files resolve null, matching UI behavior. Backfill migration oneOffMigrations:backfillFundingWorkflowPath exists but has not yet been run against prod (see Status at a glance).
  • PR #970 — F1: deleted reconcileSummaryRecommendation + the summaryRecommendation field (see Phase-2 section for history).
Deliberately remaining (→ Phase 5): normalizeCardsDecision/normalizeTermLoanDecision in ReviseUnderwritingDialog.tsx survive as typed canonicalizers for legacy persisted values; the workflowPath column and deriveLegacyFundingPath/deriveSystemTwoNextAction are untouched until Phase 5. Test-infra side quest (PR #971, merged): CI-only bun test failures (“Export named ’…’ not found”, e.g. lucide-react icons) were process-global mock.module() leakage between test files, not missing icons. Fix: export-complete mocks (spread the await import-ed real module into every partial mock.module) + a global preload (tests/setup/preload.ts, wired via bunfig.toml) aliasing lucide-react to its real ESM build. Convention going forward: any mock.module() of a shared module must spread the real module first.

Phase 3-cardtype — Capture card-type at approval ⏳ NEXT

When card_stacking is selected in ApproveUnderwritingDialog, reveal a Personal / Business / Personal + Business sub-choice, each showing its range (Personal → personalOnlyRange, Business → businessOnlyRange, Personal + Business → estimatedRange — all already on the stored result). Persist as a typed cardFundingCategory on the underwriting doc via approveUnderwriting / updateSelectedFundingTypes (convex/underwriting.ts ~lines 1794, 2114): v.optional(v.union(v.literal('personal'), v.literal('business'), v.literal('personal_and_business'))). SubmitToProcessingDialog pre-fills fundingCategory from it (operator may still change before submit). Does not feed underwriting routing — just a stored selection surfaced for the 7F submission. OPEN QUESTION (decide at this phase): if the operator changes card-type in SubmitToProcessingDialog after picking one at approval, does the submit value write back to cardFundingCategory (lean yes — single source of truth = latest operator intent), or does the approval-time choice stay the record while the submission carries its own value?

Phase 4 — Decompose god-files ⏳

Split convex/underwriting.ts (~2894 lines) into adapter/store/operator-workflow/queue. Break components/contact/hooks/useContactActions.ts (~1925) along seams and lift the decision parsers into the shared model. Address the ~1575-line processing page duplication.

Phase 5 — Delete legacy (once no readers remain) ⏳

Remove legacyFundingPath, workflowPath column, deriveLegacyFundingPath, deriveSystemTwoNextAction, the normalize* canonicalizers (ReviseUnderwritingDialog.tsx), the legacy no-space paydowns tolerance in classifyCardStatus, and remaining dead enums. Prerequisites — both code-side ones are met: the tool-handlers.ts workflowPath consumer was migrated in PR #969 ✅; the server-persisted fundingWorkflowPath replacement exists ✅. Still required before deleting the workflowPath column: run bunx convex run oneOffMigrations:backfillFundingWorkflowPath --prod (until hasMore: false) so historical rows carry the new field.

Phase 6 — Align periphery ⏳

Single buildUnderwritingInput contract shared by production/tests/console. Update trace narration (convex/lib/buildUnderwritingTrace.ts) + AI prompts to the new taxonomy.

Working conventions (from this effort)

  • bun only. Commit author Brock Jeppesen <brock@7figures.com> (git -c user.name=... -c user.email=...). Never commit to main. Branch naming feat/....
  • Run bunx convex codegen before bunx tsc --noEmit. Don’t stage convex/_generated/api.d.ts (unrelated codegen drift on this machine).
  • Golden snapshots are the gate: behavior-equivalent changes must keep the workflow snapshots byte-identical; deliberate deltas update snapshots with bun test -u and are described in the PR/commit.
  • Read convex/_generated/ai/guidelines.md before Convex work.
  • bun test mocking (post-#971): mock.module() is process-global — partial mocks leak into other test files as “Export named ’…’ not found” CI failures. Always spread the real module (const actual = await import(...); mock.module(path, () => ({ ...actual, ...overrides }))). tests/setup/preload.ts (via bunfig.toml) globally aliases lucide-react to its ESM build; CI pins Bun 1.3.14.
  • Merge permissions: the agent’s GitHub account (khilla10) has write but cannot bypass branch protection — final PR merges are done by the user (or auto-merge once checks pass and a privileged account approves).
  • Convex auto-deploys to prod on main pushes touching convex/** (Vercel buildCommand + GitHub Actions) — no manual convex deploy needed after merge.

Suggested first message for a new session

“Resume the underwriting rework from docs/design/underwriting-rework-plan.md. Phases 0–3 are done and merged (#912 = Phase 0+1; #918 = Phase 2; #929/#930/#951/#967/#969/#970 = Phase 3 incl. all thermo-audit carry-overs, the paydowns string collapse, server-persisted fundingWorkflowPath, and the reconcileSummaryRecommendation deletion). First: run the outstanding prod backfill bunx convex run oneOffMigrations:backfillFundingWorkflowPath --prod (rerun until hasMore: false) — it’s the remaining prerequisite for Phase 5. Then start Phase 3-cardtype (capture card-type at approval — see its section, incl. the open write-back question) on a fresh branch off main; after that Phase 4 (decompose god-files) and Phase 5 (delete legacy: workflowPath column, deriveLegacyFundingPath, deriveSystemTwoNextAction, the normalize* canonicalizers, legacy no-space paydowns tolerance). Tighten processingSubmissions.fundingCategory to the typed union as part of 3-cardtype. Use subagent-driven-development, one branch/PR per phase; commits authored Brock Jeppesen.”