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 |
|---|---|---|
|
|
The decision, bypass state, the shaped profile, and the legacy next-increase requirements |
|
|
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:
-
Float tiers (
float_tiers.go) — the MLResult carries an assignment withauthoritative_v2set: the assignment is the whole decision. -
Model override (
model.go) — the MLResult isauthoritative: its prediction overrides the rulebook’s approve/deny. -
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:
-
Not already qualified.
-
An MLResult exists for the current evaluation — the model ran, or its same-day result was reused.
-
The base evaluation is approved — qualification never lifts a denied user.
-
The user is not already at $200 (
FloatProfile.IsIncreasedTo(200)). -
cfi_state.float_rank(completed-float count) is at leasttwoHundredMinFloatRank. -
risk_scoreexceedstwoHundredRiskScoreThreshold. -
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:
-
Float tiers, enforced assignment — the MLResult’s assignment has
authoritative_v2set. Approved: the assigned amount, raised or lowered past the stored limit. Denied: no float limit, every float tier disabled. -
Float tiers, no assignment — no MLResult, or one without an assignment, and the live
underwriting.float_tiers.rolloutflag on for the user: no float limit. -
$200 qualification —
TwoHundredTestOfferedon the profile and the MLResult’srisk_scoreabovetwoHundredRiskScoreThreshold: $200, every tier at or below it enabled. -
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