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.
- voiceledger-0.1.3/.gitignore +11 -0
- voiceledger-0.1.3/LICENSE +21 -0
- voiceledger-0.1.3/PKG-INFO +41 -0
- voiceledger-0.1.3/README.md +26 -0
- voiceledger-0.1.3/pyproject.toml +24 -0
- voiceledger-0.1.3/src/voiceledger/__init__.py +51 -0
- voiceledger-0.1.3/src/voiceledger/bundle.py +83 -0
- voiceledger-0.1.3/src/voiceledger/chain.py +94 -0
- voiceledger-0.1.3/src/voiceledger/cli.py +59 -0
- voiceledger-0.1.3/src/voiceledger/pg.py +133 -0
- voiceledger-0.1.3/src/voiceledger/result.py +33 -0
- voiceledger-0.1.3/src/voiceledger/rules.py +330 -0
- voiceledger-0.1.3/src/voiceledger/sign.py +94 -0
- voiceledger-0.1.3/src/voiceledger/verify.py +58 -0
- voiceledger-0.1.3/tests/fixtures/showcase-2026-10-16.zip +0 -0
- voiceledger-0.1.3/tests/fixtures/slotback_rules.toml +28 -0
- voiceledger-0.1.3/tests/fixtures/trusted_key.txt +1 -0
- voiceledger-0.1.3/tests/test_ledger_pg.py +78 -0
- voiceledger-0.1.3/tests/test_rules.py +203 -0
- voiceledger-0.1.3/tests/test_showcase.py +78 -0
- voiceledger-0.1.3/tests/test_verify.py +307 -0
|
@@ -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
|
+
}
|