<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ansh-Sonkusare</title>
    <description>The latest articles on DEV Community by Ansh-Sonkusare (@anshsonkusare).</description>
    <link>https://dev.to/anshsonkusare</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F763386%2F46b8ab2a-f362-44df-b9e1-c7514989f3ca.png</url>
      <title>DEV Community: Ansh-Sonkusare</title>
      <link>https://dev.to/anshsonkusare</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anshsonkusare"/>
    <language>en</language>
    <item>
      <title>Building a Fraud Investigator on TigerGraph</title>
      <dc:creator>Ansh-Sonkusare</dc:creator>
      <pubDate>Wed, 23 Sep 2026 22:19:52 +0000</pubDate>
      <link>https://dev.to/anshsonkusare/building-a-fraud-investigator-on-tigergraph-3hk0</link>
      <guid>https://dev.to/anshsonkusare/building-a-fraud-investigator-on-tigergraph-3hk0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; We built an agent that investigates card fraud on a TigerGraph knowledge graph. It gathers evidence with GSQL queries, asks the cardholder when the evidence is thin, never makes up the answer, and only recommends actions the bank's policy allows. On 50 closed cases we never tuned against, it missed none of the 40 fraud cases, named 37 of 40 fraud patterns correctly, and called all 10 cleared cases legitimate.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Fraud analysts rarely miss fraud because they can't see it. They miss it because the job is slow. They pull the transaction history, trace the connected accounts, check devices against old cases, read the policy, and only then decide. By the time the case is written up, the money is often gone.&lt;/p&gt;

&lt;p&gt;We built an agent to do that legwork, on TigerGraph, for the HHGOA challenge. We expected the hard part to be detection. It wasn't. The hard part was getting the agent to be honest about what it didn't know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is a crowd of cards on one device really a ring?&lt;/li&gt;
&lt;li&gt;What do you call a pattern that fits none of the five documented ones?&lt;/li&gt;
&lt;li&gt;And the one that cost us most: what would the cardholder have said, when the dataset never tells us?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we built
&lt;/h2&gt;

&lt;p&gt;You give the agent a trigger (a risk score, a customer report, or an analyst request), and it works the case through to an action it can defend.&lt;/p&gt;

&lt;p&gt;It opens a case and resolves the trigger to a card. It reads the transaction history, looks for rings of cards that share a device, region or email, and pulls up prior cases. Then it weighs competing hypotheses, each with a probability, and decides whether it knows enough to act.&lt;/p&gt;

&lt;p&gt;If the policy says to ask the cardholder first, it asks. The dataset has no replies, so the agent writes down that none came back and re-recommends under the policy's "no reply" rule. It files a suspicious activity report when the policy calls for one, writes the case back to the graph, and explains every step.&lt;/p&gt;

&lt;p&gt;Every tool takes an &lt;code&gt;as_of&lt;/code&gt; timestamp and ignores anything after it. A case opened on 12 November can't see 13 November. We enforce that inside the queries instead of trusting the caller, because in a fraud benchmark a time leak looks exactly like good performance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The stack:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;TypeScript end to end: pnpm workspaces, Turborepo, zod at every boundary, vitest, and a Next.js analyst UI.&lt;/li&gt;
&lt;li&gt;GSQL for the graph logic, on TigerGraph Community Edition 4.3 in Docker.&lt;/li&gt;
&lt;li&gt;The official &lt;code&gt;tigergraph-mcp&lt;/code&gt; server as the only way the agent reaches the graph. No direct driver.&lt;/li&gt;
&lt;li&gt;A local model for the assessment: Qwen2.5-7B-Instruct (Q4_K_M) on llama.cpp, at temperature 0, with its reply constrained to a JSON schema.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;The agent is an explicit state machine, not a free-running loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TRIGGERED → CASE_OPENED
  → INVESTIGATING       GSQL queries through the TigerGraph MCP server
  → ASSESSING           competing hypotheses with probabilities
  → EVIDENCE_PLANNING   ask the cardholder when the policy says so
  → AWAITING_EVIDENCE   no reply exists, and none is invented
  → EVIDENCE_RECEIVED   records "no reply"; rule R4 applies
  → DECIDING            final actions, each citing its rule
  → APPROVAL_ROUTING    auto, L1 (team lead) or L2 (fraud manager)
  → EXPLAINING          narrative and suspicious activity report
  → MEMORY_UPDATE       case written back into TigerGraph
  → DONE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tool calls have a budget, evidence rounds are capped, and the stop rule is code. The model gets to propose. The code decides what's allowed.&lt;/p&gt;

&lt;p&gt;Here's one place that shows up. The model proposes hypotheses and probabilities, but code caps its confidence by how many independent kinds of evidence it actually looked at: fewer than two caps it at 0.45, and two cap it at 0.65. A 7B model can't argue its way past that, and the prompt tells it so.&lt;/p&gt;

&lt;p&gt;We added a second guard later. If a case rests on a single independent fraud signal, even a strong one like a detector firing at 1.0, the code holds the fraud probability just under the blocking line. That way rule R1 ("verify before you block on a weak signal") applies whatever number the model returned.&lt;/p&gt;

&lt;p&gt;The code is split into workstreams that talk to each other only through frozen contracts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workstream&lt;/th&gt;
&lt;th&gt;What it owns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contracts/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The tool catalog, the answer-file schema, and fakes. Frozen after the first milestone, so everything else codes against it instead of someone else's half-finished code.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;graph/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The schema, the loaders, and the official TigerGraph MCP server in a container.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gsql/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The installed queries and graph algorithms (next section).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rag/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GraphRAG: chunks and embeds the policy and fraud typologies, and hands the model &lt;em&gt;summarized evidence&lt;/em&gt; instead of raw rows.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;agent/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The state machine above.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;policy/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The rule engine, permissions and approval routing.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eval/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The benchmark runner, the backtest harness, answer validation and leak checks.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How TigerGraph is used
&lt;/h2&gt;

&lt;p&gt;For us the graph is the investigation, not a lookup table.&lt;/p&gt;

&lt;p&gt;The schema covers cards, customers, transactions, devices, addresses, email domains, identities and fraud cases. A &lt;code&gt;NEXT&lt;/code&gt; edge chains each card's transactions in time, so velocity and bursts are a short traversal instead of a scan.&lt;/p&gt;

&lt;p&gt;The agent's graph tools are installed GSQL queries, reached through the MCP server:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;What it answers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;resolve_trigger&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which transaction, card, customer and identity a trigger is about&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_entity_profile&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A profile of one card, customer, transaction, identity or device&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;txn_history&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A card's or customer's transactions up to &lt;code&gt;as_of&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;neighborhood&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The devices, addresses and recipient emails around a card, 1 to 3 hops out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;card_velocity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How many charges, and how much money, in a recent window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;shared_rings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every device, address or email this card shares with other cards&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;baseline_deviation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How a charge compares with the card's own earlier history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;detect_patterns&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Scores for the documented fraud patterns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;community_lookup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which fraud community a card belongs to, if any&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;find_prior_cases&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Earlier cases on this card whose outcome was known at &lt;code&gt;as_of&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_pattern_profile&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A pattern's required evidence and permitted actions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vector_search&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cosine top-k similarity, inside TigerGraph&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;upsert_case_record&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Writes the finished case back into the graph&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On top of those we run graph algorithms: weakly connected components, label propagation, shortest path and hub detection.&lt;/p&gt;

&lt;h3&gt;
  
  
  Detectors and rings
&lt;/h3&gt;

&lt;p&gt;The five documented fraud patterns are GSQL detectors over the graph. We added a sixth for a pattern the dataset doesn't name (more on that below). Shared-entity rings, meaning the cards that touch the same device, billing region or recipient email in a recent window, are a traversal. We couldn't express them cleanly as a table query.&lt;/p&gt;

&lt;h3&gt;
  
  
  Community detection found a data artifact first
&lt;/h3&gt;

&lt;p&gt;A naive weakly-connected-components pass lumped 274 cards into one "cluster". It turned out to be the busiest cards in the dataset bumping into each other through sheer volume, and its confirmed-fraud rate was &lt;em&gt;below&lt;/em&gt; the dataset's baseline.&lt;/p&gt;

&lt;p&gt;So we added an overlap guard: two cards connect only if what they share is a meaningful slice of each card's own activity, not just any shared value. That broke the blob down to a largest component of 41 cards and left three real communities, all at 100% confirmed fraud. Those communities and the shared-device rings are written back as &lt;code&gt;Pattern&lt;/code&gt; vertices, so a cluster found once becomes evidence for the next case too.&lt;/p&gt;

&lt;h3&gt;
  
  
  Vector search and case memory
&lt;/h3&gt;

&lt;p&gt;Vector search lives in TigerGraph as well: cosine top-k over 29 policy chunks and 5,565 closed-case embeddings, with no separate vector database. Finding similar prior cases and finding the relevant policy text use the same engine over the same store.&lt;/p&gt;

&lt;p&gt;Case memory closes the loop. Each investigation is written back as a &lt;code&gt;FraudCase&lt;/code&gt; vertex by &lt;code&gt;upsert_case_record&lt;/code&gt;, and the next investigation's &lt;code&gt;find_prior_cases&lt;/code&gt; call can find it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agentic capabilities
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Competing hypotheses, not a single label
&lt;/h3&gt;

&lt;p&gt;The assessor proposes several fraud types with probabilities that add up to one, and each cites the evidence for and against it. The verdict comes from probability bands whose edges line up with the policy's 0.70 blocking threshold.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two scorers with separate jobs
&lt;/h3&gt;

&lt;p&gt;Jev, a hosted decision model from TypeSafe, only re-splits the probability the assessor has already put on fraud across the documented patterns. It never decides fraud versus legitimate, and it never sees a case the assessor called legitimate.&lt;/p&gt;

&lt;p&gt;We also wired in Kev, a Jev-like model family we can fine-tune and run ourselves, behind the same interface. Its training export stopped partway through, so we don't trust it yet. It's our path to a fully local pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Knowing when to stop, and when to ask first
&lt;/h3&gt;

&lt;p&gt;The stop rule weighs how many independent kinds of evidence agree, what contradicts them, whether the intended action is even permitted, and how much budget is left.&lt;/p&gt;

&lt;p&gt;If a case sits between roughly 0.40 and 0.85, the agent asks the cardholder before stopping, even when the stop rule would let it finish. The policy's own order is recommend, verify if needed, then recommend again, and we follow it rather than call the case early. &lt;code&gt;uncertain&lt;/code&gt; is a perfectly good answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Asking for evidence honestly
&lt;/h3&gt;

&lt;p&gt;The dataset has no cardholder or analyst replies. We used to simulate one and stopped (more on that below). Now the agent states its assumption plainly: the request went out and no reply came within 24 hours.&lt;/p&gt;

&lt;p&gt;Rule R4 ("no reply within 24 hours") then drives the final recommendation, not a guess about what the customer would have said. The answer keeps both the first and the final recommendation, and says what changed and why.&lt;/p&gt;

&lt;h3&gt;
  
  
  Policy as code, with guards the model can't talk its way around
&lt;/h3&gt;

&lt;p&gt;Every action carries an approval route. Only &lt;code&gt;auto&lt;/code&gt; actions run; &lt;code&gt;L1&lt;/code&gt; and &lt;code&gt;L2&lt;/code&gt; actions wait for a person. Beyond the single-signal cap above, a few more rules live in code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A case opens once fraud probability reaches 0.30, exactly as the policy's "a case is not a report" section says.&lt;/li&gt;
&lt;li&gt;The "coordinated or repeated abuse" rule (R9) fires only when the abuse actually spans customers, not for a burst on one card.&lt;/li&gt;
&lt;li&gt;An online-flagged charge can never be labelled account takeover or out-of-region use, because the dataset defines both of those patterns as card-present.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;Nobody has the answer key for the 20 benchmark cases. What we do have is &lt;code&gt;closed_cases_history.csv&lt;/code&gt;: 5,565 finished investigations with real outcomes.&lt;/p&gt;

&lt;p&gt;We built a leak-free backtest from it. It replays each closed case as if it had just opened, with every tool seeing only what was true at that case's &lt;code&gt;opened_at&lt;/code&gt;, and compares the agent's answer with the analyst's.&lt;/p&gt;

&lt;p&gt;We report two samples. "Fresh 50" is cases we never used to tune or measure anything. "Original 50" is the sample we iterated against.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Fresh 50&lt;/th&gt;
&lt;th&gt;Original 50&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fraud pattern correct&lt;/td&gt;
&lt;td&gt;37/40 (92.5%)&lt;/td&gt;
&lt;td&gt;35/42 (83%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fraud cases missed&lt;/td&gt;
&lt;td&gt;0/40&lt;/td&gt;
&lt;td&gt;0/42&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cleared cases called legitimate&lt;/td&gt;
&lt;td&gt;10/10&lt;/td&gt;
&lt;td&gt;6/8 (2 uncertain)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cleared cases blocked&lt;/td&gt;
&lt;td&gt;0/10&lt;/td&gt;
&lt;td&gt;0/8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verdict agreement with analysts&lt;/td&gt;
&lt;td&gt;50/50&lt;/td&gt;
&lt;td&gt;50/50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Report decision matches analysts&lt;/td&gt;
&lt;td&gt;46/50 (92%)&lt;/td&gt;
&lt;td&gt;44/50 (88%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exposure within 25% of the analysts' figure&lt;/td&gt;
&lt;td&gt;32/40 (80%)&lt;/td&gt;
&lt;td&gt;26/42 (62%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The history never shows a model alert that turned out to be fraud: every fraud case started as a customer dispute. So we also replayed confirmed disputes as the alerts they could have been. On 31 cleared alerts and 50 fraud-as-alert cases, all held out from everything else:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Held-out alerts&lt;/th&gt;
&lt;th&gt;Before calibration&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cleared alerts called legitimate&lt;/td&gt;
&lt;td&gt;0 of 45&lt;/td&gt;
&lt;td&gt;18 of 31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cleared alerts blocked&lt;/td&gt;
&lt;td&gt;6 of 45&lt;/td&gt;
&lt;td&gt;5 of 31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fraud alerts blocked&lt;/td&gt;
&lt;td&gt;32 of 69&lt;/td&gt;
&lt;td&gt;25 of 50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Probability error (1:1 Brier)&lt;/td&gt;
&lt;td&gt;0.304&lt;/td&gt;
&lt;td&gt;0.160&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;4 fraud alerts were called legitimate, but none was closed or allowed.&lt;/p&gt;

&lt;p&gt;With about 40 fraud cases per sample, a swing of two cases either way between runs is noise. The local model isn't perfectly deterministic under small input changes, even at temperature 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A coin flip beat our entire graph
&lt;/h3&gt;

&lt;p&gt;The dataset doesn't include cardholder replies and tells you to simulate them. We did, carefully. A hash of the request fields picked "customer confirms" or "customer denies". It was deterministic, documented, and never read a fraud label, so it wasn't leakage.&lt;/p&gt;

&lt;p&gt;It was still made up, and it was a disaster. Every confirmed-fraud case that happened to draw "confirms" closed as legitimate: six out of six in one backtest. One of those cases had 159 pieces of graph evidence, and a single hash bit threw all of it away. Twice, in fact, because a second piece of code applied the same fake reply again as a separate veto.&lt;/p&gt;

&lt;p&gt;We took the simulation out completely. The agent now records that no reply came, which is the only true thing we can say about a reply we don't have. It still &lt;em&gt;recommends&lt;/em&gt; verifying with the customer, because the policy asks for that recommendation. The policy never asks us to invent the answer and reason from it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Calibration was mostly us fixing our own evidence
&lt;/h3&gt;

&lt;p&gt;On an early 20-case sample (17 of them confirmed fraud), pattern accuracy was roughly 41–47% with the 7B model alone, and it wobbled between runs even at temperature 0. Giving the pattern scorer calibrated, weighted evidence instead of raw text took it to 58.8%.&lt;/p&gt;

&lt;p&gt;On a 50-case sample the same chase went 76.2%, then 78.6%, 81.0% and 83.3%, and each step was a bug where our evidence was quietly lying to the model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"This charge has no history" was really a 500-row history cap running out before it reached the right date.&lt;/li&gt;
&lt;li&gt;A card's own earlier fraud was being read as &lt;em&gt;another&lt;/em&gt; card's fraud, which made too many cases look like rings.&lt;/li&gt;
&lt;li&gt;A baseline check reported a z-score of 1,110 from just two earlier transactions. That's a small-sample artifact, not a signal.&lt;/li&gt;
&lt;li&gt;A shared-device ring counted a card's own fraud from a month earlier as if the device were active &lt;em&gt;now&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those were model problems. Each time, we handed the model a wrong fact and then acted surprised when it reasoned from it.&lt;/p&gt;

&lt;h3&gt;
  
  
  A subagent found the pattern we were missing
&lt;/h3&gt;

&lt;p&gt;The dataset says outright that not every fraud pattern in it is documented. We sent a read-only subagent to look for one, and barred it from designing anything against our own backtest cases.&lt;/p&gt;

&lt;p&gt;It found a second cluster inside the closed "undocumented" cases: four online charges within an hour, each in a narrow $400–$500 band, that none of our five detectors caught. We built a sixth detector for it and checked it against the closed-case history before trusting it. It now correctly names both closed "undocumented" cases in our backtest sample. Before, we could only mislabel them as a documented pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Known limitations
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Cleared alerts can't fully close
&lt;/h3&gt;

&lt;p&gt;An analyst closes a false alarm after actually talking to the cardholder. We don't fake that conversation, so our agent gets as far as "asked, no reply, the policy says decline the flagged charge and monitor", but not to the analyst's clean &lt;code&gt;CLOSE_NO_FRAUD&lt;/code&gt;. That's the direct cost of removing the fake reply, and we'd rather pay it than bring the fake back.&lt;/p&gt;

&lt;p&gt;We did test a middle ground: assume a confirmation only when the evidence shows no independent fraud signal, and keep that rule only if it closed zero fraud. On 69 confirmed fraud cases replayed as model alerts plus 45 real cleared alerts, the best such rule closed 24 of 39 cleared alerts, but also 6 of 37 fraud cases. It failed the zero-fraud bar, so it isn't in the agent. A calibrated evidence model fitted on 605 alerts failed too: even at its strictest cutoff it closed 1 of 388 held-out fraud cases.&lt;/p&gt;

&lt;h3&gt;
  
  
  About half of fraud alerts are held, not blocked
&lt;/h3&gt;

&lt;p&gt;Every confirmed fraud case in the history began as a cardholder dispute, so the history never shows a model alert that turned out to be fraud. Replaying disputes as the alerts they could have been, the agent blocked 25 of 50 and held the rest for verification and monitoring. 4 were called legitimate, but none had its transaction allowed or its case closed.&lt;/p&gt;

&lt;h3&gt;
  
  
  A legitimate verdict is still a judgement call
&lt;/h3&gt;

&lt;p&gt;The local LLM put every cleared alert at 0.50 or higher. So on model alerts the fraud probability now comes from an evidence model fitted on 605 replayed alerts and checked on 629 held-out ones (AUC 0.909). Five facts drive it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;whether the flagged charge was online&lt;/li&gt;
&lt;li&gt;whether the device is new to the account&lt;/li&gt;
&lt;li&gt;whether there are prior cases&lt;/li&gt;
&lt;li&gt;whether a device-sharing fraud signal fired&lt;/li&gt;
&lt;li&gt;whether the card has a cleared case&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Surprisingly, a new device leans legitimate here (84% of cleared alerts against 24% of fraud), which fits the dataset's own caveat that "people buy new phones".&lt;/p&gt;

&lt;p&gt;Six of our 20 answers are now &lt;code&gt;legitimate&lt;/code&gt;, all at 0.37. On held-out alerts with exactly that evidence (11 cleared, 9 fraud), about one in three was fraud once both outcomes are weighted equally. So the verdict follows the likelier reading, and the actions hedge: they ask the cardholder and keep the case open instead of closing it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Account takeover and out-of-region use can look identical
&lt;/h3&gt;

&lt;p&gt;With what our tools can see, the two are sometimes truly indistinguishable. We searched every combination of up to three features the agent sees, on held-out cases. Even for the best one, the rarer pattern was only 30–42% of the cases it matched, so any rule that names the rarer pattern gets more cases wrong than right. A learned scorer reading the same evidence hits the same wall.&lt;/p&gt;

&lt;h3&gt;
  
  
  Exposure runs short on long episodes
&lt;/h3&gt;

&lt;p&gt;This happens especially on card-testing runs that stretch over weeks rather than hours. Our episode window is tuned for the common case and undercounts the tail.&lt;/p&gt;

&lt;h3&gt;
  
  
  Jev is a hosted API call
&lt;/h3&gt;

&lt;p&gt;Pattern re-scoring sends every assessed case out to TypeSafe's service. The local alternative, Kev, uses the same interface but isn't trained well enough to turn on by default.&lt;/p&gt;

&lt;h3&gt;
  
  
  We open cases the analysts never opened
&lt;/h3&gt;

&lt;p&gt;The policy opens a case once probability reaches 0.30 &lt;em&gt;or&lt;/em&gt; evidence is requested, and the agent requests verification on every uncertain alert. The rule has no "unless it later clears" exception, so we followed it as written instead of adding one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would improve with more time
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Finish training Kev.&lt;/strong&gt; Swapping an external API call for a small model we control and can inspect would help both latency and auditability, and the interface is already there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep pushing on the cases that look irreducible.&lt;/strong&gt; The 30–42% ceiling holds for the features we extract today. Authorization and hold status, or a real multi-hop path feature instead of single-hop rings, might move it. We haven't tried yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calibrate beyond model alerts.&lt;/strong&gt; The evidence model sets the probability only on risk-score alerts. Disputes and analyst requests still take the language model's number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real answer for cleared alerts.&lt;/strong&gt; If a future version of the dataset, or a live deployment, supplies the cardholder's actual reply, it goes into the same &lt;code&gt;evidence_requests&lt;/code&gt; record the agent already writes. The rules that use it (R2, R3, R4) are already built.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Built on TigerGraph Community Edition with the official TigerGraph MCP server. Every number above comes from closed cases in the provided history, not from the 20 benchmark cases. We can't know our score on those, and neither can anyone reading this before judging.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>database</category>
      <category>security</category>
    </item>
    <item>
      <title>Secrets In, Proofs Out: How Witnesses Power Midnight Privacy</title>
      <dc:creator>Ansh-Sonkusare</dc:creator>
      <pubDate>Tue, 08 Sep 2026 08:50:09 +0000</pubDate>
      <link>https://dev.to/anshsonkusare/secrets-in-proofs-out-how-witnesses-power-midnight-privacy-10cn</link>
      <guid>https://dev.to/anshsonkusare/secrets-in-proofs-out-how-witnesses-power-midnight-privacy-10cn</guid>
      <description>&lt;p&gt;Imagine you open a private voting app. You cast a vote, your vote is counted, and the&lt;br&gt;
result is public for everyone to see — but &lt;strong&gt;nobody, not even the machine counting the&lt;br&gt;
votes, ever sees &lt;em&gt;which&lt;/em&gt; vote was yours&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That sounds impossible. And on most blockchains it kind of is.&lt;/p&gt;

&lt;p&gt;Midnight does it with something called a &lt;strong&gt;witness&lt;/strong&gt;. This post explains what a witness&lt;br&gt;
is, how it works, and why it matters — using plain language and one running example you&lt;br&gt;
can follow from start to finish. No cryptography degree needed.&lt;/p&gt;
&lt;h2&gt;
  
  
  The core idea: use a secret without giving it away
&lt;/h2&gt;

&lt;p&gt;Every blockchain is basically a shared public notebook. When you write to it, everyone in&lt;br&gt;
the world can read what you wrote. That's great for honesty — but terrible if you want to&lt;br&gt;
keep something private.&lt;/p&gt;

&lt;p&gt;Here's the trick Midnight pulls off: you can let the network &lt;em&gt;check&lt;/em&gt; something about your&lt;br&gt;
private data without ever &lt;em&gt;seeing&lt;/em&gt; the data itself.&lt;/p&gt;

&lt;p&gt;Think of it like a lie-detector test for math. You whisper your answer to me. I don't&lt;br&gt;
tell anyone what you said. Instead, I stand up and say: &lt;em&gt;"I can prove this person's&lt;br&gt;
answer was valid, without showing you the answer."&lt;/em&gt; If everyone trusts me, they accept&lt;br&gt;
your answer without ever learning it.&lt;/p&gt;

&lt;p&gt;In Midnight, your private data never leaves your phone. Only a &lt;strong&gt;proof&lt;/strong&gt; that the data is&lt;br&gt;
correct travels to the network. And the person who "whispers" your secret into the proof&lt;br&gt;
is called a &lt;strong&gt;witness&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  What, exactly, is a witness?
&lt;/h2&gt;

&lt;p&gt;A witness is a helper that runs &lt;em&gt;on your own device&lt;/em&gt;, not on the blockchain.&lt;/p&gt;

&lt;p&gt;Here's the analogy that clicks for most people:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Your private data is a key hidden in your pocket. A witness is your hand — it reaches&lt;br&gt;
into your pocket, grabs the key, uses it to open a lock, and pulls out a &lt;em&gt;receipt&lt;/em&gt; that&lt;br&gt;
proves the door opened. Everyone sees the receipt. Nobody sees the key.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In Midnight's programming language (called Compact), a witness is declared inside the&lt;br&gt;
smart contract but its actual logic lives in your app's code, running locally.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// This tells the contract: "hey, there's a helper that can grab the secret key"
witness localSecretKey(): Bytes&amp;lt;32&amp;gt;;
witness localVote(): Uint&amp;lt;8&amp;gt;;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice: there's &lt;strong&gt;no implementation here&lt;/strong&gt;. Compact just declares &lt;em&gt;that&lt;/em&gt; a witness exists.&lt;br&gt;
Your app provides the actual "reach into the pocket and grab the key" part later. The&lt;br&gt;
secret itself is never compiled into the on-chain contract, so it can never leak.&lt;/p&gt;
&lt;h2&gt;
  
  
  The "private oracle" — a fancy name for a simple idea
&lt;/h2&gt;

&lt;p&gt;Midnight borrows a term: the &lt;strong&gt;private oracle&lt;/strong&gt;. It just means two things working together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Private state&lt;/strong&gt; — a small file on your device holding your secrets (your key, your
vote, your balance).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Witness functions&lt;/strong&gt; — the helpers that read from that file.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. When a circuit needs your secret, it calls a witness, the witness reads your&lt;br&gt;
local file, and hands the value into the math that builds a proof. The secret stays on&lt;br&gt;
your device the whole time.&lt;/p&gt;
&lt;h2&gt;
  
  
  A running example: your private score
&lt;/h2&gt;

&lt;p&gt;Let's make this real. Say you're building an app where players have a &lt;strong&gt;score&lt;/strong&gt;, and you&lt;br&gt;
want to prove your score is legit on the blockchain — without ever revealing what the&lt;br&gt;
score is, or who you are.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 1: Tell Compact about your secrets
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;witness localSecretKey(): Bytes&amp;lt;32&amp;gt;;   // your private key
witness localScore(): Uint&amp;lt;64&amp;gt;;        // your score
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Step 2: Turn the score into a "locked box"
&lt;/h3&gt;

&lt;p&gt;You don't put the raw score on-chain. You put a &lt;strong&gt;hash&lt;/strong&gt; of it — a scrambled, one-way&lt;br&gt;
fingerprint. It's like sealing your score inside a box and writing only the seal's&lt;br&gt;
serial number on the wall. Anyone can see the serial number. Nobody can open the box.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;circuit commitment(sk: Bytes&amp;lt;32&amp;gt;, score: Uint&amp;lt;64&amp;gt;): Bytes&amp;lt;32&amp;gt; {
  return persistentHash(...);   // the "seal"
}

export circuit commitScore(): [] {
  const _sk = localSecretKey();        // the hand reaches into the pocket
  const score = localScore();          // grabs the secret score
  scoreCommitment = disclose(commitment(_sk, score));  // only the seal goes on-chain
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Write the witnesses in your app's code
&lt;/h3&gt;

&lt;p&gt;Now the part you actually write as a developer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;witnesses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// "Reach into the pocket, grab the secret key"&lt;/span&gt;
  &lt;span class="na"&gt;localSecretKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;privateState&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;privateState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;privateState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secretKey&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;

  &lt;span class="c1"&gt;// "Reach into the pocket, grab the score"&lt;/span&gt;
  &lt;span class="na"&gt;localScore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;privateState&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;privateState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;privateState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each witness returns two things: the (maybe-updated) private state, and the value the&lt;br&gt;
circuit asked for. The score and key are read straight from the user's own device.&lt;/p&gt;
&lt;h3&gt;
  
  
  What ends up on the blockchain?
&lt;/h3&gt;

&lt;p&gt;Just a &lt;strong&gt;hash&lt;/strong&gt; — a meaningless-looking string of characters. Nobody can reverse it to&lt;br&gt;
find your score. Your private data never left your device. Yet the network can still&lt;br&gt;
verify your commitment is legitimate whenever you reveal it later, by re-checking the&lt;br&gt;
hash.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why &lt;code&gt;disclose()&lt;/code&gt; is the guardrail
&lt;/h2&gt;

&lt;p&gt;Midnight is paranoid about privacy on purpose. The language refuses to let you accidentally&lt;br&gt;
publish private data.&lt;/p&gt;

&lt;p&gt;By default, anything that came from a witness is treated as &lt;strong&gt;private&lt;/strong&gt;. If you try to&lt;br&gt;
write it to the public ledger without saying so, the compiler stops you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// This won't even compile. It would leak private data.
scoreCommitment = commitment(sk, score);

// This compiles: you're explicitly saying "yes, publish this on purpose."
scoreCommitment = disclose(commitment(sk, score));
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The word &lt;code&gt;disclose()&lt;/code&gt; is essentially billingual to the compiler: &lt;em&gt;"I know this came from&lt;br&gt;
private data. I'm doing it on purpose. Let me through."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Even clever attempts to hide it get caught. For example, if you take a secret, run it&lt;br&gt;
through some math, compare it to a public value, and then try to publish the &lt;em&gt;result&lt;/em&gt; of&lt;br&gt;
that comparison — the compiler traces the whole path back to the secret and refuses.&lt;/p&gt;

&lt;p&gt;Here's how to read one rule of thumb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If a value is &lt;strong&gt;private&lt;/strong&gt;, treat it like it's radioactive.&lt;/li&gt;
&lt;li&gt;The only way to let it touch public things is to wrap it in &lt;code&gt;disclose()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One big warning: don't trust the witness
&lt;/h2&gt;

&lt;p&gt;Here's the part that surprises people: &lt;strong&gt;the witness is not magic, and it is not trustworthy&lt;br&gt;
by itself.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Remember: the witness runs on &lt;em&gt;your&lt;/em&gt; device, in &lt;em&gt;your&lt;/em&gt; app. If you're the one writing the&lt;br&gt;
app, you write the honest witness. But the blockchain can't assume that. Anyone could ship&lt;br&gt;
a witness that returns fake values.&lt;/p&gt;

&lt;p&gt;That's why the smart contract must &lt;strong&gt;double-check&lt;/strong&gt; whatever the witness hands over —&lt;br&gt;
against data that's already on the chain. In our example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First, we put the commitment (the "seal") on the chain.&lt;/li&gt;
&lt;li&gt;Later, when you reveal, the contract recomputes the seal and compares it to what's on
the chain.&lt;/li&gt;
&lt;li&gt;If a witness tried to lie, the seals wouldn't match, and the contract rejects it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A witness can tell you anything. The math on-chain is what actually holds it accountable.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should you use a witness?
&lt;/h2&gt;

&lt;p&gt;Use a witness when you need to touch private data or do hard off-device math:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read a secret key, vote, or balance that must never be public&lt;/li&gt;
&lt;li&gt;Do division or other math the blockchain can't do cheaply, then prove the result&lt;/li&gt;
&lt;li&gt;Update private state based on what happened in a circuit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don't use a witness as a shortcut around good design — anything important it returns must&lt;br&gt;
eventually be checked against on-chain truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tell me the whole thing again, simply
&lt;/h2&gt;

&lt;p&gt;Here's the entire idea in one paragraph:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A witness is a helper that runs on your own device. When your smart contract needs a&lt;br&gt;
secret, it calls the witness, which pulls the secret from your private local file and&lt;br&gt;
feeds it into a zero-knowledge proof. Only the proof goes to the blockchain — never the&lt;br&gt;
secret itself. And &lt;code&gt;disclose()&lt;/code&gt; is your explicit, compiler-enforced "yes I meant to share&lt;br&gt;
this" button for the rare times something does become public.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick glossary in plain words
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Witness&lt;/strong&gt; — a helper on your device that supplies private data to a proof&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private state&lt;/strong&gt; — the secrets living in a local file on your device&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private oracle&lt;/strong&gt; — private state + the witnesses that read it, working together&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hash / commitment&lt;/strong&gt; — a one-way "seal" of your data; public to see, impossible to open&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;disclose()&lt;/strong&gt; — the explicit "share this" tag the compiler requires before private data
can touch the public ledger&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Learn more
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.midnight.network/concepts/how-midnight-works/compact-privacy-first-language" rel="noopener noreferrer"&gt;Compact as a privacy-first language (official docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.midnight.network/compact/reference/explicit-disclosure" rel="noopener noreferrer"&gt;Explicit disclosure (official docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.midnight.network/blog/compact-2" rel="noopener noreferrer"&gt;Midnight Dev Diaries — Circuit and Witness Calls&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
  </channel>
</rss>
