WritingCompliance

A risk scoring service that stores the factors, not the score

How to build a risk scoring service where every point is a stored factor row with its rule version and input snapshot, so a reviewer can explain any score.

A company applies at 09:14 on a Tuesday. It was incorporated eleven months ago, its registered office is in one country and its sole director lives in another, name screening returned a politically exposed person match at 0.82 similarity, and the declared expected volume is 250,000 EUR a month. The scoring service answers 64, the review band starts at 60, and a case opens. The reviewer's first question is not whether 64 is right but where the 64 came from, and a service that can only answer "the model said so" has already failed her.

This post describes a risk scoring service built for that question. The one constraint that shapes it: the score is never stored as the source of truth. It is the sum of factor rows, each naming the rule version that produced it, the input values it read and the points it added, so any score a reviewer, an auditor or the customer sees two years later can be rebuilt from rows they can open. I have built centralised KYB, KYC and AML dashboards, and this is the shape I would give the scoring behind one.

The request freezes its inputs

A scoring request names a subject (a company id) and a trigger (onboarding, periodic review or profile change) and nothing else. The service fetches every input the rules will need, registry record, director list, screening results, declared volume and country lists, and writes each as a snapshot row with the provider name, the provider's own list date or record version, and fetched_at. A provider that has not answered after 3 seconds gets a snapshot row with status missing rather than a null, and the snapshot is sealed before any rule runs.

The rule engine reads only the snapshot

The engine loads the rule set version that is current when the request arrives and pins its id to the run; the per-category caps live in that same version. Each rule is a pure function of the snapshot: it cannot call a provider, read the live profile or see another rule's output. A rule emits zero or more factors, and a rule whose inputs are missing emits a factor with status missing instead of returning nothing, because silence is indistinguishable from a pass.

A factor is a row, not a number

Each factor row holds the run id, rule id, rule version, points, category (geography, ownership, screening or volume), the snapshot row ids it read and a sentence filled from those values at write time: "Director resident outside the registered country (snapshot 4181, list version 2026-10): 12 points". No column in this table holds a score. The rows are append-only, and once the run is sealed nothing can be added to it.

The aggregator sums under caps

The aggregator sums points per category, applies the cap (geography cannot pass 30 however many address rules fire), then sums the capped categories into the run's score and stores it beside a hash of the factor rows it summed, so a verifier can re-sum and compare. It then reads the current threshold version, which is versioned apart from the rules so the bands can move without re-scoring anyone, and finds the band: 64 sits in review, which runs from 60 to 79. A run with any missing factor cannot land in approve, whatever the sum says.

The decision cites both versions

The decision row stores the run id, score, rule set version, threshold version, band, completeness and the trigger. When the band is review, a case opens with the factor rows rendered as their stored sentences, sorted by points, each linking to the snapshot rows behind it. Nothing in the case is computed at display time, so the reviewer who reopens it in two years reads the same sentences as the one who closed it.

The aggregator is the only place arithmetic happens, and it is small enough to read in one screen. It takes the factor rows and a threshold set and returns everything the decision row needs; it never touches the profile, the providers or the rules.

TypeScript
// simplified: the aggregation step, given sealed factor rows
type Category = 'geography' | 'ownership' | 'screening' | 'volume';

type Factor = {
  ruleId: string;
  ruleVersion: number;
  category: Category;
  points: number;
  status: 'scored' | 'missing';
  snapshotIds: number[];
};

type ThresholdSet = {
  version: number;
  bandFor(score: number): 'approve' | 'review' | 'decline';
};

const CAPS: Record<Category, number> = {
  geography: 30, ownership: 25, screening: 40, volume: 20,
};

function aggregate(factors: Factor[], thresholds: ThresholdSet) {
  const byCategory = new Map<Category, number>();
  for (const f of factors) {
    byCategory.set(f.category, (byCategory.get(f.category) ?? 0) + f.points);
  }
  let score = 0;
  for (const [category, sum] of byCategory) {
    score += Math.min(sum, CAPS[category]);
  }
  const complete = factors.every((f) => f.status === 'scored');
  const band = thresholds.bandFor(score);
  // a run with a missing input is never approved without a person
  const outcome = !complete && band === 'approve' ? 'review' : band;
  return { score, complete, band, outcome, thresholdVersion: thresholds.version };
}

Trade-offs

ChoiceWhat it buysWhat it costs
Snapshot every input before scoringThe explanation stays true after the profile or the list changesA snapshot table that grows with every run, and a re-score trigger for every input you care about
Points and caps in a versioned rule set, not in codeCompliance staff change a weight without a deploy, and every old run still cites the version it usedA rule set editor that needs four-eyes approval of its own, or weights drift without anyone noticing
Score derived from factor rowsAny score can be re-summed and verified against its hashReading a score is a query over rows, not a column read, so dashboards need a materialised copy
Thresholds versioned apart from rulesBands move on a Tuesday without re-scoring the whole baseEvery decision cites two versions, and a report has to say which threshold set it is grouped by
Missing input as a factor, never as zeroNothing is approved on data the service did not haveEvery provider outage becomes review cases, so the re-score job on recovery is not optional

Failure modes

  • The screening provider times out. At 09:14:00 the registry answers in 400 ms; the screening provider has not answered at 09:14:03. The snapshot row for screening is written with status missing, the screening rule emits a missing factor with 0 points, and the other categories sum to 41. Under a plain sum the company is approved below 60 with the 0.82 match never scored; here completeness is false, the outcome is review, and the case reads "Screening unavailable at 09:14:03, provider timed out". The customer sees "your application is under review". When the provider's health check passes, a provider_recovered event opens a new run on a fresh snapshot and its decision supersedes the first.
  • Someone edits a weight in place. A rule's points change from 12 to 20 without a new rule set version. Re-summing yesterday's run from its stored factor rows still gives 64, because the rows carry their own points, but a verifier that re-evaluates the rules against the snapshot gets 72 and the hash check fails. The containment is a trigger that refuses UPDATE on published rule set rows, and the verifier job that re-sums a sample of runs each night and pages on a mismatch.
  • Two rules score the same evidence. The registered-office rule and the director-residence rule both fire on the same country, 15 points each, and geography would reach 45. The cap holds it at 30, which is visible in the case as "geography 45, capped to 30", so the reviewer can see the overlap rather than wonder why the score is high. A longer fix is an evidence key on the factor row so the second rule on the same snapshot id contributes zero.
  • The director's address changes after scoring. The profile now shows a different country, but the case still says the old one. Because the sentence cites snapshot 4181 and the snapshot is immutable, the mismatch is between the profile and the snapshot, not inside the case, and the profile change event is a trigger that opens a new run. The old decision is never edited; it is superseded with a pointer to the new run id.
  • A threshold change would reclassify closed cases. The review band moves from 60 to 55. A dashboard that recomputes bands from live thresholds shows yesterday's approvals at 57 as review. The decision row pins threshold version 7, the dashboard groups by that column, and only a periodic review trigger re-scores those subjects under version 8.

Build the snapshot table and the factor table before any rule exists, and write the aggregator as a function over factor rows with a hash check. Add rules one at a time against that, and make the first rule the one that emits a missing factor when a provider does not answer, because every other guarantee in the service depends on it.

Written by Md Nasim Anjum, senior full-stack engineer in Manchester. He builds payment orchestration, KYC and KYB compliance platforms and conversational AI.

Get in touchAll writingRSS

More writing