Rule Engine
Evaluates a user against every active rulebook and aggregates the outcomes into one approve/deny decision per product (float, loan).
Rule — one eligibility check (Rules Reference). Dispatched by reflection:
the stored rule_arn must match a method on rules.RuleReflector
(pkg/rules/models.go).
Rulebook — an ordered set of rules with type (floats/loan), priority, apply_to, and
superseding (Rulebooks). A rulebook approves only if all of its rules
pass.
EvaluationResult — the persisted decision (DynamoDB).
Evaluation pipeline
Event-driven path:
-
An event lands on the rule-runner queue (Events) carrying a bare user ID. The event type is recorded as the run’s invocation type; every event triggers the same full run.
-
The rule runner fetches user data from the peer services, runs the CFI increase check against the float profile, resolves the early-advance gate, then executes every configured rule plus the model-feature rules.
-
Each rule’s outcome (result, error, features) is written as a RuleOutcome, then the runner publishes
user_rule_run_completed. -
The result runner aggregates the outcomes (below), persists the EvaluationResult, and sends the early-advance notification when granted.
-
API reads (
float_check,eligibility) serve the latest stored evaluation; there is no freshness window beyond the record’s TTL (RULE_OUTCOME_TTLdays, default 7). When none exists the response status isNOEVAL. A denied evaluation is served only once the prequal honor window has nothing to override it with.
Instant path (POST /{user_id}/underwriting/instant, invocation types signup and
reactivation): the API invokes the rule runner directly (same data fetch, CFI check, and rule
execution; the EventBridge publish is skipped), passes the returned event to a direct
result-runner invocation, and re-reads the just-written evaluation — one synchronous request,
no polling. Source: pkg/api/instant_check.go, pkg/rulerunner/handler.go
An instant check that approves also writes the same evaluation to a
PREQUAL_EVAL record whose TTL is six hours
(dynamo.PrequalHonorWindow). While that record lives, every API read that would otherwise serve
a denial serves it instead — the whole record, so result_id, rulebook results, and CFI state all
describe the evaluation the user is being approved on. The lookup runs after the
float decision is made — whichever branch made it — so it is
checked against the final answer, and the record is served as stored rather than re-decided.
This exists because the applicability roll in result aggregation is re-drawn on every run: a user whose inputs have not changed at all can be approved by one evaluation and denied by the next, because the rulebook that approved them rolled inapplicable the second time. Only approvals are written, and a failed write is logged rather than returned — the instant response still approves the user, they just lose the fallback.
The window ends when the user takes a float: creating one clears every prequal record they have
— across items, accounts, and ages, whichever record funded the float and whether one funded it at
all (after a float is created). No stored approval outlives an outstanding float.
Source: pkg/resultrunner/handler.go, pkg/floatdecision/resolver.go, pkg/floatcreated/handler.go
Every terminal outcome (success or a specific failure reason, e.g. rule_runner_timeout,
result_runner_error, eligibility_check_error) emits distribution metric
underwriting.instant_check.outcome, tagged flow:prequalify, invocation_type, outcome,
and — on success — approved; underwriting.instant_check.duration carries the same tags with
end-to-end request latency.
If insight-service has no employment record for the user (not merely an employment record with
unpopulated fields), the direct result-runner invocation overrides both evaluations to denied —
approved: false, amount: 0 — after the rulebooks have already decided, regardless of which
rulebook would otherwise have approved. This exists because several CFI increase
rows substitute account balance or competitor-EWA history for income verification, so a brand-new
user can otherwise clear a float/loan check before ever having their employment verified. Source:
pkg/resultrunner/handler.go
Result aggregation
Per rulebook (RequireAllRules, pkg/resultrunner/models.go):
-
The rulebook’s result starts approved (
-1) and flips to denied (0) if any rule outcome is0. -
calc_statusstartsOK; a rule error sets it to the rule’serror_code(CALCERR) orEVALERR, andCALCERRis sticky. An erroring rule also denies, so an errored rulebook never approves. -
A rule with no stored outcome contributes a zero-value (denied) result to its rulebook.
Across rulebooks (ProcessFloatCheck, pkg/float_evaluation/engine.go), run separately for
float and loan rulebooks:
-
Priority and
apply_toare re-read from the live rulebook set (not the stored outcome). -
Each rulebook gets a fresh random applicability roll:
rand(0,10000) < apply_to. The roll is per evaluation run — not sticky per user. -
Rulebooks are evaluated in descending priority order:
Condition Effect Not applicable (failed the roll)
Skipped
Already approved, rulebook not superseding
Skipped
Rulebook denied with status not
OKResult status set to that status; continue
Rulebook denied and superseding
Final deny: approval revoked, this rulebook decides, iteration stops
Rulebook approved and superseding
Skipped — a superseding rulebook can never approve
Rulebook approved, not superseding, not yet approved
Approve: this rulebook decides
The one production superseding rulebook (primary_rulebook_v1_0, type floats) has priority
100 — the lowest — so it is evaluated last, as a final veto.
A superseding rulebook whose own status is CALCERR/EVALERR only vetoes a result that
is already approved; on a still-denied result it just propagates its status.
|
Approved amount
The aggregated amount is a sentinel, not dollars: -1 = approved, 0 = denied. The dollar
limit a user can draw is the ladder limit pkg/floatdecision
chooses — an enforced Float Tiers assignment, the $200 qualification, else the stored
FloatProfile's highest enabled tier (FloatProfile.GetLimit), which the
CFI check maintains.
Status codes
| Status | Origin |
|---|---|
|
Default; evaluation completed |
|
A rule errored with |
|
Rule error without a |
|
No rule outcomes / no evaluations exist for the user (also the API’s no-result response) |
|
Rule-level: required input absent |
Source: pkg/resultrunner/runner.go, pkg/resultrunner/models.go, pkg/rules/models.go
A/B testing
-
Rulebook-level:
apply_tobasis points (YAML values are percent, multiplied by 100 on load) drive the per-run applicability roll above.apply_to: 0disables a rulebook without deleting it. -
A/B groups: a rulebook or an individual rule may carry an
ab_group; the result runner reads a GrowthBook string flag named after the group to pick which member applies to the user (pkg/resultrunner/runner.go).
After a float is created
The float-created handler consumes user_float_created and, per float:
-
Clears every prequal record the user has, ending the honor window. It runs first and needs nothing but the user id, so no lookup below can skip it — including for a float whose deciding evaluation cannot be resolved.
-
Copies the deciding evaluation into a permanent HistoricalEvaluation, flagged
two_hundred_test_floatfor a $200 float granted by the $200 qualification (skipped with a log when no evaluation exists). The deciding evaluation is the one the float event’sevaluation_idnames, not whatever is latest at handler time: a recalculation can land between the float being created and the handler running, and under the prequal honor window the latest evaluation can even be a denial. Falls back to the latest evaluation for events with noevaluation_idand for a deciding evaluation that has aged out. -
Settles the MLResult when the prediction was authoritative.
-
Sends the MLResult’s risk score and float tier to Segment as
ad_float_created_tier_<tier>whenever an MLResult exists, authoritative or not, and the user-service user record has afirebase_instance_id. A failed user lookup or send is logged and does not fail the event. -
Emits
underwriting_recalculate_user— for every created float — so the user’s stored evaluation always reflects the new float.