Custody

Prove what your AI did to a loan. Including when it was wrong.

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

Why now

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.

70–75% of loan conditions now cleared automatically by AI at leading lenders Uptiq, 2026
1.71% critical defect rate in Q1 2026 — the largest quarterly jump in four years Scotsman Guide
$32,288 average cost to buy back a single defective loan National Mortgage News

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.

How it works

Three things, and none of them ask you to change your AI.

01 / Check

Every number must be in a document you supplied

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"
02 / Record

The call is the log entry

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")
03 / Seal

Edit a record afterwards and it shows

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

Real output

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

Verify it yourself

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.

Not yet verified Press verify to recompute the chain in your browser

What an examiner receives

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.

The four checks

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.

Figure binding A number in the answer that appears in none of the supplied documents is rejected.
Field grounding Every extracted field must name the document it came from. Uncited is indistinguishable from invented once the moment has passed.
Closed vocabulary A classification outside its allowed set is rejected, rather than handing a downstream system a status it has no branch for.
Confidence floor Below the threshold it goes to a person — not rejected. Not being sure is not the same as being wrong.

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.

Install

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.

Two things a security review asks first

Where does the key live, and why should we believe you?

Key

Not in a file. In your vault.

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
Trust

A verifier you can read in five minutes

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.

Being straight about it

What it does

  • Stops fabricated figures reaching a loan file, deterministically.
  • Records every AI decision — model, prompt (redacted), verdict, outcome, who ran it, when.
  • Records the human who reviewed, corrected or approved, chained to the decision they acted on.
  • Makes edits detectable by anyone, with no access to your systems.
  • Exports a per-loan packet answering "what did your AI do to this loan".

What it does not

  • Certify compliance. LL-2026-04 requires governance and disclosure; it names no schema. The record design here is ours, not Fannie Mae's.
  • Bias or fair-lending testing. A separate obligation with mature vendors already in it.
  • Inventory models it never sees. A list built only from what happened to be instrumented is worse than none, because it looks complete.
  • Answer the InfoSec Supplement. Custody stores redacted prompts and outcomes, so it is in scope of it and has not yet been assessed against it. Ask us about this.
  • Write to your LOS. It records what you say you wrote.
  • Replace your policy. The letter wants a named owner reviewing it annually. Custody records which policy version each decision ran under, which makes that review answerable — it does not perform it.