ledgerchain 0.1.0__py3-none-any.whl

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,62 @@
1
+ """ledgerchain: a hash-chained, tamper-evident decision ledger on SQLAlchemy.
2
+
3
+ Append events inside your own transaction, verify the whole chain by
4
+ recomputation, checkpoint it with Merkle roots, and anchor those roots to
5
+ object storage as an independent witness.
6
+ """
7
+
8
+ from .anchor import (
9
+ AnchorConfig,
10
+ AnchorResult,
11
+ AnchorVerifyResult,
12
+ anchor_checkpoint,
13
+ anchor_payload,
14
+ retry_failed_anchors,
15
+ verify_anchor,
16
+ )
17
+ from .canonical import CanonicalizationError, canonicalize, sha256_hex
18
+ from .checkpoint import CheckpointResult, build_checkpoint, merkle_root
19
+ from .immutability import create_all, drop_immutability, install_immutability
20
+ from .models import Base, DecisionLedgerEntry, LedgerCheckpoint, LedgerHead, metadata
21
+ from .verify import VerifyResult, verify_chain
22
+ from .writer import (
23
+ CHECKPOINT_EVENT_TYPE,
24
+ GENESIS_EVENT_TYPE,
25
+ GENESIS_PREV_HASH,
26
+ LedgerWriter,
27
+ compute_entry_hash,
28
+ )
29
+
30
+ __version__ = "0.1.0"
31
+
32
+ __all__ = [
33
+ "AnchorConfig",
34
+ "AnchorResult",
35
+ "AnchorVerifyResult",
36
+ "Base",
37
+ "CHECKPOINT_EVENT_TYPE",
38
+ "CanonicalizationError",
39
+ "CheckpointResult",
40
+ "DecisionLedgerEntry",
41
+ "GENESIS_EVENT_TYPE",
42
+ "GENESIS_PREV_HASH",
43
+ "LedgerCheckpoint",
44
+ "LedgerHead",
45
+ "LedgerWriter",
46
+ "VerifyResult",
47
+ "anchor_checkpoint",
48
+ "anchor_payload",
49
+ "build_checkpoint",
50
+ "canonicalize",
51
+ "compute_entry_hash",
52
+ "create_all",
53
+ "drop_immutability",
54
+ "install_immutability",
55
+ "merkle_root",
56
+ "metadata",
57
+ "retry_failed_anchors",
58
+ "sha256_hex",
59
+ "verify_anchor",
60
+ "verify_chain",
61
+ "__version__",
62
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
ledgerchain/anchor.py ADDED
@@ -0,0 +1,243 @@
1
+ """External anchoring of checkpoints to S3-compatible object storage.
2
+
3
+ The hash chain is already tamper-evident. Anchoring defeats a different
4
+ threat: a fully privileged database administrator rewriting the whole
5
+ chain and its checkpoints consistently. A checkpoint's Merkle root written
6
+ to a bucket under Object Lock is an independent witness that the live
7
+ database cannot alter.
8
+
9
+ Anchoring is eventually consistent by design. A storage outage marks the
10
+ checkpoint ``failed`` for retry and never blocks or fails the job that
11
+ built it.
12
+
13
+ Configuration comes from :class:`AnchorConfig`, either built explicitly or
14
+ read from the environment:
15
+
16
+ ``LEDGER_ANCHOR_BUCKET``
17
+ Target bucket. Anchoring is skipped, not failed, when unset.
18
+ ``LEDGER_ANCHOR_PREFIX``
19
+ Key prefix. Default ``ledger-checkpoints/``.
20
+ ``LEDGER_ANCHOR_ENDPOINT_URL``
21
+ S3-compatible endpoint (MinIO and friends). Unset uses AWS defaults.
22
+ ``LEDGER_ANCHOR_OBJECT_LOCK_MODE``
23
+ ``COMPLIANCE`` (default) or ``GOVERNANCE``. An empty string disables
24
+ Object Lock, for buckets that do not have it enabled.
25
+
26
+ The S3 client is injectable. Anything with ``put_object(**kwargs)`` and
27
+ ``get_object(Bucket=..., Key=...)`` works, which keeps the test suite free
28
+ of network access and credentials. ``boto3`` is only imported when no
29
+ client is supplied (install the ``s3`` extra).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import logging
36
+ import os
37
+ from collections.abc import Mapping
38
+ from dataclasses import dataclass
39
+ from datetime import datetime, timezone
40
+ from typing import Any, Protocol
41
+
42
+ from sqlalchemy import select
43
+ from sqlalchemy.orm import Session
44
+
45
+ from .models import LedgerCheckpoint
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ DEFAULT_PREFIX = "ledger-checkpoints/"
50
+ DEFAULT_RETAIN_UNTIL = datetime(2126, 1, 1, tzinfo=timezone.utc)
51
+ """A fixed far-future retain-until date approximates "keep forever" without
52
+ depending on bucket-default retention being configured."""
53
+
54
+
55
+ class ObjectStore(Protocol):
56
+ """The slice of the boto3 S3 client this module uses."""
57
+
58
+ def put_object(self, **kwargs: Any) -> Any: ...
59
+
60
+ def get_object(self, **kwargs: Any) -> Any: ...
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class AnchorConfig:
65
+ bucket: str | None
66
+ prefix: str = DEFAULT_PREFIX
67
+ endpoint_url: str | None = None
68
+ object_lock_mode: str | None = "COMPLIANCE"
69
+ retain_until: datetime = DEFAULT_RETAIN_UNTIL
70
+
71
+ @classmethod
72
+ def from_env(cls, environ: Mapping[str, str] | None = None) -> AnchorConfig:
73
+ env = os.environ if environ is None else environ
74
+ bucket = env.get("LEDGER_ANCHOR_BUCKET", "").strip() or None
75
+ prefix = env.get("LEDGER_ANCHOR_PREFIX", DEFAULT_PREFIX)
76
+ endpoint = env.get("LEDGER_ANCHOR_ENDPOINT_URL", "").strip() or None
77
+ lock_mode = env.get("LEDGER_ANCHOR_OBJECT_LOCK_MODE", "COMPLIANCE").strip().upper() or None
78
+ return cls(bucket=bucket, prefix=prefix, endpoint_url=endpoint, object_lock_mode=lock_mode)
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class AnchorResult:
83
+ ok: bool
84
+ anchor_uri: str | None = None
85
+ error: str | None = None
86
+ skipped: bool = False
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class AnchorVerifyResult:
91
+ """Outcome of :func:`verify_anchor`.
92
+
93
+ ``ok`` means the anchored copy was read and matches the checkpoint row.
94
+ ``matched=False`` with ``ok=False`` means it was read and disagrees,
95
+ which is the signal that the database copy was rewritten after the
96
+ anchor was taken. ``error`` is set when the copy could not be read.
97
+ """
98
+
99
+ ok: bool
100
+ matched: bool | None = None
101
+ anchored: dict[str, Any] | None = None
102
+ error: str | None = None
103
+
104
+
105
+ def anchor_payload(checkpoint: LedgerCheckpoint) -> dict[str, Any]:
106
+ """``{from_seq, to_seq, merkle_root, ts, prev_checkpoint_root}``."""
107
+ return {
108
+ "from_seq": checkpoint.from_seq,
109
+ "to_seq": checkpoint.to_seq,
110
+ "merkle_root": checkpoint.merkle_root,
111
+ "ts": checkpoint.ts.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
112
+ "prev_checkpoint_root": checkpoint.prev_checkpoint_root,
113
+ }
114
+
115
+
116
+ def anchor_key(config: AnchorConfig, checkpoint: LedgerCheckpoint) -> str:
117
+ return f"{config.prefix}{checkpoint.from_seq:020d}-{checkpoint.to_seq:020d}.json"
118
+
119
+
120
+ def parse_anchor_uri(uri: str) -> tuple[str, str]:
121
+ """Split ``s3://bucket/key`` into ``(bucket, key)``."""
122
+ if not uri.startswith("s3://"):
123
+ raise ValueError(f"not an s3:// URI: {uri!r}")
124
+ rest = uri[len("s3://") :]
125
+ bucket, sep, key = rest.partition("/")
126
+ if not bucket or not sep or not key:
127
+ raise ValueError(f"malformed s3:// URI: {uri!r}")
128
+ return bucket, key
129
+
130
+
131
+ def default_client(config: AnchorConfig) -> ObjectStore:
132
+ """A boto3 S3 client for ``config``. Requires the ``s3`` extra."""
133
+ import boto3 # imported lazily so the base install has no AWS dependency
134
+
135
+ client: ObjectStore = boto3.client("s3", endpoint_url=config.endpoint_url)
136
+ return client
137
+
138
+
139
+ def anchor_checkpoint(
140
+ session: Session,
141
+ checkpoint: LedgerCheckpoint,
142
+ *,
143
+ config: AnchorConfig | None = None,
144
+ client: ObjectStore | None = None,
145
+ ) -> AnchorResult:
146
+ """Write ``checkpoint``'s anchor payload to the configured bucket.
147
+
148
+ Updates ``anchor_status`` and ``anchor_uri`` in place and commits.
149
+ Never raises: any failure (bucket unset, credentials missing, network
150
+ error, Object Lock rejected) is logged and recorded as
151
+ ``anchor_status='failed'`` for a later retry.
152
+ """
153
+ cfg = config if config is not None else AnchorConfig.from_env()
154
+ if cfg.bucket is None:
155
+ return AnchorResult(ok=False, skipped=True, error="LEDGER_ANCHOR_BUCKET not configured")
156
+
157
+ key = anchor_key(cfg, checkpoint)
158
+ body = json.dumps(anchor_payload(checkpoint), sort_keys=True).encode("utf-8")
159
+ put_kwargs: dict[str, Any] = {
160
+ "Bucket": cfg.bucket,
161
+ "Key": key,
162
+ "Body": body,
163
+ "ContentType": "application/json",
164
+ }
165
+ if cfg.object_lock_mode:
166
+ put_kwargs["ObjectLockMode"] = cfg.object_lock_mode
167
+ put_kwargs["ObjectLockRetainUntilDate"] = cfg.retain_until
168
+
169
+ try:
170
+ store = client if client is not None else default_client(cfg)
171
+ store.put_object(**put_kwargs)
172
+ anchor_uri = f"s3://{cfg.bucket}/{key}"
173
+ checkpoint.anchor_uri = anchor_uri
174
+ checkpoint.anchor_status = "anchored"
175
+ session.commit()
176
+ return AnchorResult(ok=True, anchor_uri=anchor_uri)
177
+ except Exception as exc: # noqa: BLE001 - anchoring is best-effort and retryable
178
+ logger.error("checkpoint %s anchor failed: %s: %s", checkpoint.id, type(exc).__name__, exc)
179
+ try:
180
+ checkpoint.anchor_status = "failed"
181
+ session.commit()
182
+ except Exception: # noqa: BLE001
183
+ session.rollback()
184
+ return AnchorResult(ok=False, error=f"{type(exc).__name__}: {exc}")
185
+
186
+
187
+ def retry_failed_anchors(
188
+ session: Session,
189
+ *,
190
+ limit: int = 20,
191
+ config: AnchorConfig | None = None,
192
+ client: ObjectStore | None = None,
193
+ ) -> list[AnchorResult]:
194
+ """Retry every ``pending`` or ``failed`` checkpoint, oldest first.
195
+
196
+ Run this from a periodic job so a transient storage outage self-heals
197
+ without operator intervention.
198
+ """
199
+ rows = (
200
+ session.execute(
201
+ select(LedgerCheckpoint)
202
+ .where(LedgerCheckpoint.anchor_status.in_(["pending", "failed"]))
203
+ .order_by(LedgerCheckpoint.id.asc())
204
+ .limit(limit)
205
+ )
206
+ .scalars()
207
+ .all()
208
+ )
209
+ return [anchor_checkpoint(session, row, config=config, client=client) for row in rows]
210
+
211
+
212
+ def verify_anchor(
213
+ checkpoint: LedgerCheckpoint,
214
+ *,
215
+ config: AnchorConfig | None = None,
216
+ client: ObjectStore | None = None,
217
+ ) -> AnchorVerifyResult:
218
+ """Read the anchored copy of ``checkpoint`` and compare it to the row.
219
+
220
+ This is the independent-witness check: the object in the bucket was
221
+ written when the checkpoint was taken and cannot be changed under
222
+ Object Lock, so a mismatch proves the database row was rewritten after
223
+ anchoring. Never raises.
224
+ """
225
+ if checkpoint.anchor_uri is None:
226
+ return AnchorVerifyResult(ok=False, error="checkpoint has no anchor_uri")
227
+ cfg = config if config is not None else AnchorConfig.from_env()
228
+ try:
229
+ bucket, key = parse_anchor_uri(checkpoint.anchor_uri)
230
+ store = client if client is not None else default_client(cfg)
231
+ response = store.get_object(Bucket=bucket, Key=key)
232
+ raw = response["Body"].read()
233
+ anchored = json.loads(raw)
234
+ except Exception as exc: # noqa: BLE001 - report, never raise
235
+ return AnchorVerifyResult(ok=False, error=f"{type(exc).__name__}: {exc}")
236
+
237
+ matched = (
238
+ anchored.get("from_seq") == checkpoint.from_seq
239
+ and anchored.get("to_seq") == checkpoint.to_seq
240
+ and anchored.get("merkle_root") == checkpoint.merkle_root
241
+ and anchored.get("prev_checkpoint_root") == checkpoint.prev_checkpoint_root
242
+ )
243
+ return AnchorVerifyResult(ok=matched, matched=matched, anchored=anchored)
@@ -0,0 +1,32 @@
1
+ """RFC 8785 (JSON Canonicalization Scheme) helpers.
2
+
3
+ Every hash in this package is computed over the canonical byte form of a
4
+ JSON value: ``payload_sha256`` for ledger entries, and anything a caller
5
+ chooses to sign before appending. Two dicts with the same content but a
6
+ different key order, or the same number written two ways, hash the same.
7
+
8
+ Canonicalization is delegated to the ``rfc8785`` package rather than
9
+ hand-rolled. ECMAScript shortest-round-trip number formatting and UTF-16
10
+ code-unit key ordering are easy to get subtly wrong, and every hash in the
11
+ chain depends on them being exact.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ from typing import Any
18
+
19
+ import rfc8785
20
+
21
+ CanonicalizationError = rfc8785.CanonicalizationError
22
+ """Raised when a value cannot be canonicalized (for example, a ``set``)."""
23
+
24
+
25
+ def canonicalize(value: Any) -> bytes:
26
+ """Serialize ``value`` to canonical JSON bytes per RFC 8785."""
27
+ return rfc8785.dumps(value)
28
+
29
+
30
+ def sha256_hex(value: Any) -> str:
31
+ """SHA-256 of the canonical JSON serialization, as lowercase hex."""
32
+ return hashlib.sha256(canonicalize(value)).hexdigest()
@@ -0,0 +1,132 @@
1
+ """Merkle checkpoints over the ledger.
2
+
3
+ A checkpoint summarizes a contiguous ``seq`` range with one Merkle root and
4
+ chains to the previous checkpoint's root via ``prev_checkpoint_root``. The
5
+ checkpoint is also appended to the ledger as a ``checkpoint`` entry, so the
6
+ chain carries its own checkpoint history. External anchoring is a separate
7
+ step (:mod:`ledgerchain.anchor`) so a storage outage never blocks
8
+ checkpoint creation.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ from collections.abc import Sequence
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ from sqlalchemy import select
19
+ from sqlalchemy.orm import Session
20
+
21
+ from .models import DecisionLedgerEntry, LedgerCheckpoint, LedgerHead
22
+ from .writer import CHECKPOINT_EVENT_TYPE, LedgerWriter
23
+
24
+ CHECKPOINT_ACTOR: dict[str, Any] = {"kind": "system", "sub": "ledger-checkpoint"}
25
+
26
+
27
+ def merkle_root(leaf_hexdigests: Sequence[str]) -> str:
28
+ """Binary Merkle root over hex SHA-256 leaves, returned as hex.
29
+
30
+ Pairs are hashed as ``sha256(left || right)`` over the raw 32-byte
31
+ digests. An odd node at any level is promoted unchanged rather than
32
+ duplicated, which avoids the known second-preimage weakness of the
33
+ duplicate-last-node variant. Raises ``ValueError`` on an empty input.
34
+ """
35
+ if not leaf_hexdigests:
36
+ raise ValueError("merkle_root requires at least one leaf")
37
+
38
+ level = [bytes.fromhex(h) for h in leaf_hexdigests]
39
+ while len(level) > 1:
40
+ next_level = [
41
+ hashlib.sha256(level[i] + level[i + 1]).digest() for i in range(0, len(level) - 1, 2)
42
+ ]
43
+ if len(level) % 2 == 1:
44
+ next_level.append(level[-1])
45
+ level = next_level
46
+ return level[0].hex()
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class CheckpointResult:
51
+ checkpoint: LedgerCheckpoint | None
52
+ skipped_reason: str | None = None
53
+
54
+
55
+ def build_checkpoint(
56
+ session: Session,
57
+ *,
58
+ tenant_id: str = "default",
59
+ commit: bool = True,
60
+ ) -> CheckpointResult:
61
+ """Build one checkpoint over every entry since the last checkpoint.
62
+
63
+ The range is ``(last_checkpoint.to_seq, head.last_seq]``, or
64
+ ``[1, head.last_seq]`` when no checkpoint exists yet. Writes the
65
+ ``LedgerCheckpoint`` row and appends a ``checkpoint`` ledger entry
66
+ carrying the same ``{from_seq, to_seq, merkle_root,
67
+ prev_checkpoint_root}``. With ``commit=True`` (the default) both land
68
+ in one commit; pass ``commit=False`` to fold them into a larger
69
+ transaction you control.
70
+
71
+ Returns ``skipped_reason`` and no checkpoint when there is nothing new
72
+ to cover: an empty ledger, or a head that has not advanced past the
73
+ last checkpoint.
74
+ """
75
+ head = session.execute(select(LedgerHead)).scalar_one_or_none()
76
+ if head is None:
77
+ return CheckpointResult(checkpoint=None, skipped_reason="ledger is empty (no genesis yet)")
78
+
79
+ last_checkpoint = (
80
+ session.execute(select(LedgerCheckpoint).order_by(LedgerCheckpoint.to_seq.desc()))
81
+ .scalars()
82
+ .first()
83
+ )
84
+ from_seq = 1 if last_checkpoint is None else last_checkpoint.to_seq + 1
85
+ to_seq = head.last_seq
86
+
87
+ if from_seq > to_seq:
88
+ covered = last_checkpoint.to_seq if last_checkpoint is not None else 0
89
+ return CheckpointResult(
90
+ checkpoint=None,
91
+ skipped_reason=f"nothing new since last checkpoint (to_seq={covered})",
92
+ )
93
+
94
+ hashes = (
95
+ session.execute(
96
+ select(DecisionLedgerEntry.entry_hash)
97
+ .where(DecisionLedgerEntry.seq >= from_seq, DecisionLedgerEntry.seq <= to_seq)
98
+ .order_by(DecisionLedgerEntry.seq.asc())
99
+ )
100
+ .scalars()
101
+ .all()
102
+ )
103
+ root = merkle_root(hashes)
104
+ prev_root = last_checkpoint.merkle_root if last_checkpoint is not None else None
105
+
106
+ checkpoint = LedgerCheckpoint(
107
+ from_seq=from_seq,
108
+ to_seq=to_seq,
109
+ merkle_root=root,
110
+ prev_checkpoint_root=prev_root,
111
+ anchor_status="pending",
112
+ )
113
+ session.add(checkpoint)
114
+ session.flush()
115
+
116
+ LedgerWriter.append(
117
+ session,
118
+ event_type=CHECKPOINT_EVENT_TYPE,
119
+ payload={
120
+ "checkpoint_id": checkpoint.id,
121
+ "from_seq": from_seq,
122
+ "to_seq": to_seq,
123
+ "merkle_root": root,
124
+ "prev_checkpoint_root": prev_root,
125
+ },
126
+ actor=CHECKPOINT_ACTOR,
127
+ tenant_id=tenant_id,
128
+ subject_uri=f"ledger://checkpoints/{checkpoint.id}",
129
+ )
130
+ if commit:
131
+ session.commit()
132
+ return CheckpointResult(checkpoint=checkpoint)
ledgerchain/cli.py ADDED
@@ -0,0 +1,170 @@
1
+ """``ledgerchain`` command line.
2
+
3
+ Built for auditors and operators, not for the application: every
4
+ subcommand opens its own engine from ``--database-url`` (or
5
+ ``LEDGER_DATABASE_URL``, then ``DATABASE_URL``), does one thing, prints
6
+ JSON, and exits non-zero on a failed verification so it composes in
7
+ scripts and CI.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import sys
16
+ from collections.abc import Sequence
17
+ from typing import Any
18
+
19
+ from sqlalchemy import create_engine
20
+ from sqlalchemy.orm import Session, sessionmaker
21
+
22
+ from .anchor import AnchorConfig, anchor_checkpoint, retry_failed_anchors, verify_anchor
23
+ from .checkpoint import build_checkpoint
24
+ from .immutability import create_all
25
+ from .models import LedgerCheckpoint
26
+ from .verify import verify_chain
27
+
28
+
29
+ def resolve_database_url(explicit: str | None) -> str:
30
+ url = explicit or os.environ.get("LEDGER_DATABASE_URL") or os.environ.get("DATABASE_URL")
31
+ if not url:
32
+ raise SystemExit(
33
+ "no database URL: pass --database-url or set LEDGER_DATABASE_URL / DATABASE_URL"
34
+ )
35
+ return url
36
+
37
+
38
+ def _session(url: str) -> tuple[Any, Session]:
39
+ engine = create_engine(url)
40
+ return engine, sessionmaker(bind=engine)()
41
+
42
+
43
+ def _emit(payload: dict[str, Any]) -> None:
44
+ print(json.dumps(payload, indent=2, default=str))
45
+
46
+
47
+ def cmd_init_db(args: argparse.Namespace) -> int:
48
+ engine = create_engine(resolve_database_url(args.database_url))
49
+ try:
50
+ create_all(engine, immutable=not args.no_triggers)
51
+ finally:
52
+ engine.dispose()
53
+ _emit({"ok": True, "triggers": not args.no_triggers})
54
+ return 0
55
+
56
+
57
+ def cmd_verify(args: argparse.Namespace) -> int:
58
+ engine, session = _session(resolve_database_url(args.database_url))
59
+ try:
60
+ result = verify_chain(session, from_seq=args.from_seq, to_seq=args.to_seq)
61
+ body = result.to_dict()
62
+ anchors_ok = True
63
+ if args.check_anchors:
64
+ anchor_reports = []
65
+ for summary in result.checkpoints:
66
+ if summary["anchor_status"] != "anchored":
67
+ continue
68
+ cp = session.get(LedgerCheckpoint, summary["id"])
69
+ if cp is None:
70
+ continue
71
+ report = verify_anchor(cp)
72
+ anchors_ok = anchors_ok and report.ok
73
+ anchor_reports.append(
74
+ {
75
+ "id": cp.id,
76
+ "anchor_uri": cp.anchor_uri,
77
+ "ok": report.ok,
78
+ "matched": report.matched,
79
+ "error": report.error,
80
+ }
81
+ )
82
+ body["anchors"] = anchor_reports
83
+ body["anchors_valid"] = anchors_ok
84
+ finally:
85
+ session.close()
86
+ engine.dispose()
87
+ _emit(body)
88
+ return 0 if result.valid and anchors_ok else 1
89
+
90
+
91
+ def cmd_checkpoint(args: argparse.Namespace) -> int:
92
+ engine, session = _session(resolve_database_url(args.database_url))
93
+ try:
94
+ result = build_checkpoint(session, tenant_id=args.tenant_id)
95
+ body: dict[str, Any] = {
96
+ "checkpoint": result.checkpoint.to_dict() if result.checkpoint else None,
97
+ "skipped_reason": result.skipped_reason,
98
+ }
99
+ if args.anchor and result.checkpoint is not None:
100
+ anchored = anchor_checkpoint(session, result.checkpoint, config=AnchorConfig.from_env())
101
+ body["anchor"] = anchored.__dict__
102
+ finally:
103
+ session.close()
104
+ engine.dispose()
105
+ _emit(body)
106
+ return 0
107
+
108
+
109
+ def cmd_anchor(args: argparse.Namespace) -> int:
110
+ engine, session = _session(resolve_database_url(args.database_url))
111
+ try:
112
+ results = retry_failed_anchors(session, limit=args.limit, config=AnchorConfig.from_env())
113
+ finally:
114
+ session.close()
115
+ engine.dispose()
116
+ _emit({"attempted": len(results), "results": [r.__dict__ for r in results]})
117
+ return 0 if all(r.ok or r.skipped for r in results) else 1
118
+
119
+
120
+ def build_parser() -> argparse.ArgumentParser:
121
+ parser = argparse.ArgumentParser(
122
+ prog="ledgerchain", description="Tamper-evident decision ledger tools."
123
+ )
124
+ sub = parser.add_subparsers(dest="command", required=True)
125
+
126
+ def add_db(p: argparse.ArgumentParser) -> None:
127
+ p.add_argument(
128
+ "--database-url",
129
+ default=None,
130
+ help="SQLAlchemy URL. Overrides LEDGER_DATABASE_URL and DATABASE_URL.",
131
+ )
132
+
133
+ p = sub.add_parser("init-db", help="Create the ledger tables and immutability triggers.")
134
+ add_db(p)
135
+ p.add_argument("--no-triggers", action="store_true", help="Create tables only.")
136
+ p.set_defaults(func=cmd_init_db)
137
+
138
+ p = sub.add_parser("verify", help="Recompute the chain and report the first bad seq.")
139
+ add_db(p)
140
+ p.add_argument("--from-seq", type=int, default=None)
141
+ p.add_argument("--to-seq", type=int, default=None)
142
+ p.add_argument(
143
+ "--check-anchors",
144
+ action="store_true",
145
+ help="Also read each anchored checkpoint back from object storage and compare.",
146
+ )
147
+ p.set_defaults(func=cmd_verify)
148
+
149
+ p = sub.add_parser("checkpoint", help="Build a Merkle checkpoint over new entries.")
150
+ add_db(p)
151
+ p.add_argument("--tenant-id", default="default")
152
+ p.add_argument("--anchor", action="store_true", help="Anchor it immediately.")
153
+ p.set_defaults(func=cmd_checkpoint)
154
+
155
+ p = sub.add_parser("anchor", help="Retry pending and failed anchors.")
156
+ add_db(p)
157
+ p.add_argument("--limit", type=int, default=20)
158
+ p.set_defaults(func=cmd_anchor)
159
+
160
+ return parser
161
+
162
+
163
+ def main(argv: Sequence[str] | None = None) -> int:
164
+ args = build_parser().parse_args(argv)
165
+ code: int = args.func(args)
166
+ return code
167
+
168
+
169
+ if __name__ == "__main__": # pragma: no cover
170
+ sys.exit(main())