Custody
Lenders now run AI over paystubs, W-2s and bank statements. It is fast, it mostly works, and occasionally it returns a number that is in none of the documents. Custody stops that number before it reaches the file, and keeps a record the person asking can verify without taking your word for it.
Open a live ledger → Install it Source
Python library and CLI · one dependency · MIT
The rules changed on 6 August 2026.
Fannie Mae Lender Letter LL-2026-04 requires seller/servicers using AI in origination or servicing to govern its use, extend that governance to their vendors, and — on request — promptly disclose what AI they run, for what purpose, and what safeguards are in place.
You can answer a request like that with a document describing what is supposed to happen. Or with a record of what did happen, decision by decision, that the person asking can check themselves. This is the second.
Three things, and none of them ask you to change your AI.
The main rule is deliberately dumb: if the model says the borrower earns $6,842 a month, and $6,842 appears in no paystub, no W-2 and no bank statement you gave it, the answer is stopped. Not flagged for a report later — stopped, before anything downstream sees it.
# the model's answer, and the documents it was given verdict = d.gate(output, citations=cites, confidence=0.91) # verdict.treatment -> "pass" | "review" | "reject"
Most compliance logging fails because it is a separate step someone skips when they are busy. Here there is no way to reach the model except through an open decision, so the record cannot be forgotten. A decision that is abandoned still writes a record. One that throws writes a record and re-raises.
with ledger.decision(loan="1000254", principal="jane@lender.com",
purpose="income_calculation") as d:
out = d.call(model="claude-sonnet-5", prompt=p, sources=docs)
verdict = d.gate(out, citations=cites, confidence=0.91)
if verdict.ok: d.commit(outcome=out)
else: d.route_to_human(queue="uw-review")
Each record is cryptographically linked to the one before it and signed. Change one later — say, editing a rejection to look like the model was right all along — and anyone can detect it, and see exactly which record was touched. The database refuses the edit too, but the chain is what proves it to somebody who does not trust your database.
$ custody verify custody.db --public-key ee1ffe7f...
BROKEN at record 1 (dec_ce88805aae674524)
its contents no longer hash to the value stored with it
This is an actual session, not a mockup.
A clean install, a real Claude call over two real (synthetic) documents, and then the same command with a model that invented a figure. Copied verbatim.
$ custody keygen
private key custody-signing.key (mode 600)
public key ee1ffe7f5e8c34eccb2e6cf4eaa77d77037dc9d07c1cd38322b52468eeeb4ecd
$ custody run --loan 2000811 --principal jane@lender.example \
--instruction "Extract gross pay and annual wages." \
--doc paystub-2026-07-15.txt --doc w2-2025.txt \
--redact "Dana Whitfield" --model claude-sonnet-5
model returned : {"monthly_gross_pay": 4206.0, "annual_wages": 98410.0}
confidence : 0.9
verdict : pass
committed.
# same command, a model that returned a figure from nowhere
model returned : {"monthly_income": 6842.0}
confidence : 0.91
verdict : reject
- [figure_binding] '6842' appears in no source document supplied to the model
routed to review — nothing was committed.
$ custody verify custody.db --public-key ee1ffe7f...
OK 2 records verified — hash chain and signatures
# and an attempt to edit the ledger directly, in SQL
database refused the edit: custody: the ledger is append-only
Press verify. Your browser does the arithmetic.
This is the ledger that session actually produced, shipped in the repo. Nothing is checked on a server and no tick is drawn on a picture — the page recomputes every hash locally, so a broken chain is something you observe rather than something we assert. Press Tamper, then verify again.
One synthetic loan file through five AI steps. Everything here — borrower, employer, documents, loan number — is fabricated. The hashes are not: your browser recomputes every one of them from the record contents when you press verify.
The packet for one loan: every decision, in order, including the ones that were rejected. It carries the public key and the records, so the recipient verifies it without access to any of the lender’s systems — which is what turns a disclosure into evidence.
Deterministic. No model judges another model.
Given the same output and the same documents, the verdict is the same forever, and whoever is auditing you can re-run it. A second model scoring the first one is not evidence — it is a second thing to audit.
There is a second reason this matters. LL-2026-04 requires you to govern your vendors’ use of AI/ML “no less protective” than your own. Custody uses no AI/ML at all — the checks are arithmetic and string comparison, and there is deliberately no model anywhere in the verification path. Adopting it adds nothing new for you to govern.
Two minutes, one dependency.
Default storage is stdlib SQLite, the review UI is stdlib http.server, and the model adapters and the Postgres backend are optional extras you install only if you use them. Anything sitting in the call path of a regulated workflow should be something a security review can read in an afternoon.
$ pip install custody-ledger
$ custody keygen
$ custody run --loan 1000254 --principal you@lender.com \
--instruction "Extract qualifying monthly income." \
--doc paystub.txt --doc w2.txt
$ custody serve # browse and verify at localhost:8787
$ custody packet 1000254 # the chain of evidence for one loan
SQLite is one file and one process, which is right for a pilot. Run more than one application instance and you want Postgres, where the same guarantees hold and a process lock would not:
$ pip install "custody-ledger[postgres]" $ custody run --db postgresql://user:pw@host/custody --loan 1000254 ...
The reason that is a one-line change rather than a rewrite: the ledger
cannot fork because prev_hash is UNIQUE, not
because a lock says so. Two writers cannot both chain onto the same predecessor
— the second one is refused and retries, rather than quietly writing a
ledger that will not verify. A lock protects one process, which is exactly as
far as it goes, and two instances is the whole reason to want Postgres in the
first place. Both backends abort UPDATE and DELETE at
the database, and one conformance suite runs against both so they cannot drift
into disagreeing about what verifies.
Custody does not call your model — it wraps a call you already make. A governance layer that requires you to rewrite your AI does not get adopted. Adapters ship for Azure OpenAI and Anthropic, and writing your own is about twenty lines.
$ export AZURE_OPENAI_ENDPOINT=https://acme-uw.openai.azure.com/
$ custody run --provider azure-openai --deployment gpt-4o-prod \
--loan 1000254 --principal you@lender.com \
--instruction "Extract qualifying monthly income." --doc paystub.txt
# with no API key set, it authenticates with managed identity --
# nothing stored to leak, rotate, or find in a config file in three years
One detail that matters more than it looks: it records the model
version, not the deployment name. Azure routes on a deployment, and
the model behind it can change — an auto-update policy, someone in the
portal — without a line of your code changing. So the ledger stores
azure-openai:acme-uw:gpt-4o-prod:gpt-4o-2024-11-20, not
gpt-4o-prod. When an examiner asks which model produced a figure
eighteen months ago, that is the difference between an answer and a shrug.
Where does the key live, and why should we believe you?
Signing is an interface. In production it is an EC P-256 key in Azure Key Vault: Custody sends a digest, the vault returns a signature, and the private key never enters the process. The most a compromised host gets is the ability to sign while it holds the credential — which the vault logs.
Key Vault supports no Ed25519, so the algorithm is a parameter rather than a constant, and it is written inside the hashed body of every record. An attacker cannot claim a record was signed with something weaker than it was: that claim breaks the chain before anyone checks a signature.
$ az keyvault key create --vault-name v --name custody --kty EC --curve P-256 $ export CUSTODY_KEY_VAULT=https://v.vault.azure.net/ CUSTODY_KEY_NAME=custody
The rest of this is a library you would have to take on faith.
verify_packet.py is deliberately not that: one file, no
dependency on the package, nothing outside Python’s standard library.
Your auditor reads the whole thing and satisfies themselves the
cryptography is real. If it ever disagrees with our tooling, believe it.
$ python3 verify_packet.py packet.json
OK hash chain verified across 6 records
OK 6 signatures verified (ecdsa-p256-sha256)
This proves the records have not been altered since they were
written. It does not prove they are true, and it cannot show a
decision that was never recorded in the first place.
That last paragraph is printed by the tool itself. A verifier that only ever says OK teaches people to over-read it.