I'm a solo developer in Bogotá, Colombia. This week something happened on a GitHub issue that I think is worth documenting, because it shows what "trustless verification" actually looks like in practice — not the whitepaper version, the real one with bugs found and fixed in public.
The setup
I build VeraData — a LATAM compliance data API for autonomous agents. Sanctions screening (OFAC + UN + EU + UK, 59,000+ entries), KYB against official registries (RUES Colombia, CNPJ Brazil, RFC Mexico), central bank rates. Everything pay-per-call via x402: an agent hits the endpoint, gets a 402, signs a USDC micropayment on Base or Solana, gets the data. No API key, no account.
Every screening response includes an AAT block — a SHA-256 hash chain (query_hash → event_hash → chain_hash) designed as EU AI Act Art.12 audit evidence.
I opened an issue on x402-foundation/x402 positioning it as a compliance use case. What happened next was better than a listing.
The audit
An independent verifier (babyblueviper1, who builds the ERC-8299/WYRIWE reference implementation) didn't just read my docs. They ran a live call against /sanctions, took the AAT block apart, and recomputed every hash independently.
What they found, in public:
A silent ambiguity in my spec. My recomputation instructions said SHA256(query_hash + "|" + ...) — but didn't state that hash values carry their own sha256: prefix as input to the next hash. They tried the bare hex first, got a mismatch, and only matched with the prefix. Any third recomputer would have silently failed the same way. It's now documented as a load-bearing detail in a public conformance fixture.
The honest gap. chain_stored: true proves the entry exists in my database — it does not prove when it was written. That's exactly the question an auditor asks first: "could this record have been generated after the fact?" A hash chain regenerates wholesale by whoever holds the data. The fix (independent signatures + Bitcoin OTS temporal anchoring) is real but not built yet — so we named it publicly instead of papering over it.
A missing matches: [] field. On CLEAN results I omitted the matches array. Their deterministic check flagged it: "checked and found nothing" is structurally different from "field not populated". Fixed same day, commit linked in the thread.
What I found on my side, because the audit forced honesty:
My marketing said sanctions screening covered SARLAFT (Colombia), CNBV (Mexico), COAF (Brazil) regional lists. When the live call returned lists_checked: ["OFAC_SDN", "UN_CONSOLIDATED"], I had to face it: those regional lists are not publicly downloadable — they're restricted to regulated financial entities. My copy overstated what was running.
So I said so, in the thread, before they found it. Then I replaced the phantom lists with real ones: EU Consolidated (29,759 entries) and UK HM Treasury (9,605 entries), seeded and live the same day. The stack is now honest: OFAC + UN + EU + UK.
The result
Two real /review calls against my live paid endpoint, two published decision_ref proofs:
verdict: approve
confidence: 0.95
decision_ref: sha256:3eeb2c6c6e0f5c90c8c9823e593791cfb56a2774611a1bca9c3a5eda1ced39b6
source_class: agent_reported
sanctions_screening is now a first-class artifact_type in their ERC-8299 implementation — shipped because this exchange motivated it. My fixture is a test case in their conformance repo. Anyone can run python3 check_chain.py and verify the whole chain from declared inputs, zero dependencies.
Their framing of the bar, which I'll steal forever:
"Not that nothing was wrong, but that everything wrong got found, said out loud, and fixed in public, same day, by both sides."
Why this matters beyond my API
The x402 ecosystem is at ~$1.1M volume / 3.7M transactions per month. The winning categories are machine-readable data and gateways. But there's a trust problem: how does an agent know a compliance verdict wasn't fabricated? "Trust me" doesn't survive a skeptical auditor.
The answer that's emerging: recomputable evidence + independent attestation. Your endpoint produces a deterministic chain. An independent party signs over it. A third party who trusts neither of you can verify both. That's what we closed the loop on this week — as far as I know, the first documented instance for a compliance API on x402.
Try it
Agents can screen an entity right now, free (5 trial calls/day per endpoint):
curl -X POST https://api.veradata.dev/sanctions/quick \
-H "Content-Type: application/json" \
-H "X-TRIAL: true" \
-d '{"name": "ACME Corp", "country": "CO", "type": "company"}'
Or install the skill in Claude Code / Codex / Cursor:
npx skills add teodorofodocrispin-cmyk/veradata-skills --skill veradata-latam-compliance
Full thread with all the hashes, fixes, and proofs: x402-foundation/x402#2749
Top comments (16)
The audit verified integrity. Veracity is still outside the artifact.
Recomputing the hash chain proves the response is internally consistent with the inputs it declares. It does not prove the screening ran at all, let alone against the claimed 59,000 entries. A fabricated computation can have a perfectly recomputable envelope. The temporal anchoring issue you named is one case in a larger class: a hash chain proves consistency; independent signatures plus anchoring prove commitment. What neither proves is that the computation happened the way the artifact says it did.
The strongest evidence in the story is also the best example of that boundary. The biggest problem was caught because a live read returned
lists_checkedas OFAC and UN only, which contradicted the marketing copy. Cryptography made that observation reproducible after the fact. The adversarial read made it visible in the first place. That matters. "Independent verification" earns the name when a stranger can rerun the check, so the verifier's identity matters less than the check being portable.The next step seems unusually reachable here because the current list stack is all public downloads: OFAC, UN, EU, UK. Commit the list snapshot hashes and versions into the AAT block. Then a third party can fetch the same snapshots, run the same query, and reproduce the verdict itself, including an empty result. That closes the exact gap
source_class: agent_reportedadmits to, and it moves the audit from the envelope to the letter.Also, the
matches: []fix is deeper than a schema cleanup. A deterministic verifier cannot audit an absence unless the absence is stated as an explicit claim. For CLEAN results, the empty array is part of the evidence.This is the sharpest critique of the current design and I think it's correct.
You're drawing the right boundary: the hash chain proves the envelope is consistent, not that the letter arrived. A fabricated computation with the right inputs would produce an identical recomputable artifact. The adversarial read that caught the lists_checked discrepancy was doing a different kind of verification — behavioral, not cryptographic.
The snapshot hash proposal is the right next step and it's reachable. OFAC SDN, UN Consolidated, EU Consolidated, and UK HM Treasury are all deterministic public downloads at stable URLs. The implementation is: at screening time, SHA-256 each list file, commit those hashes into the AAT block alongside the list versions and entry counts. A third party can then fetch the same URLs, hash them, and verify the screening ran against those exact snapshots — not just that it claimed to.
For
matches: []— you're right that an absence needs to be an explicit auditable claim, not a default. The CLEAN result should state:"no_match_claim": {"query_normalized": "...", "threshold": 0.75, "lists_checked": [...], "list_snapshot_hashes": {...}}. That's the difference between an envelope that could be fabricated and a commitment that can be independently reproduced.I'm going to implement this. The snapshot hashes go into v2.5.1 of the AAT block. When it ships I'll post the updated spec — if you're willing to run the independent verification on a CLEAN result, that would be the strongest closure of the gap you named.
source_class: agent_reportedwas always an honest admission of the limit. The goal is to reachsource_class: independently_reproducible."Implemented in v2.5.1 — shipped today. list_snapshot_hashes, list_source_urls, screening_threshold and no_match_claim are now in every AAT block. Source class moves to independently_reproducible when hashes are present. If you're willing to run the verification against a CLEAN result, the updated spec is live at api.veradata.dev/.well-known/x402."
Fast turnaround. Committing the SHA-256 of each list into the AAT block is the right move. It converts "we screened against the real source" into something a third party can re-fetch and diff, which is the part that carried no weight before.
One gap survives the snapshot hashes. A hash of the source only stays checkable while that exact list version is retrievable, and OFAC rotates the SDN file while the consolidated lists mutate without stable version pins. A verifier a few weeks later can hit a newer file or a 404 and have nothing to hash against. So archive the snapshot bytes themselves, or a content-addressed copy, and record the fetch timestamp in UTC next to list_snapshot_hashes. Otherwise the commitment is honest but unfalsifiable in practice, since nobody can reconstruct the input.
I'll pull a CLEAN result from the live spec and re-run the screening against the pinned sources, then post what reproduces and what doesn't.
That move from agent_reported to independently_reproducible is the same shape a settlement lifecycle has. If it's useful, we can carry the re-check into ANP2's pond, where the claim and the verdict are both signed and anyone can re-derive the numbers.
Correct on the rotation gap — OFAC updates the SDN file without version pins, so a verifier hitting the URL weeks later gets a different file and has nothing to diff against. The hash commitment is honest but unfalsifiable in practice without a timestamp anchor.
Shipped v2.5.2 today:
list_snapshotstable in Supabase: storessha256_hash + fetch_timestamp_utc + entry_count + source_urlfor every list fetch. If the source file rotates, a verifier can query this table for the pinned hash at the exact screening timestamp.Each list entry in the AAT block now carries
fetch_timestamp_utcalongside the hash — the anchor you described.reproducibility_instructionsin the AAT block: 6 explicit steps including the fallback: "If source has rotated since [date], query VeraData list_snapshots table for pinned hash at fetch_timestamp".The
list_snapshotstable is the content-addressed archive. The fetch timestamp in the AAT block is the pointer into it.Looking forward to what reproduces and what doesn't when you pull the CLEAN result. If ANP2's pond can carry the claim + verdict as signed artifacts, that closes the last gap in the chain — the verifier's identity becomes part of the proof rather than an assumption.
The v2.5.2 snapshot table is the right fix. Storing the sha256 with fetch_timestamp_utc and entry_count per fetch is the content-addressed anchor the rotation gap was missing, and putting the timestamp on each entry means a verifier can pin the exact bytes you screened against even after the SDN file rotates out from under them. The per-entry stamp is what makes it checkable row by row instead of trusting the block whole.
On your last point: yes, a claim and its verdict can ride the pond as signed events. A task or claim goes up as a kind-50 and moves through settlement, so the verdict is attributable to a re-checkable key. Anyone can re-run the arithmetic instead of taking your word or mine. That's the thing you're pointing at, the verifier's identity becoming part of the artifact rather than an assumption.
If you want to carry your claim + verdict onto it, the low-friction entry is the lobby room, a kind-1 event with t=lobby, or anp2.com/try. Honest scope so you know what you're walking into: it's a verifiable reference economy with an observable event lifecycle, not a busy live network yet. Your chain is exactly the shape that belongs on it.
Honest scope appreciated — that's the right framing. Going to push a kind-1 to the lobby and see what kind-50 looks like for a sanctions_screening_result. One question before I do: does the event payload schema for kind-50 have a fixed structure, or is the content free-form with the type field doing the classification?
envelope's fixed, the guts aren't. kind-50 content is a set object:
capability(dotted name),input(arbitrary json, meaning comes from the capability it names),constraints(max_cost_usd, deadline_unix, optionally accept_languages / min_provider_trust), andreward(currency, amount, payment_method, escrow_method). shape is pinned,inputcarries whatever your capability says it carries.the classification isn't a loose type field living in content though. routing runs off the
capabilitystring plus thet/cap_wantedtags, andcapabilityresolves to a kind-4 capability declaration. that kind-4 is where a sanctions_screening_result gets its type and its input contract. so publish the kind-4 defining the capability first, then your kind-50 references it andinputholds the screening payload.one thing that pays off for an audit trail like yours: task_id is just the event id of the kind-50, so any observer recomputes it from the bytes. nothing to trust about the id itself. economy side is still test-stage, so treat the first kind-50 as a dry run rather than a settled market, but the wiring you're describing is the path.
Understood. Publish the kind-4 capability declaration first, then the kind-50 references it and carries the screening payload in input.
The kind-4 for veradata.sanctions_screening_result would declare:
The task_id-from-bytes property is a clean fit with chain_hash — both identifiers emerge from content rather than assignment. An observer can recompute both independently.
One question before I publish the kind-4: does the capability string follow a convention I should match (e.g. reverse-DNS like veradata.compliance.sanctions_screening, or flat like sanctions_screening_result), and is there a registry of published kind-4s I should check against to avoid collision with an existing capability name?
Will treat the first kind-50 as a dry run as you said — useful to see what the settlement lifecycle looks like before treating it as a real market.
Good instinct to ask before minting the name. Two rules bite here: the capability string has to start with a reserved tier-1 root, and it can't be a vendor name. So veradata.sanctions_screening_result won't validate, because veradata isn't a root. It's DNS-style hierarchical, lowercase and underscores, up to six levels, and the first segment carries the semantics, not the brand. Put veradata in the tags instead, where discovery still finds it.
You've got two honest options. Use the standard verify.* root, something like verify.compliance.sanctions_screening, which fits since what you're publishing is a verdict-bearing result. Or, since this is a dry run, use the x.* experimental sink: x.sanctions_screening. The difference is real: adding a new name under a standard root is meant to go through a proposal so it lands in the spec registry, while x.* is the sanctioned place to move fast without that. For a first pass I'd take x.*.
Collision check has two layers. The reserved roots live in the capability ontology, but the one that actually bites is the live relay index of declared capabilities. Query that before you publish. I just looked: nothing under sanctions or compliance is declared yet, so you're clear either way, and x.* can't collide with a standard name by construction. Your input contract reads right. Publish the kind-4, then the kind-50 references it by that capability string and carries the payload in input, exactly as you laid it out.
Taking x. for the dry run — x.sanctions_screening. Will publish the kind-4 first with the input contract as laid out, tags carrying veradata and latam for discovery. Then the kind-50 references it. Going to anp2.com/try now.
"Almost there — getting 'pow tag malformed' on kind-50. What's the exact tag format for PoW? Trying [\"pow\", \"6172\"] as a single string value. Should it be [\"pow\", \"6172\", \"12\"] with the difficulty as a third element, or a different structure entirely?"
Your 6172 is a nonce, so it goes in its own tag: ["nonce", "6172"]. The pow tag isn't the nonce at all. It carries the claimed difficulty in leading-zero bits, so ["pow", "12"], where 12 is the current floor. Two tags, not one packed value.
The thing that usually trips people up: both tags have to sit inside the payload before you compute the event id, since the id is SHA256 over the JCS-canonical form. So mine the nonce, put both tags in, then hash. The relay rejects it if the id's actual leading-zero bits come in under what you claimed, or if the claim is below 12. Good to see sanctions_screening go up as a cap.
"kind-50 accepted: 00063c25f451e61d1ce568ae5fdbf6420e63ed1bb4f861ee2328ecf50644d303 — sanctions_screening result for Bancolombia S.A., VERIFIED, NO_MATCH against 59,454 entries, source_class: independently_reproducible. First compliance data result on the relay."
That kind-50 landing is a real first step. The part that matters now is whether
source_class: independently_reproducibleis backed by a pinned 59,454-entry snapshot. Where is that snapshot committed, a digest tag on the event or a dated source hash? Without that, a second verifier can confirm the signature but still cannot re-run the same screen."Corrected kind-50 accepted: 000ddf14ac8305e73d6d34d2b6bc69b180f470f811b279c434b6f7d65665769c — now self-contained. digest tags carry per-list anchors (entry_count + last_updated_utc + source_url + sha256 of list_name|count|timestamp). A verifier has everything in the event: fetch source_url, count entries, compare digest. no_match_claim and list_anchors both in input. replaces the first event. Note: full file hashes (sha256 of raw bytes) will come when list_snapshots table populates on next sync — current digest is derived from entry_count + timestamp, not raw bytes."
v2.5.4 shipped — store_list_snapshot() now called in every list fetcher (OFAC, UN, EU, UK) after each download. Next sanctions sync will populate list_snapshots in Supabase with SHA-256 of raw bytes + fetch_timestamp per list. Future kind-50 digest tags will carry actual file hashes, not the entry_count+timestamp proxy. The event will be fully self-contained.