ledgerchain 0.1.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.
- ledgerchain-0.1.0/.gitignore +11 -0
- ledgerchain-0.1.0/CHANGELOG.md +29 -0
- ledgerchain-0.1.0/LICENSE +21 -0
- ledgerchain-0.1.0/PKG-INFO +264 -0
- ledgerchain-0.1.0/README.md +228 -0
- ledgerchain-0.1.0/SECURITY.md +34 -0
- ledgerchain-0.1.0/pyproject.toml +72 -0
- ledgerchain-0.1.0/src/ledgerchain/__init__.py +62 -0
- ledgerchain-0.1.0/src/ledgerchain/__main__.py +5 -0
- ledgerchain-0.1.0/src/ledgerchain/anchor.py +243 -0
- ledgerchain-0.1.0/src/ledgerchain/canonical.py +32 -0
- ledgerchain-0.1.0/src/ledgerchain/checkpoint.py +132 -0
- ledgerchain-0.1.0/src/ledgerchain/cli.py +170 -0
- ledgerchain-0.1.0/src/ledgerchain/immutability.py +126 -0
- ledgerchain-0.1.0/src/ledgerchain/models.py +182 -0
- ledgerchain-0.1.0/src/ledgerchain/py.typed +0 -0
- ledgerchain-0.1.0/src/ledgerchain/verify.py +218 -0
- ledgerchain-0.1.0/src/ledgerchain/writer.py +182 -0
- ledgerchain-0.1.0/tests/conftest.py +105 -0
- ledgerchain-0.1.0/tests/test_anchor.py +228 -0
- ledgerchain-0.1.0/tests/test_canonical.py +39 -0
- ledgerchain-0.1.0/tests/test_checkpoint.py +164 -0
- ledgerchain-0.1.0/tests/test_cli.py +110 -0
- ledgerchain-0.1.0/tests/test_immutability.py +100 -0
- ledgerchain-0.1.0/tests/test_postgres.py +116 -0
- ledgerchain-0.1.0/tests/test_tamper.py +132 -0
- ledgerchain-0.1.0/tests/test_writer.py +164 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
|
+
|
|
6
|
+
## [0.1.0] - 2026-10-18
|
|
7
|
+
|
|
8
|
+
First standalone release, extracted from a production decision-integrity
|
|
9
|
+
platform where the same code records HITL approvals, signed attestations,
|
|
10
|
+
guardrail verdicts, and correction dispatches.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `LedgerWriter.append`: the single append path. Runs inside the caller's
|
|
15
|
+
SQLAlchemy transaction, locks the head row, chains the entry, never commits.
|
|
16
|
+
- `verify_chain`: recomputes every payload hash and entry hash in a range,
|
|
17
|
+
checks contiguity, checks the head row, and recomputes every checkpoint's
|
|
18
|
+
Merkle root against the entries it covers.
|
|
19
|
+
- `build_checkpoint` and `merkle_root`: Merkle checkpoints over contiguous
|
|
20
|
+
ranges, chained by `prev_checkpoint_root`, each also recorded as a ledger
|
|
21
|
+
entry.
|
|
22
|
+
- `anchor_checkpoint`, `retry_failed_anchors`, `verify_anchor`: write a
|
|
23
|
+
checkpoint's root to S3-compatible storage with Object Lock, retry failures,
|
|
24
|
+
and read the anchored copy back as an independent witness.
|
|
25
|
+
- `install_immutability`: BEFORE UPDATE/DELETE triggers for PostgreSQL and
|
|
26
|
+
SQLite so the application role cannot rewrite history even through a bug.
|
|
27
|
+
- `ledgerchain` CLI: `init-db`, `verify`, `checkpoint`, `anchor`.
|
|
28
|
+
- Canonical JSON (RFC 8785) via the `rfc8785` package; every hash in the
|
|
29
|
+
system is computed over that exact byte form.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mike Pointer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ledgerchain
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hash-chained, tamper-evident decision ledger on SQLAlchemy: append-only rows, Merkle checkpoints, external anchoring, and a verifier that recomputes everything.
|
|
5
|
+
Project-URL: Homepage, https://github.com/mpointer/ledgerchain
|
|
6
|
+
Project-URL: Changelog, https://github.com/mpointer/ledgerchain/blob/main/CHANGELOG.md
|
|
7
|
+
Project-URL: Issues, https://github.com/mpointer/ledgerchain/issues
|
|
8
|
+
Author-email: Mike Pointer <mpointer@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-governance,audit-trail,hash-chain,ledger,merkle,sqlalchemy,tamper-evident
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Database
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: System :: Logging
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: rfc8785>=0.1.4
|
|
26
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
31
|
+
Provides-Extra: postgres
|
|
32
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
|
|
33
|
+
Provides-Extra: s3
|
|
34
|
+
Requires-Dist: boto3>=1.34; extra == 's3'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# ledgerchain
|
|
38
|
+
|
|
39
|
+
A hash-chained, tamper-evident decision ledger on SQLAlchemy. Append-only
|
|
40
|
+
rows, a verifier that recomputes everything, Merkle checkpoints, and
|
|
41
|
+
external anchoring to object storage as an independent witness.
|
|
42
|
+
|
|
43
|
+
Built for one job: recording the determinations an AI-assisted system makes,
|
|
44
|
+
and the human approvals, verdicts, and corrections around them, in a form an
|
|
45
|
+
auditor can check without trusting the application. Extracted from a
|
|
46
|
+
production decision-integrity platform where this code records HITL
|
|
47
|
+
approvals, signed attestations, guardrail verdicts, and correction
|
|
48
|
+
dispatches.
|
|
49
|
+
|
|
50
|
+
No blockchain, no ledger database, no sidecar. Three tables in the
|
|
51
|
+
PostgreSQL or SQLite you already run, and about 1,300 lines of Python you
|
|
52
|
+
can read in an afternoon.
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
pip install ledgerchain # core: sqlalchemy + rfc8785
|
|
56
|
+
pip install "ledgerchain[s3]" # adds boto3 for checkpoint anchoring
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## What it guarantees
|
|
60
|
+
|
|
61
|
+
| Threat | Mechanism | Detected by |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| A payload is edited in place | `payload_sha256` over RFC 8785 canonical JSON | `verify_chain` names the seq |
|
|
64
|
+
| A row is deleted or reordered | strictly contiguous `seq`, each `entry_hash` includes `str(seq)` | `verify_chain` names the seq after the gap |
|
|
65
|
+
| The head pointer is forged | `ledger_head` compared against the last recomputed hash | `verify_chain(head_valid=False)` |
|
|
66
|
+
| A checkpoint row is rewritten | Merkle root recomputed over the entries it covers | `verify_chain` marks `root_valid=False` |
|
|
67
|
+
| The app role updates or deletes | BEFORE UPDATE/DELETE triggers raise | the write fails |
|
|
68
|
+
| A superuser rewrites the whole chain consistently | checkpoint roots anchored under Object Lock | `verify_anchor` compares the live row to the anchored copy |
|
|
69
|
+
|
|
70
|
+
What it does not do: sign entries (the chain proves integrity and order, not
|
|
71
|
+
authorship; sign payloads before appending if you need that), or trust the
|
|
72
|
+
clock (timestamps are hashed as given). See `SECURITY.md`.
|
|
73
|
+
|
|
74
|
+
## Quickstart
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from sqlalchemy import create_engine
|
|
78
|
+
from sqlalchemy.orm import sessionmaker
|
|
79
|
+
|
|
80
|
+
import ledgerchain
|
|
81
|
+
from ledgerchain import LedgerWriter, verify_chain
|
|
82
|
+
|
|
83
|
+
engine = create_engine("sqlite:///ledger.db")
|
|
84
|
+
ledgerchain.create_all(engine) # tables + append-only triggers
|
|
85
|
+
Session = sessionmaker(bind=engine)
|
|
86
|
+
|
|
87
|
+
with Session() as session:
|
|
88
|
+
# Append inside the transaction that holds the state change the entry
|
|
89
|
+
# documents. The writer flushes but never commits.
|
|
90
|
+
entry = LedgerWriter.append(
|
|
91
|
+
session,
|
|
92
|
+
event_type="hitl.approval",
|
|
93
|
+
payload={"claim_id": "c-17", "decision": "approve", "model_score": 0.91},
|
|
94
|
+
actor={"kind": "human", "sub": "reviewer-42", "roles": ["approver"]},
|
|
95
|
+
tenant_id="acme",
|
|
96
|
+
subject_uri="/claims/c-17/decision",
|
|
97
|
+
)
|
|
98
|
+
session.commit()
|
|
99
|
+
print(entry.seq, entry.entry_hash) # 2 <64 hex chars>; seq 1 is genesis
|
|
100
|
+
|
|
101
|
+
with Session() as session:
|
|
102
|
+
result = verify_chain(session)
|
|
103
|
+
assert result.valid, result.reason
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Tamper with it and the verifier tells you where:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
$ sqlite3 ledger.db "DROP TRIGGER trg_decision_ledger_no_update;
|
|
110
|
+
UPDATE decision_ledger SET payload='{\"x\":1}' WHERE seq=2"
|
|
111
|
+
$ ledgerchain verify --database-url sqlite:///ledger.db
|
|
112
|
+
{
|
|
113
|
+
"valid": false,
|
|
114
|
+
"checked_entries": 2,
|
|
115
|
+
"first_bad_seq": 2,
|
|
116
|
+
"reason": "payload_sha256 mismatch: payload was modified",
|
|
117
|
+
...
|
|
118
|
+
}
|
|
119
|
+
$ echo $?
|
|
120
|
+
1
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## How the chain is built
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
payload_sha256 = sha256( RFC8785(payload) )
|
|
127
|
+
entry_hash = sha256( prev_hash \n payload_sha256 \n seq \n event_type \n ts_rfc3339_utc \n tenant_id )
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- `seq` is assigned by the writer under a `SELECT ... FOR UPDATE` on the
|
|
131
|
+
single `ledger_head` row, so it is known before insert and concurrent
|
|
132
|
+
appends serialize. On SQLite this degrades to the file writer lock.
|
|
133
|
+
- The genesis entry (`seq=1`, `event_type="genesis"`) chains from
|
|
134
|
+
`"0" * 64`. The writer creates it lazily on the first append.
|
|
135
|
+
- Every digest is a full 64-hex string. Nothing is truncated anywhere.
|
|
136
|
+
- `event_type` is yours to define. Only `"genesis"` is reserved;
|
|
137
|
+
`"checkpoint"` is what `build_checkpoint` writes.
|
|
138
|
+
|
|
139
|
+
## Checkpoints and anchoring
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from ledgerchain import AnchorConfig, anchor_checkpoint, build_checkpoint, verify_anchor
|
|
143
|
+
|
|
144
|
+
with Session() as session:
|
|
145
|
+
result = build_checkpoint(session) # Merkle root over new entries
|
|
146
|
+
cp = result.checkpoint # None if nothing new; see skipped_reason
|
|
147
|
+
|
|
148
|
+
config = AnchorConfig(bucket="ledger-anchors", endpoint_url="http://minio:9000")
|
|
149
|
+
anchor_checkpoint(session, cp, config=config) # never raises; failed => retried later
|
|
150
|
+
assert cp.anchor_status == "anchored"
|
|
151
|
+
|
|
152
|
+
report = verify_anchor(cp, config=config) # reads the object back and compares
|
|
153
|
+
assert report.ok
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
A checkpoint covers `(last_checkpoint.to_seq, head.last_seq]`, is chained to
|
|
157
|
+
the previous checkpoint via `prev_checkpoint_root`, and is itself appended to
|
|
158
|
+
the ledger. Anchoring writes
|
|
159
|
+
`{from_seq, to_seq, merkle_root, ts, prev_checkpoint_root}` to
|
|
160
|
+
`s3://<bucket>/<prefix><from>-<to>.json` with Object Lock in COMPLIANCE mode
|
|
161
|
+
by default. Run `retry_failed_anchors` from a periodic job; storage outages
|
|
162
|
+
self-heal.
|
|
163
|
+
|
|
164
|
+
Configuration can also come from the environment via `AnchorConfig.from_env()`:
|
|
165
|
+
`LEDGER_ANCHOR_BUCKET`, `LEDGER_ANCHOR_PREFIX`, `LEDGER_ANCHOR_ENDPOINT_URL`,
|
|
166
|
+
`LEDGER_ANCHOR_OBJECT_LOCK_MODE` (set to an empty string to disable Object
|
|
167
|
+
Lock on buckets without it).
|
|
168
|
+
|
|
169
|
+
The S3 client is injectable: anything with `put_object(**kw)` and
|
|
170
|
+
`get_object(Bucket=, Key=)`. The test suite uses an in-memory fake and never
|
|
171
|
+
touches the network.
|
|
172
|
+
|
|
173
|
+
## CLI
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
ledgerchain init-db --database-url postgresql://... # tables + triggers
|
|
177
|
+
ledgerchain verify --database-url ... [--from-seq N --to-seq M] [--check-anchors]
|
|
178
|
+
ledgerchain checkpoint --database-url ... [--anchor]
|
|
179
|
+
ledgerchain anchor --database-url ... [--limit 20] # retry pending/failed
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`verify` prints JSON and exits 1 on any failure, so it drops into cron or
|
|
183
|
+
CI. `--database-url` falls back to `LEDGER_DATABASE_URL`, then
|
|
184
|
+
`DATABASE_URL`. `python -m ledgerchain` works too.
|
|
185
|
+
|
|
186
|
+
## Fitting it into an existing app
|
|
187
|
+
|
|
188
|
+
**Schema.** The models live on their own declarative base. In Alembic:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
# env.py
|
|
192
|
+
import ledgerchain
|
|
193
|
+
|
|
194
|
+
target_metadata = [app_metadata, ledgerchain.metadata]
|
|
195
|
+
|
|
196
|
+
# in the migration that creates the tables
|
|
197
|
+
from ledgerchain import install_immutability
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def upgrade():
|
|
201
|
+
... # autogenerated create_table calls
|
|
202
|
+
install_immutability(op.get_bind())
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Table names are `decision_ledger`, `ledger_head`, `ledger_checkpoint`.
|
|
206
|
+
|
|
207
|
+
**Writes.** Call `LedgerWriter.append` inside the same session and
|
|
208
|
+
transaction as the state change you are recording, then commit once. If
|
|
209
|
+
the state lives in a different database, use a transactional outbox on that
|
|
210
|
+
side and relay into the ledger; the outbox row is the evidence that a relay
|
|
211
|
+
is pending.
|
|
212
|
+
|
|
213
|
+
**Reads.** Expose `verify_chain(...).to_dict()` behind whatever auth your
|
|
214
|
+
auditors use, and page `DecisionLedgerEntry` by `subject_uri` or
|
|
215
|
+
`event_type` for receipts. There is no write endpoint to expose.
|
|
216
|
+
|
|
217
|
+
**Workers.** Schedule `build_checkpoint` hourly and `retry_failed_anchors`
|
|
218
|
+
every few minutes, or run the CLI from cron.
|
|
219
|
+
|
|
220
|
+
## Design notes
|
|
221
|
+
|
|
222
|
+
- Canonical JSON is delegated to the [`rfc8785`](https://pypi.org/project/rfc8785/)
|
|
223
|
+
package. Number formatting and key ordering are easy to get subtly wrong
|
|
224
|
+
and every hash depends on them.
|
|
225
|
+
- The Merkle tree promotes an odd node unchanged rather than duplicating it,
|
|
226
|
+
which avoids the second-preimage weakness of the duplicate-last-node
|
|
227
|
+
variant.
|
|
228
|
+
- `anchor_checkpoint` and `verify_anchor` never raise. Anchoring is
|
|
229
|
+
eventually consistent on purpose; the chain is already tamper-evident
|
|
230
|
+
without it.
|
|
231
|
+
- Hashing cost is microseconds per entry. The head-row lock serializes
|
|
232
|
+
appends, which is fine at back-office determination volumes. Shard the
|
|
233
|
+
head per tenant if you ever need more.
|
|
234
|
+
|
|
235
|
+
## Development
|
|
236
|
+
|
|
237
|
+
```
|
|
238
|
+
python -m venv .venv && . .venv/bin/activate
|
|
239
|
+
pip install -e ".[dev]"
|
|
240
|
+
ruff check . && mypy && pytest
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
The suite runs on in-memory and file-backed SQLite and needs no services.
|
|
244
|
+
The PostgreSQL trigger SQL is the same text the origin application runs in
|
|
245
|
+
CI against a real Postgres.
|
|
246
|
+
|
|
247
|
+
## Releasing
|
|
248
|
+
|
|
249
|
+
1. Bump `version` in `pyproject.toml` and add a `CHANGELOG.md` entry.
|
|
250
|
+
2. Commit, then publish a GitHub release whose tag is `vX.Y.Z` (a bare
|
|
251
|
+
`X.Y.Z` also works) targeting `main`, or push such a tag by hand. The
|
|
252
|
+
release workflow checks the tag against the version, re-runs the suite,
|
|
253
|
+
builds, and publishes to PyPI through trusted publishing (no stored
|
|
254
|
+
token). A release saved as a draft creates no tag and runs nothing.
|
|
255
|
+
|
|
256
|
+
## Provenance
|
|
257
|
+
|
|
258
|
+
Extracted from `mpointer/rmfx-v2` (`packages/ledgerchain`), where the same
|
|
259
|
+
code is consumed by the application that produced it. The history here is
|
|
260
|
+
the `git subtree split` of that directory.
|
|
261
|
+
|
|
262
|
+
## License
|
|
263
|
+
|
|
264
|
+
MIT.
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# ledgerchain
|
|
2
|
+
|
|
3
|
+
A hash-chained, tamper-evident decision ledger on SQLAlchemy. Append-only
|
|
4
|
+
rows, a verifier that recomputes everything, Merkle checkpoints, and
|
|
5
|
+
external anchoring to object storage as an independent witness.
|
|
6
|
+
|
|
7
|
+
Built for one job: recording the determinations an AI-assisted system makes,
|
|
8
|
+
and the human approvals, verdicts, and corrections around them, in a form an
|
|
9
|
+
auditor can check without trusting the application. Extracted from a
|
|
10
|
+
production decision-integrity platform where this code records HITL
|
|
11
|
+
approvals, signed attestations, guardrail verdicts, and correction
|
|
12
|
+
dispatches.
|
|
13
|
+
|
|
14
|
+
No blockchain, no ledger database, no sidecar. Three tables in the
|
|
15
|
+
PostgreSQL or SQLite you already run, and about 1,300 lines of Python you
|
|
16
|
+
can read in an afternoon.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
pip install ledgerchain # core: sqlalchemy + rfc8785
|
|
20
|
+
pip install "ledgerchain[s3]" # adds boto3 for checkpoint anchoring
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## What it guarantees
|
|
24
|
+
|
|
25
|
+
| Threat | Mechanism | Detected by |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| A payload is edited in place | `payload_sha256` over RFC 8785 canonical JSON | `verify_chain` names the seq |
|
|
28
|
+
| A row is deleted or reordered | strictly contiguous `seq`, each `entry_hash` includes `str(seq)` | `verify_chain` names the seq after the gap |
|
|
29
|
+
| The head pointer is forged | `ledger_head` compared against the last recomputed hash | `verify_chain(head_valid=False)` |
|
|
30
|
+
| A checkpoint row is rewritten | Merkle root recomputed over the entries it covers | `verify_chain` marks `root_valid=False` |
|
|
31
|
+
| The app role updates or deletes | BEFORE UPDATE/DELETE triggers raise | the write fails |
|
|
32
|
+
| A superuser rewrites the whole chain consistently | checkpoint roots anchored under Object Lock | `verify_anchor` compares the live row to the anchored copy |
|
|
33
|
+
|
|
34
|
+
What it does not do: sign entries (the chain proves integrity and order, not
|
|
35
|
+
authorship; sign payloads before appending if you need that), or trust the
|
|
36
|
+
clock (timestamps are hashed as given). See `SECURITY.md`.
|
|
37
|
+
|
|
38
|
+
## Quickstart
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from sqlalchemy import create_engine
|
|
42
|
+
from sqlalchemy.orm import sessionmaker
|
|
43
|
+
|
|
44
|
+
import ledgerchain
|
|
45
|
+
from ledgerchain import LedgerWriter, verify_chain
|
|
46
|
+
|
|
47
|
+
engine = create_engine("sqlite:///ledger.db")
|
|
48
|
+
ledgerchain.create_all(engine) # tables + append-only triggers
|
|
49
|
+
Session = sessionmaker(bind=engine)
|
|
50
|
+
|
|
51
|
+
with Session() as session:
|
|
52
|
+
# Append inside the transaction that holds the state change the entry
|
|
53
|
+
# documents. The writer flushes but never commits.
|
|
54
|
+
entry = LedgerWriter.append(
|
|
55
|
+
session,
|
|
56
|
+
event_type="hitl.approval",
|
|
57
|
+
payload={"claim_id": "c-17", "decision": "approve", "model_score": 0.91},
|
|
58
|
+
actor={"kind": "human", "sub": "reviewer-42", "roles": ["approver"]},
|
|
59
|
+
tenant_id="acme",
|
|
60
|
+
subject_uri="/claims/c-17/decision",
|
|
61
|
+
)
|
|
62
|
+
session.commit()
|
|
63
|
+
print(entry.seq, entry.entry_hash) # 2 <64 hex chars>; seq 1 is genesis
|
|
64
|
+
|
|
65
|
+
with Session() as session:
|
|
66
|
+
result = verify_chain(session)
|
|
67
|
+
assert result.valid, result.reason
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Tamper with it and the verifier tells you where:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
$ sqlite3 ledger.db "DROP TRIGGER trg_decision_ledger_no_update;
|
|
74
|
+
UPDATE decision_ledger SET payload='{\"x\":1}' WHERE seq=2"
|
|
75
|
+
$ ledgerchain verify --database-url sqlite:///ledger.db
|
|
76
|
+
{
|
|
77
|
+
"valid": false,
|
|
78
|
+
"checked_entries": 2,
|
|
79
|
+
"first_bad_seq": 2,
|
|
80
|
+
"reason": "payload_sha256 mismatch: payload was modified",
|
|
81
|
+
...
|
|
82
|
+
}
|
|
83
|
+
$ echo $?
|
|
84
|
+
1
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## How the chain is built
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
payload_sha256 = sha256( RFC8785(payload) )
|
|
91
|
+
entry_hash = sha256( prev_hash \n payload_sha256 \n seq \n event_type \n ts_rfc3339_utc \n tenant_id )
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- `seq` is assigned by the writer under a `SELECT ... FOR UPDATE` on the
|
|
95
|
+
single `ledger_head` row, so it is known before insert and concurrent
|
|
96
|
+
appends serialize. On SQLite this degrades to the file writer lock.
|
|
97
|
+
- The genesis entry (`seq=1`, `event_type="genesis"`) chains from
|
|
98
|
+
`"0" * 64`. The writer creates it lazily on the first append.
|
|
99
|
+
- Every digest is a full 64-hex string. Nothing is truncated anywhere.
|
|
100
|
+
- `event_type` is yours to define. Only `"genesis"` is reserved;
|
|
101
|
+
`"checkpoint"` is what `build_checkpoint` writes.
|
|
102
|
+
|
|
103
|
+
## Checkpoints and anchoring
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from ledgerchain import AnchorConfig, anchor_checkpoint, build_checkpoint, verify_anchor
|
|
107
|
+
|
|
108
|
+
with Session() as session:
|
|
109
|
+
result = build_checkpoint(session) # Merkle root over new entries
|
|
110
|
+
cp = result.checkpoint # None if nothing new; see skipped_reason
|
|
111
|
+
|
|
112
|
+
config = AnchorConfig(bucket="ledger-anchors", endpoint_url="http://minio:9000")
|
|
113
|
+
anchor_checkpoint(session, cp, config=config) # never raises; failed => retried later
|
|
114
|
+
assert cp.anchor_status == "anchored"
|
|
115
|
+
|
|
116
|
+
report = verify_anchor(cp, config=config) # reads the object back and compares
|
|
117
|
+
assert report.ok
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
A checkpoint covers `(last_checkpoint.to_seq, head.last_seq]`, is chained to
|
|
121
|
+
the previous checkpoint via `prev_checkpoint_root`, and is itself appended to
|
|
122
|
+
the ledger. Anchoring writes
|
|
123
|
+
`{from_seq, to_seq, merkle_root, ts, prev_checkpoint_root}` to
|
|
124
|
+
`s3://<bucket>/<prefix><from>-<to>.json` with Object Lock in COMPLIANCE mode
|
|
125
|
+
by default. Run `retry_failed_anchors` from a periodic job; storage outages
|
|
126
|
+
self-heal.
|
|
127
|
+
|
|
128
|
+
Configuration can also come from the environment via `AnchorConfig.from_env()`:
|
|
129
|
+
`LEDGER_ANCHOR_BUCKET`, `LEDGER_ANCHOR_PREFIX`, `LEDGER_ANCHOR_ENDPOINT_URL`,
|
|
130
|
+
`LEDGER_ANCHOR_OBJECT_LOCK_MODE` (set to an empty string to disable Object
|
|
131
|
+
Lock on buckets without it).
|
|
132
|
+
|
|
133
|
+
The S3 client is injectable: anything with `put_object(**kw)` and
|
|
134
|
+
`get_object(Bucket=, Key=)`. The test suite uses an in-memory fake and never
|
|
135
|
+
touches the network.
|
|
136
|
+
|
|
137
|
+
## CLI
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
ledgerchain init-db --database-url postgresql://... # tables + triggers
|
|
141
|
+
ledgerchain verify --database-url ... [--from-seq N --to-seq M] [--check-anchors]
|
|
142
|
+
ledgerchain checkpoint --database-url ... [--anchor]
|
|
143
|
+
ledgerchain anchor --database-url ... [--limit 20] # retry pending/failed
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`verify` prints JSON and exits 1 on any failure, so it drops into cron or
|
|
147
|
+
CI. `--database-url` falls back to `LEDGER_DATABASE_URL`, then
|
|
148
|
+
`DATABASE_URL`. `python -m ledgerchain` works too.
|
|
149
|
+
|
|
150
|
+
## Fitting it into an existing app
|
|
151
|
+
|
|
152
|
+
**Schema.** The models live on their own declarative base. In Alembic:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
# env.py
|
|
156
|
+
import ledgerchain
|
|
157
|
+
|
|
158
|
+
target_metadata = [app_metadata, ledgerchain.metadata]
|
|
159
|
+
|
|
160
|
+
# in the migration that creates the tables
|
|
161
|
+
from ledgerchain import install_immutability
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def upgrade():
|
|
165
|
+
... # autogenerated create_table calls
|
|
166
|
+
install_immutability(op.get_bind())
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Table names are `decision_ledger`, `ledger_head`, `ledger_checkpoint`.
|
|
170
|
+
|
|
171
|
+
**Writes.** Call `LedgerWriter.append` inside the same session and
|
|
172
|
+
transaction as the state change you are recording, then commit once. If
|
|
173
|
+
the state lives in a different database, use a transactional outbox on that
|
|
174
|
+
side and relay into the ledger; the outbox row is the evidence that a relay
|
|
175
|
+
is pending.
|
|
176
|
+
|
|
177
|
+
**Reads.** Expose `verify_chain(...).to_dict()` behind whatever auth your
|
|
178
|
+
auditors use, and page `DecisionLedgerEntry` by `subject_uri` or
|
|
179
|
+
`event_type` for receipts. There is no write endpoint to expose.
|
|
180
|
+
|
|
181
|
+
**Workers.** Schedule `build_checkpoint` hourly and `retry_failed_anchors`
|
|
182
|
+
every few minutes, or run the CLI from cron.
|
|
183
|
+
|
|
184
|
+
## Design notes
|
|
185
|
+
|
|
186
|
+
- Canonical JSON is delegated to the [`rfc8785`](https://pypi.org/project/rfc8785/)
|
|
187
|
+
package. Number formatting and key ordering are easy to get subtly wrong
|
|
188
|
+
and every hash depends on them.
|
|
189
|
+
- The Merkle tree promotes an odd node unchanged rather than duplicating it,
|
|
190
|
+
which avoids the second-preimage weakness of the duplicate-last-node
|
|
191
|
+
variant.
|
|
192
|
+
- `anchor_checkpoint` and `verify_anchor` never raise. Anchoring is
|
|
193
|
+
eventually consistent on purpose; the chain is already tamper-evident
|
|
194
|
+
without it.
|
|
195
|
+
- Hashing cost is microseconds per entry. The head-row lock serializes
|
|
196
|
+
appends, which is fine at back-office determination volumes. Shard the
|
|
197
|
+
head per tenant if you ever need more.
|
|
198
|
+
|
|
199
|
+
## Development
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
python -m venv .venv && . .venv/bin/activate
|
|
203
|
+
pip install -e ".[dev]"
|
|
204
|
+
ruff check . && mypy && pytest
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The suite runs on in-memory and file-backed SQLite and needs no services.
|
|
208
|
+
The PostgreSQL trigger SQL is the same text the origin application runs in
|
|
209
|
+
CI against a real Postgres.
|
|
210
|
+
|
|
211
|
+
## Releasing
|
|
212
|
+
|
|
213
|
+
1. Bump `version` in `pyproject.toml` and add a `CHANGELOG.md` entry.
|
|
214
|
+
2. Commit, then publish a GitHub release whose tag is `vX.Y.Z` (a bare
|
|
215
|
+
`X.Y.Z` also works) targeting `main`, or push such a tag by hand. The
|
|
216
|
+
release workflow checks the tag against the version, re-runs the suite,
|
|
217
|
+
builds, and publishes to PyPI through trusted publishing (no stored
|
|
218
|
+
token). A release saved as a draft creates no tag and runs nothing.
|
|
219
|
+
|
|
220
|
+
## Provenance
|
|
221
|
+
|
|
222
|
+
Extracted from `mpointer/rmfx-v2` (`packages/ledgerchain`), where the same
|
|
223
|
+
code is consumed by the application that produced it. The history here is
|
|
224
|
+
the `git subtree split` of that directory.
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
MIT.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## What this library does and does not defend against
|
|
4
|
+
|
|
5
|
+
`ledgerchain` makes a ledger tamper-evident, not tamper-proof.
|
|
6
|
+
|
|
7
|
+
- A row edited, deleted, or reordered by anyone who lacks the ability to
|
|
8
|
+
rewrite the entire chain is detected by `verify_chain`, which names the
|
|
9
|
+
first bad sequence number.
|
|
10
|
+
- The immutability triggers stop the application database role from
|
|
11
|
+
updating or deleting ledger rows, including through application bugs.
|
|
12
|
+
- A fully privileged database administrator can drop the triggers and
|
|
13
|
+
rewrite the whole chain consistently. That is what checkpoint anchoring
|
|
14
|
+
is for: a Merkle root written to object storage under Object Lock is an
|
|
15
|
+
independent witness, and `verify_anchor` compares the live chain against
|
|
16
|
+
it. Anchoring is eventually consistent by design, so the window between a
|
|
17
|
+
checkpoint and its anchor is not covered.
|
|
18
|
+
- Entries are not signed. The chain proves integrity and order, not
|
|
19
|
+
authorship. Sign the payloads you care about before appending them.
|
|
20
|
+
- Timestamps come from the application clock and are hashed as given. A
|
|
21
|
+
hostile application clock can backdate an entry at write time; the chain
|
|
22
|
+
still fixes its order relative to every other entry.
|
|
23
|
+
|
|
24
|
+
## Supported versions
|
|
25
|
+
|
|
26
|
+
Only the latest minor release receives fixes.
|
|
27
|
+
|
|
28
|
+
## Reporting a vulnerability
|
|
29
|
+
|
|
30
|
+
Email mpointer@gmail.com with "ledgerchain security" in the subject. Do not
|
|
31
|
+
open a public issue for an unpatched vulnerability. You will get an
|
|
32
|
+
acknowledgement within five business days. Coordinated disclosure is
|
|
33
|
+
preferred: a fix and a release first, then a public advisory crediting the
|
|
34
|
+
reporter.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ledgerchain"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Hash-chained, tamper-evident decision ledger on SQLAlchemy: append-only rows, Merkle checkpoints, external anchoring, and a verifier that recomputes everything."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Mike Pointer", email = "mpointer@gmail.com" }]
|
|
14
|
+
keywords = ["ledger", "audit-trail", "hash-chain", "merkle", "tamper-evident", "sqlalchemy", "ai-governance"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Database",
|
|
25
|
+
"Topic :: Security",
|
|
26
|
+
"Topic :: System :: Logging",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"sqlalchemy>=2.0",
|
|
31
|
+
"rfc8785>=0.1.4",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
s3 = ["boto3>=1.34"]
|
|
36
|
+
postgres = ["psycopg[binary]>=3.1"]
|
|
37
|
+
dev = [
|
|
38
|
+
"pytest>=8",
|
|
39
|
+
"mypy>=1.10",
|
|
40
|
+
"ruff>=0.5",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
ledgerchain = "ledgerchain.cli:main"
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://github.com/mpointer/ledgerchain"
|
|
48
|
+
Changelog = "https://github.com/mpointer/ledgerchain/blob/main/CHANGELOG.md"
|
|
49
|
+
Issues = "https://github.com/mpointer/ledgerchain/issues"
|
|
50
|
+
|
|
51
|
+
[tool.hatch.build.targets.wheel]
|
|
52
|
+
packages = ["src/ledgerchain"]
|
|
53
|
+
|
|
54
|
+
[tool.hatch.build.targets.sdist]
|
|
55
|
+
include = ["src/ledgerchain", "tests", "README.md", "CHANGELOG.md", "LICENSE", "SECURITY.md"]
|
|
56
|
+
|
|
57
|
+
[tool.pytest.ini_options]
|
|
58
|
+
testpaths = ["tests"]
|
|
59
|
+
addopts = "-q"
|
|
60
|
+
|
|
61
|
+
[tool.ruff]
|
|
62
|
+
line-length = 100
|
|
63
|
+
target-version = "py310"
|
|
64
|
+
|
|
65
|
+
[tool.ruff.lint]
|
|
66
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
67
|
+
|
|
68
|
+
[tool.mypy]
|
|
69
|
+
python_version = "3.10"
|
|
70
|
+
strict = true
|
|
71
|
+
ignore_missing_imports = true
|
|
72
|
+
files = ["src/ledgerchain"]
|