Fraud investigation is not only a classification problem.
An analyst needs to understand why a transaction is risky, gather the right evidence, follow policy, choose an action, document approvals, and preserve the investigation for the next case.
GraphSentinel was built to explore that complete workflow.
It is an agentic fraud-investigation and next-best-action system for the TigerGraph Agentic Fraud Investigation challenge. The system starts from a risk alert, customer report, or analyst request and produces a traceable investigation record containing graph evidence, model belief, policy citations, evidence requests, actions, approvals, SAR decisions, and case memory.
The central idea is:
Use the graph to gather structured evidence, use a model to estimate risk, use policy to constrain actions, and use an agent to decide what investigation should happen next.
GraphSentinel combines five main ideas:
The interesting part is that the workflow records a next-best action before requesting additional evidence.
If the case is still uncertain, the system chooses an allowed evidence request using value-of-information scoring. After the response, it updates its belief and records another next-best action.
This makes the effect of evidence visible instead of hiding everything inside a final classification.
The application supports:
Local graph store
β
βββ Offline development
β
βββ TigerGraph REST
β
βββ TigerGraph MCP
The local graph implementation follows the same query contract as the TigerGraph backends, allowing the investigation workflow to be tested without requiring a live TigerGraph instance.
At a high level, an investigation follows this path:
Trigger
β
βΌ
Intake
β
βΌ
Baseline Graph Evidence
β
βΌ
Agent-selected Follow-up Queries
β
βββββββββββββββββ
βΌ βΌ
Similar Cases GraphRAG
β β
βββββββββ¬ββββββββ
βΌ
Risk Model + Fraud Classification
β
βΌ
Policy Decision
β
βΌ
NBA Before Evidence
β
βΌ
Is the case uncertain?
/ \
No Yes
β β
β βΌ
β Select Evidence
β β
β βΌ
β Apply Response
β β
β βΌ
β Update Belief
β β
β βΌ
β NBA After Evidence
β β
ββββββββ¬ββββββββ
βΌ
Actions / Approvals / SAR
β
βΌ
Explanation
β
βΌ
Graph Write-back
The main runtime is assembled by services/runtime.py. It loads the dataset, graph store, policy configuration, pattern library, risk model, likelihood tables, case repository, evidence provider, and optional CrewAI client.
The agent/workflow.py module builds the LangGraph state machine with explicit nodes for:
intake
baseline evidence
follow-ups
memory / RAG
assessment
decision
evidence
finalization
This explicit state-machine approach makes the investigation path easier to test and reason about than putting the entire workflow inside a single agent prompt.
One of the most important architectural decisions was deliberately separating responsibilities.
βββββββββββββββββββββββββββββ
β TigerGraph β
β β
β Evidence + Relationships β
βββββββββββββββ¬ββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββ
β Risk Model β
β β
β Fraud probability β
βββββββββββββββ¬ββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββ
β Policy Engine β
β β
β Permissions + Approvals β
βββββββββββββββ¬ββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββ
β Agent / LLM β
β β
β Follow-up + Explanation β
βββββββββββββββ¬ββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββ
β Action Gateway β
β β
β Approved actions only β
βββββββββββββββββββββββββββββ
The graph supplies evidence.
The risk model estimates fraud probability.
The policy engine determines permissions and approval routes.
The LLM proposes optional follow-up work and generates language.
The action gateway executes only policy-approved actions.
The LLM is never allowed to authorize or execute a protective action.
Its JSON output is validated, unknown tool names are discarded, citations are filtered against retrieved policy clauses, and failures fall back to deterministic templates.
Every investigation begins with a focal transaction.
The system gathers a bounded set of temporal graph queries:
Transaction details + owner
β
βββ Customer history
βββ Card activity
βββ Shared devices
βββ Address peers
βββ Customer case history
βββ Linked cases
Representative queries include:
gs_txn_detail
gs_customer_history
gs_device_customers
gs_address_peers
gs_linked_cases
These queries are transformed into signals such as:
signals = {
"amount_ratio": amount_ratio,
"device_novelty": device_novelty,
"account_age": account_age,
"card_velocity": card_velocity,
"email_mismatch": email_mismatch,
"linked_confirmed_cases": linked_confirmed_cases,
"graph_fraud_proximity": fraud_proximity,
"address_cluster_size": address_cluster_size,
}
A critical constraint is that behavioral queries are evaluated strictly before the focal event.
If a transaction occurred on January 10, information that only became available on January 20 cannot influence the January 10 decision.
A simplified interface therefore looks like:
def get_customer_history(
customer_id: str,
as_of: datetime,
):
return graph.run_query(
"gs_customer_history",
{
"customer_id": customer_id,
"as_of": as_of.isoformat(),
},
)
This as_of boundary is part of the graph-access layer rather than an assumption made by the analyst.
Baseline evidence is not always enough.
The agent can select additional graph investigations, for example:
Device ring expansion
Card activity
Address-cluster transactions
Community statistics
Spending trajectory
The agent can select up to three additional tools.
CrewAI acts as a bounded investigation assistant for this stage.
It receives the available signals and a menu of installed tools and returns structured output such as:
{
"tools": [
{
"name": "device_ring",
"reason": "Device is shared with multiple high-risk customers."
},
{
"name": "address_cluster",
"reason": "Several recently created accounts share this address."
}
]
}
The important part is that the model doesn't receive arbitrary database access.
The returned tool names are validated against the installed tool registry:
TOOLS = {
"device_ring": investigate_device_ring,
"card_activity": investigate_card_activity,
"address_cluster": investigate_address_cluster,
"community_stats": investigate_community,
"spending_trajectory": investigate_spending,
}
def validate_tools(requested):
return [
tool
for tool in requested
if tool["name"] in TOOLS
]
Unknown tools are discarded.
If the model produces malformed output or fails completely, the workflow falls back to deterministic planning.
This creates a controlled boundary:
LLM
β
β proposes
βΌ
Tool Registry
β
β validates
βΌ
Installed Graph Queries
rather than:
LLM ββββββββββββββΊ arbitrary database access
Fraud investigation requires more than transaction data.
The agent may need to understand:
GraphSentinel therefore uses GraphRAG.
The case is embedded and compared with previous cases using both vector similarity and structural relationships such as shared devices and cards.
Policy documents are parsed into a document graph containing:
DocumentChunk
β
βββ PolicyClause
βββ Pattern
βββ Regulation
Retrieval combines vector search with graph expansion:
User / Case Question
β
βΌ
Vector Search
β
βΌ
Relevant Document Chunks
β
βΌ
Graph Expansion
β
βΌ
Policy / Pattern / Regulation Context
β
βΌ
Cited Investigation Context
gs_similar_cases_vec
gs_doc_search_vec
gs_policy_context
This allows the system to attach policy citations to evidence requests, actions, explanations, and SAR decisions.
Vector similarity alone is not enough for fraud investigations.
Two cases may have similar descriptions but completely different graph structures.
Therefore case retrieval combines:
Vector similarity
+
Shared devices
+
Shared cards
+
Structural relationships
+
Previous case outcomes
Conceptually:
similar_cases = retrieve_similar_cases(
embedding=current_case_embedding,
graph_links=[
customer_id,
*device_ids,
*card_ids,
],
)
This allows the risk model to use historical cases without reducing the investigation to a simple nearest-neighbor search.
After graph evidence, follow-up investigation, and memory retrieval, the system estimates fraud probability.
The risk model is a regularized logistic model.
features = [
bank_risk_score,
amount_ratio,
device_novelty,
account_age,
card_velocity,
graph_fraud_proximity,
confirmed_case_links,
similar_case_outcomes,
pattern_strength,
trigger_type,
]
fraud_probability = model.predict_proba(
[features]
)[0, 1]
The resulting belief is separated into fraud hypotheses:
Legitimate
Third-party fraud
First-party fraud
Evidence can later update those hypotheses.
For example, the supplied likelihood tables can make a failed step-up authentication increase the probability of third-party fraud, while a passed authentication shifts belief in the other direction.
This is one of the most important parts of the architecture.
The policy engine applies configurable thresholds.
if probability < CLEAR_THRESHOLD:
decision = "clear"
elif probability > ACTION_THRESHOLD:
decision = "action"
else:
decision = "uncertain"
For uncertain cases, the system evaluates potential evidence requests.
But policy is applied before optimization.
Suppose we have:
Customer validation
Step-up authentication
Analyst review
Device investigation
A naive implementation might calculate information gain first.
GraphSentinel instead does:
Candidate Evidence
β
βΌ
Policy Filter
β
βΌ
Allowed Evidence
β
βΌ
Value of Information
β
βΌ
Selected Evidence
allowed = []
for request in evidence_requests:
policy_result = policy.check(
case=case,
request=request,
)
if policy_result.allowed:
allowed.append(request)
best_request = max(
allowed,
key=lambda request:
expected_information_gain(request)
- request_cost(request)
)
This matters because a request can be statistically useful while still being impermissible.
For example:
The optimizer never sees those prohibited requests.
Before any additional evidence is requested, the system records the current next-best action.
nba_before = {
"decision": "escalate",
"probability": 0.71,
"risk_level": "high",
"fraud_class": "third_party",
"action": "hold_transaction",
"permission": "approval",
"approval_route": "fraud_analyst",
"policy_clause": "POL-2.1",
}
This creates an important property:
We know what the system would have done before seeing the additional evidence.
Each NBA records information such as:
decision
probability
risk level
fraud class
selected evidence request
excluded requests
policy reasons
actions
permission type
approval route
policy clause
received evidence
If the case remains uncertain, the selected evidence request is executed.
evidence = {
"type": "step_up_auth",
"result": "failed",
}
The result updates the fraud hypotheses.
posterior = bayesian_update(
prior=belief,
evidence=evidence,
likelihoods=likelihood_tables,
)
The important architectural property is that evidence is represented as an explicit transition:
Belief Before
β
βΌ
NBA Before
β
βΌ
Evidence Request
β
βΌ
Evidence Response
β
βΌ
Belief Update
β
βΌ
NBA After
This makes it possible to inspect exactly how new evidence changed the investigation.
After updating the belief, the system records a second next-best action.
nba_after = {
"decision": "protect",
"probability": posterior.fraud_probability,
"fraud_class": posterior.fraud_class,
"action": "block_transaction",
"permission": "approval",
"approval_route": "fraud_analyst",
"policy_clause": "POL-2.1",
}
The case now contains a complete decision timeline:
Initial Evidence
β
βΌ
Initial Belief
β
βΌ
NBA Before Evidence
β
βΌ
Evidence Request
β
βΌ
Evidence Response
β
βΌ
Updated Belief
β
βΌ
NBA After Evidence
This is more informative than simply returning:
{
"fraud": true
}
The complete investigation is represented as a state machine.
A simplified version looks like:
from langgraph.graph import StateGraph, END
workflow = StateGraph(InvestigationState)
workflow.add_node("intake", intake)
workflow.add_node("baseline", baseline_evidence)
workflow.add_node("followups", followup_planning)
workflow.add_node("memory", retrieve_memory)
workflow.add_node("assessment", assess_risk)
workflow.add_node("decision", policy_decision)
workflow.add_node("evidence", request_evidence)
workflow.add_node("finalize", finalize_case)
workflow.set_entry_point("intake")
workflow.add_edge("intake", "baseline")
workflow.add_edge("baseline", "followups")
workflow.add_edge("followups", "memory")
workflow.add_edge("memory", "assessment")
workflow.add_edge("assessment", "decision")
workflow.add_conditional_edges(
"decision",
route_after_decision,
{
"evidence": "evidence",
"finalize": "finalize",
},
)
workflow.add_edge("evidence", "assessment")
workflow.add_edge("finalize", END)
app = workflow.compile()
The important property is that an evidence response can send the case back through assessment.
The system is therefore:
Trigger
β
Investigate
β
Assess
β
Decide
β
Need evidence?
βββ No ββββββββΊ Finalize
β
βββ Yes
β
Evidence
β
Reassess
β
Decide
This is the core agentic loop.
TigerGraph is the graph system of record for investigation evidence and case memory.
The graph contains vertices for:
Transaction
Customer
Device
Card
Address
FraudCase
Finding
Action
DocumentChunk
PolicyClause
Pattern
Regulation
Edges represent relationships such as:
Customer ββownsβββββββΊ Card
Customer ββusesβββββββΊ Device
Customer ββlives_atβββΊ Address
Transaction ββbelongs_toβββΊ Customer
Case ββhas_findingβββΊ Finding
Case ββhas_actionβββββΊ Action
Case ββsimilar_toβββββΊ Case
Document ββreferencesββΊ PolicyClause
This turns the fraud investigation into a connected evidence problem rather than a flat feature table.
Every graph read is a named installed query.
The contract in graph/contract.py defines query names, parameters, and result parsing.
QUERY_CONTRACT = {
"gs_txn_detail": [
"txn_id",
],
"gs_customer_history": [
"cust",
"as_of",
"max_rows",
],
"gs_address_peers": [
"cust",
"window_sec",
"as_of",
"min_first_seen",
],
"gs_similar_cases_vec": [
"query_vec",
"k",
],
}
The local graph store, TigerGraph REST store, and MCP store all implement the same interface.
This gives us two major advantages:
Representative installed queries include:
gs_txn_detail
gs_customer_history
gs_device_customers
gs_address_peers
gs_linked_cases
gs_similar_cases_vec
gs_doc_search_vec
gs_policy_context
The repository also includes:
Weakly Connected Components
Louvain Communities
Personalized PageRank
Personalized PageRank is seeded from confirmed-fraud customers to create a graph-based fraud-proximity feature.
The setup also computes:
device degrees
customer links
connected components
communities
fraud proximity
Highly connected hub devices are excluded from useful proximity signals because shared corporate or public devices can otherwise create misleading fraud relationships.
The agent-plane MCP adapter exposes four operations:
run_installed_query
add_nodes
add_edges
get_node
The architecture becomes:
LangGraph Agent
β
βΌ
TigerGraph MCP
β
βββ run_installed_query
βββ get_node
βββ add_nodes
βββ add_edges
β
βΌ
TigerGraph
The MCP server is launched over stdio using the same TigerGraph configuration.
The repository also contains an MCP emulator backed by the local graph store.
This made it possible to test the complete MCP path without requiring a live TigerGraph deployment.
Before investigations run, graph-derived features are precomputed.
graph.compute_device_degrees()
graph.compute_customer_links()
graph.compute_wcc()
graph.compute_louvain()
graph.compute_fraud_pagerank()
These values can then be used during investigation instead of repeatedly traversing the entire graph.
One of the less obvious challenges was preventing future information from leaking into the investigation.
Consider:
January 10
β
βββ suspicious transaction
January 15
β
βββ investigation starts
January 20
β
βββ case confirmed as fraud
The January 20 outcome must not become a feature for the January 10 transaction.
Therefore graph queries use:
as_of = case_opened_at
and only retrieve information available before the relevant event.
The same principle applies to customer history, account age, devices, cards, and linked cases.
Left-censored accounts also need special handling. If the available dataset starts after the account was created, we shouldn't automatically classify that account as "new."
These rules belong in the graph access layer and tests, not only in analyst convention.
The investigation does not disappear after the final API response.
The case is written back to graph memory as a FraudCase.
graph.add_node(
"FraudCase",
{
"id": case.id,
"status": case.status,
"embedding": case.embedding,
},
)
Findings and actions are then connected:
graph.add_node(
"Finding",
{
"id": finding.id,
"type": finding.type,
"confidence": finding.confidence,
},
)
graph.add_edge(
"HAS_FINDING",
case.id,
finding.id,
)
The case can be connected to:
Customer
Focal Transaction
Related Transactions
Findings
Actions
Similar Cases
This creates a continuous memory loop:
Past Investigations
β
βΌ
Case Memory
β
βΌ
New Investigation
β
βΌ
New Findings
β
βΌ
Updated Memory
But agent outcomes and analyst-confirmed outcomes are deliberately kept separate.
An agent prediction should not automatically become training ground truth.
Only analyst-confirmed outcomes should become authoritative learning data.
SAR eligibility is evaluated by policy.
The system considers factors such as:
Posterior probability
Aggregate amount
Suspect identification
Money-laundering indicators
Applicable thresholds
When a SAR is required, the agent can draft the narrative:
if policy.requires_sar(case):
sar = draft_sar(
case=case,
evidence=evidence,
citations=policy_context,
)
approval_queue.submit(
sar,
role="bsa_officer",
)
The LLM can help write the narrative, but it does not independently authorize the SAR.
The policy engine controls eligibility and the required approval route.
The system is agentic in a constrained, auditable sense.
It can:
The important design choice is that agency is bounded by contracts and policy.
The agent can explore and explain.
It cannot silently bypass an approval route or turn an uncertain case into an automatic protective action.
GraphSentinel also contains a discovery loop for closed investigations.
The idea is:
Closed Cases
β
βΌ
Residual Analysis
β
βΌ
Unexpected Pattern
β
βΌ
Candidate Rule
β
βΌ
Policy Review
For example, the discovery system might identify an unexplained cluster of newly created accounts sharing the same address.
The important part is that discovery does not automatically become policy.
The candidate is flagged for review:
candidate_pattern = {
"pattern": pattern,
"documented": False,
"requires_policy_review": True,
}
This keeps pattern discovery separate from authorization.
Once the investigation is complete:
Automatic actions
β
βΌ
Mock Action Gateway
Approval actions
β
βΌ
Required Approval Route
SAR required
β
βΌ
BSA Officer Approval
All paths
β
βΌ
Explanation
β
βΌ
Graph Memory
Automatic actions are sent to the mock gateway.
Approval actions are queued for the required role.
SAR eligibility is evaluated using the policy rules.
Finally, the complete case is written back to graph memory.
The project can run without TigerGraph for the initial development loop.
pip install -e ".[dev]"
graphsentinel synth
graphsentinel build
graphsentinel run-benchmark
graphsentinel eval
graphsentinel serve
pytest -q
The local graph store follows the same query contract as the TigerGraph implementation.
For TigerGraph:
cp .env.example .env
graphsentinel tg-setup
graphsentinel tg-check
graphsentinel serve
The default agent access path is MCP:
GS_TG_ACCESS=mcp
REST access is also supported:
GS_TG_ACCESS=rest
Administrative operations such as DDL, bulk , and algorithm setup use the REST path.
The application also supports the actual HHGOA/IEEE-style dataset through configurable column mappings.
The dataset directory is configured with:
GS_DATA_DIR=/path/to/dataset
The resolves file and column names using:
config/dataset_mapping.yaml
For the provided benchmark case pack:
case_pack.csv
is placed alongside the dataset files.
The benchmark can then be run with:
GS_DATA_DIR=/path/to/case-pack \
graphsentinel run-benchmark
The generated cases are written as:
cases/
βββ HHG-001.json
βββ HHG-002.json
βββ ...
βββ HHG-020.json
Each investigation record contains the investigation evidence, findings, decisions, actions, graph write-back status, SAR details where applicable, and the next-best action before and after evidence.
The project contains two different evaluation modes.
The first is the generated benchmark.
The second is temporal replay.
This distinction is important because the benchmark uses synthetic data where the generator deliberately plants patterns.
The benchmark therefore demonstrates that the system behaves correctly against the generated ground truth.
It should not be interpreted as a production fraud-detection accuracy estimate.
The temporal replay is a more realistic test because the model trains on earlier cases and investigates later cases.
The repository reports:
Training:
Months 1β3
69 closed cases
Replay:
37 later cases
The replay produced:
21 cases decided directly
20 correct decisions
16 escalations
Precision: 1.0
The important limitation is that many escalations came from evidence requests for which no response was recorded in the closed-case data.
This means the system sometimes correctly identifies uncertainty but does not have enough historical evidence to resolve it automatically.
That is an important difference between:
"I don't know"
and:
"I am confident this is legitimate."
A production system should preserve that distinction.
A large graph is not automatically useful.
Device, card, and address relationships matter when they:
The query contract helped keep graph investigation focused on decisions rather than graph traversal for its own sake.
Fraud data contains future outcomes, later cases, and accounts that may predate the available dataset.
Every query therefore needs an as_of boundary.
Left-censored accounts must also be handled correctly.
Otherwise a seemingly good model can quietly learn from information that would not have been available at decision time.
A request can be statistically informative and still be impermissible.
Therefore:
Evidence candidates
β
Policy constraints
β
Allowed candidates
β
Value-of-information
β
Selected evidence
This ordering prevents an optimizer from selecting a request that creates customer-contact or tipping-off risk.
Agent-confirmed and agent-cleared cases are useful memory.
But they are not automatically analyst ground truth.
Keeping those outcomes separate prevents feedback loops where the model starts training on its own previous decisions.
LLM calls can fail because of:
Credentials
Rate limits
Provider changes
Malformed JSON
Network failures
The investigation should still continue.
That's why GraphSentinel has deterministic planning and explanation fallbacks.
The LLM is an optional reasoning and language layer, not a single point of failure.
Execute every GSQL query against a real TigerGraph Savanna or Community Edition deployment and add stronger deployment/version checks.
Replace the mock action gateway with authenticated banking, notification, evidence-provider, and e-filing integrations.
Replace the development X-Role header with an identity-provider integration and enforce role claims at a trusted proxy boundary.
Train and calibrate thresholds on real closed investigations, monitor drift, and add confidence intervals and champion/challenger evaluation.
Connect real authentication, customer-validation, and analyst-review systems with asynchronous response handling.
Run the complete HHG-001 through HHG-020 case pack and compare decisions with independent review.
The current console is a lightweight static analyst UI. A production version would use richer graph interactions, accessibility improvements, and durable event streaming.
Add structured traces for:
Graph latency
Model versions
LLM calls
Policy decisions
Approval turnaround
Action outcomes
These would be essential for production monitoring.
The entire system can ultimately be reduced to this:
βββββββββββββββββββββ
β TRIGGER β
β β
β Risk alert β
β Customer report β
β Analyst request β
βββββββββββ¬ββββββββββ
β
βΌ
βββββββββββββββββββββ
β LANGGRAPH β
β AGENT β
βββββββββββ¬ββββββββββ
β
βββββββββββββββΌββββββββββββββ
β β β
βΌ βΌ βΌ
ββββββββββββ ββββββββββββ βββββββββββββ
βTigerGraphβ β GraphRAG β β Case β
β β β β β Memory β
β Evidence β β Policies β β Similar β
β Relationsβ β Patterns β β Cases β
ββββββ¬ββββββ ββββββ¬ββββββ βββββββ¬ββββββ
β β β
βββββββββββββββΌβββββββββββββββ
βΌ
βββββββββββββββββββββ
β RISK MODEL β
β β
β Probability β
β Fraud class β
βββββββββββ¬ββββββββββ
β
βΌ
βββββββββββββββββββββ
β POLICY ENGINE β
β β
β Permissions β
β Approval routes β
β SAR rules β
βββββββββββ¬ββββββββββ
β
βΌ
βββββββββββββββββββββ
β NBA BEFORE β
β EVIDENCE β
βββββββββββ¬ββββββββββ
β
uncertain?
/ \
no yes
β β
β βΌ
β ββββββββββββββββ
β β EVIDENCE β
β β SELECTION β
β ββββββββ¬ββββββββ
β β
β βΌ
β ββββββββββββββββ
β β BELIEF UPDATEβ
β ββββββββ¬ββββββββ
β β
β βΌ
β ββββββββββββββββ
β β NBA AFTER β
β β EVIDENCE β
β ββββββββ¬ββββββββ
β β
ββββββββ¬ββββββββ
βΌ
βββββββββββββββββββββ
β ACTION / APPROVAL β
β SAR / EXPLANATIONβ
βββββββββββ¬ββββββββββ
β
βΌ
βββββββββββββββββββββ
β GRAPH MEMORY β
β β
β Case β
β Findings β
β Actions β
β Evidence β
βββββββββββββββββββββ
The final system is not an unconstrained chatbot making banking decisions.
It is a traceable investigation workflow in which:
Graph
β provides evidence
Risk Model
β estimates risk
Policy
β controls permissions
Agent
β chooses useful investigation work
Human
β provides required approvals
Action Gateway
β executes approved actions
Graph Memory
β preserves the investigation
That separation is the central design principle behind GraphSentinel.
The goal is not simply to predict fraud.
The goal is to build an investigation system where evidence, reasoning, policy, actions, approvals, and outcomes remain connected and auditable.