card-sentry¶
Card-fraud detection and the response to it. Five pattern detectors and a rules check watch card payments and account activity for fraud campaigns, every card authorisation is decided, and each alert and decision is delivered to the systems that act on it, exactly once, even when a Worker dies mid-stream and the payment feed delivers a batch twice.
Repository ยท pinned to clink v0.15.0
What it does¶
A generator writes a deterministic tape of card authorisations, logins, one-time-password requests, password changes and transfers, out of event-time order: three days of it, or ninety for the cluster scenes. Thirteen campaigns are planted on dedicated accounts: nine that must raise an alert, and four negative controls that look close to fraud and must not, such as a verified account recovery or a plausible journey between two card-present payments.
| Check | The fraud it catches |
|---|---|
| Card testing | a burst of small declined payments, then one large approved one |
| Impossible travel | two card-present payments further apart than anyone could travel in the time |
| Account takeover | failed logins, a password change and a draining transfer, with no one-time password verified in between |
| Unverified one-time password | a code requested and never verified; the alert is the pattern that timed out |
| Structuring | transfers just under a reporting threshold whose running total crosses it |
| Watchlist and caps (rules) | payments at listed merchants or over a cap, with rules that arrive on a second stream and change without a redeploy |
The same detectors run three ways: embedded in one process over the tape, as a compiled job on a Coordinator and two Workers, and as a payment rail that reads Kafka and writes to PostgreSQL and Kafka:
flowchart LR
feed["Payment feed<br/>batches, one sent twice"] --> topic["Kafka topic"]
topic --> guard["clink: idempotency guard"]
guard --> det["five detectors"]
guard --> dec["one decision per authorisation"]
det --> cases["PostgreSQL case table<br/>two-phase commit"]
det --> alerts["Kafka alerts topic<br/>transactional"]
dec --> d1["decisions<br/>two-phase commit"]
dec --> d2["decisions_fast<br/>upsert by id"]
dec --> d3["decisions_append<br/>plain insert"]
Where clink does the work¶
Kafka carries the feed, and PostgreSQL and Kafka hold the results. Detection, decisions and the delivery guarantees are clink's.
| What happens | How clink does it | Read more |
|---|---|---|
| Fraud is recognised as a sequence, an absence or a timeout | clink's C++ pattern API: quantifiers, negative zones inside a sequence, and timed-out partial matches as a side output | Time, windows and CEP |
| Late events still land in the right place | watermarks bounded at 60 seconds of event time | Time, windows and CEP |
| Rules change mid-stream | broadcast state, updated from a second stream | State and backends |
| A batch sent twice counts once | the guard's seen-set is keyed state with an event-time expiry, checkpointed with everything else | State and backends |
| A card's risk profile is looked up live | queryable state: JSON point lookups and scans over HTTP, straight from keyed state | State and backends |
| The detectors run on a cluster | a compiled job plugin, built from the same pattern code as the embedded run | Jobs as compiled plugins |
| Each alert lands once in PostgreSQL and in Kafka | a two-phase-commit sink (PREPARE TRANSACTION at the checkpoint barrier, COMMIT PREPARED once the checkpoint is durable) and Kafka transactions committed with the same checkpoint |
Sink committer framework |
| A killed Worker loses nothing and repeats nothing | the job rolls back to its last checkpoint, the Kafka source resumes from its checkpointed offsets, and recovery finishes the commits | Checkpointing |
| The profile changes shape between versions | a registered state migration, and a pre-deploy check that refuses the upgrade without it | Fault tolerance and rescale |
| A fired alert can be re-examined | the flight recorder and clink replay --verify, on the detectors' own C++ types |
Deterministic replay |
What a run proves¶
An independent checker holds every run to the record of planted campaigns: exactly the nine expected alerts, silence from all four controls, and no alert anywhere else. Each detector also has its own tests through clink's testing framework.
The payment-rail run kills a Worker with SIGKILL while alerts are
committing, after the producer has re-sent a batch. Read from outside clink,
the case table and the alerts topic (at read_committed) each end with
exactly the nine alerts. The three decision tables show what each delivery
guarantee buys in the run the README records: 569,873 authorisations give
569,873 rows in the two-phase-commit table, an upsert table identical to it,
and 7,600 duplicate rows in the plain-insert table, which is what at-least-once
means. The price is visibility: rows in the two-phase-commit table appear when
their checkpoint completes (about a second at the median with one-second
checkpoints), and the other two tables show them within tens of milliseconds.
Switching the guard off is the negative control: the re-sent batch reaches the detectors twice and the checker fails the run. Exactly-once processing guarantees each record affects the output once; it cannot know that two records with one id are one event. That is domain logic, and the guard is where it lives.
What is distinctive here¶
The run is checked, not only its results. Every clink process can record the protocol steps it takes, and the model checker follows a recorded rail run, with its Worker kill and redeploy, through the TLA+ specification of clink's exactly-once protocol. The run is accepted only if every step is one the specification allows. The first recording found a case the model had not covered, an interval in which a sink stages nothing; the specification was extended for it, and that run is now one of the traces clink validates on every push.
Every alert is also evidence: with the flight recorder on, an alert's epoch
replays offline byte for byte, and --emit-test freezes it into a regression
test.
Run it¶
You need Python 3, CMake and a C++ compiler; the cluster and rail scenes also need Docker.
git clone https://github.com/orhaugh/card-sentry && cd card-sentry
scripts/get-clink.sh # the pinned clink release, into .clink/prefix
scripts/run-detections.sh # tape, build, tests, detection and the checker
cluster/run-rail.sh # Kafka in, PostgreSQL and Kafka out, a Worker killed
The repository's README covers the cluster run, the trace validation, replay and the state upgrade.