Custody
A signed chain of evidence for AI decisions in mortgage lending.
Fannie Mae Lender Letter LL-2026-04 took effect on 6 August 2026. Seller/servicers using AI or ML in origination or servicing must have policies governing its development, use and maintenance, must extend governance no less protective to their vendors, and on Fannie Mae's request must promptly disclose the types of AI/ML in use, the purpose and manner of that use, and the safeguards implemented to mitigate the risks.
There are two ways to answer that request. One is a document describing what you intend to happen. The other is a record of what did happen, decision by decision, that the person asking can verify without taking your word for it.
Custody produces the second.
It is worth being precise, because vendor summaries of this letter are not.
LL-2026-04 does not specify a record schema, does not name any fields, and does
not require append-only or signed logs. The design below is one implementation of
the letter's disclosure and safeguard obligations — a defensible one, and the one
this library takes. It is not a transcription of Fannie Mae's instructions.
docs/ll-2026-04.md sets out the letter's actual requirements and marks the
boundary between them and our choices.
Live demo — a synthetic loan file through five AI steps, with the hash chain re-verified in your own browser. Break a record and watch it get caught.
The idea
Compliance logging fails when it is a separate step. Someone adds a model call in
a hurry, the log.write(...) after it never gets written, and nobody notices
until an examiner asks. So here the call is the record:
from custody import Ledger
ledger = Ledger(policy="uw-policy-v3.2", signing_key=key)
with ledger.decision(loan="1000254", principal="jane@lender.com",
purpose="income_calculation", identifiers=[borrower]) as d:
out = d.call(model="claude-sonnet-5", prompt=prompt,
sources=[paystub, w2], invoke=call_the_model)
verdict = d.gate(out, citations={"monthly_income": "paystub-2026-07-15"},
confidence=0.91)
if verdict.ok:
d.commit(outcome=out)
else:
d.route_to_human(queue="uw-review")
Every field is filled as a side effect of normal use. A decision that is opened and abandoned still writes a record. One that raises writes a record and re-raises. An audit trail with holes where the awkward cases were is worse than none, because it looks complete.
The gate
Deterministic checks only — no model judges another model. Given the same output and the same sources, the verdict is the same forever, and an examiner can re-run it.
| Check | Rejects |
|---|---|
| Figure binding | a number in the output that appears in no supplied source document |
| Field grounding | an extracted field that cites no source |
| Closed vocabulary | a classification outside its allowed set |
| Confidence floor | routes to a human below threshold — not being sure is not being wrong |
Verdicts are pass / review / reject. That verdict is the recorded
response_treatment — the evidence that a safeguard ran and what it concluded.
The chain
Each record's hash covers the previous record's hash, and each hash is signed. The two answer different questions: the chain says was anything changed or removed, the signature says did this come from the system that claims to have written it. The chain needs no key to verify, which is why the demo page can re-verify in a visitor's own browser.
Storage is append-only, enforced by database triggers rather than by convention.
The chain cannot fork because prev_hash is UNIQUE — two writers cannot both
chain onto the same predecessor, and the second one is refused rather than
quietly writing a ledger that will not verify. Locking is an optimisation on top
of that, not the guarantee.
SQLite or Postgres
SQLite is the default and needs nothing installed. 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 ...
Ledger(policy="income-calc-v3", signer=signer,
path="postgresql://user:pw@host/custody")
One conformance suite runs against both backends, so they cannot drift into disagreeing about what verifies. On Postgres, also revoke the privileges — the trigger should be your backstop, not your only defence:
REVOKE UPDATE, DELETE ON records FROM custody_app;
Which model you call
Custody wraps a call you already make; it does not choose your model. Adapters
ship for Anthropic and Azure OpenAI, and an adapter is just a callable
returning (fields, confidence, endpoint, citations) — write your own in twenty
lines if neither fits.
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 AZURE_OPENAI_API_KEY set it authenticates with managed identity —
no stored key to leak, rotate, or find in a config file in three years.
It records the model version, not the deployment name. Azure routes on a
deployment, and the model behind that deployment can be changed by an
auto-update policy or by somebody in the portal without a line of your code
changing. A record saying gpt-4o-prod therefore does not identify what made the
decision. The response carries the real version, so that is what lands in the
ledger, with the deployment and the resource kept alongside:
endpoint azure-openai:acme-uw:gpt-4o-prod:gpt-4o-2024-11-20
When an examiner asks which model produced a figure eighteen months ago, that is the difference between an answer and a shrug.
Where the signing key lives
A key in a file is a development convenience, and the first thing a lender's security review will object to. So the signer is pluggable:
from custody.signing import KeyVaultSigner
ledger = Ledger(policy="uw-v3", signer=KeyVaultSigner(vault_url, "custody"))
The private key never enters the process. Custody sends a digest and gets a signature back; the most an attacker gets from a compromised host is the ability to sign while they hold the credential, which the vault logs.
Azure Key Vault has no Ed25519 -- EC and RSA only, and AWS KMS is the same.
So the algorithm is a parameter, not a constant: ed25519 locally,
ecdsa-p256-sha256 (ES256) in a vault. It is written into the hashed body of
every record, so a downgraded algorithm claim breaks the chain before anyone
reaches a signature check.
az keyvault key create --vault-name <vault> --name custody --kty EC --curve P-256
export CUSTODY_KEY_VAULT=https://<vault>.vault.azure.net/
export CUSTODY_KEY_NAME=custody
Verifying without trusting us
verify_packet.py is a single file with no dependency on this package and
nothing outside the standard library. An auditor reads it end to end in a few
minutes and satisfies themselves the cryptography is real:
python3 verify_packet.py packet.json
With cryptography installed it checks signatures too; without it, it checks
the chain and says plainly that it did not check signatures rather than printing
a bare OK. It also states what it cannot prove -- that the records are true,
and that nothing was withheld -- because a verifier that only ever says OK
teaches people to over-read it.
Install and run it
pip install custody-ledger # one dependency: cryptography
custody keygen # signing key, mode 600
custody run --loan 1000254 --principal you@lender.com \
--instruction "Extract qualifying monthly income." \
--doc paystub.txt --doc w2.txt \
--redact "Borrower Name" --model claude-sonnet-5
custody verify custody.db --public-key <hex> # recompute the chain
custody packet 1000254 --out packet.json # evidence for one loan
custody serve # prints a URL with a one-time token
custody serve binds loopback and mints a token unless you supply one, and
refuses outright to bind anywhere else without one — it is serving an audit
trail containing loan numbers. A shared token is a floor, not a control; put it
behind your SSO before anyone but you uses it.
custody run calls a real model when ANTHROPIC_API_KEY is set
(pip install custody-ledger[anthropic]), or replays a fixed response with
--replay fixture.json. The gate neither knows nor cares which.
Custody does not call your model on your behalf in library use — it wraps a call you already make. A governance layer that requires you to rewrite your AI does not get adopted.
From a source checkout
python demo/pipeline.py # the synthetic loan, produces demo/ledger.json
python demo/build_page.py # bakes the review page from that ledger
python tests/test_chain.py # and test_gate, test_ledger, test_crosslang
node tests/verify_like_the_page.js demo/ledger.json
The demo's model outputs are fixed, so the ledger is reproducible byte-for-byte by anyone who clones this.
tests/test_crosslang.py diffs the Python and JavaScript canonicalisers against
every shipped record. If they ever drift, the browser would report tampering on
an honest ledger — the most damaging failure this project could have — so
the agreement is tested rather than assumed.
What this is not
Not legal advice, it does not certify compliance, and it is not a statement of what Fannie Mae requires. It does no bias or fair-lending testing — that is an ECOA and fair-lending obligation rather than something this letter specifies, it is a separate product, and vendors already occupy it. It writes to no loan origination system. It cannot inventory models it never sees.
docs/ll-2026-04.md sets out what the letter actually says, what Custody helps
with, and the obligations it does not touch at all.
docs/information-security.md does the same against Fannie Mae's Information
Security and Business Resiliency Supplement, control by control. It says no five
times — encryption at rest, encryption in transit, real access management, log
retention, and independent review of the cryptography. A buyer should start from
that page rather than from a questionnaire.
Data
All demo data is synthetic — borrower, employer, documents, loan number. There is no real PII in this repository. The demo signing key is committed on purpose so the shipped ledger verifies for anyone who clones it; a real deployment keeps its private key in a KMS and never in source.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file custody_ledger-0.5.0.tar.gz.
File metadata
- Download URL: custody_ledger-0.5.0.tar.gz
- Upload date:
- Size: 76.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9e9b8da23736e7ea80ab70e18730526c0eef8b2f8b28dc15c4582f4ec7e6ad4b
|
|
| MD5 |
b17d389a9187cca941dc66e9d9232ea0
|
|
| BLAKE2b-256 |
09dbb6d6c01842b273762e66a74dfeccad17699ef81ea9d504479c98c25c3f42
|
Provenance
The following attestation bundles were made for custody_ledger-0.5.0.tar.gz:
Publisher:
publish.yml on Himansh97/custody
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
custody_ledger-0.5.0.tar.gz -
Subject digest:
9e9b8da23736e7ea80ab70e18730526c0eef8b2f8b28dc15c4582f4ec7e6ad4b - Sigstore transparency entry: 2520636484
- Sigstore integration time:
-
Permalink:
Himansh97/custody@83bf751fc6e72197570db1ab6ea8c66eb29feda2 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/Himansh97
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@83bf751fc6e72197570db1ab6ea8c66eb29feda2 -
Trigger Event:
push
-
Statement type:
File details
Details for the file custody_ledger-0.5.0-py3-none-any.whl.
File metadata
- Download URL: custody_ledger-0.5.0-py3-none-any.whl
- Upload date:
- Size: 49.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e9ac50b7ca0a465673e058f2668130b43f0bbe3772bf8bd1c8ffd7c2ae5dc6a3
|
|
| MD5 |
e5efa13b74270b17c4167e6dc51dec92
|
|
| BLAKE2b-256 |
431fb5c6e5cede644351f43d0499df0bbe54995b10bc01b68f764911c2558ec2
|
Provenance
The following attestation bundles were made for custody_ledger-0.5.0-py3-none-any.whl:
Publisher:
publish.yml on Himansh97/custody
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
custody_ledger-0.5.0-py3-none-any.whl -
Subject digest:
e9ac50b7ca0a465673e058f2668130b43f0bbe3772bf8bd1c8ffd7c2ae5dc6a3 - Sigstore transparency entry: 2520637068
- Sigstore integration time:
-
Permalink:
Himansh97/custody@83bf751fc6e72197570db1ab6ea8c66eb29feda2 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/Himansh97
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@83bf751fc6e72197570db1ab6ea8c66eb29feda2 -
Trigger Event:
push
-
Statement type: