Every RAG system I have built eventually runs into the same question, usually months after the answer was generated: "how do we know what actually happened here?" A document leaked into an answer it should not have. A fact turned out to be wrong. A compliance reviewer wants to see which sources supported a claim. The standard reply is "check the logs."
Logs are a weak answer. They are mutable, they live in your database, and they prove nothing to anyone outside your walls. If I am the party who is being questioned, "trust my logs" is not evidence. That is the gap I built answerproof to close.
The core idea
answerproof produces a receipt for each generated answer. A receipt is a signed, self-contained artifact that records what the system did: the query, the answer, which sources were retrieved, which the answer claimed to use, under whose permissions, with which model and parameters, plus a content hash of every source and a Merkle root over the retrieval set.
The important property is that a third party can verify a receipt independently, using nothing but the receipt and the library. No access to my database, my servers, or my logs. It turns "trust us" into "verify it yourself."
One design decision matters a lot here: source contents are never stored in the receipt, only their SHA-256 hashes. So a receipt is safe to hand out even when the underlying documents are sensitive. If someone later has the original content, they can confirm it hashes byte-for-byte to what was recorded. If they do not, the hash and the Merkle root still let them reason about membership without ever seeing the text.
Building a receipt
The builder sits at the seam between retrieval and generation. You feed it the same things you already have in a RAG loop.
from answerproof import ReceiptBuilder, SigningKey, verify_receipt
signing_key = SigningKey.generate()
builder = ReceiptBuilder(signing_key)
builder.set_query("How tall is the Eiffel Tower?")
builder.set_answer("The Eiffel Tower is 330 metres tall.")
builder.set_principal("analyst-7", permissions=["kb:paris"], tenant="acme")
builder.set_model("gpt-x", provider="openai", params={"temperature": 0.0})
builder.add_source("doc-1", content="The Eiffel Tower is 330 metres tall.", score=0.92)
receipt = builder.finalize()
When finalize() runs, the builder hashes each source's content, builds a Merkle tree over those hashes, runs a rule-based citation and grounding pass, assembles the payload, serializes it to canonical JSON, and signs that byte string with an Ed25519 key. The signature is detached and the signer's public key travels alongside it in the receipt.
Canonicalization is the quiet part that makes the whole thing work. Two systems have to agree on the exact bytes being signed, or verification would depend on whitespace and key ordering. answerproof serializes with sorted keys, no extra whitespace (separators=(",", ":")), and ensure_ascii=False so UTF-8 is preserved. Same payload, same bytes, everywhere.
Verifying, and watching tampering fail
Verification needs only the receipt, plus optionally the original source contents if you want to check them too.
verdict = verify_receipt(
receipt,
source_contents={"doc-1": "The Eiffel Tower is 330 metres tall."},
)
assert verdict.valid
The verdict is not a single boolean under the hood. Each check runs independently and is reported separately: signature (the payload is unmodified and signed by the embedded key), merkle (the recomputed root matches the signed root), sources (supplied contents hash to the recorded hashes), grounding (citations reference real sources), and an optional signer_pin for when you want to require a specific public key.
The failure case is where the design earns its keep. Change a single character of the answer after signing:
import json
from answerproof import verify_receipt
from answerproof.schema import Receipt
tampered = json.loads(receipt.to_json())
tampered["payload"]["answer"] = "The Eiffel Tower is in Berlin."
bad = Receipt.from_json(json.dumps(tampered))
verdict = verify_receipt(bad)
assert not verdict.valid
print(verdict.failures()[0].name) # -> "signature"
The mutated payload no longer canonicalizes to the bytes that were signed, so the Ed25519 check fails and points straight at signature. There is no way to edit the answer, swap a source, or rewrite the permissions without breaking the signature, because all of it is inside the signed payload.
Merkle inclusion proofs
The Merkle root is not decoration. It lets you prove that one specific source was part of the retrieval set without revealing the others, which is exactly what you want when the rest of the set is confidential.
from answerproof.merkle import MerkleTree
from answerproof.verifier import verify_inclusion
hashes = [s.content_hash for s in receipt.payload.sources]
proof = MerkleTree.from_hashes(hashes).proof(0)
assert verify_inclusion(receipt, receipt.payload.sources[0].id, proof).passed
The tree uses domain-separated hashing, with a 0x00 prefix for leaves and 0x01 for internal nodes, and it promotes odd nodes rather than duplicating them. Both details close well-known Merkle forgery vectors where a leaf can be passed off as an internal node or a duplicated node manipulates the root.
The honest limitation
I want to be precise about what a receipt does and does not prove, because it is easy to oversell this.
A receipt proves integrity, source authenticity, set membership, and provenance of the signer. It does not prove truth. A perfectly grounded claim can still be wrong if the source is wrong. answerproof records what the system did, not whether the world agrees with it.
The grounding and citation signal is deliberately transparent, not clever. Citation binding is n-gram overlap, which means it can miss a correct paraphrase or accept a coincidental lexical match. It is an auditable, rule-based signal, not a semantic judge, and I document it as such. Likewise, answerproof verifies signatures but does not run a PKI; you decide which public keys you trust. And it records what was retrieved, not what should have been, so it cannot tell you your retrieval was complete or unbiased.
Being clear about these boundaries is the point. A receipt is trustworthy precisely because it does not claim more than the cryptography supports.
Closing
answerproof is a real library and verifier, not a wrapper around a model. It ships with a CLI (keygen, verify, inspect), an optional FastAPI verifier service that can return a shareable HTML verification page, and a test suite of 85 tests including negative tamper cases, run on Python 3.11 and 3.12.
If you run RAG or agents in any setting where someone might later ask "prove it," a signed receipt is a much better answer than a log line.
Top comments (0)