-
-
101 synthetic counterparties re-screened against the 19,199-record OFAC publication: 59 held, $1,181,434.51 frozen, chain INTACT.
-
Every adjudication carries its Gemini rationale, the oracle guard result, yente's independent verdict and Gemma's second opinion.
-
Held money against the statutory clock. Each hold shows the blocked date and the 10-business-day report deadline it must meet.
-
Run history. trigger=SCHEDULER means the 6-hourly poll started it on a publication hash it had not seen — no human pressed anything.
-
The append-only ledger. Every row carries seq, prev_hash and entry_hash; the chain is re-verified on page load, not asserted.
-
make challenge screens any name you type against all 19,199 records — full signal breakdown, with the independent oracle beside it.
-
Add a contradicting date of birth and the hit disappears. A disjoint interval cuts the score below the bar, so the model is never asked.
-
Top-1 0.995 against the independent oracle's 0.840, on 400 names deliberately perturbed so none appear on the list verbatim.
-
The sentinel book re-derives byte-for-byte from its sealed SHA-256, and the ledger hash chain verifies end to end.
-
Every OFAC publication archived by content hash, with digests in PROVENANCE.md. The data is Treasury's, unmodified and checkable.
-
364 tests, 100% statement coverage across all 13 modules (948/948), zero pragmas. CI fails if the database tests silently skip.
-
Six-stage CI green on main: lint+types, tests on a real Postgres, dependency audit, reproduce the numbers, E2E, and Lighthouse.
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=weakflag (4,393 of them in the 08/07/2026 publication — real data, nothing synthesised), DOB corroboration through the feed's ownisApproximate/isDateRangefields. Calls no model. - adjudicator-agent —
gemini-3.5-flash-litevia the Google GenAI SDK, structured output throughresponse_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.93with 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_identifierthat 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 logproves the book predates the removal.data/PROVENANCE.mddocuments 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
yente4.2.1 (MIT), pulled as the published container imageghcr.io/opensanctions/yente:4.2.1and 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 theus_ofac_sdndataset inops/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 tous_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 ininterdict/schema.sqlare original work. - US Treasury OFAC data — the SDN publication (
SDN.XML) and the/changes/latestdelta, US Government public records, ingested unmodified and archived by SHA-256 content hash. The 4,393weakalias 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-genai2.18.1 (Apache-2.0),google-cloud-firestore2.28.1 (Apache-2.0),rapidfuzz3.9.7 (MIT),psycopg[binary]3.2.3 (LGPLv3),httpx0.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@v4andgithub/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
- codeql
- docker
- elasticsearch
- github-actions
- gitleaks
- google-cloud
- google-cloud-firestore
- google-gemini
- google-genai
- mypy
- opensanctions-yente
- postgresql
- psycopg
- pytest
- python
- rapidfuzz
- ruff
- sql
Log in or sign up for Devpost to join the conversation.