A fraud system can make two expensive mistakes. It can miss a harmful action, or it can block a legitimate user who needed the product to work. A useful pipeline must therefore do more than produce a score. It must preserve the evidence behind the score, respond within the product’s time limit, and let an operator correct a decision safely.

The architecture starts with one question: which decision must happen before the user action completes, and which analysis can happen afterward?

separate the product decision from later analysis

Some checks belong in the request path. A withdrawal may need an immediate allow, challenge, review, or deny decision before funds move. Other work can happen asynchronously: device graph updates, model feature calculation, investigator notifications, and long-term pattern analysis.

A practical shape looks like this:

API request
    |
    v
fast checks ---> allow / challenge / hold
    |
    v
durable event ---> enrichment ---> rules and scoring ---> review queue
                                         |
                                         v
                                  decision audit log

Do not put every fraud calculation in the synchronous path. A slow enrichment provider should not turn every checkout or withdrawal into a timeout. Decide which minimum signals are required for the immediate action, then publish durable facts for deeper analysis.

design the event as a fact

Events should describe what happened, not command an unknown consumer to do something.

type WithdrawalRequestedV1 = {
  eventId: string;
  eventType: 'withdrawal.requested.v1';
  occurredAt: string;
  accountId: string;
  withdrawalId: string;
  amountMinor: number;
  currency: string;
  destinationFingerprint: string;
  requestId: string;
  context: {
    ipHash: string;
    deviceId?: string;
    sessionAgeSeconds: number;
  };
};

Use minor currency units so 1250 means a precise amount such as 12.50. Keep raw secrets and unnecessary personal data out of the event. A destination fingerprint can support matching without copying the full destination into every system.

The event needs a stable eventId for duplicate handling and a domain identifier such as withdrawalId for investigation. requestId connects the event to API and worker logs.

publish only after the business state is durable

This sequence is unsafe:

  1. Publish withdrawal.requested.
  2. Attempt to insert the withdrawal.
  3. The database insert fails.

Consumers now analyze an operation that does not exist. Reversing the order creates the opposite gap: the database commits, the process crashes, and no event is published.

Use a transactional outbox when losing the event would break the workflow:

BEGIN;

INSERT INTO withdrawals (id, account_id, amount_minor, status)
VALUES ($1, $2, $3, 'pending_review');

INSERT INTO outbox_events (id, event_type, aggregate_id, payload)
VALUES ($4, 'withdrawal.requested.v1', $1, $5);

COMMIT;

A relay reads unsent outbox rows and publishes them. It may publish the same row more than once, so consumers still need idempotency. The outbox solves “state committed but event disappeared”; it does not promise exactly-once delivery.

make every consumer safe to replay

At-least-once delivery is normal. A worker can finish its database write and crash before acknowledging the message. The broker then delivers it again.

Give each consumer its own processed-event record:

CREATE TABLE processed_events (
  consumer_name text NOT NULL,
  event_id text NOT NULL,
  processed_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (consumer_name, event_id)
);

Claim the event and write the consumer’s result in one transaction. If the primary key already exists, return success without repeating the side effect.

Do not use one global “processed” flag. The enrichment worker and notification worker are independent consumers. One completing should not hide the event from the other.

keep rules understandable before making them clever

Start with explicit signals and record which ones fired.

type RiskSignal = {
  code: string;
  weight: number;
  detail: Record<string, string | number | boolean>;
};

function evaluateWithdrawal(input: FraudContext): RiskSignal[] {
  const signals: RiskSignal[] = [];

  if (input.accountAgeHours < 24 && input.amountMinor > 50_000) {
    signals.push({
      code: 'NEW_ACCOUNT_HIGH_VALUE',
      weight: 35,
      detail: { accountAgeHours: input.accountAgeHours },
    });
  }

  if (input.failedAuthAttemptsLastHour >= 5) {
    signals.push({
      code: 'RECENT_AUTH_FAILURES',
      weight: 25,
      detail: { attempts: input.failedAuthAttemptsLastHour },
    });
  }

  return signals;
}

The numbers are policy choices, not universal fraud thresholds. Store the rule version with every decision. If the threshold changes next week, an investigator must still know why yesterday’s action was held.

treat the score as one input to a decision

A score without a policy is ambiguous. Define decision bands explicitly:

Score Decision Product behavior
0-29 Allow Continue and log the decision
30-59 Challenge Require stronger verification
60-79 Review Hold the action for an operator
80-100 Deny Stop the action and record the reason

These example bands must be tuned using your own outcomes and risk tolerance. The architecture should let policy change without rewriting event history.

Store the score, fired signals, rule version, model version if present, and final policy action. Never store only riskScore: 72; that number becomes useless when someone asks what produced it.

a SentinelFi project slice

For a focused SentinelFi portfolio implementation, I would build one end-to-end card-payment or withdrawal flow rather than a giant generic fraud platform.

The demonstrable slice would include:

  1. An API records the pending action and outbox event in one transaction.
  2. A relay publishes the event with a stable ID.
  3. An enrichment consumer loads account age, recent attempts, and device history.
  4. A rules consumer stores named signals and a versioned score.
  5. Policy maps the score to allow, challenge, review, or deny.
  6. A review endpoint lets an operator record a reason and override a held decision.
  7. Replaying the original event changes no completed side effect.

The proof would be a duplicate-delivery integration test, a timeline query for one decision, and a small load test separating synchronous latency from asynchronous processing time. That is enough to demonstrate architecture, reliability, and operator thinking without pretending a student project has production-quality fraud intelligence.

preserve an investigation timeline

An audit timeline should answer who, what, when, and why:

{
  "decisionId": "risk_018",
  "subjectId": "withdrawal_72",
  "eventId": "evt_91",
  "ruleVersion": "withdrawal-rules-7",
  "signals": ["NEW_ACCOUNT_HIGH_VALUE", "RECENT_AUTH_FAILURES"],
  "score": 60,
  "initialAction": "review",
  "finalAction": "allow",
  "reviewedBy": "analyst_12",
  "reviewReason": "verified customer travel and device change",
  "decidedAt": "2026-07-11T09:12:00Z"
}

Protect this data with strict authorization. Hash or tokenize identifiers where full values are unnecessary. Define retention by legal and operational need instead of keeping every signal forever.

plan for late and missing data

Events can arrive out of order. Enrichment providers can fail. A device signal may appear after an immediate decision.

Represent data quality directly:

  • available: the signal was fetched successfully.
  • missing: no value exists.
  • stale: the value is older than the policy accepts.
  • error: retrieval failed.

Do not silently convert every missing value to zero. “No failed attempts” and “failed-attempt service unavailable” are different facts. Your policy may allow low-risk actions when data is unavailable and hold high-risk actions until it recovers.

monitor decisions, not only worker health

Queue depth and error rate matter, but a healthy fraud system also watches outcomes:

  • Percentage of actions allowed, challenged, reviewed, and denied.
  • Review queue age and operator resolution time.
  • Duplicate-event rate by producer.
  • Signal availability and enrichment latency.
  • Override rate for each rule version.
  • Confirmed fraud and legitimate-user friction, measured with an honest delay window.

A sudden rise in denials may mean an attack, a broken feature, or a bad threshold. Operational metrics need enough context to distinguish them.

test the uncomfortable paths

The most valuable tests are not another successful score calculation.

Test that duplicate delivery creates one decision. Test a crash after the consumer writes but before it acknowledges. Test an unavailable enrichment service. Test two analysts reviewing the same case. Test replay under a newer rule version without rewriting the original decision. Test that an unauthorized user cannot read the investigation details.

The core design principle is traceable uncertainty. The pipeline should preserve what happened, what was known at the time, which policy acted, and how a human changed the result. That makes fraud detection safer to operate and much easier to improve than a mysterious number flowing through a queue.