verityledger 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,24 @@
1
+ from .chain import Entry, verify_chain
2
+ from .core import Session, Tracer
3
+ from .exceptions import (
4
+ ChainIntegrityError,
5
+ SessionNotFoundError,
6
+ StorageError,
7
+ VerityLedgerError,
8
+ )
9
+ from .storage import LocalStore, Store
10
+
11
+ __all__ = [
12
+ "Tracer",
13
+ "Session",
14
+ "Entry",
15
+ "verify_chain",
16
+ "Store",
17
+ "LocalStore",
18
+ "VerityLedgerError",
19
+ "StorageError",
20
+ "ChainIntegrityError",
21
+ "SessionNotFoundError",
22
+ ]
23
+
24
+ __version__ = "0.1.0"
verityledger/chain.py ADDED
@@ -0,0 +1,107 @@
1
+ """
2
+ Hash-chained, tamper-evident log entries.
3
+
4
+ Each entry includes a hash of the previous entry, forming a chain.
5
+ Any modification to a past entry breaks the chain for everything after it,
6
+ making tampering detectable without needing a blockchain or external service.
7
+
8
+ This module has zero dependencies on storage or the public API — it is the
9
+ single source of truth for what an "entry" is and how the chain is verified.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import time
17
+ import uuid
18
+ from dataclasses import asdict, dataclass, field
19
+ from typing import Any
20
+
21
+ GENESIS_HASH = "0" * 64
22
+
23
+
24
+ def _canonical_json(data: dict[str, Any]) -> str:
25
+ """Serialize a dict deterministically so hashes are reproducible."""
26
+ return json.dumps(data, sort_keys=True, separators=(",", ":"), default=str)
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Entry:
31
+ """A single chained log entry."""
32
+
33
+ id: str
34
+ session_id: str
35
+ timestamp: float
36
+ event_type: str
37
+ data: dict[str, Any]
38
+ previous_hash: str
39
+ hash: str = field(default="")
40
+
41
+ def to_dict(self) -> dict[str, Any]:
42
+ return asdict(self)
43
+
44
+ @classmethod
45
+ def from_dict(cls, raw: dict[str, Any]) -> Entry:
46
+ return cls(**raw)
47
+
48
+ @property
49
+ def content_hash(self) -> str:
50
+ """Hash of this entry's content, excluding the stored `hash` field."""
51
+ content = {k: v for k, v in self.to_dict().items() if k != "hash"}
52
+ return hashlib.sha256(_canonical_json(content).encode("utf-8")).hexdigest()
53
+
54
+
55
+ def make_entry(
56
+ session_id: str,
57
+ event_type: str,
58
+ data: dict[str, Any],
59
+ previous_hash: str,
60
+ ) -> Entry:
61
+ """
62
+ Build a single chained log entry, with its hash computed and set.
63
+
64
+ Args:
65
+ session_id: identifier for the agent run / conversation this belongs to.
66
+ event_type: e.g. "tool_call", "model_call", "decision".
67
+ data: arbitrary JSON-serializable payload for this event.
68
+ previous_hash: hash of the prior entry in this session's chain
69
+ (use GENESIS_HASH for the first entry).
70
+
71
+ Returns:
72
+ A completed, hashed Entry.
73
+ """
74
+ entry = Entry(
75
+ id=str(uuid.uuid4()),
76
+ session_id=session_id,
77
+ timestamp=time.time(),
78
+ event_type=event_type,
79
+ data=data,
80
+ previous_hash=previous_hash,
81
+ hash="",
82
+ )
83
+ return Entry(**{**entry.to_dict(), "hash": entry.content_hash})
84
+
85
+
86
+ def verify_chain(entries: list[Entry]) -> tuple[bool, int | None]:
87
+ """
88
+ Verify a list of entries forms an unbroken, untampered hash chain.
89
+
90
+ The list is assumed to be in chronological order for a single session.
91
+ An empty list is considered valid (vacuously).
92
+
93
+ Returns:
94
+ (is_valid, index_of_first_break_or_none)
95
+ """
96
+ expected_previous = GENESIS_HASH
97
+
98
+ for i, entry in enumerate(entries):
99
+ if entry.previous_hash != expected_previous:
100
+ return False, i
101
+
102
+ if entry.content_hash != entry.hash:
103
+ return False, i
104
+
105
+ expected_previous = entry.hash
106
+
107
+ return True, None
@@ -0,0 +1,3 @@
1
+ from .__main__ import main
2
+
3
+ __all__ = ["main"]
@@ -0,0 +1,134 @@
1
+ """
2
+ VerityLedger CLI.
3
+
4
+ Lets you inspect, verify, and export logs from the terminal without
5
+ writing any Python. Built entirely on top of the public `verityledger`
6
+ API (Tracer/LocalStore) - the CLI has no logic of its own beyond
7
+ argument parsing and formatting.
8
+
9
+ Usage:
10
+ verityledger sessions [--log PATH]
11
+ verityledger show SESSION_ID [--log PATH]
12
+ verityledger verify SESSION_ID [--log PATH]
13
+ verityledger export SESSION_ID OUT_PATH [--log PATH]
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import sys
20
+ from datetime import datetime, timezone
21
+
22
+ from ..core import Tracer
23
+ from ..exceptions import ChainIntegrityError, SessionNotFoundError, VerityLedgerError
24
+
25
+ DEFAULT_LOG_PATH = "./verityledger_log.jsonl"
26
+
27
+
28
+ def _build_parser() -> argparse.ArgumentParser:
29
+ parser = argparse.ArgumentParser(
30
+ prog="verityledger",
31
+ description="Inspect, verify, and export VerityLedger agent logs.",
32
+ )
33
+ parser.add_argument(
34
+ "--log",
35
+ default=DEFAULT_LOG_PATH,
36
+ help=f"Path to the log file (default: {DEFAULT_LOG_PATH})",
37
+ )
38
+
39
+ subparsers = parser.add_subparsers(dest="command", required=True)
40
+
41
+ subparsers.add_parser("sessions", help="List all session ids in the log.")
42
+
43
+ show = subparsers.add_parser("show", help="Print all entries for a session.")
44
+ show.add_argument("session_id")
45
+
46
+ verify = subparsers.add_parser("verify", help="Check a session's hash chain for tampering.")
47
+ verify.add_argument("session_id")
48
+
49
+ export = subparsers.add_parser("export", help="Export a session as a JSON audit report.")
50
+ export.add_argument("session_id")
51
+ export.add_argument("out_path")
52
+
53
+ return parser
54
+
55
+
56
+ def _format_timestamp(ts: float) -> str:
57
+ return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
58
+
59
+
60
+ def cmd_sessions(tracer: Tracer) -> int:
61
+ sessions = tracer.store.all_sessions()
62
+ if not sessions:
63
+ print("No sessions found.")
64
+ return 0
65
+ for sid in sessions:
66
+ count = len(tracer.store.get_session(sid))
67
+ print(f"{sid} ({count} entries)")
68
+ return 0
69
+
70
+
71
+ def cmd_show(tracer: Tracer, session_id: str) -> int:
72
+ entries = tracer.store.get_session(session_id)
73
+ if not entries:
74
+ print(f"No entries found for session '{session_id}'.", file=sys.stderr)
75
+ return 1
76
+ for entry in entries:
77
+ print(f"[{_format_timestamp(entry.timestamp)}] {entry.event_type}")
78
+ for key, value in entry.data.items():
79
+ print(f" {key}: {value}")
80
+ print(f" hash: {entry.hash[:12]}...")
81
+ return 0
82
+
83
+
84
+ def cmd_verify(tracer: Tracer, session_id: str) -> int:
85
+ try:
86
+ tracer.assert_valid(session_id)
87
+ except SessionNotFoundError as exc:
88
+ print(str(exc), file=sys.stderr)
89
+ return 1
90
+ except ChainIntegrityError as exc:
91
+ print(f"TAMPERING DETECTED: {exc}", file=sys.stderr)
92
+ return 1
93
+
94
+ entry_count = len(tracer.store.get_session(session_id))
95
+ print(f"Chain valid: {entry_count} entries, no tampering detected.")
96
+ return 0
97
+
98
+
99
+ def cmd_export(tracer: Tracer, session_id: str, out_path: str) -> int:
100
+ try:
101
+ report = tracer.export_report(session_id, out_path)
102
+ except SessionNotFoundError as exc:
103
+ print(str(exc), file=sys.stderr)
104
+ return 1
105
+
106
+ status = "valid" if report["chain_valid"] else "TAMPERED"
107
+ print(f"Exported {report['entry_count']} entries to {out_path} (chain: {status}).")
108
+ return 0
109
+
110
+
111
+ def main(argv: list[str] | None = None) -> int:
112
+ parser = _build_parser()
113
+ args = parser.parse_args(argv)
114
+ tracer = Tracer(log_path=args.log)
115
+
116
+ try:
117
+ if args.command == "sessions":
118
+ return cmd_sessions(tracer)
119
+ if args.command == "show":
120
+ return cmd_show(tracer, args.session_id)
121
+ if args.command == "verify":
122
+ return cmd_verify(tracer, args.session_id)
123
+ if args.command == "export":
124
+ return cmd_export(tracer, args.session_id, args.out_path)
125
+ except VerityLedgerError as exc:
126
+ print(f"Error: {exc}", file=sys.stderr)
127
+ return 1
128
+
129
+ parser.print_help()
130
+ return 1
131
+
132
+
133
+ if __name__ == "__main__":
134
+ sys.exit(main())
verityledger/core.py ADDED
@@ -0,0 +1,204 @@
1
+ """
2
+ VerityLedger — public API.
3
+
4
+ Quickstart:
5
+
6
+ from verityledger import Tracer
7
+
8
+ tracer = Tracer() # writes to ./verityledger_log.jsonl by default
9
+
10
+ with tracer.session(agent="weather-bot") as session:
11
+
12
+ @session.trace_tool
13
+ def get_weather(city: str) -> dict:
14
+ return {"city": city, "forecast": "sunny"}
15
+
16
+ result = get_weather("Mumbai")
17
+
18
+ session.log_decision(
19
+ "no umbrella needed",
20
+ reasoning="forecast is sunny",
21
+ )
22
+
23
+ tracer.export_report(session.id, "report.json")
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import functools
29
+ import json
30
+ import logging
31
+ import time
32
+ import uuid
33
+ from collections.abc import Callable, Iterator
34
+ from contextlib import contextmanager
35
+ from typing import Any, TypeVar
36
+
37
+ from .chain import Entry, make_entry, verify_chain
38
+ from .exceptions import ChainIntegrityError, SessionNotFoundError
39
+ from .storage import LocalStore, Store
40
+
41
+ logger = logging.getLogger("verityledger")
42
+
43
+ F = TypeVar("F", bound=Callable[..., Any])
44
+
45
+
46
+ class Session:
47
+ """A single traced run (e.g. one agent conversation or job)."""
48
+
49
+ def __init__(self, tracer: Tracer, session_id: str, metadata: dict[str, Any] | None = None):
50
+ self._tracer = tracer
51
+ self.id = session_id
52
+ if metadata:
53
+ self.log_event("session_start", metadata)
54
+
55
+ def log_event(self, event_type: str, data: dict[str, Any]) -> Entry:
56
+ """Log a raw event to this session's chain. Returns the stored entry."""
57
+ store = self._tracer.store
58
+ previous_hash = store.latest_hash(self.id)
59
+ entry = make_entry(self.id, event_type, data, previous_hash)
60
+ store.append(entry)
61
+ logger.debug("logged %s entry for session %s", event_type, self.id)
62
+ return entry
63
+
64
+ def log_decision(self, decision: str, reasoning: str | None = None, **extra: Any) -> Entry:
65
+ """Record a decision an agent made, with optional reasoning/context."""
66
+ data: dict[str, Any] = {"decision": decision}
67
+ if reasoning is not None:
68
+ data["reasoning"] = reasoning
69
+ data.update(extra)
70
+ return self.log_event("decision", data)
71
+
72
+ def log_model_call(
73
+ self, prompt: Any, response: Any, model: str | None = None, **extra: Any
74
+ ) -> Entry:
75
+ """Record an LLM call's input and output."""
76
+ data: dict[str, Any] = {"prompt": prompt, "response": response}
77
+ if model:
78
+ data["model"] = model
79
+ data.update(extra)
80
+ return self.log_event("model_call", data)
81
+
82
+ def trace_tool(self, func: F) -> F:
83
+ """
84
+ Decorator: wraps a tool function so each call (args, result, timing,
85
+ and any exception) is recorded as a tool_call entry.
86
+ """
87
+
88
+ @functools.wraps(func)
89
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
90
+ started = time.monotonic()
91
+ try:
92
+ result = func(*args, **kwargs)
93
+ self.log_event(
94
+ "tool_call",
95
+ {
96
+ "tool": func.__name__,
97
+ "args": _safe(args),
98
+ "kwargs": _safe(kwargs),
99
+ "result": _safe(result),
100
+ "duration_seconds": time.monotonic() - started,
101
+ "status": "success",
102
+ },
103
+ )
104
+ return result
105
+ except Exception as exc:
106
+ self.log_event(
107
+ "tool_call",
108
+ {
109
+ "tool": func.__name__,
110
+ "args": _safe(args),
111
+ "kwargs": _safe(kwargs),
112
+ "error": str(exc),
113
+ "error_type": type(exc).__name__,
114
+ "duration_seconds": time.monotonic() - started,
115
+ "status": "error",
116
+ },
117
+ )
118
+ raise
119
+
120
+ return wrapper # type: ignore[return-value]
121
+
122
+
123
+ class Tracer:
124
+ """Top-level entry point. Holds the storage backend and creates sessions."""
125
+
126
+ def __init__(self, store: Store | None = None, log_path: str = "./verityledger_log.jsonl"):
127
+ self.store: Store = store or LocalStore(log_path)
128
+
129
+ @contextmanager
130
+ def session(self, session_id: str | None = None, **metadata: Any) -> Iterator[Session]:
131
+ """
132
+ Context manager that yields a Session with a unique id (auto-generated
133
+ if not provided). Any metadata kwargs are recorded as a session_start event.
134
+ """
135
+ sid = session_id or str(uuid.uuid4())
136
+ session = Session(self, sid, metadata=metadata or None)
137
+ try:
138
+ yield session
139
+ finally:
140
+ session.log_event("session_end", {})
141
+
142
+ def verify(self, session_id: str) -> tuple[bool, int | None]:
143
+ """Verify the hash chain for a session is intact (no tampering)."""
144
+ entries = self.store.get_session(session_id)
145
+ return verify_chain(entries)
146
+
147
+ def assert_valid(self, session_id: str) -> None:
148
+ """
149
+ Like verify(), but raises instead of returning a tuple.
150
+
151
+ Raises:
152
+ SessionNotFoundError: if the session has no entries.
153
+ ChainIntegrityError: if the chain is broken or tampered with.
154
+ """
155
+ entries = self.store.get_session(session_id)
156
+ if not entries:
157
+ raise SessionNotFoundError(session_id)
158
+ valid, break_index = verify_chain(entries)
159
+ if not valid:
160
+ assert break_index is not None
161
+ raise ChainIntegrityError(session_id, break_index)
162
+
163
+ def export_report(self, session_id: str, path: str) -> dict[str, Any]:
164
+ """
165
+ Export a JSON audit report for a session: full entry list plus
166
+ a verification result. Returns the report dict and writes it to `path`.
167
+
168
+ Raises:
169
+ SessionNotFoundError: if the session has no entries.
170
+ """
171
+ entries = self.store.get_session(session_id)
172
+ if not entries:
173
+ raise SessionNotFoundError(session_id)
174
+
175
+ valid, break_index = verify_chain(entries)
176
+ report = {
177
+ "session_id": session_id,
178
+ "entry_count": len(entries),
179
+ "chain_valid": valid,
180
+ "chain_break_index": break_index,
181
+ "entries": [e.to_dict() for e in entries],
182
+ }
183
+ with open(path, "w", encoding="utf-8") as f:
184
+ json.dump(report, f, indent=2, default=str)
185
+ return report
186
+
187
+
188
+ def _safe(value: Any) -> Any:
189
+ """
190
+ Best-effort JSON-safe conversion for log payloads.
191
+
192
+ Recurses into lists/tuples/dicts so that a single non-serializable
193
+ object (e.g. a custom class instance) doesn't make the whole
194
+ container fall back to a single opaque repr string.
195
+ """
196
+ if isinstance(value, (list, tuple)):
197
+ return [_safe(v) for v in value]
198
+ if isinstance(value, dict):
199
+ return {str(k): _safe(v) for k, v in value.items()}
200
+ try:
201
+ json.dumps(value)
202
+ return value
203
+ except TypeError:
204
+ return repr(value)
@@ -0,0 +1,34 @@
1
+ """
2
+ VerityLedger exception hierarchy.
3
+
4
+ All exceptions raised by this library inherit from VerityLedgerError, so
5
+ callers can catch broadly (`except VerityLedgerError`) or narrowly.
6
+ """
7
+
8
+
9
+ class VerityLedgerError(Exception):
10
+ """Base class for all VerityLedger exceptions."""
11
+
12
+
13
+ class StorageError(VerityLedgerError):
14
+ """Raised when a storage backend fails to read or write entries."""
15
+
16
+
17
+ class ChainIntegrityError(VerityLedgerError):
18
+ """Raised when a hash chain is found to be broken or tampered with."""
19
+
20
+ def __init__(self, session_id: str, break_index: int):
21
+ self.session_id = session_id
22
+ self.break_index = break_index
23
+ super().__init__(
24
+ f"Hash chain for session '{session_id}' is broken at entry "
25
+ f"index {break_index}."
26
+ )
27
+
28
+
29
+ class SessionNotFoundError(VerityLedgerError):
30
+ """Raised when a requested session has no entries in storage."""
31
+
32
+ def __init__(self, session_id: str):
33
+ self.session_id = session_id
34
+ super().__init__(f"No entries found for session '{session_id}'.")
verityledger/py.typed ADDED
File without changes
@@ -0,0 +1,4 @@
1
+ from .base import Store
2
+ from .local import LocalStore
3
+
4
+ __all__ = ["Store", "LocalStore"]
@@ -0,0 +1,45 @@
1
+ """
2
+ Storage interface.
3
+
4
+ Any backend (local file, SQLite, Postgres, hosted API) implements this
5
+ protocol. The rest of the library only depends on this interface, never
6
+ on a concrete backend - that's what lets a hosted "VerityLedger Cloud" store
7
+ be dropped in later with zero changes to chain.py or core.py.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from abc import ABC, abstractmethod
13
+
14
+ from ..chain import GENESIS_HASH, Entry
15
+
16
+
17
+ class Store(ABC):
18
+ """Abstract base class for VerityLedger storage backends."""
19
+
20
+ @abstractmethod
21
+ def append(self, entry: Entry) -> None:
22
+ """Persist a single entry. Must preserve insertion order."""
23
+
24
+ @abstractmethod
25
+ def get_session(self, session_id: str) -> list[Entry]:
26
+ """Return all entries for a session, in the order they were written."""
27
+
28
+ @abstractmethod
29
+ def all_sessions(self) -> list[str]:
30
+ """Return distinct session ids, in first-seen order."""
31
+
32
+ @abstractmethod
33
+ def all_entries(self) -> list[Entry]:
34
+ """Return every entry across all sessions, in insertion order."""
35
+
36
+ def latest_hash(self, session_id: str) -> str:
37
+ """
38
+ Return the hash of the most recent entry for a session, or
39
+ GENESIS_HASH if the session has no entries yet.
40
+
41
+ Backends may override this with a more efficient implementation
42
+ (e.g. an indexed query) - the default just scans get_session().
43
+ """
44
+ entries = self.get_session(session_id)
45
+ return entries[-1].hash if entries else GENESIS_HASH
@@ -0,0 +1,73 @@
1
+ """
2
+ Local, file-based storage backend.
3
+
4
+ Free-tier default: an append-only JSONL file. No database required.
5
+ Each line is one Entry, serialized as JSON. Append is O(1); reads scan
6
+ the file, which is fine for the local/single-developer use case this
7
+ backend targets. A SQLite or Postgres backend (for the hosted product)
8
+ can implement the same Store interface with indexed lookups.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ from collections.abc import Iterator
16
+ from pathlib import Path
17
+
18
+ from ..chain import Entry
19
+ from ..exceptions import StorageError
20
+ from .base import Store
21
+
22
+
23
+ class LocalStore(Store):
24
+ """Append-only JSONL store, one file per project/log."""
25
+
26
+ def __init__(self, path: str | os.PathLike[str] = "./verityledger_log.jsonl"):
27
+ self.path = Path(path)
28
+ self.path.parent.mkdir(parents=True, exist_ok=True)
29
+ if not self.path.exists():
30
+ self.path.touch()
31
+
32
+ def append(self, entry: Entry) -> None:
33
+ try:
34
+ serialized = json.dumps(entry.to_dict(), default=str)
35
+ except TypeError as exc:
36
+ raise StorageError(f"Entry is not JSON-serializable: {exc}") from exc
37
+
38
+ try:
39
+ with open(self.path, "a", encoding="utf-8") as f:
40
+ f.write(serialized + "\n")
41
+ except OSError as exc:
42
+ raise StorageError(f"Failed to write to {self.path}: {exc}") from exc
43
+
44
+ def get_session(self, session_id: str) -> list[Entry]:
45
+ return [e for e in self._read_all() if e.session_id == session_id]
46
+
47
+ def all_sessions(self) -> list[str]:
48
+ seen: list[str] = []
49
+ for entry in self._read_all():
50
+ if entry.session_id not in seen:
51
+ seen.append(entry.session_id)
52
+ return seen
53
+
54
+ def all_entries(self) -> list[Entry]:
55
+ return list(self._read_all())
56
+
57
+ def _read_all(self) -> Iterator[Entry]:
58
+ if not self.path.exists():
59
+ return
60
+ try:
61
+ with open(self.path, encoding="utf-8") as f:
62
+ for line_number, line in enumerate(f, start=1):
63
+ line = line.strip()
64
+ if not line:
65
+ continue
66
+ try:
67
+ yield Entry.from_dict(json.loads(line))
68
+ except (json.JSONDecodeError, TypeError) as exc:
69
+ raise StorageError(
70
+ f"Corrupt entry at {self.path}:{line_number}: {exc}"
71
+ ) from exc
72
+ except OSError as exc:
73
+ raise StorageError(f"Failed to read {self.path}: {exc}") from exc
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: verityledger
3
+ Version: 0.1.0
4
+ Summary: Tamper-evident decision and tool-call logging for AI agents.
5
+ Author: VerityLedger
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/verityledger/verityledger
8
+ Project-URL: Issues, https://github.com/verityledger/verityledger/issues
9
+ Keywords: ai,agents,audit,logging,llm,observability
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8.0; extra == "dev"
20
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
21
+ Requires-Dist: ruff>=0.6; extra == "dev"
22
+ Requires-Dist: mypy>=1.10; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # VerityLedger
26
+
27
+ **Know exactly what your AI agent did, and prove nothing was changed after the fact.**
28
+
29
+ VerityLedger is a small Python library that wraps your agent's tool calls and
30
+ decisions in a tamper-evident, hash-chained log. When something goes wrong —
31
+ a bad refund, a broken deploy, a strange customer reply — you can pull up the
32
+ exact sequence of tool calls, model inputs/outputs, and reasoning that led
33
+ there, and prove the record hasn't been altered.
34
+
35
+ No database. No external service. No blockchain. Just an append-only file
36
+ and SHA-256.
37
+
38
+ ```python
39
+ from verityledger import Tracer
40
+
41
+ tracer = Tracer() # writes to ./verityledger_log.jsonl
42
+
43
+ with tracer.session(agent="support-bot", user="user_123") as session:
44
+
45
+ @session.trace_tool
46
+ def issue_refund(order_id: str, amount: float) -> str:
47
+ return f"refunded {amount} for {order_id}"
48
+
49
+ issue_refund("ORD-4471", 42.00)
50
+
51
+ session.log_decision(
52
+ "approved refund",
53
+ reasoning="customer reported damaged item, photo provided, within policy",
54
+ )
55
+ ```
56
+
57
+ Every call to `issue_refund`, every `log_decision`, and any model calls you
58
+ log are written as chained entries — each one includes a hash of the
59
+ previous entry. If anyone edits a past entry, the chain breaks at exactly
60
+ that point.
61
+
62
+ ## Why this exists
63
+
64
+ Agents are making real decisions — refunds, emails, code pushes, customer
65
+ replies — and most teams have no record of *why* beyond scattered print
66
+ statements and provider dashboards. When a regulator, a customer, or your
67
+ own team asks "why did the bot do that?", you want an answer that's both
68
+ complete and verifiable.
69
+
70
+ ## Install
71
+
72
+ ```bash
73
+ pip install verityledger
74
+ ```
75
+
76
+ ## Verify the chain
77
+
78
+ ```python
79
+ valid, break_index = tracer.verify(session.id)
80
+ # valid == True, break_index == None (until someone tampers with the log)
81
+
82
+ # Or raise on problems:
83
+ tracer.assert_valid(session.id)
84
+ # raises ChainIntegrityError or SessionNotFoundError
85
+ ```
86
+
87
+ ## Export an audit report
88
+
89
+ ```python
90
+ tracer.export_report(session.id, "incident_report.json")
91
+ ```
92
+
93
+ Produces a single JSON file with every entry for that session, plus the
94
+ verification result — ready to attach to an incident review or compliance
95
+ request.
96
+
97
+ ## CLI
98
+
99
+ Installing the package also installs a `verityledger` command:
100
+
101
+ ```bash
102
+ verityledger sessions # list session ids in the log
103
+ verityledger show <session_id> # print all entries for a session
104
+ verityledger verify <session_id> # check the hash chain for tampering
105
+ verityledger export <session_id> report.json # write a JSON audit report
106
+ ```
107
+
108
+ All commands accept `--log PATH` to point at a specific log file
109
+ (default: `./verityledger_log.jsonl`).
110
+
111
+ ## Architecture
112
+
113
+ The library is layered so each piece can be tested and replaced independently:
114
+
115
+ - **`chain.py`** — the cryptographic primitive. Defines `Entry`, hashing,
116
+ and `verify_chain`. No I/O, no dependencies.
117
+ - **`storage/`** — the `Store` interface plus `LocalStore` (append-only
118
+ JSONL). A hosted backend (SQLite/Postgres/API) implements the same
119
+ interface and drops in without touching anything above it.
120
+ - **`core.py`** — the public API: `Tracer` and `Session`.
121
+ - **`cli/`** — the `verityledger` terminal command. Built entirely on top of
122
+ the public API above; no logic of its own beyond argument parsing
123
+ and output formatting.
124
+ - **`exceptions.py`** — shared error types (`StorageError`,
125
+ `ChainIntegrityError`, `SessionNotFoundError`).
126
+
127
+ ## Development
128
+
129
+ ```bash
130
+ pip install -e ".[dev]"
131
+ ruff check . # lint
132
+ mypy src/verityledger # strict type check
133
+ pytest # tests + coverage
134
+ ```
135
+
136
+ ## Status
137
+
138
+ Early release. The local file-based logger (above) is free and open source
139
+ (MIT) — your data never leaves your machine. A hosted dashboard for
140
+ searching across sessions, team access, and longer retention is in
141
+ development.
142
+
143
+ ## Roadmap
144
+
145
+ - [x] Hash-chained local logging (Python)
146
+ - [x] Tool-call decorator, decision logging, model-call logging
147
+ - [x] Tamper detection / chain verification
148
+ - [x] Audit report export
149
+ - [x] Full test suite, type checking, CI
150
+ - [x] CLI (`verityledger sessions/show/verify/export`)
151
+ - [ ] JavaScript/TypeScript SDK
152
+ - [ ] Hosted dashboard (search, team accounts, retention policies)
153
+ - [ ] LangChain / OpenAI / Anthropic tool-use integrations
154
+ - [ ] Remote ingestion endpoint (send logs to VerityLedger Cloud)
155
+
156
+ ## License
157
+
158
+ MIT
@@ -0,0 +1,16 @@
1
+ verityledger/__init__.py,sha256=KQuI912Dk5IhkwNDdIlzdF3GBgMlzwQDjtCK3UX0HZg,463
2
+ verityledger/chain.py,sha256=wyWCBmT6LIE1bOdrBThVN5kqRTnB-1_-goxzjM8gRYY,3050
3
+ verityledger/core.py,sha256=w3BB2XPNbKVGIpOUn8puwNXOfvJzE1S--MGji7Zmu4k,7057
4
+ verityledger/exceptions.py,sha256=DoQAjBi4tqKaMTAiw4JmmCGN-XDVdqEC_M90RhZXWNY,1064
5
+ verityledger/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ verityledger/cli/__init__.py,sha256=9jRVm2gCiDxGHfum8z9JBVNCkzjdCHJ0OApdD8C7SiM,47
7
+ verityledger/cli/__main__.py,sha256=OWS5h2rTpemLF2qun-zo9wgxPxeoZXyge5mv3OZ_Pnw,4160
8
+ verityledger/storage/__init__.py,sha256=IlbXaXOsLtaHpj5v0IcweG9zVrjGRsezk-DyUTAl6to,89
9
+ verityledger/storage/base.py,sha256=aNYv1PU56AwRZANfDx8ZbMx51SrXGYWY1AnKvsUA4Ds,1518
10
+ verityledger/storage/local.py,sha256=OlYdTfBtqlacQqC_jTe8r492qZAsZVWWyWMvOLKvXts,2596
11
+ verityledger-0.1.0.dist-info/licenses/LICENSE,sha256=tiNRXiYK2XsJ05BBIbDCBgsEdlgv-fl1a4A75WNDU0A,1066
12
+ verityledger-0.1.0.dist-info/METADATA,sha256=KdN8jaOT00pu6cIIEJV2h9PDupwF9qG2sioYkhvmrmo,5444
13
+ verityledger-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
14
+ verityledger-0.1.0.dist-info/entry_points.txt,sha256=nI_PhtYtmXKQMHw_ysBwTWnE8RfNbS2uOzbYOPCP8WQ,55
15
+ verityledger-0.1.0.dist-info/top_level.txt,sha256=eU62LohLIZrqD0Wc52-2rIWYzlJbNZ10pGKvubfgocg,13
16
+ verityledger-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ verityledger = verityledger.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tracewell
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 @@
1
+ verityledger