← All posts

Build walkthrough · Custom AI Development

Building an Air-Gapped Fraud Detection Pipeline

Inside the build: an AI audit engine that finds duplicate payments and shell vendors without data ever leaving the building.

Asaasin EngineeringPublished August 24, 20267 min read

In short

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.

on-premise, air-gapped environment

AP data feeds (multiple counties)

Ingest

Enrich

Detect 8 fraud detectors running independently

Ollama local model inference only

Score

findings.json frozen contract, engine writes once

Next.js dashboard county to payment

cloud LLM APIs 0 calls made

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.

# 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.

// 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: 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 build described on our security page. 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.

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.

Frequently asked questions

What does "air-gapped" actually mean in this build, technically?
It means every model call in the pipeline resolves to a model running locally through Ollama, on hardware inside the auditor's own network, rather than to a cloud API. There is no outbound network call to a model provider to misconfigure or audit, because none exists in the architecture.
Does an air-gapped pipeline lose accuracy compared to a cloud-model version?
There is a real tradeoff: a locally hosted model is generally less capable than a frontier cloud model on open-ended language tasks. We managed it by keeping most of the detection in a deterministic rules layer that needs no language model at all, and reserving the local model for the narrower jobs where free text has to be read.
How many fraud detectors run in this pipeline?
Eight. Each is an independent function over the enriched payment record. Three are named in the brief: duplicate payments, contract-splitting, and shell-vendor relationships. The remaining five run the same way, each looking for a different signature of accounts-payable fraud. The engine was built and validated against modeled data, so what we can say is what it detects, not what any real audit turned up.
What happens if one county's data feed is down when an audit runs?
The engine runs the detectors it can on the data available and records the gap in the findings summary. It does not fail the entire audit, and the dashboard shows exactly what was and was not scanned for that run.
Is this the same review process used on the HIPAA-aligned builds in your portfolio?
Yes. Every change, in this build or any other, goes through a pull request in the client's repository, reviewed by a named engineer, with typed contracts and tests in CI, the same gate described in our [practical guide to custom AI development](/blog/custom-ai-development).

Sources

Get in touch.

Thirty minutes to map your problem to a plan and a timeline. You will leave the call with scope, price, and a start date.

What happens on the call
01You describe the outcome you need.
02We map it to scope, price, and a start date.
03You decide whether to proceed to a free prototype.
Schedule a 30-minute call