$200 Float-Limit Test

This page documents a temporary experiment: certain high-risk-score, maxed-out-CFI-rank users are offered a $200 float tier that’s otherwise hidden from everyone. $200 is a pre-existing, deliberately hidden tier in this system (see Rulebooks/CFI internals — dynamo.FloatAmount200, the CFI rule sub_rank_8_float_rank_6_prev_200_amt_200) — it was previously granted only via a one-off ops script (scripts/200_float_test, reverted by scripts/undo_200_float_test). This feature replaces that ad hoc mechanism with a GrowthBook-gated one: turning the master flag off immediately stops new users from qualifying, and stops a fresh eligibility check from showing $200 to anyone (including a previously-offered user) — but does not retroactively rug-pull a user already mid-flow on a float they were already shown and are actively creating. See Hot-Path-Only Tier Injection for exactly which endpoints do which, and why.

Where It Runs

The qualification check runs inside Checker.Check (pkg/float_evaluation/eligibility.go) — evaluateTwoHundredFloatTest, called right after the user’s FloatProfile is loaded — so it only evaluates during eligibility building (GetEligibilityCheck, InstantCheck), not on a raw FloatRequest call (which never runs Eligibility.Check). It requires evaluation.CFIState (rank, highest float) and a persisted MLResult (see Payback Predictor V2), so it depends on the payback predictor v2 model having produced a prediction for the user’s current evaluation — but is otherwise independent of that model’s own authoritative rollout state: it reads RiskScore/Threshold directly off the persisted MLResult via MLResults.GetByEvaluationID, bypassing `MaybeRunFloatCheck’s authoritative gate (which would otherwise hide the score entirely in shadow mode).

Qualification Criteria

All of the following must hold (checked in this order, cheapest/no-I/O gates first):

  1. underwriting.two_hundred_float_test GrowthBook flag is on for the user (master kill switch).

  2. The user’s base evaluation is already approved (evaluation.FloatEvaluation.Approved) — this test never offers a higher tier to an otherwise-denied user.

  3. The user is not already organically increased to $200 (FloatProfile.IsIncreasedTo(200)) — this test is for users who don’t already have it through the real CFI graduation path.

  4. evaluation.CFIState.FloatRank >= 8 (completed-float count, not a capped rank).

  5. A MLResult exists for the current evaluation, with RiskScore > 0.97 — a single threshold; evaluation.CFIState.HighestFloat is no longer part of the qualification decision (an earlier version branched on whether the user had paid back a $100+ float, using two different thresholds — simplified to one, with rollout risk managed entirely by the two flags instead).

Criterion 2 means TwoHundredTestOffered can only ever be set when the current evaluation is approved. Since it’s a permanent decision (never re-evaluated) while base approval is recomputed fresh on every rule run, it’s possible in principle for a later evaluation to deny a previously-offered user. warnIfTwoHundredTestOfferedButDenied (called from Check() right after evaluateTwoHundredFloatTest, regardless of the master flag’s current state) logs a warning if this combination is ever observed — a pure monitoring check, not expected to fire in practice.

Rollout: Two Independent Per-User Percentages

Both GrowthBook flags are evaluated per-user (GetFeatureEnabledForUser(ctx, flag, userID, false)) and are expected to be configured in GrowthBook as sticky percentage rollouts keyed by user ID — "sticky" meaning a given user’s bucketing outcome for a flag doesn’t change across evaluations, the same property MLResult.Authoritative snapshotting and TwoHundredTestAvailable/TwoHundredTestOffered already rely on elsewhere in this feature:

  • underwriting.two_hundred_float_test — what fraction of users even enter the test at all (e.g. 1%). Users outside this rollout never reach the risk-score check.

  • underwriting.two_hundred_float_test_authoritative — of the users who qualify (pass every criterion above), what fraction get the authoritative (treatment) arm vs. control (e.g. 50/50).

Once a user meets every criterion above, underwriting.two_hundred_float_test_authoritative buckets them into the authoritative (treatment) or control arm — mirroring the Payback Predictor V2 enabled/authoritative two-flag pattern. Two booleans are persisted onto dynamo.FloatProfile, once, permanently:

Field Meaning

200_test_available

The user met every qualification criterion, regardless of which arm they landed in. Set for both arms, so there’s a stable comparison population for analyzing the experiment.

200_test_offered

The user was additionally bucketed into the authoritative arm — only these users actually see the $200 tier.

Once 200_test_available is true, evaluateTwoHundredFloatTest short-circuits immediately on every future call — the decision, once made, is never revisited or recomputed, even if the user’s risk score or CFI data changes later. While false, it’s cheap to re-evaluate on every eligibility check (one extra MLResults.GetByEvaluationID read) until the user either qualifies or the test ends.

Important: these two fields are never cleared or touched by the CFI increase-check path (pkg/rulerunner/increase.go, saveNewFloatProfile) — that function takes the loaded FloatProfile by value and only explicitly overwrites CFIEnabled, Floats, Reason, Notes, and LastCFIRuleName, so any other field (including these two) survives a normal, unrelated CFI increase untouched by ordinary Go value-copy semantics. Locked in by a regression test (TestIncreaseFloatLimitPreservesTwoHundredTestFlags).

Hot-Path-Only Tier Injection

The stored FloatProfile.Floats array is never modified by this feature — the $200 tier is injected only into API responses, in toAPIFloatProfile (pkg/api/float_profile.go), the single shared conversion function used by four call sites. A checkTwoHundredTestLive bool parameter splits its behavior by caller:

twoHundredTestShows := profile.TwoHundredTestOffered &&
    (!checkTwoHundredTestLive || masterFlagIsOnLiveForThisUser)

if twoHundredTestShows {
    // enable every tier <= $200 (FloatProfile.SetToLimit, on a copy — never from.Floats itself),
    // not just the $200 row, so there's no gap like $80/$100 disabled while $200 shows enabled
    inject an enabled $200 FloatProfileSetting into the response
}
Caller checkTwoHundredTestLive Why

buildFloatEligibility (GetEligibilityCheck/InstantCheck, pkg/api/float_check.go)

true

The app’s actual "check my eligibility" call — floatme-flutter never calls GetFloatProfile directly, it only ever sees FloatProfile embedded in this response (confirmed by direct research). A fresh call here must stop showing $200 once the flag is off, even for a user who was already offered it — otherwise re-opening the app / re-checking eligibility would keep advertising an offer that’s supposed to be dead.

eligibilityToAPILoanEligibility (GetLoanEligibility, pkg/api/loan_check.go)

true

Same reasoning — another app-facing eligibility-check response.

GetFloatProfile (standalone /underwriting/profile endpoint, pkg/api/float_profile.go)

false

Called by float-service’s fee lookup (getFloatFeeGetFloatProfile(ctx, userID)) during actual float creation — a fresh, independent call with no amount or evaluation ID to correlate it back to whatever eligibility check already showed the user the offer (confirmed directly in float-service’s checkUnderwriting/getFloatFee, pkg/api/floats.go). Also called directly by admin-api/backoffice’s member-detail support tooling, which should reflect the user’s true persisted entitlement rather than flicker with the live flag.

GetTemporaryFloatProfiles (pkg/api/temporary_float_profile.go)

false

Not a live eligibility check; moot in practice since TwoHundredTestOffered is never set on a TemporaryFloatProfile’s embedded `FloatProfile anyway.

Why GetFloatProfile can’t just also check live: if it did, a live recheck there would let the master flag be turned off in the exact gap between "shown $200 in the app" and "submits the $200 float creation" — silently dropping the tier from float-service’s fee lookup, which falls back to the default fee for an unmatched amount rather than erroring (getFeeFromProfile). That’s the rug-pull this split avoids: the user would be charged the wrong fee for a float they were already shown and committed to. Splitting by caller instead gets both properties at once — a fresh eligibility check honors the kill switch, but an in-flight creation is never second-guessed.

The injection runs after the existing $80/$100 "naive" tier-injection check in toAPIFloatProfile (which inspects the last, by-Id, entry’s Amount) — running before it would have the $200 row (Id: "8") sort last and break that check.

floatme-flutter requires no changes for this: its float-amount tier buttons are rendered dynamically from whatever float_profile.floats array the backend returns (no fixed enum), and additive JSON fields are safely ignored by already-installed app versions.

Float-Created Tracking

pkg/floatcreated/handler.go’s `trackTwoHundredFloatTest (called from CreateHistoricalFloatEval, alongside the existing settleMLResult step) fires whenever a $200 float is actually created (float.Amount == 200, from the user_float_created event float-service emits). The FloatProfile is fetched once (only for $200 floats) and reused both here and for the historical-eval field below:

  • If the user’s FloatProfile.TwoHundredTestOffered is true, it logs "200 float created under two hundred float test" and emits underwriting.two_hundred_float_test.float_created (a Datadog count) — the log gives an immediately-searchable per-float record alongside the aggregate metric; watch either to manually disable underwriting.two_hundred_float_test once volume reaches 2800.

  • If TwoHundredTestOffered is false (a $200 float created some other way, e.g. an organic CFI graduation), it’s logged ("200 float created unrelated to two hundred float test") but not counted toward the cap.

There is no automated enforcement of the 2800 cap — this is a manual, metric-driven cutoff by design.

Historical Evaluation Field

dynamo.HistoricalEvaluation.TwoHundredTestFloat (pkg/dynamo/evaluation_results.go) is set alongside the log/metric above, on the same historical-evaluation record CreateHistoricalFloatEval already saves for every created float — true only when this specific float was $200 and created under the test. This lets the data team query funding volume directly off historical evaluations (joinable with all their other reporting) rather than relying solely on the Datadog metric.

Ops Checklist Before Enabling

  • float-service’s fraud-check ceiling (MAX_FLOAT_AMOUNT, cmd/commons/config.go in float-service) must be deployed at ≥ 200 — its Terraform default is 200 (deploy/variables.tf:229-233 in float-service), but the actual deployed value lives outside that repo and should be confirmed before enabling the test.

  • GrowthBook’s loan/float fee schedule (GBLoanFeesConfigName) should have a "200" entry — if missing, the injected $200 tier’s fee will show as $0 rather than erroring, since `toAPIFloatSetting’s fee lookup is a plain map read with no missing-key guard.

  • No code changes are required in float-service or floatme-flutter for this feature.

See Also