Inspiration

Amara runs grants at a 12-person NGO moving humanitarian money toward three sanctioned regions. She is subject to exactly the same strict-liability statute as JPMorgan — a single payment to a designated party is a federal violation whether or not anyone meant it, and there is no "we're small" exemption anywhere in the regulation.

What she does not have is a compliance department. When Treasury publishes a change to the SDN list — roughly weekly, without warning — every queued disbursement in her book becomes her personal liability until someone re-screens the entire counterparty list against the new publication. A true hit has to be blocked and reported within 10 business days. A delisting means blocked funds have to be released, and nobody is coming to remind her.

The vendors who automate this — Firco, LexisNexis, ComplyAdvantage — sell to banks at $30k+/year and do not return her call at any price. So it gets done by hand, late, or not at all.

That is the gap: not a chat interface over sanctions data, but the unattended execution of a legal obligation that currently depends on someone remembering to check a government website. This is the "Unlikely Hero" the Fortified Enterprise Fleet track asks for — a compliance operator with no compliance function, carrying a bank's liability on an NGO's budget.

What it does

One flow, end to end, with no human in the loop — the start included (see the note on the trigger below, which says exactly what runs it and where):

OFAC delta lands → full-book re-screen → true hits held (money stops) → lookalikes cleared with a written rationale → funds released on delisting → 10-day blocking report drafted.

Four decisions execute autonomously, and each is graded against something the system does not control:

Decision What moves Who grades it
1 HOLD an idempotent hold freezes every queued disbursement to that counterparty the SDN record on treasury.gov — make challenge NAME="..." reproduces it for any name a judge types
2 CLEAR the disbursement proceeds, with the reason on record OpenSanctions yente, self-hosted and scope-pinned to us_ofac_sdn
3 RELEASE a delisting retires the hold and the money moves again Treasury's own published delta (/changes/latest)
4 REPORT the blocking report is drafted against the statutory clock and filed to the ledger the federal calendar, 5 U.S.C. 6103

Both the content and the start of every decision come from Treasury, not from a user. A committed six-hourly timer (ops/com.interdict.ofac-archiver.plist) runs scripts/archive_delta.py, which fetches OFAC's /changes/latest and the full SDN export and archives each publication by content hash — and on a hash it has not seen, the poll fires the re-screen itself. trigger_rescreen() spawns scripts/run_rescreen.py --trigger SCHEDULER under an flock, so a publication landing while a full-book run is still going cannot start a second one writing decisions for the same counterparties. The plist sets INTERDICT_RESCREEN_ON_NEW=1, which is what arms it; unset it and the timer goes back to archiving only. An unchanged publication — the overwhelmingly common case — costs nothing. A new one screens the entire book, adjudicates, holds, clears, quarantines and drafts, with no human in the loop and no click anywhere. make archive-status fails on the first missed poll window, so a timer that dies is a loud failure rather than a silent one.

SCHEDULER here means the local launchd timer, not Google Cloud Scheduler. Cloud Scheduler was never deployed — the billing account is closed — and the trigger column reads SCHEDULER because that is this system's name for "started by the timer rather than by a person". The loop is genuinely closed and genuinely unattended; the machine it closes on is a laptop, not Google Cloud compute, and this submission says so rather than letting the label imply otherwise. run_rescreen.py remains runnable by hand for demos, and the trigger column records which it was — that is what the column is for.

How we built it

Three agents in one process, with one enforced boundary between them. They are three roles with three responsibilities and a checked hand-off, not three deployments — there is no network hop, no A2A, and no fourth agent.

  • matcher-agent — deterministic screening. Normalisation, blocking, scoring, weak-alias downweighting against OFAC's own category=weak flag (4,393 of them in the 08/07/2026 publication — real data, nothing synthesised), DOB corroboration through the feed's own isApproximate / isDateRange fields. Calls no model.
  • adjudicator-agentgemini-3.5-flash-lite via the Google GenAI SDK, structured output through response_schema, temperature 0 for reproducible verdicts, confined to one module. Returns HOLD or CLEAR plus a written rationale citing the specific record fields it relied on.
  • orchestrator-agent — routes matcher → adjudicator, and is the only writer of decisions, through a transaction plus a transactional outbox.

The boundary is asserted, not assumed. tests/test_architecture.py walks the import graph with ast and fails the build if the adjudicator can reach the money or the ledger — transitively, because adjudicator → helper → money hides the forbidden edge one hop deeper than a direct-import check can see. The model cannot hold a disbursement or clear a counterparty because that edge does not exist in the graph, and now nothing can quietly add it. The oracle imports nothing at all, so the second opinion cannot agree with itself through a longer path, and the Firestore mirror imports nothing either, which is what makes "never a source of truth" enforceable rather than a promise.

A second Google model, kept outside the decision path. gemma-4-31b-it is asked the same question as Gemini, under the same system instruction, and its answer is written to a gemma_verdict column on every adjudication whether or not it agrees — the same rule the external oracle is held to, because an oracle consulted only where it already agrees is not an oracle. It cannot hold money, clear a counterparty or route anything to quarantine. Divergence between two independent models is evidence for the human reading the evidence console, never a vote, and NULL means "not asked or unreachable" rather than "agreed".

Four GenAI SDK surfaces, each load-bearing. genai.Client and models.generate_content carry the adjudication. models.count_tokens prices a run before any quota is spent — free-tier Gemini caps requests per model per project per day, and that ceiling used to be discovered halfway through a book; it now predicts the cost up front, and predicted 59 of 101 counterparties would reach adjudication, which is exactly what the run then did. models.list refuses to start when the pinned model is not served to the key, turning a typo in INTERDICT_MODEL from a mid-run failure into a refusal to open the run.

The interesting part is the guard at the routing boundary. The model's verdict is not a decision until the deterministic plane grades it. orchestrator.guard() re-checks every verdict against the matcher's own score and signals before the orchestrator is allowed to act, and refuses four things:

  • a CLEAR on a near-identical name — deterministic score at or above GUARD_CLEAR_CEILING = 0.93 with neither a contradicting date of birth nor an entity-type mismatch. Clearing those names requires evidence the matcher already looked for and did not find. (The ceiling sits deliberately above the adjudication threshold: the band below it is exactly where the model is supposed to have discretion.)
  • a HOLD below the no-hit floor — freezing money on evidence the screening plane cannot see.
  • a matched_identifier that does not appear in the SDN record. This is the hallucination check that matters: a fabricated alias transcribed into a federal blocking report is the worst output this system could produce.
  • a rationale too thin to transcribe into that report.

On refusal the specific complaint goes back to the adjudicator verbatim — it is told what disagreed, not merely that something did — for one more round trip. MAX_ROUND_TRIPS = 2, enforced in application code and again as a database constraint, and a case still disagreeing at the cap goes to a terminal quarantine state where a human is told, rather than looping. A hallucinating worker agent cannot move money: the routing layer refuses to carry its output, which is precisely the failure-tolerance the track's architecture criterion asks about.

Separately, and deliberately not as a gate: an independent OpenSanctions yente verdict is fetched per batch and stored beside every adjudication in adjudications.yente_verdict — not only where the two disagree — so any decision can be diffed against an outside opinion after the fact. That is what the "we cleared it, yente flagged it = 0" figure below is measured from. It is recorded rather than enforced on purpose: _oracle_verdicts() returns empty on any yente failure and the run proceeds. Making a live third-party HTTP service a hard precondition on whether money can move would mean an oracle outage stops the compliance loop — the oracle is evidence, not a dependency, and the enforcing check is the deterministic plane, which is in-process and cannot be unavailable.

Correctness lives in the database, not in application code. Postgres 16 enforces the invariants: append-only triggers on the ledger, illegal-transition triggers on the hold state machine, UNIQUE NULLS NOT DISTINCT for hold idempotency, a two-round-trip cap expressed as a DB constraint as well as in code, and a hash-chained ledger whose sequence is assigned inside the chaining trigger under an advisory lock — so two concurrent writers cannot fork the chain. There is a test that runs two writers at once and asserts a single unforked chain.

Cloud Firestore is the evidence plane. Committed ledger entries are mirrored out with their seq, prev_hash and entry_hash, so the chain verifies from the cloud copy alone, against a local database the verifier does not have and does not have to trust. Agents never write to it — the mirror republishes committed rows only, so a document exists in Firestore if and only if the ledger entry that produced it committed. The watermark lives in Firestore too, so the mirror is resumable without a local table.

Crash safety, because unattended means nobody restarts it. Re-screens checkpoint per batch in a rescreen_batches table, not against a scalar cursor. An interrupted run refuses to mark itself finished, and a resume restarts at MIN(batch_start) over incomplete batches rather than after the last completed one — a scalar cursor would resume past ranges still in flight and leave counterparties unscreened against a live sanctions list while reporting success. python scripts/run_rescreen.py --kill-after 2 kills a worker mid-book on demand.

Stack: Python 3.11 · Gemini 3.5 Flash Lite (Google GenAI SDK) · Cloud Firestore · Postgres 16 · rapidfuzz · OpenSanctions yente + Elasticsearch (Docker) · pytest · ruff · mypy · GitHub Actions · CodeQL · gitleaks.

Challenges we ran into

The headline accuracy number was meaningless, and we had to go find that out. Screening the seeded book verbatim scores top-1 = 1.000 — and it is worthless, because those names were copied out of the very publication being searched. It is a string-equality test wearing a costume. The reported number instead screens deterministic perturbations: transliteration families taken from OFAC's own alias lists, token reordering, dropped particles, transcription confusables, dropped middle names — each derived from the SHA-256 of the name, so the challenge set is byte-identical on any machine and a judge gets the same one we did. On that set: top-1 0.995 against the independent oracle's 0.840, recall 1.000, n = 400.

The model was being graded on work it never did. An early results table reported "lookalike CLEAR 60/60" as decision quality. Then we added a column counting how many rows actually reached the model, and it was zero. A contradicting date of birth cuts a lookalike below the adjudication threshold before a model call is ever spent on it, so every clear in the book came from the deterministic plane and all 59 model verdicts were HOLD. That is the design working — proving two people are different from a documented DOB does not need a language model — but it means the table grades the matcher on clears and the model on holds, and the README now says so in the table, in the limitations, and in the row that used to promise a written rationale for every clear.

The unattended part stopped being unattended and nothing noticed. A lint pass modernised datetime.timezone.utc into datetime.UTC while the six-hourly timer was invoking a Python 3.9 interpreter. Every poll from 2026-08-17 died with an AttributeError into a log file nothing was reading — and OFAC published on 08/20 inside that window. The fix took a minute; the lesson took longer. Looking for the same shape elsewhere found a CI step named "Verify the sealed sentinel book still re-derives to its committed hash" that echoed two hashes and never compared them, a skip guard that computed a test count and never asserted on it, and a make audit that ran the CVE scanner with || true. All four are fixed, and make archive-status now fails on the first missed poll window instead of the tenth.

A feature that passed every test and did nothing. The second-model integration shipped with 100% coverage and fifteen green unit tests, and recorded exactly zero verdicts across a 59-adjudication run. GemmaSecondOpinion was correct; screen_counterparty accepted the provider; the tests all passed. run_rescreen.py built the provider and never handed it to rescreen_book — the parameter was threaded through two function signatures and dropped at the call site. What made it invisible was our own design: the failure path swallowed exceptions silently so a transient Gemma outage could never take down a screening run, which meant a dead feature and a declining model looked identical. Fixed three ways — pass it through, log the reason instead of swallowing it, and add the integration test at the seam the unit tests skipped. That test is mutation-verified: remove the pass-through and it fails.

Free-tier Gemini, honestly. Five requests a minute and a daily per-project cap, so grading all 536 counterparties against the real model does not fit in a day. The graded run is a stratified sample of 101 that keeps all four strata and holds the two that actually test judgement at full strength. The adjudicator honours the server's own retry in Ns hint rather than hammering it. The screening numbers are unaffected and measured across all 400.

No Google Cloud compute. Every billing account available to this project is closed, so Cloud Run, Cloud SQL and Cloud Scheduler were never deployed — Firestore's free tier is the one Google Cloud service that runs without one. The loop still closes: chaining the poll to the re-screen was Cloud Scheduler's job in the intended deployment, and rather than leave it undone we did it locally, in archive_delta.py, on a committed launchd timer. What the closed billing account actually cost is the hosted run history — a Google-side record of the timer firing would have been better third-party evidence than a plist and a log on our own machine. The README names which one is running rather than implying otherwise.

The unattended loop had never once closed, and the check written to catch exactly that could not see it. A launchd timer polls OFAC every six hours and starts a re-screen on any publication hash it has not seen. It polled 37 times and captured four real Treasury publications. But on both occasions a publication arrived and the re-screen actually fired, the child died at import: the timer's interpreter was the system Python — new enough to run the archiver, which is pure stdlib, and carrying none of the project's dependencies — and the spawn passes sys.executable straight down. Two for two, into a gitignored log. The deeper problem is that nothing noticed for eleven days. archive_status.py exists because of an earlier five-day poll outage, and its own docstring says an unattended job with no liveness check is not unattended, it is unobserved. It then proved that in the other direction: it checked the poll was alive and never checked the poll had ever done anything. The trigger now returns a structured outcome rather than a status string — nothing downstream could previously tell "re-screen FAILED" from "nothing new", because both were text — and the gate fails on a single failed attempt. The mechanism is repaired and tested; it has not yet had a live publication to fire on, and the README says so.

Accomplishments that we're proud of

  • We beat the independent oracle on its own turf. 0.995 top-1 against yente's 0.840, on a challenge set neither of us chose, reproducible byte-for-byte on a judge's machine.
  • Zero of the dangerous error. Across every adjudication, the count of "we cleared it and the oracle flagged it" is 0. Under strict liability the dangerous direction is being more permissive than the oracle, and we never are. 1 missed hit in 60, 0 frozen grantees in 41.
  • The sentinel book was sealed before the evidence existed. 400 counterparties drawn from the live SDN list, committed with their SHA-256 (66eb151c…) on 2026-08-13, before any later OFAC publication existed. If Treasury delists one of them, git log proves the book predates the removal. data/PROVENANCE.md documents the stratification and why it cannot steer the outcome. As of the 08/20/2026 publication: 400/400 still listed, no sentinel has fired, and the release leg is still the labelled replay — which the README states rather than blurs.
  • The ledger cannot be rewritten, and there is a test that proves it — two concurrent writers, one unforked chain, plus UPDATE ledger … rejected by the database itself.
  • 364 tests at 100% statement coverage (948/948, zero pragmas), and a CI that can actually fail. Real Postgres in CI, a guard that fails the job if the database or provenance suites silently skip, a benchmark that re-derives the published figures, CodeQL, gitleaks and pip-audit.

What we learned for Interdict

A check whose only failure signal is text nobody reads is not a check. We learned this from our own archiver, and then found three more instances of it in code we had already audited twice. The generalisation — an unattended system needs a liveness gate that fails loudly, not a log that records its own death — is now enforced in the repo rather than believed.

Ask what the model is actually being graded on. The "reached the model" column changed what our own results meant. Any agent evaluation without it is measuring the deterministic path and crediting the LLM.

Pin your numbers to a publication, not to a moment. Our tests asserted "19,199 records" against a live feed. Treasury published on 08/20 and it went to 19,249 — the assertion was correct on the day it was written and would have turned every future OFAC action into a red build. Figures are now labelled with the publication they describe.

100% coverage proves the lines ran, not that the feature works. Our second-model integration had full coverage and fifteen passing unit tests while being wired to nothing. Every one of those tests exercised the class directly; not one crossed the seam where the provider is handed from the script to the screening loop, and that seam was where the bug lived. Coverage measures the lines a test touched. It says nothing about the lines between two correct components. We now write one test per integration seam, and mutation-check it — if deleting the wiring doesn't fail a test, the test was decoration.

Structured output plus temperature 0 is the difference between a demo and an audit trail. A compliance decision has to be reproducible and citable. response_schema and a fixed temperature make the adjudicator's verdict a record rather than an opinion.

What's next for Interdict

  • Cloud Run + Cloud Scheduler + Cloud SQL the moment there is a billing account — the architecture already assumes it, and Postgres is local by consequence rather than by design.
  • A live release leg. The sentinel book is waiting for Treasury. Every publication is a fresh chance for a genuinely unstaged release, and the book was sealed to make that provable when it happens.
  • The full 536-counterparty graded run on a billed Gemini project, replacing the stratified sample of 101.
  • Consolidated Sanctions List and EU/UK lists through the same matcher, which is already list-agnostic below the parser.
  • Filing, not just drafting. Transmission of the blocking report to OFAC stays human today, and that is a deliberate line rather than a gap — but the artifact is already complete and clock-tracked.

Pre-existing code and work incorporated (required disclosure)

Official Rules, "New Projects Only": standard frameworks, libraries, starter templates and AI coding assistants are permitted, but any other pre-existing code or work incorporated into the Project must be disclosed. This is that disclosure, in full.

All application code in this repository — everything under interdict/, scripts/, tests/ and ops/ — was written during the submission period. No third-party source is vendored, copied or adapted into the tree, and the project was not started from a template or a boilerplate. The pre-existing work it incorporates is the following, all of it used as published and unmodified:

  • OpenSanctions yente 4.2.1 (MIT), pulled as the published container image ghcr.io/opensanctions/yente:4.2.1 and run unmodified. It is the independent oracle this project benchmarks itself against and records beside every adjudication — it is deliberately not part of the screening path, so the 0.995-vs-0.840 comparison is against software this project did not write and does not modify. The only configuration is a scope pin to the us_ofac_sdn dataset in ops/yente-manifest.yml.
  • OpenSanctions data from data.opensanctions.org, used under OpenSanctions' own published licence terms, which is what the yente index is built from. Scope-pinned to us_ofac_sdn, so the underlying records are the same US Government public records described below.
  • Elasticsearch 8.15.3, the official image, run unmodified as yente's index backend.
  • PostgreSQL 16 (postgres:16-alpine), the official image, run unmodified. The schema, triggers and hash-chaining functions in interdict/schema.sql are original work.
  • US Treasury OFAC data — the SDN publication (SDN.XML) and the /changes/latest delta, US Government public records, ingested unmodified and archived by SHA-256 content hash. The 4,393 weak alias flags, the dates of birth, the sanctions programmes and the delisting actions are Treasury's, not ours.
  • Python libraries, pinned in requirements.txt, licences as declared in each package's own distribution metadata: google-genai 2.18.1 (Apache-2.0), google-cloud-firestore 2.28.1 (Apache-2.0), rapidfuzz 3.9.7 (MIT), psycopg[binary] 3.2.3 (LGPLv3), httpx 0.28.1 (BSD-3-Clause).
  • Development and CI tooling only, shipping in no product path: pytest 9.0.3, pytest-cov 6.0.0, ruff 0.7.4, mypy 1.13.0, pip-audit 2.7.3, gitleaks 8.30.1, and the standard GitHub Actions actions/checkout@v4, actions/setup-python@v5, actions/upload-artifact@v4 and github/codeql-action@v3.
  • AI coding assistance was used during development, which the rules name explicitly as permitted standard tooling. Every claim, number and benchmark in this submission was re-derived from the repository by hand before being written down.

Google ADK is not used and is disclosed as such elsewhere in this submission: it was declared in requirements.txt for a period, imported by zero lines, and removed. The Google Agent Framework requirement is met by the Google GenAI SDK, which is what actually runs.

Built With

Share this project:

Updates

Submission history