Float Decision

pkg/floatdecision resolves a user’s float decision — approved or denied, for how much, with what evaluation status — and the float profile ladder that goes with it. The decision endpoints in pkg/api call one entry point and render what comes back; no decision logic lives in the handlers.

Entry points

Method Callers Returns

Resolve

FloatRequest, GetEligibilityCheck, LoanEvaluationCheck, GetLoanEligibility, InstantCheck

The decision, bypass state, the shaped profile, and the legacy next-increase requirements

Profile

GetFloatProfile

The shaped profile and its float-tiers marker, from the latest evaluation’s MLResult — no inference

Resolve runs the payback predictor v2 float check (inference, or same-day reuse of the saved MLResult) against the user’s latest evaluation and may persist the $200 qualification marker. The result carries the latest EvaluationResult with its FloatEvaluation replaced by the decision (dynamo.GetNoEvalResult when the user has none; the honored prequal record in place of a denial) and the stored FloatProfile with its Floats ladder replaced by the canonical tiers enabled to the chosen limit — every other stored field untouched, never written back.

Profile resolves the latest evaluation as Resolve does (primary account, then latest evaluation on it) and reads that evaluation’s MLResult, whatever day it was invoked, until its TTL expires. It shapes the ladder from it as in Profile ladder. No primary account, evaluation, or MLResult means no assignment; a failed lookup fails the request.

Decision

An enforced user the float check produced no assignment for is denied in decide. Otherwise applyFloatCheck (resolver.go) picks the branch from the MLResult the float check returned, first match wins:

  1. Float tiers (float_tiers.go) — the MLResult carries an assignment with authoritative_v2 set: the assignment is the whole decision.

  2. Model override (model.go) — the MLResult is authoritative: its prediction overrides the rulebook’s approve/deny.

  3. Rulebook — no MLResult for an unenforced user (model disabled, or it couldn’t run) or shadow mode: the stored rulebook decision stands.

A decision that denies is served only while no instant-check approval is inside its honor window; otherwise that prequal record replaces the evaluation, as stored. The MLResult the check ran still shapes the ladder and feeds the $200 qualification.

A float bypass is resolved alongside and reported separately; the endpoints apply it over whatever the branch decided.

$200 qualification

The $200 tier is unlocked by risk score rather than by CFI's repaid-float ladder. qualifyTwoHundred (two_hundred.go) runs during Resolve — never on GetFloatProfile — on the MLResult the float check just ran or reused, and sets TwoHundredTestOffered on the float profile (stored as 200_test_offered), once, permanently, when every criterion holds. The profile’s companion TwoHundredTestAvailable (200_test_available) is true for every user — set on the default profile and stamped on every save — and plays no part in the decision:

  1. Not already qualified.

  2. An MLResult exists for the current evaluation — the model ran, or its same-day result was reused.

  3. The base evaluation is approved — qualification never lifts a denied user.

  4. The user is not already at $200 (FloatProfile.IsIncreasedTo(200)).

  5. cfi_state.float_rank (completed-float count) is at least twoHundredMinFloatRank.

  6. risk_score exceeds twoHundredRiskScoreThreshold.

  7. The MLResult carries no enforced Float Tiers assignment — an enforced assignment already decides the user’s amount per evaluation (a shadow, unenforced assignment does not block).

The marker is saved onto the stored profile version already on record, never as a new version, so a limit change CFI saves meanwhile stays the latest; a failed save clears it again. Once set, the marker short-circuits every future qualification. It never touches the stored floats array: CFI has no $200 row, so the ladder raise is how a qualified user is offered $200 — an enforced Float Tiers assignment is the only other source of a $200 offer. The raise is not permanent: twoHundredRaises requires the marker and an MLResult on the call whose risk_score still exceeds twoHundredRiskScoreThreshold, so a qualified user whose score has dropped — or who has no MLResult on record — is on their stored limit until a later score clears the threshold again. The float-created handler flags the historical record of a $200 float two_hundred_test_float when the user carries the marker and no enforced Float Tiers assignment decided the float.

Profile ladder

limitFor (resolver.go) picks the limit the ladder is enabled up to, first match wins. Both entry points use it, Resolve with the MLResult its float check ran or reused and Profile with the latest evaluation’s:

  1. Float tiers, enforced assignment — the MLResult’s assignment has authoritative_v2 set. Approved: the assigned amount, raised or lowered past the stored limit. Denied: no float limit, every float tier disabled.

  2. Float tiers, no assignment — no MLResult, or one without an assignment, and the live underwriting.float_tiers.rollout flag on for the user: no float limit.

  3. $200 qualification — TwoHundredTestOffered on the profile and the MLResult’s risk_score above twoHundredRiskScoreThreshold: $200, every tier at or below it enabled.

  4. Rulebook — the stored profile’s limit, wherever CFI left it. An assignment snapshotted unenforced lands here too.

An enforced user therefore never gets a ladder from the stored profile. The first two branches mark the response float tiers shaped — see Float Tiers response shaping. The next-increase requirements are returned only under the rulebook branch — the stored CFI limit has to be the one the user is actually on.

The selectors read persisted snapshots (authoritative_v2 on the assignment, 200_test_offered on the profile, the MLResult’s risk_score) rather than live flags, so every call sharing an assignment — eligibility check, float request, fee lookup — shapes the same way. The live flag is read only when there is no assignment to trust.

Source: pkg/floatdecision