voiceledger 0.1.3__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
+ .env
2
+ .env.*
3
+ !.env.example
4
+ .venv/
5
+ __pycache__/
6
+ *.pyc
7
+ .DS_Store
8
+ dist/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ recordings/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Digvijay Singh
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,41 @@
1
+ Metadata-Version: 2.5
2
+ Name: voiceledger
3
+ Version: 0.1.3
4
+ Summary: Hash-chained, signed event ledger with pluggable rules and a standalone verifier.
5
+ Project-URL: Repository, https://github.com/digvijaysingh-ai/voiceops
6
+ Project-URL: Documentation, https://github.com/digvijaysingh-ai/voiceops/blob/main/docs/guide.md
7
+ Author: Digvijay Singh
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.12
11
+ Requires-Dist: cryptography>=42
12
+ Provides-Extra: postgres
13
+ Requires-Dist: psycopg[binary]>=3.2; extra == 'postgres'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # voiceledger
17
+
18
+ A hash-chained, signed event ledger with pluggable rules and a standalone verifier. One dependency (`cryptography`): an auditor can verify a ledger without installing the system that wrote it.
19
+
20
+ ```bash
21
+ pip install voiceledger
22
+ voiceledger verify bundle.zip --trusted-key <hex> --rules rules.toml
23
+ ```
24
+
25
+ ```
26
+ PASS 1707 events
27
+ PASS chain: 1707 events linked
28
+ PASS identity_before_detail: 300 detail_disclosed events, all after identity_verified
29
+ PASS list_order: 14 snapshots followed in order (per slot_id)
30
+ PASS head: signed head matches
31
+ ```
32
+
33
+ - **chain**: each event's hash covers the event and the previous hash, so editing, inserting, deleting or reordering any event breaks every later link.
34
+ - **head**: the last hash is signed with Ed25519; with the trusted key, the whole chain can't be regenerated and re-signed by anyone else.
35
+ - **rules**: what the project declares, e.g. `requires_before` (no detail before identity, per call), `follows_snapshot_order` (offers in the customer's list order), `always_followed_by`, `never`. Custom rules subclass `voiceledger.Rule`.
36
+
37
+ A bundle is a zip with `ledger.jsonl`, `list_snapshots.jsonl` and `head.json`. Reading is defensive (size caps, known members only, JSON only). Exit code 0 when every check holds, 1 otherwise, 2 for unreadable input.
38
+
39
+ What it proves: the record wasn't changed after the head was signed, and the declared rules hold over it. What it doesn't: that the events were true. Reconcile against the system of record for that.
40
+
41
+ Part of [voiceops](https://github.com/digvijaysingh-ai/voiceops); rules reference in its [guide](https://github.com/digvijaysingh-ai/voiceops/blob/main/docs/guide.md). MIT licence.
@@ -0,0 +1,26 @@
1
+ # voiceledger
2
+
3
+ A hash-chained, signed event ledger with pluggable rules and a standalone verifier. One dependency (`cryptography`): an auditor can verify a ledger without installing the system that wrote it.
4
+
5
+ ```bash
6
+ pip install voiceledger
7
+ voiceledger verify bundle.zip --trusted-key <hex> --rules rules.toml
8
+ ```
9
+
10
+ ```
11
+ PASS 1707 events
12
+ PASS chain: 1707 events linked
13
+ PASS identity_before_detail: 300 detail_disclosed events, all after identity_verified
14
+ PASS list_order: 14 snapshots followed in order (per slot_id)
15
+ PASS head: signed head matches
16
+ ```
17
+
18
+ - **chain**: each event's hash covers the event and the previous hash, so editing, inserting, deleting or reordering any event breaks every later link.
19
+ - **head**: the last hash is signed with Ed25519; with the trusted key, the whole chain can't be regenerated and re-signed by anyone else.
20
+ - **rules**: what the project declares, e.g. `requires_before` (no detail before identity, per call), `follows_snapshot_order` (offers in the customer's list order), `always_followed_by`, `never`. Custom rules subclass `voiceledger.Rule`.
21
+
22
+ A bundle is a zip with `ledger.jsonl`, `list_snapshots.jsonl` and `head.json`. Reading is defensive (size caps, known members only, JSON only). Exit code 0 when every check holds, 1 otherwise, 2 for unreadable input.
23
+
24
+ What it proves: the record wasn't changed after the head was signed, and the declared rules hold over it. What it doesn't: that the events were true. Reconcile against the system of record for that.
25
+
26
+ Part of [voiceops](https://github.com/digvijaysingh-ai/voiceops); rules reference in its [guide](https://github.com/digvijaysingh-ai/voiceops/blob/main/docs/guide.md). MIT licence.
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "voiceledger"
3
+ version = "0.1.3"
4
+ description = "Hash-chained, signed event ledger with pluggable rules and a standalone verifier."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Digvijay Singh" }]
9
+ requires-python = ">=3.12"
10
+ dependencies = ["cryptography>=42"]
11
+
12
+ [project.urls]
13
+ Repository = "https://github.com/digvijaysingh-ai/voiceops"
14
+ Documentation = "https://github.com/digvijaysingh-ai/voiceops/blob/main/docs/guide.md"
15
+
16
+ [project.optional-dependencies]
17
+ postgres = ["psycopg[binary]>=3.2"]
18
+
19
+ [project.scripts]
20
+ voiceledger = "voiceledger.cli:main"
21
+
22
+ [build-system]
23
+ requires = ["hatchling"]
24
+ build-backend = "hatchling.build"
@@ -0,0 +1,51 @@
1
+ """voiceledger: hash-chained, signed event ledger with pluggable rules and a standalone verifier.
2
+
3
+ No web framework or database driver needed to verify (Postgres storage is the optional
4
+ `voiceledger[postgres]` extra). Rules: see voiceledger.rules and docs/PLAN.md §4.2.
5
+ """
6
+
7
+ from voiceledger.bundle import BundleInvalid, read_bundle, write_bundle
8
+ from voiceledger.chain import GENESIS, Event, append, seal, snapshot_hash
9
+ from voiceledger.result import Check, Report
10
+ from voiceledger.rules import (
11
+ AlwaysFollowedBy,
12
+ FollowsSnapshotOrder,
13
+ Matcher,
14
+ Never,
15
+ RequiresBefore,
16
+ Rule,
17
+ RuleConfigError,
18
+ load_rules,
19
+ load_rules_file,
20
+ register_rule,
21
+ )
22
+ from voiceledger.sign import SignedHead, SigningKeyMissing, sign_head
23
+ from voiceledger.verify import verify
24
+
25
+ __version__ = "0.1.3"
26
+ __all__ = [
27
+ "GENESIS",
28
+ "AlwaysFollowedBy",
29
+ "BundleInvalid",
30
+ "Check",
31
+ "Event",
32
+ "FollowsSnapshotOrder",
33
+ "Matcher",
34
+ "Never",
35
+ "Report",
36
+ "RequiresBefore",
37
+ "Rule",
38
+ "RuleConfigError",
39
+ "SignedHead",
40
+ "SigningKeyMissing",
41
+ "append",
42
+ "load_rules",
43
+ "load_rules_file",
44
+ "read_bundle",
45
+ "register_rule",
46
+ "seal",
47
+ "sign_head",
48
+ "snapshot_hash",
49
+ "verify",
50
+ "write_bundle",
51
+ ]
@@ -0,0 +1,83 @@
1
+ """Evidence bundle: a zip with ledger.jsonl, list_snapshots.jsonl and head.json.
2
+
3
+ Reading is defensive: bundles are uploaded by anyone, so we cap the size, read only
4
+ the three known members, and parse JSON (never execute anything).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import json
11
+ import zipfile
12
+
13
+ from voiceledger.chain import Event, canonical_json
14
+ from voiceledger.sign import SignedHead
15
+
16
+ MAX_BUNDLE_BYTES = 5 * 1024 * 1024
17
+ MAX_MEMBER_BYTES = 20 * 1024 * 1024 # uncompressed, guards against zip bombs
18
+ LEDGER, SNAPSHOTS, HEAD = "ledger.jsonl", "list_snapshots.jsonl", "head.json"
19
+
20
+
21
+ class BundleInvalid(ValueError):
22
+ pass
23
+
24
+
25
+ def write_bundle(events: list[Event], snapshots: dict[str, list[str]], head: SignedHead | None):
26
+ buf = io.BytesIO()
27
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
28
+ z.writestr(LEDGER, "\n".join(e.to_json() for e in events) + "\n")
29
+ z.writestr(
30
+ SNAPSHOTS,
31
+ "".join(
32
+ canonical_json({"snapshot_id": k, "ordered_pids": v}) + "\n"
33
+ for k, v in sorted(snapshots.items())
34
+ ),
35
+ )
36
+ if head is not None:
37
+ z.writestr(HEAD, canonical_json(head.to_dict()))
38
+ return buf.getvalue()
39
+
40
+
41
+ def _read_member(z: zipfile.ZipFile, name: str) -> str | None:
42
+ try:
43
+ info = z.getinfo(name)
44
+ except KeyError:
45
+ return None
46
+ if info.file_size > MAX_MEMBER_BYTES:
47
+ raise BundleInvalid(f"{name} is too large")
48
+ return z.read(name).decode("utf-8")
49
+
50
+
51
+ def read_bundle(data: bytes):
52
+ """Returns (events, snapshots | None, head | None). Raises BundleInvalid."""
53
+ if len(data) > MAX_BUNDLE_BYTES:
54
+ raise BundleInvalid("file over 5 MB")
55
+ try:
56
+ z = zipfile.ZipFile(io.BytesIO(data))
57
+ except zipfile.BadZipFile as e:
58
+ raise BundleInvalid("not a ledger bundle (not a zip file)") from e
59
+ with z:
60
+ ledger = _read_member(z, LEDGER)
61
+ if ledger is None:
62
+ raise BundleInvalid("not a ledger bundle (no ledger.jsonl)")
63
+ try:
64
+ events = [Event.from_dict(json.loads(line)) for line in ledger.splitlines() if line]
65
+ snap_text = _read_member(z, SNAPSHOTS)
66
+ snapshots = None
67
+ if snap_text is not None:
68
+ snapshots = {
69
+ row["snapshot_id"]: list(row["ordered_pids"])
70
+ for row in map(json.loads, filter(None, snap_text.splitlines()))
71
+ }
72
+ head_text = _read_member(z, HEAD)
73
+ head = SignedHead(**json.loads(head_text)) if head_text else None
74
+ except (json.JSONDecodeError, TypeError, KeyError) as e:
75
+ raise BundleInvalid(f"malformed bundle: {e}") from e
76
+ return events, snapshots, head
77
+
78
+
79
+ def read_ledger_jsonl(text: str) -> list[Event]:
80
+ try:
81
+ return [Event.from_dict(json.loads(line)) for line in text.splitlines() if line]
82
+ except (json.JSONDecodeError, TypeError) as e:
83
+ raise BundleInvalid(f"malformed ledger: {e}") from e
@@ -0,0 +1,94 @@
1
+ """Hash chain primitives. Pure functions, no storage.
2
+
3
+ Each event is hashed over its canonical JSON plus the previous event's hash:
4
+
5
+ hash_n = sha256( prev_hash_n || canonical_json(event_n without "hash") )
6
+
7
+ so changing, inserting, deleting or reordering any event breaks every later link.
8
+ A chain is one stream of events, typically per tenant per day ("<tenant>:<YYYY-MM-DD>").
9
+
10
+ The event fields (including `slot_id`, the event's subject key) are the v1 wire format:
11
+ renaming any of them would change every hash and invalidate existing bundles.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ from collections.abc import Collection
19
+ from dataclasses import asdict, dataclass, field
20
+ from typing import Any
21
+
22
+ GENESIS = "0" * 64
23
+
24
+
25
+ def canonical_json(obj: Any) -> str:
26
+ return json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Event:
31
+ chain_id: str
32
+ seq: int
33
+ ts: str
34
+ type: str
35
+ actor: str # "agent" | "system" | "staff"
36
+ payload: dict = field(default_factory=dict)
37
+ slot_id: str | None = None
38
+ prev_hash: str = GENESIS
39
+ hash: str = ""
40
+
41
+ def body(self) -> dict:
42
+ d = asdict(self)
43
+ d.pop("hash")
44
+ return d
45
+
46
+ def to_json(self) -> str:
47
+ return canonical_json(asdict(self))
48
+
49
+ @classmethod
50
+ def from_dict(cls, d: dict) -> Event:
51
+ return cls(**d)
52
+
53
+
54
+ def event_hash(body: dict) -> str:
55
+ return hashlib.sha256((body["prev_hash"] + canonical_json(body)).encode("utf-8")).hexdigest()
56
+
57
+
58
+ def seal(
59
+ chain_id: str,
60
+ seq: int,
61
+ ts: str,
62
+ type: str,
63
+ actor: str,
64
+ payload: dict | None = None,
65
+ slot_id: str | None = None,
66
+ prev_hash: str = GENESIS,
67
+ types: Collection[str] | None = None,
68
+ ) -> Event:
69
+ """Seal one event. `types`, when given, is the project's allow-list of event types."""
70
+ if types is not None and type not in types:
71
+ raise ValueError(f"unknown event type: {type}")
72
+ unsealed = Event(chain_id, seq, ts, type, actor, payload or {}, slot_id, prev_hash)
73
+ return Event(**{**asdict(unsealed), "hash": event_hash(unsealed.body())})
74
+
75
+
76
+ def append(events: list[Event], ts: str, type: str, actor: str, **kw) -> Event:
77
+ """In-memory append, used by tests and replays. Storage uses pg.record_and_apply."""
78
+ chain_id = kw.pop("chain_id", events[0].chain_id if events else "demo:1970-01-01")
79
+ prev = events[-1] if events else None
80
+ ev = seal(
81
+ chain_id,
82
+ (prev.seq + 1) if prev else 1,
83
+ ts,
84
+ type,
85
+ actor,
86
+ prev_hash=prev.hash if prev else GENESIS,
87
+ **kw,
88
+ )
89
+ events.append(ev)
90
+ return ev
91
+
92
+
93
+ def snapshot_hash(ordered_ids: list[str]) -> str:
94
+ return hashlib.sha256(canonical_json(ordered_ids).encode("utf-8")).hexdigest()
@@ -0,0 +1,59 @@
1
+ """`voiceledger verify <bundle.zip | ledger.jsonl> [--trusted-key HEX] [--rules FILE]`
2
+
3
+ `--rules` takes a TOML file with `rules = [...]` or a `[ledger] rules` table (so a
4
+ project's voiceops.toml works as is). Without it only `chain` and `head` are checked.
5
+
6
+ Exit code 0 when every supplied invariant holds, 1 otherwise, 2 for unreadable input.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from voiceledger.bundle import BundleInvalid, read_bundle, read_ledger_jsonl
17
+ from voiceledger.rules import RuleConfigError, load_rules_file
18
+ from voiceledger.verify import verify
19
+
20
+
21
+ def main(argv: list[str] | None = None) -> int:
22
+ parser = argparse.ArgumentParser(prog="voiceledger")
23
+ sub = parser.add_subparsers(dest="cmd", required=True)
24
+ v = sub.add_parser("verify", help="verify a bundle or a bare ledger.jsonl")
25
+ v.add_argument("path", type=Path)
26
+ v.add_argument("--trusted-key", help="trusted public key of the head signer (hex)")
27
+ v.add_argument("--rules", type=Path, help="TOML file declaring the ledger rules")
28
+ v.add_argument("--json", action="store_true")
29
+ args = parser.parse_args(argv)
30
+
31
+ try:
32
+ rules = load_rules_file(args.rules) if args.rules else []
33
+ except (OSError, RuleConfigError, ValueError) as e:
34
+ print(f"invalid rules: {e}", file=sys.stderr)
35
+ return 2
36
+ data = args.path.read_bytes()
37
+ try:
38
+ if args.path.suffix == ".jsonl":
39
+ events, snapshots, head = read_ledger_jsonl(data.decode("utf-8")), None, None
40
+ else:
41
+ events, snapshots, head = read_bundle(data)
42
+ except BundleInvalid as e:
43
+ print(f"invalid: {e}", file=sys.stderr)
44
+ return 2
45
+ report = verify(events, snapshots, head, args.trusted_key, rules)
46
+ if args.json:
47
+ print(json.dumps(report.to_dict(), indent=2))
48
+ else:
49
+ print(("PASS" if report.ok else "FAIL") + f" {report.events} events")
50
+ for name, c in report.checks.items():
51
+ where = f" at #{c.seq}" if c.seq else ""
52
+ print(f" {c.status.upper():12} {name}{where}: {c.detail}")
53
+ if not rules:
54
+ print(" (no rules configured: only chain and head were checked)")
55
+ return 0 if report.ok else 1
56
+
57
+
58
+ if __name__ == "__main__":
59
+ raise SystemExit(main())
@@ -0,0 +1,133 @@
1
+ """Postgres storage for the ledger (optional extra: `voiceledger[postgres]`).
2
+
3
+ record_and_apply() writes a state change and its ledger event in ONE transaction:
4
+
5
+ BEGIN
6
+ SELECT ... FROM ledger_heads WHERE chain_id = $1 FOR UPDATE -- only this chain waits
7
+ apply(conn) -- caller's state change
8
+ INSERT INTO ledger_events (seq = head.seq + 1, prev_hash = head.hash, ...)
9
+ UPDATE ledger_heads SET seq, hash
10
+ COMMIT
11
+
12
+ A crash anywhere rolls back both, so the ledger and the state can never disagree.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from collections.abc import Callable, Collection
19
+
20
+ from voiceledger.chain import GENESIS, Event, seal, snapshot_hash
21
+
22
+ SCHEMA_SQL = """
23
+ CREATE TABLE IF NOT EXISTS ledger_heads (
24
+ chain_id text PRIMARY KEY,
25
+ seq integer NOT NULL,
26
+ hash text NOT NULL
27
+ );
28
+ CREATE TABLE IF NOT EXISTS ledger_events (
29
+ chain_id text NOT NULL,
30
+ seq integer NOT NULL,
31
+ ts text NOT NULL,
32
+ type text NOT NULL,
33
+ actor text NOT NULL,
34
+ slot_id text,
35
+ payload jsonb NOT NULL,
36
+ prev_hash text NOT NULL,
37
+ hash text NOT NULL,
38
+ PRIMARY KEY (chain_id, seq)
39
+ );
40
+ CREATE INDEX IF NOT EXISTS ledger_events_slot ON ledger_events (chain_id, slot_id);
41
+ CREATE TABLE IF NOT EXISTS list_snapshots (
42
+ chain_id text NOT NULL,
43
+ snapshot_id text NOT NULL,
44
+ ordered_pids jsonb NOT NULL,
45
+ snapshot_hash text NOT NULL,
46
+ PRIMARY KEY (chain_id, snapshot_id)
47
+ );
48
+ CREATE TABLE IF NOT EXISTS ledger_signed_heads (
49
+ chain_id text NOT NULL,
50
+ seq integer NOT NULL,
51
+ head jsonb NOT NULL,
52
+ PRIMARY KEY (chain_id, seq)
53
+ );
54
+ """
55
+
56
+
57
+ def apply_schema(conn) -> None:
58
+ conn.execute(SCHEMA_SQL)
59
+
60
+
61
+ def record_and_apply(
62
+ conn,
63
+ chain_id: str,
64
+ ts: str,
65
+ type: str,
66
+ actor: str,
67
+ payload: dict | None = None,
68
+ slot_id: str | None = None,
69
+ apply: Callable[[object], None] | None = None,
70
+ types: Collection[str] | None = None,
71
+ ) -> Event:
72
+ """Append one event and run `apply(conn)` atomically. Raises whatever apply raises.
73
+
74
+ `types`, when given, is the project's allow-list of event types (see chain.seal).
75
+ """
76
+ with conn.transaction():
77
+ conn.execute(
78
+ "INSERT INTO ledger_heads (chain_id, seq, hash) VALUES (%s, 0, %s)"
79
+ " ON CONFLICT (chain_id) DO NOTHING",
80
+ (chain_id, GENESIS),
81
+ )
82
+ seq, prev_hash = conn.execute(
83
+ "SELECT seq, hash FROM ledger_heads WHERE chain_id = %s FOR UPDATE", (chain_id,)
84
+ ).fetchone()
85
+ if apply is not None:
86
+ apply(conn)
87
+ ev = seal(chain_id, seq + 1, ts, type, actor, payload, slot_id, prev_hash, types)
88
+ conn.execute(
89
+ "INSERT INTO ledger_events (chain_id, seq, ts, type, actor, slot_id, payload,"
90
+ " prev_hash, hash) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
91
+ (
92
+ ev.chain_id,
93
+ ev.seq,
94
+ ev.ts,
95
+ ev.type,
96
+ ev.actor,
97
+ ev.slot_id,
98
+ json.dumps(ev.payload),
99
+ ev.prev_hash,
100
+ ev.hash,
101
+ ),
102
+ )
103
+ conn.execute(
104
+ "UPDATE ledger_heads SET seq = %s, hash = %s WHERE chain_id = %s",
105
+ (ev.seq, ev.hash, chain_id),
106
+ )
107
+ return ev
108
+
109
+
110
+ def save_snapshot(conn, chain_id: str, snapshot_id: str, ordered_pids: list[str]) -> str:
111
+ h = snapshot_hash(ordered_pids)
112
+ conn.execute(
113
+ "INSERT INTO list_snapshots (chain_id, snapshot_id, ordered_pids, snapshot_hash)"
114
+ " VALUES (%s, %s, %s, %s)",
115
+ (chain_id, snapshot_id, json.dumps(ordered_pids), h),
116
+ )
117
+ return h
118
+
119
+
120
+ def load_events(conn, chain_id: str) -> list[Event]:
121
+ rows = conn.execute(
122
+ "SELECT chain_id, seq, ts, type, actor, payload, slot_id, prev_hash, hash"
123
+ " FROM ledger_events WHERE chain_id = %s ORDER BY seq",
124
+ (chain_id,),
125
+ ).fetchall()
126
+ return [Event(*r) for r in rows]
127
+
128
+
129
+ def load_snapshots(conn, chain_id: str) -> dict[str, list[str]]:
130
+ rows = conn.execute(
131
+ "SELECT snapshot_id, ordered_pids FROM list_snapshots WHERE chain_id = %s", (chain_id,)
132
+ ).fetchall()
133
+ return {sid: list(pids) for sid, pids in rows}
@@ -0,0 +1,33 @@
1
+ """Check results and the verification report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class Check:
10
+ status: str = "pass" # pass | fail | not_checked
11
+ detail: str = ""
12
+ seq: int | None = None
13
+
14
+ def fail(self, seq: int | None, detail: str) -> None:
15
+ if self.status != "fail": # keep the first failure
16
+ self.status, self.seq, self.detail = "fail", seq, detail
17
+
18
+
19
+ @dataclass
20
+ class Report:
21
+ events: int
22
+ checks: dict[str, Check] = field(default_factory=dict)
23
+
24
+ @property
25
+ def ok(self) -> bool:
26
+ return all(c.status != "fail" for c in self.checks.values())
27
+
28
+ def to_dict(self) -> dict:
29
+ return {
30
+ "ok": self.ok,
31
+ "events": self.events,
32
+ "checks": {k: vars(v) for k, v in self.checks.items()},
33
+ }