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
| Entity | PK | SK | GSI1 | TTL |
|---|---|---|---|---|
FloatProfile |
|
|
— |
none |
RuleOutcome |
|
|
— |
|
EvaluationResult |
|
|
|
|
HistoricalEvaluation |
|
|
— |
none (permanent) |
MLResult |
|
|
— |
168h; cleared on settle |
Rulebook |
|
|
|
none |
RulebookUpdate |
|
|
— |
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 |
|---|---|---|
|
string |
|
|
string |
Path evaluated: |
|
bool |
The path’s checks cleared the user (a holdout can be approved and still denied) |
|
bool |
Approved AND |
|
string |
Check that denied an unapproved user |
|
string |
The in-processing float and its collection payment (both arms) |
|
string |
Bank display name at rule-run time |
|
float |
In-processing float principal |
|
int |
Day bucket (business days since due date) at evaluation time |
|
— |
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.
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 |
|
All rule outcomes for a user |
|
Latest evaluation for item/account |
|
Evaluation by result ID |
GSI1: |
Historical evaluation by ID |
GetItem |
ML result for an evaluation |
GetItem |
All rulebooks |
|
Rulebooks by type |
GSI1: |
Rulebook change history |
|
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