DynamoDB

Single-table design: every entity lives in site-underwriting (env var DYNAMO_TABLE), keyed by PK/SK with the # separator. GSI1 and GSI2 exist with KEYS_ONLY projection; GSI2 is declared but unused. Physical table config: Infrastructure.

Key design

Single-table layout
Entity PK SK GSI1 TTL

FloatProfile

USER#<user_id>

PROFILE#<created_on>

—

none

RuleOutcome

USER#<user_id>

RULE_OUTCOME#<rule_name>

—

RULE_OUTCOME_TTL days (default 7)

EvaluationResult

USER#<user_id>

EVAL_RESULTS#<item_id>#<account_id>#<created_date>

USER#<user_id> / EVAL_RESULTS#<result_id>

RULE_OUTCOME_TTL days (default 7)

HistoricalEvaluation

USER#<user_id>

HISTORICAL_EVALUATION#<result_id>

—

none (permanent)

MLResult

USER#<user_id>

ML_RESULT#<evaluation_result_id>

—

168h; cleared on settle

Rulebook

RULEBOOK

RULEBOOK#<rulebook_id>

RULEBOOK_TYPE#<type> / RULEBOOK#<rulebook_id>

none

RulebookUpdate

RULEBOOKCONFIGUPDATE

USER#<update_time>#<update_user>

—

none

Source: pkg/dynamo/ (SetKey/setKey functions per entity file)

RULE_OUTCOME_TTL (Terraform default 7 days) drives both rule-outcome and evaluation-result TTLs — there is no separate evaluation TTL variable. Because DynamoDB TTL deletion can lag up to 48h, every read also filters ttl > now in code.

Entities

FloatProfile — pkg/dynamo/float_profile.go

The user’s float/loan configuration, versioned by created_on. Fields: user_id, is_float_enabled, is_loan_enabled, cfi_enabled, floats (array of {id, amount, is_enabled} — amounts in dollars), loans (array of {id, amount_cents, is_enabled} — cents), created_on, reason, notes, last_cfi_rule_name, 200_test_available (true on every saved version), 200_test_offered ($200 qualification).

Semantics, defaults, and fees: Float Profiles.

RuleOutcome — pkg/dynamo/rule_outcome.go

One row per rule per user (the SK is the stored rule name, so a re-run overwrites). Fields: rule_name, user_id, loan (result encoding below), error, features (map of inputs and intermediates the rule saw), updated_date, ttl.

EvaluationResult — pkg/dynamo/evaluation_results.go

The aggregated decision for one rule run. Fields: result_id (<unix>_<uuid>), user_id, item_id, account_id, float_results and loan_results (Evaluation, below), cfi_state (CFIData snapshot), created_date, ttl, the five top-level early_advance_* attributes, and early_advance_experiment (below).

Evaluation (float_results/loan_results): approved, amount, deciding_rulebook, status (statuses), errors, results (RulebookResult array), result_id.

RulebookResult has no dynamodbav tags — items store Go field names (RulebookID, Result, Status, ApplyTo, Priority, Features, Approved, IsApplicable), while JSON responses use snake_case. Queries against stored items must use the Go names.

CFIData (cfi_state): current_limit, sub_rank, float_rank, total_float_rank, highest_float, account_balance, paid_subscription_count, is_reactivating_user, ewa_stats, is_feature_flag_enabled, two_hundred_test_available (always true), two_hundred_test_offered (mirrors FloatProfile’s 200_test_offered marker — $200 qualification).

PREQUAL_EVAL records

An approving instant check writes its evaluation a second time under SK: PREQUAL_EVAL#<item_id>#<account_id>#<created_date> (GSI1SK: PREQUAL_EVAL#<result_id>, item_type: prequal_evaluation_result). Same attributes and same result_id as the EVAL_RESULTS row — only the keys and the ttl differ, and the ttl is six hours out rather than RULE_OUTCOME_TTL days. Reads take the newest row and filter on ttl (Dynamo’s deletion lags), so the row’s own TTL is the honor window.

Creating a float deletes every one of the user’s rows, which ends the window before its TTL. The sweep is a begins_with query on the bare PREQUAL_EVAL prefix — no item, account, or date bound — and it ignores ttl, so neither a row under a relinked item nor an expired-but-uncollected one can answer a later read. Deletes are re-driven while DynamoDB reports UnprocessedItems, and a batch still pending after that fails the sweep (dynamo.ErrPrequalDeleteIncomplete) rather than reporting a deletion that did not happen.

Because result_id is unchanged, evaluation_id values handed to other services still resolve to the EVAL_RESULTS row through the GSI1SK: EVAL_RESULTS#<result_id> lookup.

What the window is for: Rule Engine.

early_advance_experiment

Written by the result runner from RuleGoodStanding’s outcome, for both experiment arms. The five superseded top-level early_advance_* attributes are dual-written and are what the live check reads; they are populated only for a granted advance, while this record’s float_id/ payment_id are recorded for holdout users too — readers migrating to this record must require granted.

Attribute Type Meaning

group

string

treatment, holdout, or empty (not bucketed — not a control population)

qualification

string

Path evaluated: transactions or float_rank

approved

bool

The path’s checks cleared the user (a holdout can be approved and still denied)

granted

bool

Approved AND treatment AND passing good standing — the field to join floats against

failed_check

string

Check that denied an unapproved user

float_id, payment_id

string

The in-processing float and its collection payment (both arms)

institution_name

string

Bank display name at rule-run time

float_amount

float

In-processing float principal

day

int

Day bucket (business days since due date) at evaluation time

float_rank, floats_considered, repayment_attempts, attempt_ratio, paid_subscriptions, approved_at

—

Float-rank path only; zero on the transaction path

Field semantics: Early Advance.

HistoricalEvaluation — pkg/dynamo/evaluation_results.go

Permanent copy of the evaluation that decided a taken float/loan, written by the float-created handler. Fields: EvaluationResult’s field set (minus ttl) plus amount, float_id, loan_id, two_hundred_test_float (true for a $200 float granted by the $200 qualification, not by an enforced Float Tiers assignment), and float_tier_assignment (copied from the decisive evaluation’s MLResult — Float Tiers). CreateHistoricalEval copies both early-advance shapes; records written before early_advance_experiment existed never age out, so history readers must handle both shapes indefinitely.

MLResult — pkg/dynamo/ml_result.go

One payback-predictor-v2 prediction per evaluation. Fields: user_id, evaluation_result_id, version, prediction_id, risk_score, authoritative (flag value snapshotted at prediction time), rank, term, threshold, invoked_at, float_tier_assignment (the Float Tiers snapshot: tier, tier_amount_cents, assigned_amount_cents (cents, unlike float amounts elsewhere), approved, guardrail, guardrail_amount_cents, last_repaid_amount_cents, last_repaid_days_late, current_offer_amount_cents, authoritative_v2, guardrails_applied), reasons (the model’s reason codes, most negative first; absent on records predating them), float_id, ttl (168h). When a float is created under an authoritative prediction (or an enforced float-tier assignment), Settle records the float_id and removes the TTL so the record is kept for audit. Flow: Payback Predictor V2. The record is exposed read-only as model_result on the summary-history and historical-evaluation responses (schema: API spec, ModelResult).

Rulebook — pkg/dynamo/rulebook.go

Global rule-set configuration. Fields: rulebook_id, rulebook_name, type (floats or loan), rules (array of {rule, rule_arn, props, ab_group}), apply_to (basis points, 10000 = 100%), priority (higher evaluated first), superseding, last_updated, ab_group. Loaded from YAML by CI: Rulebooks.

RulebookUpdate — pkg/dynamo/rulebook_update.go

Audit record per rulebook sync: update_user, update_time, and a full rulebooks snapshot.

Enums and encodings

Evaluation/calc statuses — OK, NODATA, CALCERR, NOEVAL, EVALERR. Where each originates: Rule Engine.
Source: pkg/resultrunner/runner.go, pkg/resultrunner/models.go, pkg/rules/models.go

Rule result encoding (RuleOutcome.loan, RulebookResult.Result, Evaluation.amount): -1 = approved, 0 = denied, >0 = approved for that dollar amount. In practice the aggregation only produces -1/0; the dollar limit comes from the FloatProfile (Rule Engine).
Source: pkg/dynamo/evaluation_results.go:25, pkg/resultrunner/models.go

Rulebook types: floats, loan (note the singular/plural asymmetry).
Source: pkg/dynamo/rulebook.go

Float amounts (dollars): 10, 20, 30, 40, 50, 75, 80, 100, 150, 200 — FloatAmount75 maps to 80 on read (CorrectFloatAmount), and $150 is never a limit (Float Profiles). Loan amounts (cents): 20000, 25000, 30000, 35000.
Source: pkg/dynamo/float_profile.go

Access patterns

Use case Query

Latest float profile

PK=USER#<id>, begins_with(SK, PROFILE#), desc, limit 1

All rule outcomes for a user

PK=USER#<id>, begins_with(SK, RULE_OUTCOME), filter ttl > now

Latest evaluation for item/account

PK=USER#<id>, begins_with(SK, EVAL_RESULTS#<item>#<acct>), desc, limit 1

Evaluation by result ID

GSI1: GSI1PK=USER#<id>, GSI1SK=EVAL_RESULTS#<result_id>

Historical evaluation by ID

GetItem PK=USER#<id>, SK=HISTORICAL_EVALUATION#<result_id>

ML result for an evaluation

GetItem PK=USER#<id>, SK=ML_RESULT#<eval_id> (+ app-level TTL check)

All rulebooks

PK=RULEBOOK (limit 100, unpaginated)

Rulebooks by type

GSI1: GSI1PK=RULEBOOK_TYPE#<type> (KEYS_ONLY → BatchGet re-fetch)

Rulebook change history

PK=RULEBOOKCONFIGUPDATE, ascending, paginated

Source: pkg/dynamo/ repository functions per entity file

Rulebooks are additionally cached in memory for RULEBOOK_CACHE_EXPIRE_MINUTES (Terraform default 1 minute); a refresh failure serves stale data when the cache is non-empty. Source: pkg/dynamo/cached_rulebooks.go