Skip to content

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's observed_anomalies.length field 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: <5 historical 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 in input_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

  1. bank.risk.fraud_alert_raised — trace_id catalogue update. The wiki catalogue entry doesn't list trace_id as a required field. Every other event schema in this codebase includes it (ADR-031 conformance). Add trace_id (uuid, required) to the entry.

  2. Two new SD04 tables — flag for SD04 data-model add:

  3. payments.fraud_scores (immutable, ADR-048 Cat 1, FR-136 / DT-005)
  4. payments.scam_scored_payees (mutable, CAP-040 cache)

  5. Cross-bus IAM grantMOD-104-bank-risk-publish-grant.handoff.md covers both: BankPaymentsRole events:PutEvents for MOD-023 publish AND events:PutRule/PutTargets for MOD-024's relocated consumer rule.

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

  7. 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 under bank-payments/{env}/mod-023-thresholds, set the APPCONFIG_APPLICATION / APPCONFIG_ENVIRONMENT / APPCONFIG_PROFILE env vars and the ThresholdConfig service automatically picks them up.

  8. v2 model — XGBoost via SageMaker / ONNX is the documented swap path. Implement SageMakerScorer (or OnnxScorer) behind IFraudScorer, change the SSM/AppConfig value that selects which implementation is injected at Lambda cold start, bump model_version to xgboost-v1.0.0. SHAP values replace the static weights in feature_weights. The handler, audit write, event publisher, API contract, and DB schema are unchanged.

  9. MOD-149 (scam intelligence) integration — when MOD-149 ships, add an EB consumer for bank.scam.scam_database_updated that upserts into payments.scam_scored_payees. The score-payee API then returns real classifications instead of the NO_SCAM_DB_AVAILABLE stub.

  10. MOD-020 counterparty history — when MOD-020 ships, the payments.payments table will be populated and the AMOUNT_DEVIATION / COUNTERPARTY_NEW features will use real customer history. v1's to_regclass fallback degrades gracefully to "no history" defaults.