Detect the ring
while it's still forming.
A fraud ring is a graph shape on a clock. OriginChain runs connected components,
bidirectional all-paths and triangle enumeration against the same store that
/watch streams —
the detector sees every write the instant it commits.
Fraudsters exploit the gap between your systems.
Split the stack into Kafka + Neo4j + Postgres and your graph view runs a sync lag behind reality. A ring spinning up inside that window is invisible to the detector that has the right algorithm — the data it needs exists, but not where the algorithm runs.
On one substrate,
reactive /watch and graph
queries run against the same store. Zero replication lag between them, because there is
nothing to replicate. The detector and the data are the same surface.
graph view: a sync lag behind reality
graph view: the write that just committed
Write → stream → graph. One store, no hand-offs.
The loop that usually spans three systems and an ops team is two HTTP requests here: a standing /watch subscription and a graph query your handler fires when an event warrants a look.
The row and its relation edges — both directions — commit in one atomic write. There is no CDC pipeline copying it anywhere, because there is nowhere else it needs to go.
row + edges, one commit Every write puts an event on the stream by construction. Subscribe to the transfers/ prefix and the filtering happens server-side — your detector sees transfers, nothing else.
SSE · server-side filtered Components, triangles and bidirectional all-paths run against the same store the write just hit. There is no index that hasn't caught up and no replica that's behind.
query the write you just saw curl -N \
-H 'Authorization: Bearer ...' \
'https://<tenant>.db.originchain.ai/v1/tenants/:t/watch?prefix=transfers/'
data: {"op":"put","schema":"transfers","row":{"from":"acct_51",…}}
data: {"op":"put","schema":"transfers","row":{"from":"acct_19",…}} POST /v1/tenants/:t/graph/triangles
{
"schema": "transfers",
"min_amount": 5000,
"since": "PT2H"
}
runs against the store the event just came from Four fraud shapes, four graph primitives.
Each one runs against the direction-tagged relation keys your rows already have — no export,
no second engine — and every traversal endpoint accepts
?explain=true for per-hop
cost, so a slow investigation query tells you which hop exploded. The full catalogue is on the
graph page.
Accounts sharing a device, an address, a card collapse into one component. The ring surfaces as a component id — no path query to write, no pattern to guess in advance.
Every node-disjoint route between two flagged accounts, bounded by depth and max_paths. The bidirectional variant walks forward and reverse edges in the same pass and meets in the middle — same answer, 5–20× faster on real graphs.
Three accounts moving money in a cycle is the smallest laundering loop. Triangle enumeration finds every 3-clique — the mule triangles that component analysis alone won't isolate.
Inside a mule network, PageRank scores who the money concentrates through. Ties break by primary key, so a rerun for casework reproduces the same ordering.
A detector can't chase an edge that isn't there yet.
When a transfer commits, the row, its forward edge, its reverse edge and the stream event land together — one atomic write. A traversal running in another connection cannot observe a state where the row exists but the reverse edge is missing; it is structurally impossible to see a half-written transfer.
That is the property fraud tooling quietly depends on. A path query that walks money backward — who fed this account? — needs the reverse edge at the same instant the forward one exists. On a synced graph copy that guarantee needs a reconciler; on one substrate it holds by construction.
> POST /v1/tenants/:t/rows/transfers
> { "from": "acct_51", "to": "acct_19", "amount": 8200 }
committed atomically
row transfers/t_9041 ✓
edge acct_51 → acct_19 ✓
edge acct_19 ← acct_51 (reverse) ✓
event watch?prefix=transfers/ ✓
a concurrent traversal sees all of it — or none SELECT from_acct, COUNT(*) AS hits,
SUM(amount) AS total
FROM transfers
WHERE amount > 5000
GROUP BY from_acct
HAVING COUNT(*) > 3; The rule engine reads the same rows the graph walks.
Not every check is a traversal. Velocity thresholds, amount limits, per-account aggregates — plain SQL over the customer and transaction tables, with predicates pushed down to secondary indexes so rule evaluation stays fast.
Because rules and graph share one store, a rule hit can hand its account id straight to a components or all-paths call — no id mapping between systems, no window where the rule engine and the graph engine disagree about what happened.