TigerGraph x Hacker House Goa 2026, final round. Team PixelPaws: Subhojyoti Maity and Arni Bachhar.
In the first round we built Kavach ("shield"), an agent that investigates card-fraud alerts on a TigerGraph graph. The final round raised the bar: 18 new alerts, 650,938 transactions (July 2016 to January 2017), the bank's 5,585 closed cases, a written Fraud Policy v1.0, and 14 challenges that read like a bank's audit checklist. This post covers what we built and how TigerGraph carries the weight.
The problem: an alert is a reason to look, not a verdict
A bank's fraud model flags a transaction. Someone then has to decide: is it fraud, how far does it go, which other cards are involved, what should the bank do, and must it file a suspicious activity report (SAR)? Wrong one way, the fraud keeps running. Wrong the other, an innocent customer gets blocked.
It is tempting to paste the alert into an LLM and ask for a verdict. For a bank, that is the wrong design:
- It cannot be re-run. An examiner asking "why did you block this card?" needs the queries, parameters and rows behind the answer, not a paragraph.
- Free text is an attack surface. Six of the 18 alerts carry hostile text. A model that reads it can be steered by it.
- It cannot be held to a policy. The policy says who may approve what, when a report is required and when to escalate. Those rules belong in code with unit tests.
So in Kavach, tools and policy decide, and the model only writes prose.
Every alert goes through nine fixed steps, in the same order every time:
- Intake. The free text becomes data: quoted, redacted, zero weight. The model never sees it.
- Gather. Graph tool calls (card history, device and email neighbourhoods, closed cases), each traced with its parameters and returned ids.
- Detectors. One-card, network, history and typology detectors, plus grown detectors a human L2 approved.
- Evidence model. Likelihood ratios calibrated on the closed cases, combined over independent evidence groups into a probability.
- Policy engine. Fraud Policy v1.0 as code (R1 to R10, section 3a, approval routes): verdict, actions with routes (auto, L1, L2), evidence requests.
- Episode. Affected transactions, connected cards, exposure.
- Recall. Fused precedent, ring component and case memory, attested at zero weight.
-
Narrative. A facts JSON goes to the LLM (Google Gemini
gemini-3.8-flash), which writes the summary and SAR narrative. Every id in its text is checked; on failure a template is used and recorded. -
Attest and persist. Every claim records what, the evidence ids, how (query plus parameters) and when (timestamp plus dataset version). The case is written back to TigerGraph and read back before the answer may say
written_to_graph: true.
A harness enforces hard bounds (80 tool calls per case, 90 s per call, 15 minutes per case) and fails closed. Tools sit behind one registry with three lanes: TigerGraph installed queries (--backend tg), the TigerGraph MCP server (--backend mcp) and a local DuckDB mirror (--backend duck).
How we used TigerGraph
The graph lives on a Savanna workspace (TigerGraph 4.2.5) at the smallest size, TG-00: 13 vertex types (from Customer, Card and Transaction to ClosedCase, InvestigationCase, CaseEvent, Approval and Detector), 650,938 transactions and about 3.6M edges, with counts checked against the source files.
Installed GSQL queries are the agent's tools. There are 45 installed queries, all read-only. Each tool is one query with typed parameters, so "who else paid this recipient email domain?" is a traversal with accumulators, not a prompt. On the TigerGraph lane there is no fallback to DuckDB: a graph failure is an incident.
Vector search fused with traversal. Closed-case notes, policy sections and investigation summaries carry 384-d embeddings searched with vectorSearch(). The interesting query is fused_precedent. It walks from the case card to its recent transactions, to the rare elements they carry (a device profile, recipient email domain or billing region used by 25 or fewer cards), to the other cards using them, to every closed case on those cards, then runs vectorSearch(..., {candidate_set: X}) over exactly that set. The fused top 5 differed from plain vector search on 13 of 13 cases with connected precedent, and from plain traversal on 9 of 13. On two legitimate alerts (HHF-014, HHF-016), traversal alone ranked five confirmed frauds first; the fused query ranked five cleared cases first.
Connected components for rings. ring_wcc runs label propagation for weakly connected components over a card graph whose edges are rare elements shared by atypical transactions in a 30-day window. Without the "atypical" restriction, the rules percolated into one 600 to 1,100 card component on every alert. With it, 8 of the 9 legitimate alerts stay single cards, and with no detector input it recovers 14 of the 16 cards in the HHF-006 ring.
MCP, restricted to an allowlist. On the MCP lane the only MCP tool called is tigergraph__run_installed_query, and only for query names on an allowlist built from our own read-only GSQL files. Any other name raises PermissionError before a call. No GSQL, upsert or schema tool is reachable, and the LLM never sees a tool.
Write-back, read-back, hash chains. Each investigation is upserted as an InvestigationCase with edges to its card, transactions, devices and similar closed cases. Edges use vertex_must_exist, so a link to a missing id is refused instead of creating an empty vertex. Case events and approvals are written insert-only with new_vertex_only; approvals form a sha256 hash chain, and the graph copy is read back and compared by count, chain hash and digest. All 18 answers carry written_to_graph: true only after that read-back.
The innovation: a detective that grows its own detectors
A miner searches seven generic pattern families (258 grid points) over the 5,585 closed cases in 24 seconds, without reading the analysts' notes. It only proposes patterns that separate confirmed fraud from both normal traffic and cleared customers, with a significance test corrected for the whole search.
Searching blind, it put both of the bank's undocumented typologies on its shortlist: the just-under-$500 four-purchase burst and an anonymous-proxy device ring. It also proposed one detector nobody wrote: a fan-in spike where a rarely used recipient email domain is suddenly paid by three or more cards in one day (8 confirmed-fraud cases, 0 cleared, lift 18).
Nothing joins the toolkit until a human with the L2 role approves it. An analyst token was refused first and logged as denied; the lead then approved it with the L2 token. Here is what that detector changes on HHF-006, where the analyst asked what several affected cards have in common:
| arm | HHF-006 decision |
|---|---|
| no grown detector, no typologies | uncertain, p 0.42; decline, case, monitor, escalate; no report |
| typologies only | fraud, p 0.96, card_not_present_fraud; block and case; no ring, no report |
| full system | fraud, p 0.88, undocumented ring: 3 transactions ($967.68) on the case card, 16 connected cards paying recipient domain sc.rr.com; BLOCK_CARD (L1), CREATE_CASE, FILE_REPORT (L2), MONITOR_CONNECTED_CARDS, ESCALATE_TO_ANALYST |
It changes no other case in the pack, so it cannot act against a legitimate customer elsewhere. On TigerGraph it runs through 13 installed GSQL mirrors and gives the identical decision.
Resilience: fail closed
- Graph or tool fails mid-run: one retry for a transient error, then an incident record, exit code 2, no answer file, nothing claimed as saved.
- Model times out or returns garbage: template prose, fallback recorded, decisions unchanged.
- Hostile text: parsed as data with zero weight; decisions are identical with and without it, and it never reaches a prompt.
-
Wrong role tries to approve: the role comes from a server-side bearer token, never the request body. The wrong role gets a 403 and a
deniedrecord in the append-only chain, and the route uses the server's own exposure, so a caller cannot lower it. - Same case twice: identical decisions and a recorded re-run event.
Each has a fault-injection drill (KAVACH_FAULT=graph:down, tool:any:5, llm:garbage).
Results
Our 18 answers: 7 fraud, 9 legitimate, 2 uncertain.
- Organizer grader: 18 of 18 answered, hard 0, policy 0, warnings 0.
-
kavach attest --allre-runs every attested claim: 0 failing. - Decisions identical on the DuckDB and TigerGraph lanes (18 of 18, 330 of 330 identical tool outputs); the MCP lane reproduces them on the cases we re-ran through it. The same case twice gives the same answer.
- 361 tests passing.
- Round 1 re-scored against the bank's real outcomes: 19 of 20 verdicts right, no legitimate customer blocked or reported.
- On three other banks' GSQL schemas from the mentors (ISO 20022, BIAN, T24), with the answer key stripped, our mapper got the target right on 81 of 82 items (72 of 82 when the mapping kind must also match).
Honest limitations
- Customer and analyst replies are simulated deterministically from the evidence. Every answer file states that assumption.
- January has no labels, so we read the January risk score as near-uninformative, and some January templates cannot be backtested.
- The grown-detector library is grid-bound: it searches shapes a human wrote, at grid points we chose, and the accepted detectors rest on 5 to 8 fraud cases each. One grown signal alone cannot make a verdict.
-
ring_wccconfirms rather than changes the six ring answers today. It also places a legitimate alert's card (HHF-013) in the same component as the HHF-006 ring, which a human reviews.
What we learned about TigerGraph
What worked first time:
-
vectorSearch()with acandidate_settaken from a traversal. Asking for k equal to the set size returns a distance for every candidate; the ranking matched our local cosine to 4 decimals on all 18 cases. - Label propagation with
MapAccum<STRING, MinAccum<INT>>and aWHILE ... LIMITloop compiled and ran on the first attempt. -
new_vertex_onlyandvertex_must_existare exactly the append-only and no-invention guarantees an audit trail needs. -
CREATE OR REPLACE QUERYwithoutINSTALLworks as a quick compile check before the slow install.
Where we lost time:
- A query result containing the text "Runtime Error:" came back as HTTP 400 with a correct body, so a good result looked like a failed query. That cost us a debugging hour.
- A missing numeric value loads as 0, so null and 0 cannot be told apart afterwards.
- The MCP server defaults to ports 9000 and 14240, while Savanna serves everything on 443.
On our wish list: an offline GSQL lint, nullable attributes, and a bulk vector loader.
Links
- Code: https://github.com/MONSTERBOY110/hhgoa-tigergraph
- Live case viewer: https://monsterboy110.github.io/hhgoa-tigergraph/site/
- Launch video: https://x.com/kavachhhgoa/status/2116176025898168593
Thanks for reading. Every number above has a command in the repo that reproduces it.
Top comments (2)
The 'tools and policy decide, the model only writes prose' rule is what makes this auditable rather than impressive — and the L2 gate on grown detectors is load-bearing, since the analyst-token refusal shows the gate actually bites. My worry is sample size: accepted detectors rest on just 5 to 8 fraud cases each, so how do you stop a grown detector from overfitting a tiny slice — does it expire or get revalidated against new cases?
Giving hostile alert text zero weight is the part that holds up. If the text can steer the model, the investigation is already compromised before it starts.