Skip to main content

Underwriting — System Reference

What this is: the single current-state reference for the underwriting subsystem — architecture, typed taxonomy, the workflow model, every locked design decision, and a symbol-anchored code index. Internal design note — not part of the Mintlify docs site (docs/docs.json). What this is NOT: a status tracker. Phase status, remaining work, and step-by-step execution instructions live in exactly one place: docs/plans/underwriting-roadmap.md (the roadmap). Never add “Updated YYYY-MM-DD — phase X merged” banners to this file; update the roadmap instead. Update this file only when the system itself changes (new field, new evaluator, a locked decision reversed). Consolidation note (2026-07-01): this doc replaces and supersedes five earlier docs — docs/funding-workflow-paths.md (design working doc + Decisions Log), docs/design/underwriting-rework-plan.md (phase tracker), docs/design/underwriting-system-map.md (visual map), docs/design/underwriting-uiux-rework-breakdown.md (founding audit), and docs/superpowers/plans/2026-06-17-underwriting-legacy-demolition.md (demolition plan). Every currently-operative decision from those docs is carried into §9 here; the full chronological reasoning trail (including superseded decisions) is preserved in the git history of the deleted files. (2026-07-10: the docs/superpowers/ tree was retired; the roadmap moved to docs/plans/underwriting-roadmap.md and the completed Phase 6 plan was deleted — history in git.) Conventions for code references in this doc: references are anchored to symbol names, never line numbers (line numbers rot). To locate a symbol, use rg -n "symbolName" <path>.

1. Mental model

Underwriting is one pure function (runUnderwriting in convex/lib/runUnderwriting.ts) that runs a sequence of independent evaluators, each answering one question about one product family. A presentation projector (deriveWorkflow in convex/lib/deriveWorkflow.ts) folds the five product verdicts into one structured workflow object plus coaching next-actions without overriding those verdicts. Results persist to the underwritingResults table; operators then act (approve / revise / park / decline) via mutations whose handlers live in convex/lib/underwritingOperator.ts. Historically the same file was described by five vocabularies at once (decision strings, TL strings, an internal coaching path, a persisted workflow-path column, and the workflow object), and the UI re-parsed the human-readable strings with ~6 non-identical matchers. The multi-phase rework (2026-06 → 2026-07) collapsed that fan-out onto typed status codes as the single machine-readable authority. As of Phase 5e (2026-07-08): codes are the only routing authority; display surfaces render codes via lib/underwriting/statusDisplay.ts; decision strings survive only as display labels and legacy persisted data. The current architecture implements the Per-Product Decision Model (§7): every run emits an equal, independent verdict for all five products, and deriveWorkflow is a pure presentation/sequencing projection over products[]. Phase 9a (PR #1182) landed the evaluators and persistence; Phase 9c (PR #1213) removed file-level vetoes and switched workflow derivation; Phase 9d + 9e (PR #1281) verified cards/TL override compatibility and cut UI, tables, and coaching consumers over. Cards/TL display projects the stored product decisions through override-aware effective codes, while Revenue Based MCA/BLOC/SBA rows read products[] directly; SBA remains non-confirmable pending its questionnaire.

2. Pipeline

Inside runUnderwriting (evaluator sequence): Three differently-named operations that are frequently confused (they are NOT variants of each other):
  • app/api/credit-report/underwriting/route.ts — a READ (lists persisted results). Misleadingly named.
  • reviseUnderwriting (handler in convex/lib/underwritingOperator.ts) — operator override; does not re-run the engine; patches operatorOverrides (strings + code mirrors).
  • app/api/credit-report/re-underwrite/route.tsreUnderwriteCreditReport (convex/creditReports.ts) — the only true re-run-and-persist path (useStoredReportData: trueanalyzeCreditReportImplstoreResults).

3. The evaluators


4. The typed taxonomy

Single source of truth: convex/lib/underwritingStatus.ts — a purity-locked, types-only module (no convex/values import; Convex validators live separately in convex/lib/underwritingStatusValidators.ts with compile-time Equals<…> drift guards in both directions).

Status codes

Notes:
  • tl_not_applicable is only produced by runUnderwriting (credit-not-found override sets tlDecision = "NA" in lockstep) — the TL evaluator never emits it.
  • qualified_paydowns unifies the historical spaced/no-space string duplication at the code level. The engine emits only the spaced canonical string ("Qualified w/ Paydowns") since PR #967.

Where the codes live (persistence)

Effective code (the value everything routing-related must use) = operatorOverrides.<code> ?? <top-level code>. Server-side resolvers: getEffectiveCardStatusCode / getEffectiveTermLoanStatusCode in convex/lib/underwritingOperator.ts (client code computes the same ?? inline because server modules can’t be imported client-side).

Classifier inventory (convex/lib/underwritingStatus.ts)

Routing predicates (lib/underwriting/workflowState.ts — pure, browser-safe + Convex-safe)

All take effective codes, not strings (since Phase 5d-3):
  • deriveCardWorkflowPath(code)'prime' | 'paydowns' | 'inq_removal' | null — exhaustive switch over CardStatusCode.
  • isQualifiedCardsDecision(code) / isQualifiedTermLoanDecision(code) — delegate to the canonical predicates.
  • deriveWorkflowState({effectiveCardStatusCode, effectiveTermLoanStatusCode}){cardsQualified, tlQualified, canStartWorkflow}the “can start a Creative funding workflow” gate (cards OR term loan qualifies).
  • deriveCreativeWorkflowPath(input) → cards path wins, else term_loan, else null.
  • resolveFundingWorkflowPath(row) → the wide FundingWorkflowPath (prime | paydowns | inq_removal | term_loan | parked | established | null). Priority: parked (exclusive) → approved+Creative-selected → approved+Revenue-Based-only → Creative fallback. The established member is a retained code id. Non-approved, non-parked rows resolve null.
  • isCreativeFundingWorkflowPath(path) — set membership over the 4 Creative templates.
fundingWorkflowPath is persisted on underwritingResults by every operator-state mutation (approve / approve-established / update-selected-types / revoke / park / decline). Read side uses the persisted field as authoritative (Phase 5e dropped the ?? resolveFundingWorkflowPath dual-read).

Display layer (post 5d-4/5d-5 — codes everywhere)

lib/underwriting/statusDisplay.ts (pure, client-safe) is the single display module: cardStatusLabel(code) / termLoanStatusLabel(code) (exhaustive switches matching the engine’s emitted labels) and statusTone(code)'qualified' | 'review' | 'declined' | 'na' (the ONE tone bucketing used by the queue, hero, CRM tables, and credit-reports table). All display surfaces render from effective codes; raw decision strings appear only as fallback text for hypothetical rows without codes (defensive — the 5d backfills + 5d-3.5 classify-at-write keep prod at 0 such rows). The 5d-era string matchers (deriveCardWorkflowPathFromDecision, isQualifiedCardsDecisionString, normalizeCardsDecision/normalizeTermLoanDecision) are deleted.

5. The workflow object (products + overlays model)

Shape (persisted on underwritingResults.workflow, null when pre-workflow):

Sequencing rules (implemented in deriveWorkflow)

  1. decline is exclusive at the file level — if present, it’s the only primary. A declined individual product does not block another actionable product (cross-track non-exclusivity, §9).
  2. parked is exclusive — the file is waiting; no funding primaries co-occur.
  3. term_loan runs before card_funding — the term loan must fund before card stacking (frequently the TL proceeds ARE the paydown funding source; see §6 spines).
  4. Cross-track (Creative vs Revenue Based) is operator-driven — both primaries are emitted; the UI shows two start buttons; both started → tabbed UI (Tab 1 Creative, Tab 2 Revenue Based). Shared steps (approve_underwriting, send_agreement) de-duplicate.
  5. When an operator confirmed BOTH tracks, the single-value resolveFundingWorkflowPath selector lets Creative win (locked decision — preserve).

Pre-workflow states (workflow === null)

  • Manual Review - * — operator must revise/override (re-runs routing) or close (→ decline). Surface: NextActionCard “needs operator review” + ReviseUnderwritingDialog.
  • Credit Not Found — bureau couldn’t match the PII; a data-mismatch issue, NOT a thin-file credit issue. Surface: “Re-try Credit Pull” prompt. (Locked 2026-04-27 — earlier design routed this to parked/thin_file; reversed.)

Parked semantics

  • pending_seasoning — accounts need to age; passive wait; default callback +6 months.
  • recent_credit_activity — too many new accounts/inquiries; wait AND don’t open new credit; callback = max(youngestAccountOpenedDate + 13 months, today + 30 days).
  • thin_file — not enough history to underwrite; client actively builds credit; callback +6 months.
  • All parked reasons share ONE GHL pipeline stage; parkedReason is a contact field/tag, not a stage. Funding Queue (Brock’s feature) batch-surfaces parked files at their callback dates.

Decline semantics

  • Reasons: repair_referral (ASAP Credit Repair referral; the referral steps live in the decline spine), no_fit, insufficient_income, duplicate_file, other (+ freeform notes).
  • Only repair_referral and no_fit are emitted by the engine today. insufficient_income / duplicate_file / other are forward-reserved for operator/automation declines — NEVER delete them (schema validator + automationOutcome.ts depend on them).
  • Decline is terminal: one generic email template, no nurture, no follow-up. A returning client re-enters as a new lead.

GHL pipeline mapping

Engine is the source of truth; GHL stages mirror it via sync (syncDecisionToGhl), never the reverse. Pipeline IDs are per-location — resolve via getGHLPipelineStages (name-match), never hardcode. Parked uses one stage for all reasons.

Funding workflow spines (operator step checklists)

Rendered by components/funding/FundingWorkflow.tsx (static template lookup keyed by fundingWorkflowPath) with steps advanced via completedWorkflowSteps. Steps named send_*/collect_* are trigger-only (server assembles content; the step is a button — do NOT build composition UIs). The as-designed spines (Tier-2 walkthroughs, 2026-04-23 — some steps shipped, some still design-only; the shipped templates are the authority in FundingWorkflow.tsx):
  • card_funding: approve_underwriting → send_agreement → (overlay slots: paydowns_required, inq_removal_required — both block submit) → submit_processing (partner: 7 Figures Funding) → wait_card_stacking_results (24–72h; returns a funding plan of 4–7 card applications) → deliver_funding_plan (trigger-only) → track_card_statuses (daily loop, soft cutoff prompt day 7) → collect_invoice (GHL invoice, operatorFeePercent × totalApprovedAmount).
  • term_loan (partner: Engine by MoneyLion, engine.tech — API-mediated offer fan-out; Engine v2 create-lead is the ONLY term-loan path, v1 removed May 2026): approve_underwriting → send_agreement → (inq_removal overlay slot) → generate_term_loan_offers → review_and_select_offer (with client, by phone, BEFORE any email) → send_offer_link → await_acceptance → wait_for_funding → collect_invoice (feePct × termLoanAmount) → send_next_steps.
  • established_funding (Revenue Based, LENDER-PAID — no client invoice): approve_underwriting → send_agreement → gather_documentation (MCA: 3–6 mo bank statements; SBA: tax returns/P&L/balance sheet) → submit_to_partner (system filters FM Lender DB by sub-variant + eligibility; manual email submission) → handoff_to_lender → record_payment_received (manual GHL payment record). The current approval dialog offers confirmable MCA and BLOC only; selectedFundingTypes records the product, while retained establishedSubVariant: 'mca'|'sba'|'both' maps BLOC through the legacy 'sba' branch for the shared lender path. SBA remains non-confirmable until its questionnaire ships.
  • parked: send_parked_email (one template per parkedReason) → mark_parked (atomic: GHL stage + reason tag + callback date).
  • decline: (repair_referral only:) send_repair_referral (ASAP link) → mark_in_credit_repair → (all reasons:) send_decline_email → close_file.

Compensation models

  • Client-paid (term_loan, card_funding): operator invoices client ~9% (per-operator configurable) of funding received, via GHL invoicing at each collect_invoice step.
  • Lender-paid (established_funding): lender pays the operator a commission directly; operator records receipt manually in GHL. No client invoice — never build one for this primary.
  • FM → operator reconciliation (internal, NOT workflow): FM charges operators 15% of received compensation on portions where FM (7 Figures) did the processing.
  • One consultancy agreement template covers all primaries; send_agreement de-duplicates across active primaries.

Partners (authoritative list = FM Lender DB; this is orientation only)

Card stacking: 7 Figures Funding. Term loans: Engine by MoneyLion (API). MCA: Credibly, Kapitus, OnDeck. SBA: Cadence Bank, Grasshopper Bank. Credit repair referral: ASAP Credit Repair USA (per-operator affiliate links). All non-Engine submissions are manual email.

6. Coaching layer (nextActions)

  • The engine emits nextActions[] (action types: 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).
  • The 5 operator “Priority” cards (prime_approval, paydown_strategy, wait_for_seasoning, mca_option, credit_repair_required) are emitted by deriveCoachingNextAction over the internal CoachingPathCode (prime | near_prime | seasoning | mca_nuclear | credit_repair | thin_file) inside deriveWorkflow. coachingPath is never persisted (the fundingPath string was dropped in Phase 5b).
  • components/funding/FundingRecommendations.tsx renders them: ACTION_CONFIG dispatcher + collapseActions (HARD_SUPPRESSOR_PRECEDENCE = credit_repair_required/complex_review/starter_step render alone; SOFT_MERGE_PRIMARIES absorb a co-firing credit_repair advisory via the caution-swap).
  • Qualification-nuance cards (borderline_score_approved, approved_aged_lates) collapse into their co-firing primary card via the QUALIFICATION_NUANCE rule in collapseActions (task QW-1, PR #1116) — the nuance surfaces as caution context on the primary instead of a competing Priority card.

7. Per-Product Decision Model (Phase 9 complete)

Roadmap Phase 9 implemented the model locked in Phase 9-pre by ADRs 0002–0009. evaluateEstablished returns per-product verdicts (real BLOC evaluator, tiered MCA revenue), deriveProductDecisions (convex/lib/productDecisions.ts) assembles ProductDecision[5], and storeResults persists it on underwritingResults.products (convex/schemas/creditFunding.ts). deriveWorkflow consumes those decisions through deriveWorkflowFromProducts; the resulting workflow is presentation/sequencing only.

The pre-Phase-9 gaps it closed

Before Phase 9, the products+overlays model still folded everything into a single file-level verdict in three ways:
  1. decline/parked were file-level exclusive vetoes. The workflow-level hasCreditRepairHardStop short-circuit returned primaryPaths: ["decline"] before any product was considered, contradicting the locked 2026-04-27 “not strictly exclusive across tracks” decision (§9). Phase 9c deleted that veto; the same-named coaching computation remains.
  2. primaryPaths listed only qualifying products. A declined product was an absence, not a verdict with reasons, so “Cards: declined because X; term loan: qualified” could not be rendered.
  3. MCA/SBA were collapsed into one established_funding primary and BLOC was omitted. evaluateEstablished returned two booleans, and the SBA gate silently covered BLOC.
Important boundary: workflow.primaryPaths.includes("established_funding") means “at least one Revenue Based product can proceed.” It does not mean MCA, SBA, and BLOC are all eligible. Per-product consumers read products[]; the legacy mcaPossible / sbaPossible columns remain compatibility fields, not current eligibility authority. The five-product panel projects cards/TL through the override-aware effective status codes, while Revenue Based MCA/BLOC/SBA verdicts read products[] directly. MCA and BLOC are confirmable when their product status is qualified or qualified_conditional; SBA shows a disabled Coming Soon questionnaire CTA and is not confirmable (ADR-0003).

The shape

Locked Phase 9-pre decisions (ADRs 0002–0009, 2026-07-09)

  1. MCA/SBA/BLOC split at the decision layer (ADR-0002). UX groups them under one Revenue Based tab, but products[] carries separate verdicts for mca, sba, and bloc. workflow.primaryPaths stays a file-level presentation sequence, never per-product eligibility.
  2. Real BLOC evaluator + SBA questionnaire gate (ADR-0003 / ADR-0009). BLOC is evaluated independently (660+ / 20k+/2yr+).SBAkeepstodaysbaselinegatesasaProductDecisionbutisnotConfirmableuntilitsquestionnaireships;theUIshowsadisabledComingSoonquestionnaireCTA.MCAusestieredrevenue(60063920k+ / 2yr+). SBA keeps today's baseline gates as a Product Decision but is **not Confirmable** until its questionnaire ships; the UI shows a disabled Coming Soon questionnaire CTA. MCA uses tiered revenue (600–639 → 50k+; 640+ → $20k+) with 6mo+ TIB.
  3. Credit-repair reasons attach to card_stacking + term_loan only (ADR-0004). Not SBA/MCA/BLOC. File-level hasCreditRepairHardStop is deleted. Coaching credit_repair* nextActions remain; settings referral-link is a follow-up.
  4. Decline and parked are emergent file states (ADR-0005). Overall decline means no product is qualified, conditionally qualified, or manual review. Overall parked means no product can proceed now, and at least one product carries a time-based prerequisite. Actionable TL/MCA/BLOC means the file is not parked. Per-product reasons are visible in the five-product panel.
  5. Operator overrides stay cards + TL only (ADR-0006). MCA/SBA/BLOC have no revise override.
  6. The UX label is “Revenue Based” (ADR-0007). Code ids (established_funding) remain unchanged.
  7. MCA/BLOC estimate plumbing only (ADR-0008). Null until a follow-up ships formulas; SBA never estimated.

Four properties this buys

  1. Every product gets a verdict every run. A declined product is status: "declined" with reasons, not an absence. BLOC is a first-class Product Decision with its own evaluator.
  2. Reasons are per-product. A credit-repair reason attaches to card_stacking.reasons and term_loan.reasons (ADR-0004) but NOT to MCA/SBA/BLOC. Example: card_stacking → declined [severe_lates_repair]; term_loan → declined [severe_lates_repair] or own TL reason; mca → qualified; sba → declined [score] (baseline); bloc → qualified independently.
  3. The file-level short-circuit is gone. hasCreditRepairHardStop → ["decline"] is deleted; “overall decline” is emergent (all products declined/not_evaluated). File-level parked is emergent too (ADR-0005).
  4. deriveWorkflow is a pure function over products[] — sequences and groups products for downstream GHL/automation mapping; it can never override a verdict.

Phase 9 implementation notes

  • 9a (DONE, #1182) was additive: emit and persist products[] while leaving existing booleans, status codes, and workflow behavior intact. BLOC is a real evaluator (ADR-0003/0009), not an SBA mirror.
  • 9b (#1196/#1203) backfilled historical products[] from the persisted typed cards/TL codes plus mcaPossible/sbaPossible; historical rows had no BLOC signals, so bloc was not_evaluated.
  • 9c (#1213) made the deliberate behavior change: deleted the workflow-level repair veto and derived workflow from products[], with only the documented golden deltas.
  • 9d (#1281 verification) confirmed cards/TL overrides remain compatible without code changes. Overrides still apply only to cards/TL (ADR-0006).
  • 9e (#1281) added the override-aware five-product panel, split Revenue Based into MCA/BLOC/SBA rows, made MCA/BLOC confirmability products[]-driven, kept SBA disabled Coming Soon/non-confirmable, and cut credit-report tables plus coaching/chat/MCP consumers over. GHL sync and automation already consumed the emergent persisted workflow; no new stages or sync code were required. The engine and golden snapshots remained byte-identical.

Migration seam

The legacy workflow object is derivable from products[], which let existing UI + GHL sync keep working during the surface-by-surface consumer cutover. Current consumers use products[] where product detail matters; the persisted workflow remains the sequencing and downstream GHL/automation projection. Phase 5d was the substrate: per-product = “extend the persisted typed codes from 2 products to 5, and stop letting the router override them.”

8. Data writers — everything that writes decision fields

This inventory matters because the “every row carries typed codes” invariant is only as good as its writers. Any writer of underwritingDecision/termLoanDecision that does not also write the codes re-contaminates the table.

9. Locked decisions (currently operative)

Distilled from the retired Decisions Log — every entry below is the current state of its topic. Chronology + superseded intermediate states: git history of docs/funding-workflow-paths.md. Do not silently change any of these (see VISION.md spec gate). Model & routing
  1. Typed status codes are the routing authority; decision strings are display labels (Phase 1 design lock, realized in 5d-3). Never route on strings.
  2. Products + overlays, not a flat decision enum: primaryPaths[] + composable overlays[]. The 3 legacy card templates collapsed into one card_funding primary + 2 overlays.
  3. Term loan is its own primary (not a card overlay); sequences before card_funding. paydowns_required NEVER applies to term_loan (the TL usually funds the paydowns). inq_removal_required applies to both; in the combined case it attaches to term_loan only (TL runs first; cards pick up the improved profile).
  4. Revenue Based stays grouped at the workflow layer — one established_funding primary sequences MCA/BLOC/SBA lender work. The retained establishedSubVariant: 'mca'|'sba'|'both' machine field maps BLOC through the legacy 'sba' branch, while selectedFundingTypes distinguishes new BLOC selections. The current approval dialog confirms MCA/BLOC only; SBA is questionnaire-gated. A workflow primary is file-level presentation and must never be reused as proof that every Revenue Based product is eligible.
  5. Cross-track non-exclusivity (2026-04-27): a file may be cards-declined (even credit-repair-class) while term loan or a Revenue Based product legitimately proceeds. The strict “credit repair blocks everything” rule was dropped on audit evidence (0.05% incidence). Phase 9c removed the contradictory workflow-level hasCreditRepairHardStop; the same-named coaching-path computation remains by ADR-0004.
  6. Credit-repair-as-decline blanket rule DROPPED (2026-04-27): evaluators keep emitting creditRepairFlag on qualified decisions. When decline is emergent, deriveWorkflowFromProducts selects repair_referral if the cards product carries a credit_repair reason or its decline code is repair-referral-class; otherwise it selects no_fit.
  7. Manual review is pre-workflow (workflow: null), never a decline reason. Operator revises (re-routes) or closes (standard decline).
  8. Credit Not Found is pre-workflow (workflow: null), NOT parked/thin_file — it’s a PII-match failure needing a re-pull, not a credit judgment.
  9. parkedReason enum is final: pending_seasoning | recent_credit_activity | thin_file. recent_credit_activity merged the earlier too_many_new_accounts/too_many_recent_inquiries. Callback formula for recent_credit_activity: max(youngestAccountOpenedDate + 13mo, today + 30d).
  10. declineReason enum is final: repair_referral | no_fit | insufficient_income | duplicate_file | other (+declineReasonNotes). The last three are forward-reserved (operator/automation declines) — never delete.
  11. repair_referral stays in decline (not parked): ASAP takes over the relationship; stale queue files add noise.
  12. Decline is terminal — one generic email, no nurture. Returning clients re-enter as new leads.
  13. Replace, don’t wrap: legacy path systems get absorbed and deleted, never wrapped (this killed fundingPath.ts, the workflowPath column, the persisted fundingPath string, reconcileSummaryRecommendation/summaryRecommendation).
  14. fundingCategories is presentation-only — zero routing authority.
  15. Both-tracks-confirmed → Creative wins the single-value fundingWorkflowPath selector.
  16. Override codes persist ALONGSIDE override strings (compat), with a later cleanup to drop the strings once nothing reads them.
  17. Strict classifiers stay loud; legacy tolerance lives ONLY in the tolerant migration/override wrappers. Live engine output must fail loudly on anything new.
  18. 5d-2b legacy string mappings are locked (see §4 tolerant-wrapper row): incl. Likelytl_qualified, Unlikelytl_declined, Qualified w/ Credit Repairqualified, TL-in-card-field → card doesnt_qualify + recovered TL code.
Process & safety
  1. Golden snapshots are the safety gate for all behavior-preserving work: tests/__snapshots__/underwriting.test.ts.snap + realFileGoldens.test.ts.snap byte-identical for workflow + nextActions. Deliberate deltas use bun test -u + documented diffs in the PR. Phase 9c carried the documented behavior deltas; the 9d/9e compatibility and consumer cutover kept both snapshots byte-identical.
  2. Backfills run to hasMore:false on prod before any phase that depends on them; all backfills tolerant + paginated + idempotent. Prod runs need the user’s explicit go-ahead.
  3. bun only; bunx convex codegen before bunx tsc --noEmit; don’t stage convex/_generated/api.d.ts drift. Commit author Brock Jeppesen <brock@7figures.com>; never commit to main; one branch/PR per sub-phase. Convex auto-deploys to prod on main pushes touching convex/**.
  4. mock.module() is process-global in bun test — always spread the real module (const actual = await import(...); mock.module(path, () => ({ ...actual, ...overrides }))).
  5. User-facing copy says “CRM”, never “GHL”/“GoHighLevel”.
  6. The what-if override seam (docs/adr/0001-underwriting-whatif-override-seam.md) must be preserved: runUnderwriting/computeCreditMetrics accept optional MultiplierOverrides defaulting to live constants; a no-knob run is byte-identical.

10. Known contradictions & gaps (each mapped to a roadmap task)

Open gaps only — resolved gaps live in the roadmap’s Done table and git history.

11. Code index (symbol-anchored jump table)


12. Worked examples

A — Cards qualified w/ paydowns + TL qualified (the common combined case)

FICO 690, util 55%, 1 old 30-day late, income $120k, DTI 38%, no business revenue.
  • Cards → qualified_paydowns (+ paydown_required action, + orthogonal creditRepairFlag)
  • TL → tl_qualified; Revenue Based → MCA/SBA/BLOC declined (no revenue)
  • workflow: primaryPaths: [term_loan, card_funding], overlays: [paydowns_required → card_funding]
  • UI: can-start gate opens (either product qualifies); coaching shows the paydown card with the credit-repair advisory merged in; operator sequence = TL funds → proceeds pay down accounts → verify + re-pull → cards submit.

B — Recent bankruptcy: cards decline, TL still qualifies (cross-track non-exclusivity)

FICO 700, BK 30 months ago, income $130k, DTI 35%, clean installments.
  • Cards → doesnt_qualify (decline code: recent bankruptcy); TL → tl_qualified (TL takes no hard-stop input)
  • products[] records card_stacking: declined [recent_bankruptcy] and term_loan: qualified; workflow.primaryPaths: [term_loan] follows from those verdicts, so the TL path proceeds.
  • Pre-Phase-9 behavior: the workflow-level hasCreditRepairHardStop short-circuit incorrectly returned [decline] while the TL code said qualified. Phase 9c removed that contradiction and retained only the same-named coaching classification.