Technical design — MOD-023 Transaction fraud scorer¶
Module: MOD-023 System: SD04 Payments Processing Repo: bank-payments FR scope: FR-133, FR-134, FR-135, FR-136 NFR scope: NFR-020, NFR-021, NFR-024 Policies satisfied: PAY-005 (AUTO), CON-001 (AUTO), DT-005 (LOG) Capabilities: CAP-022 (ALERT), CAP-038 (GATE), CAP-040 (ALERT) Author: AI coding agent (Claude) Date: 2026-05-08
Objective¶
Real-time fraud-risk scoring on every payment instruction. MOD-020
(when built) calls MOD-023 synchronously before posting; the score
0–1000 maps to a decision (PASS / STEP_UP / BLOCK) using thresholds
loaded from AppConfig. The decision drives MOD-020's gating, MOD-068's
step-up auth, MOD-018's case management, MOD-024's device-flag
update, and MOD-063's customer notification — all via the
bank.risk.fraud_alert_raised event.
v1 model is rule-based (deterministic weighted-feature scorer behind the IFraudScorer interface). v2 = XGBoost via SageMaker / ONNX, implemented as a drop-in replacement for the IFraudScorer implementation. The handler, audit write, event publisher, API contract, and DB schema are unchanged across versions.
Architecture¶
API Gateway HTTP API
POST /internal/v1/fraud/score-payment ─► Mod023ScorePaymentHandler
POST /internal/v1/fraud/score-payee ─► Mod023ScorePayeeHandler
Mod023ScorePaymentHandler:
1. Zod validate request
2. Idempotency replay short-circuit (payments.idempotency_keys)
3. Load thresholds (ThresholdConfig with 30s TTL — AppConfig + defaults)
4. buildFeatures (ScoringInput assembly):
a. Sync invoke MOD-024 check-device (defaults on failure)
b. Sync invoke MOD-021 limits-check (defaults on failure)
c. SELECT payments.scam_scored_payees (stub when no row)
d. SELECT payments.payments — counterparty (skipped if MOD-020 absent)
5. RuleScorer.score(input) — pure, no I/O
6. INSERT payments.fraud_scores (FR-136 audit row)
7. Store idempotency response
8. If decision != PASS → publish bank.risk.fraud_alert_raised
cross-bus to bank-risk-platform bus
The IFraudScorer interface — v1/v2 swap contract¶
Per the orchestrator's MOD-023 ruling, the rule scorer is a pure function behind an interface. v2 implements the same shape; the handler doesn't change.
interface IFraudScorer {
readonly modelVersion: string;
score(input: ScoringInput): ScoringOutput; // pure, no I/O
}
The ScoringInput is pre-fetched by the handler via feature-builder
(all I/O lives there). The ScoringOutput carries:
- score (int 0–1000)
- decision (PASS | STEP_UP | BLOCK)
- model_version ("rule-v1.0.0" today)
- feature_scores (raw 0..max contribution per feature)
- feature_weights (the weights table — v2 = SHAP values)
- input_features (the raw input values, FR-136 evidence)
- warn_threshold_snapshot, block_threshold_snapshot
v1 feature set and weights (FR-136 documentary table)¶
Per the MOD-023 ruling. All weights / max contributions are named
constants in src/lib/rule-scorer-weights.ts. Bumping the model
version (e.g. "rule-v1.1.0") is required on any addition / change.
Module-load tripwire (assertWeightsSumIs1_00) blocks the Lambda
cold start if the invariants drift.
| Feature | Weight | Max contribution | Source |
|---|---|---|---|
| DEVICE_ANOMALY_COUNT | 0.25 | 250 | MOD-024 check-device observed_anomalies.length |
| VELOCITY_BREACH | 0.20 | 200 | MOD-021 limits-check decision |
| AMOUNT_DEVIATION | 0.15 | 150 | payments.payments 90-day median + stddev |
| SCAM_PAYEE | 0.15 | 150 | payments.scam_scored_payees (v1 stub: false) |
| COUNTERPARTY_NEW | 0.10 | 100 | First-time payee within 90 days |
| TRANSACTION_HOUR_RISK | 0.08 | 80 | Initiated_at converted to NZST/NZDT via Intl |
| PAYMENT_TYPE_RISK | 0.07 | 70 | INTERNATIONAL_TRANSFER → 70 |
Weights sum to 1.00. Max contributions sum to 1000. Each feature
score is bounded by its max — final score is the sum of
contributions, then clamped + rounded.
Feature scoring rules¶
- DEVICE_ANOMALY_COUNT:
min(anomaly_count × 50, 250). MOD-024'sobserved_anomalies.lengthfield directly. Cap at 5+ anomalies. - VELOCITY_BREACH: PASS → 0; APPROVAL_REQUIRED → 100; FAIL → 200.
On MOD-021 invoke failure: default
APPROVAL_REQUIRED+ WARN log (mid-range — neither hides the failure nor over-blocks). - AMOUNT_DEVIATION:
<5historical 90-day payments → 50 (mild uncertainty for new customers). Otherwise z = (amount - median) / stddev, clamp [0, 3], score =round((z / 3) × 150). Negative z is not a risk signal → 0. - SCAM_PAYEE: cached row
is_scam=true→ 150; false / missing → 0. Stub state recorded ininput_features.scam_payee.was_stub. - COUNTERPARTY_NEW: no prior SETTLED payment from this customer
to the destination within
counterpartyNewWindowDays→ 100; prior payment exists → 0. - TRANSACTION_HOUR_RISK: NZ Auckland local hour via
Intl.DateTimeFormat(Pacific/Auckland). High window (default 02..05 inclusive) → 80; shoulder window (3 hours immediately before, wrapping midnight — 23..01 by default) → 40; otherwise 0. - PAYMENT_TYPE_RISK: INTERNATIONAL_TRANSFER → 70; everything else → 0. Country-level scoring is v2 (requires the ML feature store).
Final score assembly¶
raw = sum(feature_scores) // each in [0, max_i], total in [0, 1000]
score = round(min(max(raw, 0), 1000)) // defence-in-depth clamp
decision = score >= block ? 'BLOCK'
: score >= warn ? 'STEP_UP'
: 'PASS'
v1 limitations — what v1 does NOT detect¶
Per the MOD-023 ruling, this is documented explicitly so the risk team has no false expectations.
- Coordinated fraud rings — requires graph analysis across accounts, not available until a graph-analytics module ships.
- Synthetic identity fraud — application-time problem (MOD-009 / MOD-029 territory), not payment-time.
- Authorised push payment (APP) fraud where the customer is deceived into making the transfer themselves — v1 will PASS a high-amount first-time international transfer if the device is clean and velocity is not breached, because the customer initiated it deliberately. This is the dominant fraud vector in NZ banking. APP fraud detection is a v2/v3 capability requiring ML on behavioural signals.
- Mule account network detection — requires cross-customer graph traversal, not in scope until MOD-042 historical features are available at inference time.
The risk team must size their human-review capacity assuming v1 will not catch APP fraud. The CON-001 AUTO satisfaction is "scoring runs on every payment without customer opt-in" — not "scoring catches every fraud type".
Threshold configuration (AppConfig + 30s TTL cache)¶
| AppConfig key | Default | Validation |
|---|---|---|
blockThreshold |
850 | int [0, 1000], must be > warnThreshold |
warnThreshold |
600 | int [0, 1000], must be < blockThreshold |
counterpartyNewWindowDays |
90 | int [1, 3650] |
transactionHourRiskHighStart |
2 | int [0, 23] |
transactionHourRiskHighEnd |
5 | int [0, 23] |
ThresholdConfig wraps the AppConfig Data API with a 30-second TTL
cache. On AppConfig failure (or when AppConfig identifiers aren't
configured at all — the v1 reality), it falls back to env-var
defaults and emits a WARN log + FAILED_USING_DEFAULT dependency
metric. Scoring never blocks on a config dep.
The warn_threshold_snapshot and block_threshold_snapshot are
captured into every payments.fraud_scores row at scoring time, so
a payment scored at 700 in March (then-block-threshold 850 → STEP_UP)
remains explicable even if the threshold changes to 700 in June.
Tables¶
| Table | Migration | Mutability | Purpose |
|---|---|---|---|
payments.fraud_scores |
V001 | APPEND-ONLY (V002 trigger) | Per-scoring audit row. FR-136 evidence — model_version, score, decision, feature_weights, input_features, threshold snapshots. ADR-048 Cat 1. |
payments.scam_scored_payees |
V003 | Mutable (UPSERT on re-score) | CAP-040 — per-payee scam-risk cache. v1 stub: every row is {is_scam=false, reason_code='NO_SCAM_DB_AVAILABLE'} until MOD-149 ships. |
payments.idempotency_keys |
(MOD-021 V005) | n/a | Shared SD04 store; module_id='MOD-023'. |
payments.fraud_scores — fields of interest¶
score int NOT NULL CHECK (score BETWEEN 0 AND 1000),
decision text NOT NULL CHECK (decision IN ('PASS','STEP_UP','BLOCK')),
model_version text NOT NULL, -- "rule-v1.0.0"
feature_weights jsonb NOT NULL, -- documentary weights
input_features jsonb NOT NULL, -- replayable inputs
warn_threshold_snapshot int NOT NULL,
block_threshold_snapshot int NOT NULL,
trace_id text NOT NULL,
scored_at timestamptz NOT NULL DEFAULT now()
EventBridge¶
Publishes bank.risk.fraud_alert_raised cross-bus on the
bank-risk-platform bus per the wiki event catalogue. Source
bank.risk, detail-type fraud_alert_raised. Schema in
schemas/bank.risk.fraud_alert_raised.json. trace_id added to
the schema for ADR-031 conformance — flagged for catalogue update at
handoff (the catalogue entry doesn't list it yet).
Cross-bus PutEvents requires the MOD-104 IAM grant filed in
docs/handoffs/MOD-104-bank-risk-publish-grant.handoff.md.
SSM contract¶
Reads¶
| Path | Owner |
|---|---|
/bank/{stage}/neon/pooler-host, /bank/{stage}/neon/direct-host |
MOD-103 |
/bank/{stage}/eventbridge/bank-payments/arn |
MOD-104 |
/bank/{stage}/eventbridge/bank-risk-platform/arn |
MOD-104 |
/bank/{stage}/iam/lambda/bank-payments/arn |
MOD-104 |
/bank/{stage}/observability/adot-nodejs-layer-arn |
MOD-076 |
/bank/{stage}/sns/alerts/arn |
MOD-104 |
/bank/{stage}/mod-021/limits-check-lambda/arn |
MOD-021 |
/bank/{stage}/mod-024/check-device-lambda/arn |
MOD-024 |
Writes¶
| Path | Value |
|---|---|
/bank/{stage}/mod-023/api/base-url |
API Gateway base URL |
/bank/{stage}/mod-023/score-payment/url |
Full score-payment URL |
/bank/{stage}/mod-023/score-payee/url |
Full score-payee URL |
/bank/{stage}/mod-023/{score-payment,score-payee}-lambda/arn |
Lambda ARNs |
/bank/{stage}/mod-023/fraud-scores-table |
payments.fraud_scores |
/bank/{stage}/mod-023/scam-scored-payees-table |
payments.scam_scored_payees |
Performance approach¶
NFR-021 budget breakdown (typical hot path):
| Step | Budget |
|---|---|
| MOD-021 limits-check (sync invoke) | ~20 ms |
| MOD-024 check-device (sync invoke) | ~30 ms |
| DB SELECT scam_scored_payees | ~5 ms |
| DB SELECT counterparty history (optional, skipped if MOD-020 absent) | ~5 ms |
| RuleScorer.score (pure function) | ~1 ms |
| DB INSERT fraud_scores | ~10 ms |
| EventBridge PutEvents (only on non-PASS) | ~25 ms |
| Lambda overhead + JSON | ~30 ms |
| Total | ~125 ms typical, 200 ms p99 with cold-start margin |
CloudWatch alarm trips at p99 ≥ 200 ms.
Error handling¶
- Sync HTTP paths — standard error envelope (HTTP 422 / 502 / 503 / 500).
- MOD-021 / MOD-024 dependency failure — log WARN, emit
fraud_dependency_total{outcome=FAILED_USING_DEFAULT}, fall back to conservative default values (per the ruling's bias: "do not default to 0 (would hide the failure) and do not default to 200 (would over-block on infra failures)"). Scoring continues. - AppConfig failure — log WARN, use env-var defaults, emit
fraud_dependency_total{outcome=FAILED_USING_DEFAULT,dependency=AppConfig}. Scoring continues with the safer (lower-block-threshold) defaults. - EventBridge publish failure (post-INSERT) — log ERROR, do not fail the response. The fraud_scores row is the source of truth and is already committed; alarm fires from the publisher's error metric. Caller still gets the decision.
Test approach¶
| Tier | Files | Cases |
|---|---|---|
| Unit (≥80% gate) | tests/unit/{errors,trace,logger,emf,amount,nzst-hour,rule-scorer-weights,rule-scorer}.test.ts |
59 |
| Contract | tests/contract/{fraud-alert-raised-schema,api-contract}.test.ts |
18 |
| Policy satisfaction | tests/policy/{pay-005-auto,con-001-auto,dt-005-log}.test.ts |
12 |
| Integration | tests/integration/{fr-133-score,fr-134-block,fr-135-step-up,fr-136-audit,nfr-024-audit-immutability,idempotency,observability-fields}.test.ts |
one per FR + NFR + idempotency + observability |
| Smoke | tests/verify-deployment.mjs |
score-payment synthetic invoke |
Open items / handoff follow-ups¶
-
bank.risk.fraud_alert_raised— trace_id catalogue update. The wiki catalogue entry doesn't listtrace_idas a required field. Every other event schema in this codebase includes it (ADR-031 conformance). Addtrace_id (uuid, required)to the entry. -
Two new SD04 tables — flag for SD04 data-model add:
payments.fraud_scores(immutable, ADR-048 Cat 1, FR-136 / DT-005)-
payments.scam_scored_payees(mutable, CAP-040 cache) -
Cross-bus IAM grant —
MOD-104-bank-risk-publish-grant.handoff.mdcovers both: BankPaymentsRoleevents:PutEventsfor MOD-023 publish ANDevents:PutRule/PutTargetsfor MOD-024's relocated consumer rule. -
CLAUDE.md SD04 events table — corrected in this commit to reference
bank.risk.fraud_alert_raised(cross-bus to bank-risk-platform) per the catalogue. -
AppConfig profile not yet provisioned — the threshold values in v1 come from env-var defaults set in
infra/functions.ts. When MOD-076 / ops provisions the AppConfig profile underbank-payments/{env}/mod-023-thresholds, set theAPPCONFIG_APPLICATION/APPCONFIG_ENVIRONMENT/APPCONFIG_PROFILEenv vars and the ThresholdConfig service automatically picks them up. -
v2 model — XGBoost via SageMaker / ONNX is the documented swap path. Implement
SageMakerScorer(orOnnxScorer) behindIFraudScorer, change the SSM/AppConfig value that selects which implementation is injected at Lambda cold start, bumpmodel_versiontoxgboost-v1.0.0. SHAP values replace the static weights infeature_weights. The handler, audit write, event publisher, API contract, and DB schema are unchanged. -
MOD-149 (scam intelligence) integration — when MOD-149 ships, add an EB consumer for
bank.scam.scam_database_updatedthat upserts intopayments.scam_scored_payees. The score-payee API then returns real classifications instead of theNO_SCAM_DB_AVAILABLEstub. -
MOD-020 counterparty history — when MOD-020 ships, the
payments.paymentstable will be populated and the AMOUNT_DEVIATION / COUNTERPARTY_NEW features will use real customer history. v1'sto_regclassfallback degrades gracefully to "no history" defaults.