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.
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ *.db
11
+ *.sqlite
@@ -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"]