I Gave a Fraud Agent No Vector Store and No Cypher. It Remembered Anyway.
LedgerLens is three days old, and it already has a history. The seed data includes a closed case dated eight days back: a ring of accounts sharing device fingerprints. Today a shared-device alert lands in the queue, and before the agent touches a single transaction, it searches its own memory. It finds the seeded case, sees the overlap, and tells the investigator this isn't a new ring, it's a recurrence. It cites the case with the date it was learned and takes a different path through the investigation.
The history is planted so the tests can check the agent finds exactly what I put there. What matters is how it found it: a fulltext query over Finding and Case nodes, in the same FalkorDB graph that holds the bank's accounts. No vector store. No sidecar database. No retrieval layer bolted onto a chatbot.
This post covers the design, three rules that keep the agent honest, and the one bug that taught me that restraint also has to be remembered.
The stack: FalkorDB v6.0.2 (on Redis 8.10.2), FastAPI, glm-5.3 as the model. Three synthetic banks (bank_aurora, bank_nimbus, bank_verdant), each about 192 persons, 280-286 accounts, and 1,133 transactions, with two planted fraud rings and one planted negative control per bank.
One graph per bank, and nothing else
Fraud questions are rarely lookups. "Which accounts are linked to this flagged transaction?" is a walk through shared devices, addresses, owners, and money edges. Text retrieval guesses relevance from fragments. A graph follows connections that were actually recorded, and when an agent freezes an account, the path it walked is the explanation it owes compliance.
So the graph is the whole context layer: data, memory, SOPs, and audit trail. One FalkorDB instance hosts one graph per bank. Isolation is the graph key, since every handle is select_graph("bank_<slug>") with the slug validated against a strict pattern. A query can't cross tenants because there's no way to name another one.
Rule 1: the model never writes Cypher
If the agent writes its own queries, its answers depend on code nobody reviewed. So it gets a fixed menu of 26 tools, and a human wrote the query inside each one. CYPHER.md is generated from the live tool registry, so every query the product depends on is published for review.
-
Ids are checked before anything runs.
validate_entitiesverifies arguments against the graph. A wrong id comes back asunknown_entityrather than a confident guess. -
Reads can't write. Every read tool goes through
GRAPH.RO_QUERY, enforced in the runner. -
Writes leave a trail.
freeze_accountandescalate_casecreate anActionnode with actor, reason, and timestamp, linked to the case.
Compliance policy lives in the graph too, chunked and fulltext-searchable (POL-14, POL-22, POL-31...). The agent is instructed to cite section ids in freeze reasons, fetching exact chunks like POL-31-C01, so every action points at text a reviewer can open.
Rule 2: every answer declares its evidence
Every session ends with a JSON verdict naming one of five answer shapes, and the tool layer rejects anything else.
| Shape | What it tells the reviewer |
|---|---|
with_path |
The graph held the relationship; here are the hops |
from_memory |
A past session established this; here's when |
not_in_graph |
The entities exist, but nothing connects them the way the question assumed |
unknown_entity |
The id isn't in the graph, so no query ran |
action |
A deliberate, audited write |
not_in_graph blocks "I couldn't find a link, so it's probably fine." In fraud work a missing relationship is itself a finding. unknown_entity blocks invented ids: no id in the graph means no query executes. Shapes don't make the reasoning correct. They make every claim say what kind of evidence it stands on, so a human knows what to check first.
Here's a real verdict from a fresh session asked about alert AUR-ALR-004. It took three turns (get_alert, search_memory, answer) and found 160 fulltext matches across findings, cases, and past alerts:
{
"shape": "from_memory",
"confidence": 0.9,
"summary": "Investigation of AUR-ALR-004 concluded no_case (learned 2026-10-16T11:01:41+00:00): a single 12,400 transfer from Vera Petrovna (AUR-ACC-0940) to new beneficiary Zaid Reyes (AUR-ACC-0003) with no associations, no ring links, and no other suspicious flows; the alert remains open with no case attached."
}
(Names are synthetic. This verdict exists because of the bug in the next section.)
Rule 3: the account it must not touch
Every seeded bank contains one high-value, legitimate, completely isolated account, with a fresh device, a fresh address, and no ring links. It's the exact balance an over-eager agent freezes just to look productive. The investigator should examine it, find nothing, and return no_case. The live-LLM tests assert it stays untouched on every run.
The bug: the agent forgot every time it was right
While prepping the demo, I found a hole in the memory design. Every case the agent opened persisted as graph memory: findings, links, timestamps. But when the investigator correctly refused to act on the negative control (a legitimate 12,400 transfer), that decision left no trace in the graph. The graph only remembered cases we opened, never the ones we rightly refused. The next session would re-investigate the same innocent alert from scratch.
Discipline wasn't memory. An agent that has to re-prove an account's innocence every time is an agent that eventually gets it wrong once.
The fix (commit e93909f): clearances now persist as Finding nodes, linked to the session that produced them and the account they're about. It was 38 lines in agents.py plus a 27-line regression test. A brand-new session now recalls the refusal with the finding id and timestamp at confidence 0.9, which is the verdict above.
Algorithms are just more tools
FalkorDB's graph algorithms sit on the same menu:
-
algo.WCCfor ring components -
algo.pageRankandalgo.betweennessfor hubs and brokers inside a component -
algo.SPpathsfor up to three shortest money routes, with leg amounts -
algo.maxFlowfor funnel capacity between two accounts
MATCH (a:Account {id: $from}), (b:Account {id: $to})
CALL algo.maxFlow({sourceNodes: [a], targetNodes: [b],
relationshipTypes: ['TRANSFER'], capacityProperty: 'amount'})
YIELD maxFlow
RETURN maxFlow
These algorithms want direct node-to-node edges, but I model every transfer as a Transaction node because the audit trail matters. So the graph keeps two projections alongside the real model:
-
TRANSFERaggregates transactions per ordered account pair (amount summed, count kept). One edge per pair also satisfiesalgo.maxFlow's simple-adjacency requirement. -
LINKED_TOrecords that two accounts share a device, an address, or an owner.
Rings come from LINKED_TO plus internal money density, because a household sharing a laptop is a connected component too. Size and internal flow separate a ring from a family.
A v6 gotcha: the algorithm API isn't uniform. Most procedures take a config map (algo.WCC({nodeLabels: ['Account'], relationshipTypes: ['LINKED_TO']})), but algo.pageRank still takes positional arguments (algo.pageRank('Account', 'TRANSFER')). I mapped the signatures against the running server instead of trusting the docs, and the working forms are in CYPHER.md.
Memory is just more graph
All three kinds of memory are nodes and edges.
-
Episodic:
Sessionnodes with persistedMessagechains (SAID_IN,NEXT). Each case gets its own session. -
Semantic:
Findingnodes with confidence and timestamps, linked to the entities they describe and fulltext-indexed next to case titles and alert summaries.search_memoryis how both the opening scene and the clearance recall work. -
Procedural:
PlaybookandPlaybookStepnodes hold the SOP retrieved before acting on each alert kind. Closing a case can calllearn_lesson, which appends a step to the playbook, sourced to the case that taught it. Every later investigation follows it, and every change has a paper trail.
Recalling a prior session is just a query. There's no second store to keep in sync.
Handoff as an edge
Two agents run the same loop with different privileges. The intake analyst triages, reads anything, and opens cases, but can't freeze. The case investigator can freeze, escalate, and close, but only after a handoff. open_case triggers it by writing a :HANDS_OFF edge from the analyst's session to a fresh investigator session. Coordination becomes something you can query, not a function call that vanishes.
The numbers
I ran four investigations in parallel against the live stack:
| Alert | Verdict | Tool calls | Wall time |
|---|---|---|---|
| AUR-ALR-001 | no_case (refused) | 11 | 120.3s |
| NMB-ALR-001 | froze_and_escalated | 37 | 221.4s |
| NMB-ALR-003 | froze_and_escalated | 54 | 265.1s |
| AUR-ALR-003 | froze_and_escalated | 62 | 408.6s |
Wall time includes LLM inference. So it takes about 11 tool calls to refuse and 37-62 to take down a ring.
Query latency, median of 25 runs against the live FalkorDB:
| Query | Median | p95 |
|---|---|---|
search_memory (fulltext) |
0.37 ms | 0.52 ms |
| 3-hop money-path traversal | 0.53 ms | 0.70 ms |
| 1-hop association links | 0.58 ms | 0.72 ms |
algo.pageRank over TRANSFER |
1.94 ms | 2.85 ms |
algo.maxFlow funnel capacity |
46.9 ms | 57.8 ms |
algo.WCC (whole graph) |
397.5 ms | 498.2 ms |
Memory recall is a sub-millisecond query, so it can run on every step of the loop at effectively no cost. The model is the bottleneck, not the graph. WCC is the heaviest because it labels the entire association projection, which is why it runs once per investigation and not per step.
Tests
44 pytest tests pass, including 3 live-LLM agent tests and 9 auth tests. Each bank has planted ground truth in tests/fixtures/planted_<tenant>.json, and the tests assert the tools find exactly what was planted and nothing more. For concurrency, the suite runs 24 parallel reads plus concurrent case and finding writes alongside the 4-way parallel investigations. I saw zero cross-talk and used no application-level locks. The MCP server exposes the same tool registry over stdio, so any MCP client can drive it.
Next
Temporal edges, because recurrence needs time and not just pattern, and feeding algorithm output into alert prioritization.
Code: github.com/Mihir-Rabari/ledgerlens. Clone it, start FalkorDB, run python seed.py --wipe, and the console walks an alert from triage to verdict with the hops behind every conclusion on screen.
Top comments (0)