custody-ledger 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- custody_ledger-0.3.0/.github/workflows/publish.yml +97 -0
- custody_ledger-0.3.0/.gitignore +8 -0
- custody_ledger-0.3.0/PKG-INFO +229 -0
- custody_ledger-0.3.0/README.md +202 -0
- custody_ledger-0.3.0/demo/build_page.py +38 -0
- custody_ledger-0.3.0/demo/fixtures/bank-statement-2026-06.txt +11 -0
- custody_ledger-0.3.0/demo/fixtures/paystub-2026-07-15.txt +16 -0
- custody_ledger-0.3.0/demo/fixtures/w2-2025.txt +8 -0
- custody_ledger-0.3.0/demo/ledger.json +363 -0
- custody_ledger-0.3.0/demo/pipeline.py +189 -0
- custody_ledger-0.3.0/docs/information-security.md +119 -0
- custody_ledger-0.3.0/docs/ll-2026-04.md +127 -0
- custody_ledger-0.3.0/pyproject.toml +42 -0
- custody_ledger-0.3.0/src/custody/__init__.py +31 -0
- custody_ledger-0.3.0/src/custody/adapters.py +121 -0
- custody_ledger-0.3.0/src/custody/chain.py +174 -0
- custody_ledger-0.3.0/src/custody/cli.py +311 -0
- custody_ledger-0.3.0/src/custody/examiner.py +118 -0
- custody_ledger-0.3.0/src/custody/ledger.py +306 -0
- custody_ledger-0.3.0/src/custody/page.html +576 -0
- custody_ledger-0.3.0/src/custody/redact.py +81 -0
- custody_ledger-0.3.0/src/custody/render.py +52 -0
- custody_ledger-0.3.0/src/custody/server.py +179 -0
- custody_ledger-0.3.0/src/custody/signing.py +201 -0
- custody_ledger-0.3.0/src/custody/store.py +186 -0
- custody_ledger-0.3.0/src/custody/verify.py +203 -0
- custody_ledger-0.3.0/tests/canon_check.js +39 -0
- custody_ledger-0.3.0/tests/test_chain.py +195 -0
- custody_ledger-0.3.0/tests/test_concurrency.py +97 -0
- custody_ledger-0.3.0/tests/test_crosslang.py +78 -0
- custody_ledger-0.3.0/tests/test_gate.py +206 -0
- custody_ledger-0.3.0/tests/test_ledger.py +266 -0
- custody_ledger-0.3.0/tests/test_serve_auth.py +117 -0
- custody_ledger-0.3.0/tests/test_signing.py +150 -0
- custody_ledger-0.3.0/tests/test_standalone_verifier.py +118 -0
- custody_ledger-0.3.0/tests/verify_like_the_page.js +68 -0
- custody_ledger-0.3.0/verify_packet.py +185 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
# Publishing happens on a version tag, and only after the tests pass.
|
|
4
|
+
#
|
|
5
|
+
# There is no API token anywhere in this workflow or in the repository secrets.
|
|
6
|
+
# PyPI Trusted Publishing exchanges GitHub's OIDC identity for a short-lived
|
|
7
|
+
# upload token at the moment of publish, so there is no long-lived credential to
|
|
8
|
+
# leak, rotate, or find in a repo three years from now. For a project whose
|
|
9
|
+
# subject is evidence integrity, a stored publishing secret would be a poor look.
|
|
10
|
+
#
|
|
11
|
+
# One-time setup on PyPI (Your projects -> Publishing -> Add a pending publisher):
|
|
12
|
+
# PyPI project name custody-ledger
|
|
13
|
+
# Owner Himansh97
|
|
14
|
+
# Repository name custody
|
|
15
|
+
# Workflow name publish.yml
|
|
16
|
+
# Environment name pypi
|
|
17
|
+
#
|
|
18
|
+
# Then: git tag v0.2.0 && git push origin v0.2.0
|
|
19
|
+
|
|
20
|
+
on:
|
|
21
|
+
push:
|
|
22
|
+
tags: ["v*"]
|
|
23
|
+
workflow_dispatch:
|
|
24
|
+
|
|
25
|
+
permissions:
|
|
26
|
+
contents: read
|
|
27
|
+
|
|
28
|
+
jobs:
|
|
29
|
+
test:
|
|
30
|
+
# Publishing an artifact nobody ran is how a broken release reaches people
|
|
31
|
+
# who trusted the version number. The whole suite gates the upload.
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/checkout@v4
|
|
35
|
+
- uses: actions/setup-python@v5
|
|
36
|
+
with:
|
|
37
|
+
python-version: "3.12"
|
|
38
|
+
- name: Install
|
|
39
|
+
run: |
|
|
40
|
+
python -m pip install --upgrade pip
|
|
41
|
+
pip install -e .
|
|
42
|
+
- name: Run the suite
|
|
43
|
+
run: |
|
|
44
|
+
set -e
|
|
45
|
+
for t in tests/test_*.py; do
|
|
46
|
+
echo "--- $t"
|
|
47
|
+
python "$t"
|
|
48
|
+
done
|
|
49
|
+
- name: Verify the demo ledger the way a browser would
|
|
50
|
+
run: |
|
|
51
|
+
python demo/pipeline.py
|
|
52
|
+
node tests/verify_like_the_page.js demo/ledger.json
|
|
53
|
+
|
|
54
|
+
build:
|
|
55
|
+
needs: test
|
|
56
|
+
runs-on: ubuntu-latest
|
|
57
|
+
steps:
|
|
58
|
+
- uses: actions/checkout@v4
|
|
59
|
+
- uses: actions/setup-python@v5
|
|
60
|
+
with:
|
|
61
|
+
python-version: "3.12"
|
|
62
|
+
- name: Build
|
|
63
|
+
run: |
|
|
64
|
+
python -m pip install --upgrade pip build twine
|
|
65
|
+
python -m build
|
|
66
|
+
twine check dist/*
|
|
67
|
+
- name: Install the built wheel and exercise the CLI
|
|
68
|
+
# The wheel is what users get, so the wheel is what gets tested -- not
|
|
69
|
+
# the source tree it came from. This is the step that caught page.html
|
|
70
|
+
# missing from the package and serve dying on a second thread.
|
|
71
|
+
run: |
|
|
72
|
+
python -m venv /tmp/check
|
|
73
|
+
/tmp/check/bin/pip install dist/*.whl
|
|
74
|
+
cd /tmp
|
|
75
|
+
/tmp/check/bin/custody keygen --out k.key
|
|
76
|
+
printf 'Wages 98,410.00\n' > w2.txt
|
|
77
|
+
printf '{"fields":{"wages":98410.0},"confidence":0.95,"citations":{"wages":"w2"}}\n' > r.json
|
|
78
|
+
/tmp/check/bin/custody run --loan 1 --principal ci@example.com \
|
|
79
|
+
--instruction "Extract wages." --doc w2.txt --replay r.json --key k.key
|
|
80
|
+
/tmp/check/bin/custody verify custody.db
|
|
81
|
+
- uses: actions/upload-artifact@v4
|
|
82
|
+
with:
|
|
83
|
+
name: dist
|
|
84
|
+
path: dist/
|
|
85
|
+
|
|
86
|
+
publish:
|
|
87
|
+
needs: build
|
|
88
|
+
runs-on: ubuntu-latest
|
|
89
|
+
environment: pypi
|
|
90
|
+
permissions:
|
|
91
|
+
id-token: write # the only credential involved; nothing is stored
|
|
92
|
+
steps:
|
|
93
|
+
- uses: actions/download-artifact@v4
|
|
94
|
+
with:
|
|
95
|
+
name: dist
|
|
96
|
+
path: dist/
|
|
97
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: custody-ledger
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: A signed chain of evidence for AI decisions in mortgage lending
|
|
5
|
+
Project-URL: Homepage, https://himansh97.github.io/custody.html
|
|
6
|
+
Project-URL: Source, https://github.com/Himansh97/custody
|
|
7
|
+
Author-email: Himanshu Srivastava <hsrivast22@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: ai-governance,audit,compliance,ll-2026-04,mortgage
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: cryptography>=41
|
|
17
|
+
Provides-Extra: anthropic
|
|
18
|
+
Requires-Dist: anthropic>=0.40; extra == 'anthropic'
|
|
19
|
+
Provides-Extra: azure
|
|
20
|
+
Requires-Dist: azure-identity>=1.15; extra == 'azure'
|
|
21
|
+
Requires-Dist: azure-keyvault-keys>=4.9; extra == 'azure'
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: anthropic>=0.40; extra == 'dev'
|
|
24
|
+
Requires-Dist: azure-identity>=1.15; extra == 'dev'
|
|
25
|
+
Requires-Dist: azure-keyvault-keys>=4.9; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Custody
|
|
29
|
+
|
|
30
|
+
**A signed chain of evidence for AI decisions in mortgage lending.**
|
|
31
|
+
|
|
32
|
+
Fannie Mae Lender Letter [LL-2026-04](https://singlefamily.fanniemae.com/news-events/lender-letter-ll-2026-04-governance-framework-use-artificial-intelligence-and-machine-learning)
|
|
33
|
+
took effect on 6 August 2026. Seller/servicers using AI or ML in origination or
|
|
34
|
+
servicing must have policies governing its development, use and maintenance,
|
|
35
|
+
must extend governance no less protective to their vendors, and **on Fannie
|
|
36
|
+
Mae's request must promptly disclose the types of AI/ML in use, the purpose and
|
|
37
|
+
manner of that use, and the safeguards implemented to mitigate the risks.**
|
|
38
|
+
|
|
39
|
+
There are two ways to answer that request. One is a document describing what you
|
|
40
|
+
intend to happen. The other is a record of what did happen, decision by decision,
|
|
41
|
+
that the person asking can verify without taking your word for it.
|
|
42
|
+
|
|
43
|
+
Custody produces the second.
|
|
44
|
+
|
|
45
|
+
**It is worth being precise, because vendor summaries of this letter are not.**
|
|
46
|
+
LL-2026-04 does not specify a record schema, does not name any fields, and does
|
|
47
|
+
not require append-only or signed logs. The design below is one implementation of
|
|
48
|
+
the letter's disclosure and safeguard obligations — a defensible one, and the one
|
|
49
|
+
this library takes. It is not a transcription of Fannie Mae's instructions.
|
|
50
|
+
`docs/ll-2026-04.md` sets out the letter's actual requirements and marks the
|
|
51
|
+
boundary between them and our choices.
|
|
52
|
+
|
|
53
|
+
**[Live demo](https://himansh97.github.io/custody.html)** — a synthetic loan file
|
|
54
|
+
through five AI steps, with the hash chain re-verified in your own browser. Break
|
|
55
|
+
a record and watch it get caught.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## The idea
|
|
60
|
+
|
|
61
|
+
Compliance logging fails when it is a separate step. Someone adds a model call in
|
|
62
|
+
a hurry, the `log.write(...)` after it never gets written, and nobody notices
|
|
63
|
+
until an examiner asks. So here the call *is* the record:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from custody import Ledger
|
|
67
|
+
|
|
68
|
+
ledger = Ledger(policy="uw-policy-v3.2", signing_key=key)
|
|
69
|
+
|
|
70
|
+
with ledger.decision(loan="1000254", principal="jane@lender.com",
|
|
71
|
+
purpose="income_calculation", identifiers=[borrower]) as d:
|
|
72
|
+
|
|
73
|
+
out = d.call(model="claude-sonnet-5", prompt=prompt,
|
|
74
|
+
sources=[paystub, w2], invoke=call_the_model)
|
|
75
|
+
|
|
76
|
+
verdict = d.gate(out, citations={"monthly_income": "paystub-2026-07-15"},
|
|
77
|
+
confidence=0.91)
|
|
78
|
+
|
|
79
|
+
if verdict.ok:
|
|
80
|
+
d.commit(outcome=out)
|
|
81
|
+
else:
|
|
82
|
+
d.route_to_human(queue="uw-review")
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Every field is filled as a side effect of normal use. A decision
|
|
86
|
+
that is opened and abandoned still writes a record. One that raises writes a
|
|
87
|
+
record and re-raises. An audit trail with holes where the awkward cases were is
|
|
88
|
+
worse than none, because it looks complete.
|
|
89
|
+
|
|
90
|
+
## The gate
|
|
91
|
+
|
|
92
|
+
Deterministic checks only — no model judges another model. Given the same output
|
|
93
|
+
and the same sources, the verdict is the same forever, and an examiner can re-run
|
|
94
|
+
it.
|
|
95
|
+
|
|
96
|
+
| Check | Rejects |
|
|
97
|
+
|---|---|
|
|
98
|
+
| Figure binding | a number in the output that appears in no supplied source document |
|
|
99
|
+
| Field grounding | an extracted field that cites no source |
|
|
100
|
+
| Closed vocabulary | a classification outside its allowed set |
|
|
101
|
+
| Confidence floor | *routes to a human* below threshold — not being sure is not being wrong |
|
|
102
|
+
|
|
103
|
+
Verdicts are `pass` / `review` / `reject`. That verdict is the recorded
|
|
104
|
+
`response_treatment` — the evidence that a safeguard ran and what it concluded.
|
|
105
|
+
|
|
106
|
+
## The chain
|
|
107
|
+
|
|
108
|
+
Each record's hash covers the previous record's hash, and each hash is signed.
|
|
109
|
+
The two answer different questions: the chain says *was anything changed or
|
|
110
|
+
removed*, the signature says *did this come from the system that claims to have
|
|
111
|
+
written it*. The chain needs no key to verify, which is why the demo page can
|
|
112
|
+
re-verify in a visitor's own browser.
|
|
113
|
+
|
|
114
|
+
Storage is append-only, enforced by SQLite triggers rather than by convention,
|
|
115
|
+
and appends are atomic so concurrent writers cannot fork the chain.
|
|
116
|
+
|
|
117
|
+
## Where the signing key lives
|
|
118
|
+
|
|
119
|
+
A key in a file is a development convenience, and the first thing a lender's
|
|
120
|
+
security review will object to. So the signer is pluggable:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from custody.signing import KeyVaultSigner
|
|
124
|
+
ledger = Ledger(policy="uw-v3", signer=KeyVaultSigner(vault_url, "custody"))
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The private key never enters the process. Custody sends a digest and gets a
|
|
128
|
+
signature back; the most an attacker gets from a compromised host is the ability
|
|
129
|
+
to sign while they hold the credential, which the vault logs.
|
|
130
|
+
|
|
131
|
+
**Azure Key Vault has no Ed25519** -- EC and RSA only, and AWS KMS is the same.
|
|
132
|
+
So the algorithm is a parameter, not a constant: `ed25519` locally,
|
|
133
|
+
`ecdsa-p256-sha256` (ES256) in a vault. It is written into the hashed body of
|
|
134
|
+
every record, so a downgraded algorithm claim breaks the chain before anyone
|
|
135
|
+
reaches a signature check.
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
az keyvault key create --vault-name <vault> --name custody --kty EC --curve P-256
|
|
139
|
+
export CUSTODY_KEY_VAULT=https://<vault>.vault.azure.net/
|
|
140
|
+
export CUSTODY_KEY_NAME=custody
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Verifying without trusting us
|
|
144
|
+
|
|
145
|
+
`verify_packet.py` is a single file with no dependency on this package and
|
|
146
|
+
nothing outside the standard library. An auditor reads it end to end in a few
|
|
147
|
+
minutes and satisfies themselves the cryptography is real:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
python3 verify_packet.py packet.json
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
With `cryptography` installed it checks signatures too; without it, it checks
|
|
154
|
+
the chain and says plainly that it did not check signatures rather than printing
|
|
155
|
+
a bare OK. It also states what it cannot prove -- that the records are *true*,
|
|
156
|
+
and that nothing was withheld -- because a verifier that only ever says OK
|
|
157
|
+
teaches people to over-read it.
|
|
158
|
+
|
|
159
|
+
## Install and run it
|
|
160
|
+
|
|
161
|
+
Not on PyPI yet, so install from the repo:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
pip install "git+https://github.com/Himansh97/custody" # one dependency: cryptography
|
|
165
|
+
custody keygen # Ed25519 signing key, mode 600
|
|
166
|
+
|
|
167
|
+
custody run --loan 1000254 --principal you@lender.com \
|
|
168
|
+
--instruction "Extract qualifying monthly income." \
|
|
169
|
+
--doc paystub.txt --doc w2.txt \
|
|
170
|
+
--redact "Borrower Name" --model claude-sonnet-5
|
|
171
|
+
|
|
172
|
+
custody verify custody.db --public-key <hex> # recompute the chain
|
|
173
|
+
custody packet 1000254 --out packet.json # evidence for one loan
|
|
174
|
+
custody serve # prints a URL with a one-time token
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`custody serve` binds loopback and mints a token unless you supply one, and
|
|
178
|
+
refuses outright to bind anywhere else without one — it is serving an audit
|
|
179
|
+
trail containing loan numbers. A shared token is a floor, not a control; put it
|
|
180
|
+
behind your SSO before anyone but you uses it.
|
|
181
|
+
|
|
182
|
+
`custody run` calls a real model when `ANTHROPIC_API_KEY` is set
|
|
183
|
+
(`pip install "custody-ledger[anthropic] @ git+https://github.com/Himansh97/custody"`), or replays a fixed response with
|
|
184
|
+
`--replay fixture.json`. The gate neither knows nor cares which.
|
|
185
|
+
|
|
186
|
+
Custody does not call your model on your behalf in library use — it wraps a
|
|
187
|
+
call you already make. A governance layer that requires you to rewrite your AI
|
|
188
|
+
does not get adopted.
|
|
189
|
+
|
|
190
|
+
## From a source checkout
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
python demo/pipeline.py # the synthetic loan, produces demo/ledger.json
|
|
194
|
+
python demo/build_page.py # bakes the review page from that ledger
|
|
195
|
+
python tests/test_chain.py # and test_gate, test_ledger, test_crosslang
|
|
196
|
+
node tests/verify_like_the_page.js demo/ledger.json
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The demo's model outputs are fixed, so the ledger is reproducible byte-for-byte
|
|
200
|
+
by anyone who clones this.
|
|
201
|
+
|
|
202
|
+
`tests/test_crosslang.py` diffs the Python and JavaScript canonicalisers against
|
|
203
|
+
every shipped record. If they ever drift, the browser would report tampering on
|
|
204
|
+
an honest ledger — the most damaging failure this project could have — so
|
|
205
|
+
the agreement is tested rather than assumed.
|
|
206
|
+
|
|
207
|
+
## What this is not
|
|
208
|
+
|
|
209
|
+
Not legal advice, it does not certify compliance, and it is not a statement of
|
|
210
|
+
what Fannie Mae requires. It does no bias or fair-lending
|
|
211
|
+
testing — that is an ECOA and fair-lending obligation rather than something this
|
|
212
|
+
letter specifies, it is a separate product, and vendors already occupy it. It writes to no loan origination system. It cannot
|
|
213
|
+
inventory models it never sees.
|
|
214
|
+
|
|
215
|
+
`docs/ll-2026-04.md` sets out what the letter actually says, what Custody helps
|
|
216
|
+
with, and the obligations it does not touch at all.
|
|
217
|
+
|
|
218
|
+
`docs/information-security.md` does the same against Fannie Mae's Information
|
|
219
|
+
Security and Business Resiliency Supplement, control by control. It says no five
|
|
220
|
+
times — encryption at rest, encryption in transit, real access management, log
|
|
221
|
+
retention, and independent review of the cryptography. A buyer should start from
|
|
222
|
+
that page rather than from a questionnaire.
|
|
223
|
+
|
|
224
|
+
## Data
|
|
225
|
+
|
|
226
|
+
All demo data is synthetic — borrower, employer, documents, loan number. There is
|
|
227
|
+
no real PII in this repository. The demo signing key is committed on purpose so
|
|
228
|
+
the shipped ledger verifies for anyone who clones it; a real deployment keeps its
|
|
229
|
+
private key in a KMS and never in source.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# Custody
|
|
2
|
+
|
|
3
|
+
**A signed chain of evidence for AI decisions in mortgage lending.**
|
|
4
|
+
|
|
5
|
+
Fannie Mae Lender Letter [LL-2026-04](https://singlefamily.fanniemae.com/news-events/lender-letter-ll-2026-04-governance-framework-use-artificial-intelligence-and-machine-learning)
|
|
6
|
+
took effect on 6 August 2026. Seller/servicers using AI or ML in origination or
|
|
7
|
+
servicing must have policies governing its development, use and maintenance,
|
|
8
|
+
must extend governance no less protective to their vendors, and **on Fannie
|
|
9
|
+
Mae's request must promptly disclose the types of AI/ML in use, the purpose and
|
|
10
|
+
manner of that use, and the safeguards implemented to mitigate the risks.**
|
|
11
|
+
|
|
12
|
+
There are two ways to answer that request. One is a document describing what you
|
|
13
|
+
intend to happen. The other is a record of what did happen, decision by decision,
|
|
14
|
+
that the person asking can verify without taking your word for it.
|
|
15
|
+
|
|
16
|
+
Custody produces the second.
|
|
17
|
+
|
|
18
|
+
**It is worth being precise, because vendor summaries of this letter are not.**
|
|
19
|
+
LL-2026-04 does not specify a record schema, does not name any fields, and does
|
|
20
|
+
not require append-only or signed logs. The design below is one implementation of
|
|
21
|
+
the letter's disclosure and safeguard obligations — a defensible one, and the one
|
|
22
|
+
this library takes. It is not a transcription of Fannie Mae's instructions.
|
|
23
|
+
`docs/ll-2026-04.md` sets out the letter's actual requirements and marks the
|
|
24
|
+
boundary between them and our choices.
|
|
25
|
+
|
|
26
|
+
**[Live demo](https://himansh97.github.io/custody.html)** — a synthetic loan file
|
|
27
|
+
through five AI steps, with the hash chain re-verified in your own browser. Break
|
|
28
|
+
a record and watch it get caught.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## The idea
|
|
33
|
+
|
|
34
|
+
Compliance logging fails when it is a separate step. Someone adds a model call in
|
|
35
|
+
a hurry, the `log.write(...)` after it never gets written, and nobody notices
|
|
36
|
+
until an examiner asks. So here the call *is* the record:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from custody import Ledger
|
|
40
|
+
|
|
41
|
+
ledger = Ledger(policy="uw-policy-v3.2", signing_key=key)
|
|
42
|
+
|
|
43
|
+
with ledger.decision(loan="1000254", principal="jane@lender.com",
|
|
44
|
+
purpose="income_calculation", identifiers=[borrower]) as d:
|
|
45
|
+
|
|
46
|
+
out = d.call(model="claude-sonnet-5", prompt=prompt,
|
|
47
|
+
sources=[paystub, w2], invoke=call_the_model)
|
|
48
|
+
|
|
49
|
+
verdict = d.gate(out, citations={"monthly_income": "paystub-2026-07-15"},
|
|
50
|
+
confidence=0.91)
|
|
51
|
+
|
|
52
|
+
if verdict.ok:
|
|
53
|
+
d.commit(outcome=out)
|
|
54
|
+
else:
|
|
55
|
+
d.route_to_human(queue="uw-review")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Every field is filled as a side effect of normal use. A decision
|
|
59
|
+
that is opened and abandoned still writes a record. One that raises writes a
|
|
60
|
+
record and re-raises. An audit trail with holes where the awkward cases were is
|
|
61
|
+
worse than none, because it looks complete.
|
|
62
|
+
|
|
63
|
+
## The gate
|
|
64
|
+
|
|
65
|
+
Deterministic checks only — no model judges another model. Given the same output
|
|
66
|
+
and the same sources, the verdict is the same forever, and an examiner can re-run
|
|
67
|
+
it.
|
|
68
|
+
|
|
69
|
+
| Check | Rejects |
|
|
70
|
+
|---|---|
|
|
71
|
+
| Figure binding | a number in the output that appears in no supplied source document |
|
|
72
|
+
| Field grounding | an extracted field that cites no source |
|
|
73
|
+
| Closed vocabulary | a classification outside its allowed set |
|
|
74
|
+
| Confidence floor | *routes to a human* below threshold — not being sure is not being wrong |
|
|
75
|
+
|
|
76
|
+
Verdicts are `pass` / `review` / `reject`. That verdict is the recorded
|
|
77
|
+
`response_treatment` — the evidence that a safeguard ran and what it concluded.
|
|
78
|
+
|
|
79
|
+
## The chain
|
|
80
|
+
|
|
81
|
+
Each record's hash covers the previous record's hash, and each hash is signed.
|
|
82
|
+
The two answer different questions: the chain says *was anything changed or
|
|
83
|
+
removed*, the signature says *did this come from the system that claims to have
|
|
84
|
+
written it*. The chain needs no key to verify, which is why the demo page can
|
|
85
|
+
re-verify in a visitor's own browser.
|
|
86
|
+
|
|
87
|
+
Storage is append-only, enforced by SQLite triggers rather than by convention,
|
|
88
|
+
and appends are atomic so concurrent writers cannot fork the chain.
|
|
89
|
+
|
|
90
|
+
## Where the signing key lives
|
|
91
|
+
|
|
92
|
+
A key in a file is a development convenience, and the first thing a lender's
|
|
93
|
+
security review will object to. So the signer is pluggable:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from custody.signing import KeyVaultSigner
|
|
97
|
+
ledger = Ledger(policy="uw-v3", signer=KeyVaultSigner(vault_url, "custody"))
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The private key never enters the process. Custody sends a digest and gets a
|
|
101
|
+
signature back; the most an attacker gets from a compromised host is the ability
|
|
102
|
+
to sign while they hold the credential, which the vault logs.
|
|
103
|
+
|
|
104
|
+
**Azure Key Vault has no Ed25519** -- EC and RSA only, and AWS KMS is the same.
|
|
105
|
+
So the algorithm is a parameter, not a constant: `ed25519` locally,
|
|
106
|
+
`ecdsa-p256-sha256` (ES256) in a vault. It is written into the hashed body of
|
|
107
|
+
every record, so a downgraded algorithm claim breaks the chain before anyone
|
|
108
|
+
reaches a signature check.
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
az keyvault key create --vault-name <vault> --name custody --kty EC --curve P-256
|
|
112
|
+
export CUSTODY_KEY_VAULT=https://<vault>.vault.azure.net/
|
|
113
|
+
export CUSTODY_KEY_NAME=custody
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Verifying without trusting us
|
|
117
|
+
|
|
118
|
+
`verify_packet.py` is a single file with no dependency on this package and
|
|
119
|
+
nothing outside the standard library. An auditor reads it end to end in a few
|
|
120
|
+
minutes and satisfies themselves the cryptography is real:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
python3 verify_packet.py packet.json
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
With `cryptography` installed it checks signatures too; without it, it checks
|
|
127
|
+
the chain and says plainly that it did not check signatures rather than printing
|
|
128
|
+
a bare OK. It also states what it cannot prove -- that the records are *true*,
|
|
129
|
+
and that nothing was withheld -- because a verifier that only ever says OK
|
|
130
|
+
teaches people to over-read it.
|
|
131
|
+
|
|
132
|
+
## Install and run it
|
|
133
|
+
|
|
134
|
+
Not on PyPI yet, so install from the repo:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
pip install "git+https://github.com/Himansh97/custody" # one dependency: cryptography
|
|
138
|
+
custody keygen # Ed25519 signing key, mode 600
|
|
139
|
+
|
|
140
|
+
custody run --loan 1000254 --principal you@lender.com \
|
|
141
|
+
--instruction "Extract qualifying monthly income." \
|
|
142
|
+
--doc paystub.txt --doc w2.txt \
|
|
143
|
+
--redact "Borrower Name" --model claude-sonnet-5
|
|
144
|
+
|
|
145
|
+
custody verify custody.db --public-key <hex> # recompute the chain
|
|
146
|
+
custody packet 1000254 --out packet.json # evidence for one loan
|
|
147
|
+
custody serve # prints a URL with a one-time token
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`custody serve` binds loopback and mints a token unless you supply one, and
|
|
151
|
+
refuses outright to bind anywhere else without one — it is serving an audit
|
|
152
|
+
trail containing loan numbers. A shared token is a floor, not a control; put it
|
|
153
|
+
behind your SSO before anyone but you uses it.
|
|
154
|
+
|
|
155
|
+
`custody run` calls a real model when `ANTHROPIC_API_KEY` is set
|
|
156
|
+
(`pip install "custody-ledger[anthropic] @ git+https://github.com/Himansh97/custody"`), or replays a fixed response with
|
|
157
|
+
`--replay fixture.json`. The gate neither knows nor cares which.
|
|
158
|
+
|
|
159
|
+
Custody does not call your model on your behalf in library use — it wraps a
|
|
160
|
+
call you already make. A governance layer that requires you to rewrite your AI
|
|
161
|
+
does not get adopted.
|
|
162
|
+
|
|
163
|
+
## From a source checkout
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
python demo/pipeline.py # the synthetic loan, produces demo/ledger.json
|
|
167
|
+
python demo/build_page.py # bakes the review page from that ledger
|
|
168
|
+
python tests/test_chain.py # and test_gate, test_ledger, test_crosslang
|
|
169
|
+
node tests/verify_like_the_page.js demo/ledger.json
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The demo's model outputs are fixed, so the ledger is reproducible byte-for-byte
|
|
173
|
+
by anyone who clones this.
|
|
174
|
+
|
|
175
|
+
`tests/test_crosslang.py` diffs the Python and JavaScript canonicalisers against
|
|
176
|
+
every shipped record. If they ever drift, the browser would report tampering on
|
|
177
|
+
an honest ledger — the most damaging failure this project could have — so
|
|
178
|
+
the agreement is tested rather than assumed.
|
|
179
|
+
|
|
180
|
+
## What this is not
|
|
181
|
+
|
|
182
|
+
Not legal advice, it does not certify compliance, and it is not a statement of
|
|
183
|
+
what Fannie Mae requires. It does no bias or fair-lending
|
|
184
|
+
testing — that is an ECOA and fair-lending obligation rather than something this
|
|
185
|
+
letter specifies, it is a separate product, and vendors already occupy it. It writes to no loan origination system. It cannot
|
|
186
|
+
inventory models it never sees.
|
|
187
|
+
|
|
188
|
+
`docs/ll-2026-04.md` sets out what the letter actually says, what Custody helps
|
|
189
|
+
with, and the obligations it does not touch at all.
|
|
190
|
+
|
|
191
|
+
`docs/information-security.md` does the same against Fannie Mae's Information
|
|
192
|
+
Security and Business Resiliency Supplement, control by control. It says no five
|
|
193
|
+
times — encryption at rest, encryption in transit, real access management, log
|
|
194
|
+
retention, and independent review of the cryptography. A buyer should start from
|
|
195
|
+
that page rather than from a questionnaire.
|
|
196
|
+
|
|
197
|
+
## Data
|
|
198
|
+
|
|
199
|
+
All demo data is synthetic — borrower, employer, documents, loan number. There is
|
|
200
|
+
no real PII in this repository. The demo signing key is committed on purpose so
|
|
201
|
+
the shipped ledger verifies for anyone who clones it; a real deployment keeps its
|
|
202
|
+
private key in a KMS and never in source.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Bake the static demo page from the committed ledger.
|
|
2
|
+
|
|
3
|
+
Built rather than hand-written so it cannot drift from the ledger it claims to
|
|
4
|
+
display: run `pipeline.py`, then this, and the page is showing the records the
|
|
5
|
+
library actually produced.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import pathlib
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
14
|
+
sys.path.insert(0, str(ROOT / "src"))
|
|
15
|
+
|
|
16
|
+
from custody.render import build_html # noqa: E402
|
|
17
|
+
|
|
18
|
+
LEDGER = ROOT / "demo" / "ledger.json"
|
|
19
|
+
DEFAULT_OUT = pathlib.Path.home() / "careeros-portfolio" / "custody-ledger.html"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build(out: pathlib.Path = DEFAULT_OUT) -> pathlib.Path:
|
|
23
|
+
if not LEDGER.exists():
|
|
24
|
+
raise SystemExit("demo/ledger.json is missing - run demo/pipeline.py first")
|
|
25
|
+
bundle = json.loads(LEDGER.read_text(encoding="utf-8"))
|
|
26
|
+
if not bundle["chain"]["verified"]:
|
|
27
|
+
# Publishing a page whose headline claim is "this verifies" while the
|
|
28
|
+
# ledger behind it does not would be the worst thing this could ship.
|
|
29
|
+
raise SystemExit(f"refusing to build: the ledger does not verify - {bundle['chain']}")
|
|
30
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
out.write_text(build_html(bundle), encoding="utf-8")
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
target = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_OUT
|
|
37
|
+
written = build(target)
|
|
38
|
+
print(f"wrote {written} ({written.stat().st_size:,} bytes)")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
NORTHWIND CREDIT UNION — STATEMENT OF ACCOUNT
|
|
2
|
+
Statement period: 06/01/2026 - 06/30/2026
|
|
3
|
+
Account ending: 4471
|
|
4
|
+
------------------------------------------------------------------
|
|
5
|
+
Beginning balance 18,204.11
|
|
6
|
+
Total deposits 6,380.48
|
|
7
|
+
Total withdrawals 4,912.30
|
|
8
|
+
Ending balance 19,672.29
|
|
9
|
+
------------------------------------------------------------------
|
|
10
|
+
06/15 DIRECT DEP ACME LOGISTICS PAYROLL 3,190.24
|
|
11
|
+
06/30 DIRECT DEP ACME LOGISTICS PAYROLL 3,190.24
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
ACME LOGISTICS LLC EARNINGS STATEMENT
|
|
2
|
+
1400 Corporate Drive, Irving TX 75038
|
|
3
|
+
------------------------------------------------------------------
|
|
4
|
+
Employee: [BORROWER] Employee ID: E-40881
|
|
5
|
+
Pay period: 07/01/2026 - 07/15/2026 Pay date: 07/15/2026
|
|
6
|
+
------------------------------------------------------------------
|
|
7
|
+
CURRENT YEAR TO DATE
|
|
8
|
+
Regular 3,846.15 50,000.00
|
|
9
|
+
Overtime 359.85 4,672.00
|
|
10
|
+
Gross pay 4,206.00 54,672.00
|
|
11
|
+
------------------------------------------------------------------
|
|
12
|
+
Federal income tax 694.00 9,022.00
|
|
13
|
+
Social Security 260.77 3,389.66
|
|
14
|
+
Medicare 60.99 792.74
|
|
15
|
+
------------------------------------------------------------------
|
|
16
|
+
Net pay 3,190.24 41,467.60
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
FORM W-2 WAGE AND TAX STATEMENT — TAX YEAR 2025
|
|
2
|
+
Employer: ACME LOGISTICS LLC EIN: 75-3310042
|
|
3
|
+
------------------------------------------------------------------
|
|
4
|
+
Box 1 Wages, tips, other compensation 98,410.00
|
|
5
|
+
Box 2 Federal income tax withheld 16,229.00
|
|
6
|
+
Box 3 Social Security wages 98,410.00
|
|
7
|
+
Box 5 Medicare wages and tips 98,410.00
|
|
8
|
+
Box 12 D 401(k) elective deferrals 7,200.00
|