Skip to main content

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.

Proving nothing was removed from the end

A hash chain catches an edit, a deletion from the middle, a reordering. It cannot catch a truncation: delete the newest records and what remains is a shorter chain in which every link is genuine, so it verifies. That is what a hash chain is, not a defect in this one, and no verifier reading only the ledger can close it.

Closing it takes one fact from outside the ledger:

custody anchor                       # custody-anchor:v1:6:0cf2c799d2d5a25d...
custody verify --expect-anchor "$ANCHOR"
python3 verify_packet.py packet.json "$ANCHOR"

Ledger(on_append=...) hands you that line after every write so you can ship it somewhere automatically. Keep it anywhere the ledger's own operator cannot edit — a different account, your SIEM, a counterparty. Kept in the same database it protects nothing, and Custody does not pick the destination for you.

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

custody_ledger-0.6.0.tar.gz (83.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

custody_ledger-0.6.0-py3-none-any.whl (53.5 kB view details)

Uploaded Python 3

File details

Details for the file custody_ledger-0.6.0.tar.gz.

File metadata

  • Download URL: custody_ledger-0.6.0.tar.gz
  • Upload date:
  • Size: 83.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for custody_ledger-0.6.0.tar.gz
Algorithm Hash digest
SHA256 4e8f34a577da7818d2ae2917c49bd82503512d0b5feaa1c7717e0e3e2a5776ac
MD5 6e0f3f1cccb9f5e4f68def63bb53fda9
BLAKE2b-256 8165707182da354d1999a86cd8e3b6fed019cb7628d166100cf8a48318ce47df

See more details on using hashes here.

Provenance

The following attestation bundles were made for custody_ledger-0.6.0.tar.gz:

Publisher: publish.yml on Himansh97/custody

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file custody_ledger-0.6.0-py3-none-any.whl.

File metadata

  • Download URL: custody_ledger-0.6.0-py3-none-any.whl
  • Upload date:
  • Size: 53.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for custody_ledger-0.6.0-py3-none-any.whl
Algorithm Hash digest
SHA256 957a832d95c6d5658d794a4cf1b173279cf372b813b22c9597984df35961ebdd
MD5 cfce243c5329383e369dfd9e905a6bb1
BLAKE2b-256 0b81716eeef4d63b2217a523b74f728d3eccc3adebec0c191f2638dfc45abf72

See more details on using hashes here.

Provenance

The following attestation bundles were made for custody_ledger-0.6.0-py3-none-any.whl:

Publisher: publish.yml on Himansh97/custody

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page