Tech Debt
Known shortcuts, gaps, and follow-ups in the FlexRate pipeline. Update as items land or new ones are discovered.
This page tracks intentional shortcuts and known gaps that we shipped knowingly. Each entry should answer: what's the gap, why is it acceptable today, and what's the right fix?
CES — LoanProduct rows hand-coded in StaticMatricesSeeder
Where: database/seeders/StaticMatricesSeeder.php → seedCesOo() / seedCesNoo() → seedCesLoanProducts() (FlexRateAPI repo).
What's the gap. Every other Non-QM program (Full Doc, Expanded Doc, DSCR, Jumbo, ITIN, FN, Multi-Unit) gets its LoanProduct rows from the rate-sheet XLSX:
NonQmExtractorparses each XLSX tab → emitsproducts[]instorage/app/rates/non-qm/<program>.jsonNonQmSeederreadspayload['products']and runsLoanProduct::insert(...)
CES is the outlier. The CES tabs in the Non-QM XLSX don't expose a products list the extractor recognizes — ces_oo.json and ces_noo.json both ship with products: []. To work around that gap, StaticMatricesSeeder hand-codes the four CES products (10 / 15 / 20 / 30 YR FIXED, all io_na: true) inline.
Why it's acceptable today. The matrix pipeline (Track B) now owns CES doc types, LTV grids, and eligibility rules via CesParser. The static seeder block is already minimized to the single responsibility it can't shed: emitting the four LoanProduct rows that the XLSX extractor doesn't surface. The hand-coded values match the lender matrix and are stable across rate-sheet cycles.
Right fix (Option A from the May 29 design discussion). Teach NonQmExtractor to detect and emit the CES product list from the CES tab in the Non-QM XLSX. Once payload['products'] is populated for ces_oo and ces_noo, NonQmSeeder picks them up automatically and the seedCesOo() / seedCesNoo() / seedCesLoanProducts() methods can be deleted outright. No call-site changes needed beyond RatesDatabaseSeeder losing the seedCesOo / seedCesNoo invocations.
Estimated effort. 30–60 min if the CES tab layout is regular; potentially a rabbit hole if it deviates from the other tabs' product-table conventions.
Risk if left unfixed. Low. The hand-coded products will silently drift if the lender ever changes the CES amortization options (e.g. adds a 25 YR FIXED). Detection would be via the broker reporting a missing CES product in the pricer dropdown.
DSCR — static seeder deliberately overrides parser eligibility rules
Where: StaticMatricesSeeder::seedDscr() (FlexRateAPI repo) — block runs even when DscrParser has populated LTV grid rows.
What's the gap. The DSCR block has a parser-skip guard for the LTV grid (good — avoids double-seeding), but it deliberately overrides the parser's eligibility rules with wider ones (min_fico=640, max_loan=3.5M) per the inline comment. So the static seeder is silently the source of truth for DSCR eligibility while the parser populates LTV cells.
Why it's acceptable today. Intentional product decision — broker policy is more permissive than what the matrix PDF strictly states. Comment is in place.
Right fix. Decide whether broker-policy overrides belong in: (a) the matrix parser as a post-processing layer, (b) the seeder (current), or (c) a dedicated ProgramPolicyOverridesSeeder that runs after MatricesDatabaseSeeder. Likely (c) — cleanest separation of "what the lender PDF said" vs "what we price on."
Risk if left unfixed. Medium. Today's behavior is correct, but the override is invisible to anyone reading CesParser / matrix manifest output. Future agent could "fix" the parser's narrower rules without realizing the override exists.
MultiUnit / ITIN / FN — matrix parsers ship parser_status: partial
Where: storage/app/matrices/_extract-manifest.json — three entries are parser_status: partial. Static seeder blocks remain the primary source of truth for these programs' LTV grids and eligibility rules.
What's the gap. The matrix PDFs for these three programs have layouts the parsers can't fully decode (e.g. nested header rows, multi-column LLPA tables). Static seeder blocks were written to fill the gap and have no parser-skip guard.
Why it's acceptable today. Static blocks match the current lender matrices and are tested against the pricer. Cost of parser completion is high relative to refresh frequency (matrices change quarterly).
Right fix. Iterate on each parser to bring it to parser_status: complete, then add parser-skip guards to the static blocks (mirroring the DSCR pattern), then delete the static blocks once the guards have shipped a full release cycle without regressions.
Risk if left unfixed. Medium. Static blocks must be manually updated each time the lender publishes a new matrix. Stale static data is invisible until a broker prices a scenario that hits the changed cell.
Pricer — LLPA alias matcher → explicit RowLabelMap
Where: app/Services/Pricer/PriceCalculator.php → findRowByAliases() and the per-category alias tables (docTypeAliases, purposeAliasesFor, propertyTypeAliases, occupancyAliases, productAliases, dscrAliasesFor).
What's the gap. LLPA rows are selected by substring-matching scenario-derived aliases against the seeded row_label column. Substring matching has two failure modes that produce silent wrong prices:
- Missing alias — a seeded row contains no token in the alias list, so the resolver returns
nulland the LLPA is skipped (broker quoted too low). - Ambiguous alias — multiple seeded rows contain the same token, so the first-inserted row always wins and later rows are unreachable (broker quoted the wrong band).
The current code carries both: a tier-priority alias list per category that has to be hand-curated against every new rate-sheet revision.
Tripwire. tests/Feature/Pricer/LlpaRowCoverageTest.php fails-loud on any seeded (program, alias-dispatched category, group, row_label) that no probe scenario in the enum space can reach. This converts the silent-miss class into a CI-blocking failure.
Known-unmapped allowlist (scenario-dimension gaps). Seven seeded rows are reachable in principle but would require new ScenarioRequest fields to model. Adding them now without matching FE form support would create dead inputs, so the guard test allowlists them. These need a product decision before they can graduate from allowlist to real fix:
| Program | Category | Row | Missing scenario dimension |
|---|---|---|---|
ces_oo / ces_noo | Product | 30/15yr Balloon, 40/15yr Balloon | Amortization variant (balloon flag) |
Each entry in the allowlist (LlpaRowCoverageTest::isKnownUnmappedDimension()) has a comment explaining the missing dimension. Removing an entry without adding the field is a regression, not a fix.
Right fix. Replace substring matching with an explicit RowLabelMap per program × category — seeded alongside the rate sheets, joining (scenario-input tuple → exact row_label). The matcher becomes a lookup, not a substring scan. The map is generated from the rate-sheet JSON and verified against the LLPA seed at build time. The guard test then asserts the map covers every seeded row (no allowlist needed for alias-resolvable rows).
Risk if left unfixed. Low while the guard test runs in CI (the silent-miss class is now observable). Medium long-term: every new rate-sheet revision risks reintroducing the same class until the matcher is replaced. The allowlist of 11 dimension gaps grows over time as new programs add new row variants, and entries become easy to overlook.
Pricer — misc-row dispatch is stringly-typed — CLOSED 2026-05-29 (FK refactor)
Resolution. Replaced both miscRowTriggers() and the str_contains-based matchRateBlock() dispatch with FK-driven dispatch. New schema (migration 2026_05_29_210000_add_typed_lookup_tables.php):
arm_types/rate_block_variants/misc_adjustment_keyslookup tables (seeded byTypedLookupSeeder).rate_blocks.amort_term_years+rate_blocks.arm_type_id+rate_blocks.variant_id(populated byRateBlockNameParserat seed time — throws on any unknown block name, so the seed fails loud).llpa_adjustments.misc_adjustment_key_id(populated byMiscAdjustmentKeyResolverfor every row in any misc category — throws on unknown labels).
PriceCalculator::matchRateBlock() now filters by (amort_term_years, arm_type_id, variant?->allowsPurpose($purpose), HB-vs-conforming based on loan amount). matchMiscAdjustmentRows() queries whereNotNull('misc_adjustment_key_id') and dispatches by MiscAdjustmentKey::slug via keyTriggers(). The Lock Extension per Day row (previously allowlisted as non-dispatchable) is now wired through ScenarioRequest::$lockExtensionDays as a per-day multiplier.
Tripwire (post-close). tests/Feature/Pricer/FkDispatchCoverageTest.php asserts (a) every FlexPoint rate block has amort_term_years and variant_id populated, (b) every misc-adjustment row has misc_adjustment_key_id populated, (c) every program with rate blocks resolves at least one dispatchable block. The FK columns being NOT-driven-by-seed-parser means upstream renames now fail loud at migrate:fresh --seed, not silently at quote time.
Rule for new code. No str_contains or stringly-typed match on rate_blocks.name or llpa_adjustments.row_label in app/Services/Pricer/. If you need a new dispatch axis, add a lookup table + FK and a parser-seeder that throws on unknown values.
Pricer — bare-number Loan Amount labels parse as open-ended ranges (bandaid in place)
Where: app/Services/Pricer/PriceCalculator.php → parseLoanAmountRange() + findLoanAmountRow().
What's the gap. The CES NOO Loan Amount grid (and likely others) has a row labeled "125000" with no qualifier — semantically the floor tier (<= $125,000), but the extractor stored only the bare number. parseLoanAmountRange("125000") returns [125000, null], so the row matches any loan ≥ $125k. When distinctRowLabels() (a DISTINCT query with no ORDER BY) returns rows in non-deterministic order, the bare-number row wins on some DB backends and the correct bounded range row wins on others. MySQL happened to return the proper row first; SQLite returned "125000" first → zero adjustment wins → wrong price.
This was discovered 2026-05-29 via a SQLite-vs-MySQL diff: modular CES NOO test came out at final_price = 100.5 instead of the expected 101.0. Math: base 101.375 − modular 2.0 + FICO/CLTV 1.125 = 100.5, missing the +0.5 Loan Amount adjuster.
Bandaid in place. findLoanAmountRow() now sorts bounded ranges before open-ended ones, so the bounded ">=$250,000 - $850,000" row wins over "125000" for a $300k loan. Carries a // FIXME: extractor should normalise '125000' → '<= $125,000' comment naming the real owner.
Right fix (layered).
- Extractor (
NonQmExtractor). Detect standalone-numeric row labels in*_grid_rows[].labeland rewrite them to their proper range form ("<= $125,000") at parse time, before they ever land inllpa_adjustments. This is the same layer where">$125,000 - $249,000"already gets its bounds right — there's no reason"125000"should escape unbounded. - Drop the bandaid. Once the extractor normalises,
findLoanAmountRow()can revert to its original ordering andparseLoanAmountRange("<= $125,000")will produce the correct[null, 125000]tuple.
Risk if left unfixed. High in production. The bandaid covers the known case but the underlying class — bare-number labels parsed as open-ended >= ranges — will recur the moment a new rate sheet ships a label like "850000" as a ceiling tier or "1500000" as a top-band cap. Each occurrence produces a silently-wrong price in the broker's favor (zero adjustment when there should be one). No automated detection — failing CI on env diff is the only tripwire and only if the diff happens to surface in a covered test.
Estimated effort. 60–90 min to add the normalisation rule in NonQmExtractor + a NonQmExtractorTest case asserting bare numbers become bounded ranges. Bandaid removal is a 1-line follow-up after the extractor lands.
Pricer — distinctRowLabels() has no deterministic ORDER BY (latent silent-miss class)
Where: app/Services/Pricer/PriceCalculator.php → distinctRowLabels() (line 643).
What's the gap. The query that drives every alias-resolved LLPA lookup (findRowByAliases, findLoanAmountRow, findCreditScoreRow, etc.) is SELECT DISTINCT row_label FROM llpa_adjustments WHERE ... with no ORDER BY clause. Result row ordering is then DB-implementation-defined:
- MySQL InnoDB typically returns DISTINCT rows in clustered-index (primary-key) order — which is insertion order — which happens to match the seeder's row sequence.
- SQLite returns DISTINCT rows in hash-aggregation order — which is non-deterministic across versions and unrelated to insertion order.
- PostgreSQL (if we ever switch) would behave differently again.
Every alias matcher that returns on first-match is therefore implicitly trusting MySQL's ordering quirk. The CES NOO Loan Amount bug is the first observed manifestation of this class — it won't be the last.
Right fix. Add ->orderBy('id') (insertion order, deterministic and matches MySQL's accidental current behavior) or ->orderByRaw('LENGTH(row_label) DESC, row_label ASC') (most-specific-label-first, agnostic to insertion order). Prefer the latter — it survives a seeder reorder.
Why it's not just done. The fix is a one-liner but it needs a tripwire test that runs the full LLPA suite against SQLite and MySQL to prove no further ordering-dependent disagreements exist. Worth pairing with the extractor normalisation (entry above) and treating them as one combined "Loan Amount / DB-ordering hardening" PR.
Risk if left unfixed. High in production. Every alias-resolved LLPA category inherits this fragility. The CES NOO case surfaced via env diff; on MySQL-only production, the same bug class can land and persist silently because there's no MySQL-side oracle to disagree with it. Detection in prod requires a broker noticing a wrong price — the worst possible tripwire.
Estimated effort. 30 min for the orderBy + 2-3 hr for a cross-DB test harness (or accept SQLite-in-memory tests as the oracle since they reproduce the bug class).
FlexPoint — misc_adjusters stored in key_value_attributes (closed 2026-05-29)
Historical record. FlexPoint's per-program misc_adjusters originally seeded into the polymorphic key_value_attributes EAV bucket, mixing numeric pricing data (Escrow Waiver = -0.25) with free-text guidance (Temporary 2-1 Buydown = "… available through PML") under the same group. This was a load-bearing exception to the "no pricing in KV" rule and prevented PriceCalculator::matchMiscAdjustmentRows() from firing FlexPoint misc rows — the toggles applied to Non-QM only.
Resolution. Task 011 (FlexRateDocs/tasks/011-flexpoint-misc-adjusters-typed.md):
FlexPointSeedernow splits the JSONmisc_adjusterspayload at seed time. Numeric /NArows are promoted into a synthetic per-programMiscellaneous AdjustmentsLlpaCategorywithbands=['value'], so they ride the existingisMiscAdjustmentCategory()→matchMiscAdjustmentRows()dispatcher unchanged. Non-numeric guidance is rerouted into a newprogram_notesKV group.- One-time data migration
2026_05_29_200000_migrate_flexpoint_misc_adjusters_to_llpa.phpwalks existing rows in production-shaped DBs and applies the same split, then deletes the legacymisc_adjustersKV rows. - Nova
KeyValueAttributeresource now rejectsmisc_adjustersas agroupvalue with'not_in:misc_adjusters'+ a help string pointing future authors atllpa_adjustments.KeyValueAttributemodel docblock updated to declare KV metadata-only. - New regression:
tests/Feature/Pricer/PricerQuoteFlexPointMiscAdjustersTest.phpasserts (a) nomisc_adjustersrows survive, (b)Escrow Waiverexists as a typed LLPA row on every FlexPoint program, (c)Temporary 2-1 Buydownlives inprogram_notes, and (d) togglingescrow_waiverin a pricer quote movesfinal_priceby-0.25and emits aMiscellaneous Adjustments / Escrow Waiveradjuster.
Invariant going forward. key_value_attributes carries metadata only. Any numeric value that can change a price MUST live in llpa_adjustments. The Nova guard is the soft tripwire; LlpaRowCoverageTest remains the hard tripwire for the typed path.