Building an Air-Gapped Fraud Detection Pipeline

A public-sector spend auditor needed to find duplicate payments, contract-splitting, and shell vendors across millions of accounts-payable lines without any of that data leaving the building. We built an audit engine that runs entirely offline: eight fraud detectors, zero external API calls, and a dashboard that drills from a county summary to a single flagged payment.

**Key numbers**
- 8 fraud detectors running over every scanned payment
- 0 external API calls, the pipeline never resolves a model call outside the network
- 58 counties modeled from one codebase
- Built and validated on modeled data, so the claim is what the engine detects, not a recovery figure
- 1 frozen `findings.json` contract connecting the offline engine to the dashboard

## The challenge: fraud detection where the data cannot leave the building

Accounts-payable fraud usually hides in patterns, not single transactions. A vendor invoiced twice under slightly different names. A contract split into several smaller purchase orders to duck a review threshold. A vendor whose registered address turns out to belong to someone else in the same organization. None of these show up in a spreadsheet filter. They show up when you cross-reference vendor identity, payment timing, and contract history at scale, across departments and jurisdictions that do not normally talk to each other.

The engine models spend across **58 counties**. That is a dataset large enough that manual review does not scale, and a data type sensitive enough (vendor banking details, contract amounts, department budgets) that the standard move, send the data to a cloud model for analysis, was never on the table. A public agency handling taxpayer financial records cannot ship that data to a third-party API and call it compliant. So the brief was not "build a fraud detector." It was "build a fraud detector that never leaves the premises."

That constraint shaped every decision in the stack, not just the deployment target.

## The pipeline: ingest, enrich, detect, score

The engine runs four stages in sequence, and every stage runs on hardware inside the auditor's own network.

1. **Ingest** pulls accounts-payable extracts from whatever feeds a given county or department provides, in whatever format they arrive. Some counties export clean CSVs. Some hand over inconsistent legacy exports. The ingest stage normalizes both without assuming either.
2. **Enrich** attaches context a raw payment row does not carry on its own: a vendor identity hash, a normalized address, a contract linkage, a rolling payment history for that vendor and department.
3. **Detect** runs the enriched records through eight independent fraud detectors, each looking for a different pattern.
4. **Score** aggregates detector output into a severity and confidence per finding, so a reviewer sees a ranked list, not a flood of unfiltered alerts.

The whole pipeline, and the local model layer it calls for pattern matching that benefits from language understanding, sits inside a single air-gapped boundary.


<rect x="0" y="0" width="900" height="560" fill="#0C0C14"></rect>
<text x="60" y="30" font-family="monospace" font-size="15" fill="#F4F2FF">on-premise, air-gapped environment</text>
<rect x="40" y="45" width="700" height="480" rx="8" fill="none" stroke="#8B7CF6" stroke-width="2" stroke-dasharray="6,5"></rect>

<rect x="70" y="90" width="170" height="60" rx="6" fill="none" stroke="#8B7CF6" stroke-width="1.5"></rect>
<text x="85" y="115" font-family="monospace" font-size="13" fill="#F4F2FF">AP data feeds</text>
<text x="85" y="133" font-family="monospace" font-size="13" fill="#F4F2FF">(multiple counties)</text>

<line x1="240" y1="120" x2="290" y2="120" stroke="#8B7CF6" stroke-width="2"></line>
<polygon points="290,120 280,115 280,125" fill="#8B7CF6"></polygon>

<rect x="290" y="90" width="110" height="60" rx="6" fill="none" stroke="#8B7CF6" stroke-width="1.5"></rect>
<text x="308" y="125" font-family="monospace" font-size="14" fill="#F4F2FF">Ingest</text>

<line x1="400" y1="120" x2="450" y2="120" stroke="#8B7CF6" stroke-width="2"></line>
<polygon points="450,120 440,115 440,125" fill="#8B7CF6"></polygon>

<rect x="450" y="90" width="110" height="60" rx="6" fill="none" stroke="#8B7CF6" stroke-width="1.5"></rect>
<text x="465" y="125" font-family="monospace" font-size="14" fill="#F4F2FF">Enrich</text>

<line x1="505" y1="150" x2="505" y2="195" stroke="#8B7CF6" stroke-width="2"></line>
<polygon points="505,195 500,185 510,185" fill="#8B7CF6"></polygon>

<rect x="420" y="200" width="180" height="90" rx="6" fill="none" stroke="#8B7CF6" stroke-width="1.5"></rect>
<text x="440" y="230" font-family="monospace" font-size="14" fill="#F4F2FF">Detect</text>
<text x="440" y="250" font-family="monospace" font-size="13" fill="#F4F2FF">8 fraud detectors</text>
<text x="440" y="268" font-family="monospace" font-size="13" fill="#F4F2FF">running independently</text>

<line x1="600" y1="245" x2="650" y2="245" stroke="#8B7CF6" stroke-width="2" stroke-dasharray="4,3"></line>
<line x1="650" y1="245" x2="600" y2="245" stroke="#8B7CF6" stroke-width="2" stroke-dasharray="4,3"></line>
<polygon points="600,245 610,240 610,250" fill="#8B7CF6"></polygon>
<polygon points="650,245 640,240 640,250" fill="#8B7CF6"></polygon>

<rect x="650" y="200" width="150" height="90" rx="6" fill="none" stroke="#8B7CF6" stroke-width="1.5"></rect>
<text x="665" y="230" font-family="monospace" font-size="14" fill="#F4F2FF">Ollama</text>
<text x="665" y="250" font-family="monospace" font-size="13" fill="#F4F2FF">local model</text>
<text x="665" y="268" font-family="monospace" font-size="13" fill="#F4F2FF">inference only</text>

<line x1="510" y1="290" x2="510" y2="335" stroke="#8B7CF6" stroke-width="2"></line>
<polygon points="510,335 505,325 515,325" fill="#8B7CF6"></polygon>

<rect x="450" y="340" width="120" height="55" rx="6" fill="none" stroke="#8B7CF6" stroke-width="1.5"></rect>
<text x="470" y="373" font-family="monospace" font-size="14" fill="#F4F2FF">Score</text>

<line x1="510" y1="395" x2="510" y2="435" stroke="#8B7CF6" stroke-width="2"></line>
<polygon points="510,435 505,425 515,425" fill="#8B7CF6"></polygon>

<rect x="400" y="440" width="220" height="55" rx="6" fill="none" stroke="#8B7CF6" stroke-width="1.5"></rect>
<text x="415" y="465" font-family="monospace" font-size="13" fill="#F4F2FF">findings.json</text>
<text x="415" y="483" font-family="monospace" font-size="12" fill="#F4F2FF">frozen contract, engine writes once</text>

<line x1="620" y1="467" x2="670" y2="467" stroke="#8B7CF6" stroke-width="2"></line>
<polygon points="670,467 660,462 660,472" fill="#8B7CF6"></polygon>

<rect x="670" y="440" width="130" height="55" rx="6" fill="none" stroke="#8B7CF6" stroke-width="1.5"></rect>
<text x="685" y="460" font-family="monospace" font-size="12" fill="#F4F2FF">Next.js dashboard</text>
<text x="685" y="478" font-family="monospace" font-size="11" fill="#F4F2FF">county to payment</text>

<rect x="760" y="60" width="130" height="60" rx="6" fill="none" stroke="#8B7CF6" stroke-width="1.5" stroke-dasharray="3,3"></rect>
<text x="775" y="85" font-family="monospace" font-size="12" fill="#F4F2FF">cloud LLM APIs</text>
<text x="775" y="103" font-family="monospace" font-size="12" fill="#F4F2FF">0 calls made</text>
<line x1="800" y1="90" x2="850" y2="90" stroke="#8B7CF6" stroke-width="2"></line>
<line x1="800" y1="70" x2="850" y2="110" stroke="#8B7CF6" stroke-width="2"></line>
<line x1="850" y1="70" x2="800" y2="110" stroke="#8B7CF6" stroke-width="2"></line>


The dashed box on the right, outside the boundary, is what does not exist in this build: no traffic ever leaves the network to reach a cloud model provider.

## Why air-gapped is an engineering constraint, not a compliance buzzword

"Air-gapped" gets used loosely in vendor marketing. Here it means something specific and testable: every model call in the pipeline resolves to a model running on hardware inside the auditor's own network, through Ollama, rather than to an API endpoint reachable over the internet. There is no network egress rule to misconfigure, because there is no outbound call to make in the first place.

That has a real cost. A cloud-hosted frontier model is, on most benchmarks, more capable than a model small enough to run well on local hardware. Running everything through Ollama means accepting a smaller, less general model for the parts of the pipeline that benefit from language understanding, in exchange for a guarantee that no payment record, vendor name, or contract line ever leaves the building.

We treated that as a design tradeoff, not a shortcut. Most of the detection runs through a deterministic rules layer that needs no language model at all, working over structured fields the enrich stage has already normalized. The local model is reserved for the narrower jobs where free text has to be read rather than compared, and where a strict string match would be the wrong tool. How each rule is actually written is the auditor's intellectual property and not ours to publish; the point of the architecture is that the language model is the assistant in the pipeline, not the thing the findings depend on.

## Eight detectors, one contract

Each detector in the **detect** stage is a pure function over the enriched payment record. It takes the enriched output, not the raw feed, so a detector never has to know which county's export format it came from. Three of the eight are named in the brief: duplicate payments, contract-splitting, and shell-vendor relationships. The remaining five follow the same shape, independent functions over the same enriched record, each looking for a different signature of accounts-payable fraud, and each writing its own findings so a bad match in one detector never masks or distorts another.

```python
# Simplified illustration of the detector interface. The field list, the
# rules inside each detector, and their tuning are the client's, not ours.
from dataclasses import dataclass
from typing import Iterable, Protocol

@dataclass
class EnrichedPayment:
payment_id: str
vendor_id: str
amount_cents: int
invoice_date: str
department: str
# ...plus the normalized identity and history fields the enrich stage adds

@dataclass
class Finding:
detector: str
severity: str # "low" | "medium" | "high"
confidence: float
vendor_id: str
payment_ids: list[str]
rationale: str

class Detector(Protocol):
"""Every one of the eight implements this and nothing more.

Pure function over the enriched output of the ingest stage.
No network calls, no shared state, no knowledge of the other seven.
"""
def __call__(self, payments: Iterable[EnrichedPayment]) -> list[Finding]: ...
```

That interface is the whole contract. Eight detectors implement it, they run independently, and adding a ninth means writing one more function rather than editing a shared pipeline.

## The frozen findings.json contract

The engine and the dashboard never share a database, a socket, or a runtime. They share one file. The engine writes `findings.json` once per audit run and the dashboard only ever reads it. Freezing that contract meant the Python engine and the Next.js dashboard could ship on separate schedules without ever breaking each other.

```typescript
// Simplified illustration of the contract shape, not the shipped schema.
// findings.json is the only interface between the offline engine and the dashboard.
interface FindingsExport {
runId: string;
generatedAt: string;
airGapped: true;
county: string;
jurisdiction: string;
detectorVersions: Record<string, string>;
summary: {
totalPaymentsScanned: number;
totalFlagged: number;
byDetector: Record<string, number>;
};
findings: Array<{
findingId: string;
// one of eight detector ids, e.g. "duplicate_payment",
// "contract_splitting", "shell_vendor"
detector: string;
severity: "low" | "medium" | "high";
confidence: number;
vendorId: string;
paymentIds: string[];
rationale: string;
}>;
}
```

Freezing the schema also gave us a graceful way to handle a real operational problem: not every county feed is available every time an audit runs. When a feed is missing, the engine records that gap in `summary` and runs the detectors it can on the data it has, rather than failing the whole audit. The dashboard shows exactly what was scanned and what was not, so an auditor never mistakes partial coverage for a clean bill of health.

## From a county summary to one flagged payment

The dashboard's job is to let an auditor go from "something is off in this county" to "here is the exact payment and the exact reason" in a handful of clicks. The top level lists the 58 modeled counties with the flagged-payment count and dollar total for that run. Selecting a county drills into its jurisdictions, each broken out by detector. Selecting a finding opens the underlying payment record with the vendor history and the specific pattern that tripped the detector, described in plain language rather than as a raw confidence score.

That summary-to-payment path also drives the PDF export. Each jurisdiction gets a ready-to-send briefing document generated straight from the same findings the dashboard renders, so a briefing and the screen it came from can never disagree.

The review discipline behind this build was the same one we run on every regulated system we ship, including the HIPAA-aligned platforms described in our [guide to what HIPAA-compliant software actually requires](/blog/hipaa-compliant-software): every detector change goes through a pull request in the client's own repository, reviewed by a named engineer, with typed contracts and tests in CI. An offline system does not get a lighter review gate. If anything it gets a stricter one, since there is no cloud provider's own controls to lean on.

## What shipped

The engine runs onsite and touches no external API, full stop. Eight fraud detectors run over every scanned payment. Data sourcing degrades gracefully when a county feed is unavailable, so a gap in one source does not stall the rest of the audit. Per-jurisdiction briefings generate as ready-to-send PDFs a reviewer can hand to a department without editing. The engine models 58 counties from one codebase.

One thing worth stating plainly, because fraud-detection vendors are usually vague about it: this system was built and validated against modeled data across those 58 counties. What we are describing is what the engine detects and how it is designed to surface a pattern, not a dollar figure recovered from anyone's live ledger. The engineering claim and the audit-findings claim are different claims, and only the first one is ours to make.

Nothing in this system depends on us staying involved. The engine, the dashboard, and the findings contract live in the client's own repository and run on their own hardware, the same ownership model we apply to every [pod](/pods) build described on our [security page](/security). The same scoping discipline, one data contract first, detectors second, dashboard last, is the pattern we walk through in our [guide to custom AI development](/blog/custom-ai-development).

## The short version

An offline fraud-detection pipeline is not a smaller version of a cloud one, it is a different set of tradeoffs made on purpose: deterministic detectors carry the weight, a local model handles the narrower ambiguous cases, and a frozen `findings.json` contract keeps the engine and the dashboard shippable on separate timelines. For this public-sector spend auditor, that meant eight detectors, zero external calls, and 58 counties modeled from a single codebase, validated against modeled data rather than a claimed recovery number.