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.
fundingCategories→ presentation-only (still stored; its routing authority is removed).reconcileSummaryRecommendationis ✅ DELETED (2026-06-11) along with thesummaryRecommendationresults 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/analyzecaller 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 original3507a396deletion 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
| Phase | Status | Branch / 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 |
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 structuredworkflowobject across all 12 personas (the existing suite only pinneddecision.results). This is the review gate every later consolidation diffs against. Only the non-deterministicworkflow.parkedCallbackDateis stripped (live-Dateepoch 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.)978c2cbe— bug 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 fromREPAIR_REFERRAL_DECLINE_REASONS(deriveWorkflow.ts), so they fell through todecline/no_fitinstead ofdecline/repair_referral. Added the exact strings + regression test. (Interim string-match; superseded by typed verdicts.)1b1b0b3c— typed verdicts: new pure moduleconvex/lib/underwritingStatus.tswithCardStatusCode/TermLoanStatusCodeunions +CardStatus/TermLoanStatus{code,label}.evaluateCards/evaluateTermLoannow emitstatus: {code,label}alongside the untouched legacyreportStatus/tlDecision. Card code derived via an exhaustiveclassifyCardStatusswitch in the evaluator (throws on unmapped string); TL via afinalize()single-exit.650ee357— workflow authoritative:deriveWorkflow’s workflow-object derivation routes off the typed codes instead ofreportStatus/tlDecisionstring-matching. TypedcardStatus/termLoanStatusthreaded throughrunUnderwriting→buildResults→ persisted result (+lib/types/underwriting-result.ts). Credit-not-found override setstermLoanStatus = {code:"tl_not_applicable", label:"NA"}in lockstep withtlDecision="NA".1e6c9c0b— cleanup: single-sourced the code unions (result contract imports them from the pure module; no duplicate declarations) and extracted 5 exported exhaustive-switchpredicates inunderwritingStatus.ts:isCardsQualified,cardsRequirePaydowns,cardsRequireInqRemoval,isTlQualified,tlRequiresInqRemoval.deriveWorkflownow calls these. Reuse these in Phases 3 & 5.
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)
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
Branchfeat/underwriting-phase2-engine-consolidation → PR #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 incardUnderwriting.ts→ onebuildCreditRepairNextAction(flag, includeMostRecentLate)helper (preserves exactdetailskey order; 3 hard-decline sites passtrue, 2 soft-approval sites passfalse). Snapshots byte-identical. - ✅
d8344be8— Type the decline reasons into{code,label}(the deferred Phase-1 piece): addedCardDeclineReasonCode(14-member union) +CardDeclineReason+ 3 exhaustive predicates (isRepairReferralDecline/isParkedThinFileDecline/isParkedRecentActivityDecline) inunderwritingStatus.ts;cardUnderwriting.classifyCardDeclineReasonmaps the 14 legacy strings and emitsdeclineStatus;deriveWorkflowroutes off the typed code via the predicates and the 3ReadonlySet<string>decline sets are deleted. Snapshots byte-identical. - ✅
3507a396+79195490, then partly reverted byfbadba91, then deleted for real (PR #970, 2026-06-11) — DemotefundingCategoriesto presentation-only (its routing authority is removed).3507a396initially deletedreconcileSummaryRecommendationand surfacedfundingCategoriesResult.summaryRecommendationverbatim;79195490removedsummaryRecommendationfrom the AI prompt feed (aiUnderwriting.ts+aiPrompts.ts). ⚠️ Correction: deleting reconcile was premature — for 3 personas (Alex Adams, Charlie Clark, Grace Garcia)fundingCategoriesreportsdoesNotQualifywhile 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, which79195490had already shielded). The Greptile review flagged this;fbadba91restoredreconcileSummaryRecommendationas 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 thebody: any(runUnderwriting.ts): added exportedUnderwritingRequestBodyinterface (4 consumed fieldsincomeType/monthlyBankDeposits/businessDeposits/dti+ nesteddata?mirror for the existing.datafallbacks +[key: string]: unknownindex signature so the wider raw bodies in tests/webhook/console still type-check).resolveDtiparam narrowed toPick<…, "dti"|"data">. Behavior-neutral; full input-contract unification deferred to Phase 6. - ✅
ff5982e5— Drop the one genuinely-dead0.5 : 0.5ternary (creditMetrics.ts, both branches returned0.5) → direct assignment + removed the now-unusedTHIN_MULT_CONFIGimport; de-staled aderiveWorkflowcomment (doesnt_qualify now keys off the typed decline code, not thedeclineReasonstring). - ✅ 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.fundingCategoryto 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.declineReasonvaluesinsufficient_income/duplicate_file/otherare persisted-schema validator members + forward-designed for operator/automation declines (workflowValidator.ts,automationOutcome.ts, tested inautomationOutcome.test.ts).workflowPathseasoningis 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.
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 tomain:
- ✅ 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 fromunderwritingStatus.ts) and migratedNextActionCard. Schema gained theterm_loanliteral (Convex deployed). - ✅ PR #930 — migrated
FundingSummaryCard,OperatorUnderwritingQueue, andProcessingUnderwritingHeroontoderiveWorkflowState. The operator queue’s backend bucketing (getOperatorQueueinconvex/underwriting.ts) routes off typed codes. - ✅ PR #951 — thermo-audit carry-overs F2, F3, F5 (F1 below, F4 was
fbadba91): card evaluator now emitsdeclineStatus{code,label}directly at each decline branch (string→code round-trip gone);bodynarrowed at the HTTP boundary (parsed once inanalyzeCreditReportImplinto typedUnderwritingInputfields —UnderwritingRequestBodyindex-signature interface deleted); test harness reuses the hoisted productionclassifyCardStatus/classifyTermLoanStatusinstead 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;classifyCardStatusandReviseUnderwritingDialogremain tolerant of the legacy no-space variants (persisted history + Zoho) — that tolerance is Phase-5 cleanup, not deletable now. - ✅ PR #969 — persisted the workflow path server-side: new optional
fundingWorkflowPathfield onunderwritingResults(union incl.creative/established/parked/…), computed viaresolveFundingWorkflowPath(lib/underwriting/workflowState.ts) and written byapproveUnderwriting,approveEstablishedFunding,updateSelectedFundingTypes,revokeApproval,parkUnderwriting,declineAndCloseFile.useContactDataandlib/chat/tool-handlers.tsread the persisted field first, falling back to client-side resolution (uw.fundingWorkflowPath ?? resolveFundingWorkflowPath(uw)) — the tool-handlersworkflowPathconsumer is migrated, unblocking Phase 5. Semantics note (reviewed + accepted bot tightening):resolveFundingWorkflowPathonly returns creative/established whenoperatorApprovalStatusisapprovedorparked— pre-approval files resolvenull, matching UI behavior. Backfill migrationoneOffMigrations:backfillFundingWorkflowPathexists but has not yet been run against prod (see Status at a glance). - ✅ PR #970 — F1: deleted
reconcileSummaryRecommendation+ thesummaryRecommendationfield (see Phase-2 section for history).
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
Whencard_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 ⏳
Splitconvex/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) ⏳
RemovelegacyFundingPath, 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 ⏳
SinglebuildUnderwritingInput 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 tomain. Branch namingfeat/.... - Run
bunx convex codegenbeforebunx tsc --noEmit. Don’t stageconvex/_generated/api.d.ts(unrelated codegen drift on this machine). - Golden snapshots are the gate: behavior-equivalent changes must keep the
workflowsnapshots byte-identical; deliberate deltas update snapshots withbun test -uand are described in the PR/commit. - Read
convex/_generated/ai/guidelines.mdbefore Convex work. bun testmocking (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(viabunfig.toml) globally aliaseslucide-reactto its ESM build; CI pins Bun1.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
mainpushes touchingconvex/**(VercelbuildCommand+ GitHub Actions) — no manualconvex deployneeded after merge.
Suggested first message for a new session
“Resume the underwriting rework fromdocs/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-persistedfundingWorkflowPath, and thereconcileSummaryRecommendationdeletion). First: run the outstanding prod backfillbunx convex run oneOffMigrations:backfillFundingWorkflowPath --prod(rerun untilhasMore: 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 offmain; after that Phase 4 (decompose god-files) and Phase 5 (delete legacy:workflowPathcolumn,deriveLegacyFundingPath,deriveSystemTwoNextAction, thenormalize*canonicalizers, legacy no-space paydowns tolerance). TightenprocessingSubmissions.fundingCategoryto the typed union as part of 3-cardtype. Use subagent-driven-development, one branch/PR per phase; commits authored Brock Jeppesen.”

