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

Rule evaluation pipeline

Event-driven path:

  1. 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.

  2. 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.

  3. Each rule’s outcome (result, error, features) is written as a RuleOutcome, then the runner publishes user_rule_run_completed.

  4. The result runner aggregates the outcomes (below), persists the EvaluationResult, and sends the early-advance notification when granted.

  5. API reads (float_check, eligibility) serve the latest stored evaluation; there is no freshness window beyond the record’s TTL (RULE_OUTCOME_TTL days, default 7). When none exists the response status is NOEVAL. 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 is 0.

  • calc_status starts OK; a rule error sets it to the rule’s error_code (CALCERR) or EVALERR, and CALCERR is 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:

  1. Priority and apply_to are re-read from the live rulebook set (not the stored outcome).

  2. Each rulebook gets a fresh random applicability roll: rand(0,10000) < apply_to. The roll is per evaluation run — not sticky per user.

  3. 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 OK

    Result 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

OK

Default; evaluation completed

CALCERR

A rule errored with error_code: CALCERR (e.g. missing input data); sticky per rulebook

EVALERR

Rule error without a CALCERR code; outcome-fetch or result-function failure

NOEVAL

No rule outcomes / no evaluations exist for the user (also the API’s no-result response)

NODATA

Rule-level: required input absent

Source: pkg/resultrunner/runner.go, pkg/resultrunner/models.go, pkg/rules/models.go

A/B testing

  • Rulebook-level: apply_to basis points (YAML values are percent, multiplied by 100 on load) drive the per-run applicability roll above. apply_to: 0 disables 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:

  1. 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.

  2. Copies the deciding evaluation into a permanent HistoricalEvaluation, flagged two_hundred_test_float for 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’s evaluation_id names, 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 no evaluation_id and for a deciding evaluation that has aged out.

  3. Settles the MLResult when the prediction was authoritative.

  4. 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 a firebase_instance_id. A failed user lookup or send is logged and does not fail the event.

  5. Emits underwriting_recalculate_user — for every created float — so the user’s stored evaluation always reflects the new float.

Metrics

underwriting.evaluation.result, underwriting.evaluation.loan.result (result runner); aup.rulerunner.data.fetch, aup.rulerunner.data.fetch.total, aup.rulebook.execution, aup.rule.executed (rule runner). Source: pkg/resultrunner/runner.go, pkg/rulerunner/