powerailabs-acttrace 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ .uv/
8
+ .ruff_cache/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .coverage
12
+ htmlcov/
13
+ .idea/
14
+ .vscode/
15
+ .DS_Store
16
+ *.log
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: powerailabs-acttrace
3
+ Version: 0.1.0
4
+ Summary: Audit: a tamper-evident, hash-chained, auto-populated record of every AI decision โ€” verifiable offline. Evidence, not a compliance guarantee.
5
+ Author: Raghav Mishra
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: powerailabs-core<0.2,>=0.1
9
+ Description-Content-Type: text/markdown
10
+
11
+ # powerailabs-acttrace
12
+
13
+ A tamper-evident, append-only record of every AI decision โ€” what model, what context, what it
14
+ cost, which tools, and who signed off โ€” mapped to control templates and exportable as an evidence
15
+ pack. No database, no infra: integrity comes from a hash chain, not a server.
16
+
17
+ **Audit-ready evidence in 5 lines โ€” and verifiable offline.**
18
+
19
+ ![status](https://img.shields.io/badge/status-building-yellow) ![license](https://img.shields.io/badge/license-MIT-blue)
20
+
21
+ ๐Ÿšง building (v0) ยท `pip install powerailabs-acttrace` ยท `from powerailabs.acttrace import AuditLog`
22
+
23
+ ```python
24
+ from powerailabs.core import instrument
25
+ from powerailabs.acttrace import AuditLog
26
+
27
+ client = instrument(OpenAI())
28
+ audit = AuditLog(system="loan_triage", risk_tier="high") # auto-subscribes to core events
29
+
30
+ with audit.decision(input=application, actor="agent") as d:
31
+ resp = client.chat.completions.create(model="gpt-4o", messages=msgs) # auto-logged
32
+ d.record(model="gpt-4o", prompt_id="triage@v3") # cost/context captured for free
33
+ d.human_oversight(reviewer="ops@bank", action="approved")
34
+
35
+ audit.export("evidence_q3.jsonl", framework="eu_ai_act") # evidence pack
36
+ ```
37
+
38
+ ```bash
39
+ acttrace verify evidence_q3.jsonl # re-walks the hash chain; exits non-zero if broken
40
+ ```
41
+
42
+ **Wrap-around, auto-subscribing.** Because cost (`tokenguard`) and context decisions (`contextkit`)
43
+ ride core's shared event stream, the log fills itself โ€” install the stack, call `instrument()`
44
+ once, and the record already knows the model, the context, the cost, and the tools. Each entry is
45
+ chained (`hash = sha256(prev + canonical(entry))`), so any later edit breaks the chain.
46
+
47
+ > `acttrace` produces **evidence to support** compliance โ€” it is **not** legal advice and not a
48
+ > compliance guarantee. Control mappings are a starting template for your compliance team.
49
+
50
+ See [`docs/acttrace.md`](../../docs/acttrace.md). *Part of the PowerAI Labs stack โ€” github.com/PowerAI-Labs/powerailabs.*
@@ -0,0 +1,40 @@
1
+ # powerailabs-acttrace
2
+
3
+ A tamper-evident, append-only record of every AI decision โ€” what model, what context, what it
4
+ cost, which tools, and who signed off โ€” mapped to control templates and exportable as an evidence
5
+ pack. No database, no infra: integrity comes from a hash chain, not a server.
6
+
7
+ **Audit-ready evidence in 5 lines โ€” and verifiable offline.**
8
+
9
+ ![status](https://img.shields.io/badge/status-building-yellow) ![license](https://img.shields.io/badge/license-MIT-blue)
10
+
11
+ ๐Ÿšง building (v0) ยท `pip install powerailabs-acttrace` ยท `from powerailabs.acttrace import AuditLog`
12
+
13
+ ```python
14
+ from powerailabs.core import instrument
15
+ from powerailabs.acttrace import AuditLog
16
+
17
+ client = instrument(OpenAI())
18
+ audit = AuditLog(system="loan_triage", risk_tier="high") # auto-subscribes to core events
19
+
20
+ with audit.decision(input=application, actor="agent") as d:
21
+ resp = client.chat.completions.create(model="gpt-4o", messages=msgs) # auto-logged
22
+ d.record(model="gpt-4o", prompt_id="triage@v3") # cost/context captured for free
23
+ d.human_oversight(reviewer="ops@bank", action="approved")
24
+
25
+ audit.export("evidence_q3.jsonl", framework="eu_ai_act") # evidence pack
26
+ ```
27
+
28
+ ```bash
29
+ acttrace verify evidence_q3.jsonl # re-walks the hash chain; exits non-zero if broken
30
+ ```
31
+
32
+ **Wrap-around, auto-subscribing.** Because cost (`tokenguard`) and context decisions (`contextkit`)
33
+ ride core's shared event stream, the log fills itself โ€” install the stack, call `instrument()`
34
+ once, and the record already knows the model, the context, the cost, and the tools. Each entry is
35
+ chained (`hash = sha256(prev + canonical(entry))`), so any later edit breaks the chain.
36
+
37
+ > `acttrace` produces **evidence to support** compliance โ€” it is **not** legal advice and not a
38
+ > compliance guarantee. Control mappings are a starting template for your compliance team.
39
+
40
+ See [`docs/acttrace.md`](../../docs/acttrace.md). *Part of the PowerAI Labs stack โ€” github.com/PowerAI-Labs/powerailabs.*
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "powerailabs-acttrace"
3
+ version = "0.1.0"
4
+ description = "Audit: a tamper-evident, hash-chained, auto-populated record of every AI decision โ€” verifiable offline. Evidence, not a compliance guarantee."
5
+ requires-python = ">=3.11"
6
+ license = "MIT"
7
+ authors = [{ name = "Raghav Mishra" }]
8
+ readme = "README.md"
9
+ dependencies = ["powerailabs-core>=0.1,<0.2"]
10
+
11
+ [project.scripts]
12
+ acttrace = "powerailabs.acttrace.cli:main"
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/powerailabs"] # contributes powerailabs/acttrace only โ€” NEVER add src/powerailabs/__init__.py
@@ -0,0 +1,234 @@
1
+ """powerailabs.acttrace โ€” a tamper-evident, auto-populated audit log for AI decisions.
2
+
3
+ Construct an :class:`AuditLog` and it **subscribes** to ``powerailabs.core``'s event stream: every
4
+ instrumented model/tool call โ€” and the context decisions ``contextkit`` and cost ``tokenguard``
5
+ ride on that same stream โ€” becomes an audit entry with no per-call wiring. You add only the
6
+ explicit human-facing events (``decision``, ``human_oversight``).
7
+
8
+ Integrity comes from a **hash chain**, not a server: ``entry.hash = sha256(prev_hash +
9
+ canonical(entry))``, so editing any past entry breaks every entry after it. ``acttrace verify
10
+ file.jsonl`` re-walks the chain offline.
11
+
12
+ > This produces **evidence to support** compliance (e.g. EU AI Act record-keeping / human
13
+ > oversight). It is **not** legal advice and not a compliance guarantee. Control mappings are a
14
+ > starting template for your compliance team to adjust.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import json
21
+ import uuid
22
+ from collections.abc import Iterator
23
+ from contextlib import contextmanager
24
+ from contextvars import ContextVar
25
+ from dataclasses import dataclass
26
+ from datetime import UTC, datetime
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from powerailabs.core import bus
31
+ from powerailabs.core.types import LLMCall, ToolCall
32
+
33
+ __all__ = ["AuditLog", "AuditEntry", "verify", "GENESIS"]
34
+
35
+ GENESIS = "0" * 64
36
+
37
+ _active_decision: ContextVar[str | None] = ContextVar("powerailabs_acttrace_decision", default=None)
38
+
39
+ # Starting-template control mapping (NOT legal advice). docs/acttrace.md ยง5, ยง7.
40
+ _CONTROLS: dict[str, dict[str, list[str]]] = {
41
+ "eu_ai_act": {
42
+ "decision": ["Art.12 record-keeping"],
43
+ "llm_call": ["Art.12 logging"],
44
+ "tool_call": ["Art.12 logging"],
45
+ "context_assembly": ["Art.12 logging"],
46
+ "human_oversight": ["Art.14 human oversight"],
47
+ },
48
+ }
49
+
50
+
51
+ @dataclass
52
+ class AuditEntry:
53
+ """One link in the hash chain. docs/acttrace.md ยง5."""
54
+
55
+ seq: int
56
+ ts: str
57
+ type: str # decision | llm_call | tool_call | human_oversight | context_assembly | ...
58
+ payload: dict
59
+ prev_hash: str
60
+ hash: str
61
+
62
+
63
+ def _jsonable(obj: Any) -> Any:
64
+ if obj is None or isinstance(obj, (bool, int, float, str)):
65
+ return obj
66
+ if isinstance(obj, datetime):
67
+ return obj.isoformat()
68
+ if isinstance(obj, dict):
69
+ return {str(k): _jsonable(v) for k, v in obj.items()}
70
+ if isinstance(obj, (list, tuple)):
71
+ return [_jsonable(v) for v in obj]
72
+ if hasattr(obj, "amount") and hasattr(obj, "currency"): # Money
73
+ return f"{obj.amount} {obj.currency}"
74
+ if hasattr(obj, "__dict__"):
75
+ return _jsonable(vars(obj))
76
+ return str(obj)
77
+
78
+
79
+ def _canonical(payload: dict) -> str:
80
+ return json.dumps(_jsonable(payload), sort_keys=True, ensure_ascii=False, separators=(",", ":"))
81
+
82
+
83
+ def _chain_hash(prev_hash: str, seq: int, ts: str, etype: str, payload: dict) -> str:
84
+ body = _canonical({"seq": seq, "ts": ts, "type": etype, "payload": payload})
85
+ return hashlib.sha256((prev_hash + body).encode("utf-8")).hexdigest()
86
+
87
+
88
+ class AuditLog:
89
+ """A hash-chained, append-only, auto-populating audit log. docs/acttrace.md ยง3, ยง5."""
90
+
91
+ def __init__(self, system: str, risk_tier: str = "limited", path: str | None = None) -> None:
92
+ self.system = system
93
+ self.risk_tier = risk_tier
94
+ self.path = Path(path) if path else None
95
+ self.entries: list[AuditEntry] = []
96
+ self._head = GENESIS
97
+ if self.path is not None:
98
+ self.path.parent.mkdir(parents=True, exist_ok=True)
99
+ self.path.write_text("", encoding="utf-8")
100
+ self._append("audit_open", {"system": system, "risk_tier": risk_tier})
101
+ bus.subscribe(self._on_event)
102
+
103
+ # ------------------------------------------------------------------ chain
104
+
105
+ def _append(self, etype: str, payload: dict) -> AuditEntry:
106
+ seq = len(self.entries)
107
+ ts = datetime.now(UTC).isoformat()
108
+ h = _chain_hash(self._head, seq, ts, etype, payload)
109
+ entry = AuditEntry(seq, ts, etype, _jsonable(payload), self._head, h)
110
+ self.entries.append(entry)
111
+ self._head = h
112
+ if self.path is not None:
113
+ with self.path.open("a", encoding="utf-8") as fh:
114
+ fh.write(json.dumps(entry.__dict__, ensure_ascii=False) + "\n")
115
+ return entry
116
+
117
+ # ------------------------------------------------------------------ auto-capture
118
+
119
+ def _on_event(self, event: Any) -> None:
120
+ did = _active_decision.get()
121
+ if isinstance(event, LLMCall):
122
+ self._append(
123
+ "llm_call",
124
+ {
125
+ "decision_id": did,
126
+ "provider": event.provider,
127
+ "model": event.model,
128
+ "usage": _jsonable(event.usage),
129
+ "cost": _jsonable(event.cost),
130
+ "latency_ms": event.latency_ms,
131
+ "replayed": event.metadata.get("replayed", False),
132
+ },
133
+ )
134
+ elif isinstance(event, ToolCall):
135
+ self._append(
136
+ "tool_call",
137
+ {"decision_id": did, "name": event.name, "arguments": _jsonable(event.arguments)},
138
+ )
139
+ elif hasattr(event, "decisions") and hasattr(event, "budget"): # contextkit AssemblyReport
140
+ self._append(
141
+ "context_assembly",
142
+ {
143
+ "decision_id": did,
144
+ "model": getattr(event, "model", None),
145
+ "budget": event.budget,
146
+ "used": getattr(event, "used", None),
147
+ "decisions": _jsonable(event.decisions),
148
+ },
149
+ )
150
+
151
+ def detach(self) -> None:
152
+ """Stop subscribing to the core event stream."""
153
+ if self._on_event in bus._subscribers:
154
+ bus._subscribers.remove(self._on_event)
155
+
156
+ # ------------------------------------------------------------------ explicit events
157
+
158
+ @contextmanager
159
+ def decision(self, input: Any = None, actor: str = "agent") -> Iterator[Decision]:
160
+ """Group a unit of work. Auto-captured calls inside it are tagged with this decision."""
161
+ did = uuid.uuid4().hex
162
+ self._append("decision", {"decision_id": did, "input": _jsonable(input), "actor": actor})
163
+ token = _active_decision.set(did)
164
+ try:
165
+ yield Decision(self, did)
166
+ finally:
167
+ _active_decision.reset(token)
168
+ self._append("decision_end", {"decision_id": did})
169
+
170
+ # ------------------------------------------------------------------ export
171
+
172
+ def export(self, path: str, framework: str | None = None) -> None:
173
+ """Write the chain as a JSONL evidence pack, optionally annotated with control IDs."""
174
+ controls = _CONTROLS.get(framework or "", {})
175
+ out = Path(path)
176
+ out.parent.mkdir(parents=True, exist_ok=True)
177
+ with out.open("w", encoding="utf-8") as fh:
178
+ meta = {
179
+ "_meta": {
180
+ "system": self.system,
181
+ "risk_tier": self.risk_tier,
182
+ "framework": framework,
183
+ "head_hash": self._head,
184
+ "entries": len(self.entries),
185
+ "disclaimer": "Evidence to support compliance โ€” not legal advice.",
186
+ }
187
+ }
188
+ fh.write(json.dumps(meta, ensure_ascii=False) + "\n")
189
+ for entry in self.entries:
190
+ row = dict(entry.__dict__)
191
+ if framework:
192
+ row["controls"] = controls.get(entry.type, [])
193
+ fh.write(json.dumps(row, ensure_ascii=False) + "\n")
194
+
195
+
196
+ @dataclass
197
+ class Decision:
198
+ """Handle for the active decision span (yielded by :meth:`AuditLog.decision`)."""
199
+
200
+ log: AuditLog
201
+ id: str
202
+
203
+ def record(self, **fields: Any) -> None:
204
+ """Record decision metadata (e.g. ``model``, ``prompt_id``)."""
205
+ self.log._append("decision_record", {"decision_id": self.id, **fields})
206
+
207
+ def human_oversight(self, reviewer: str, action: str, note: str = "") -> None:
208
+ """Record an Art. 14-style human-oversight event: who reviewed, what action, when."""
209
+ self.log._append(
210
+ "human_oversight",
211
+ {"decision_id": self.id, "reviewer": reviewer, "action": action, "note": note},
212
+ )
213
+
214
+
215
+ def verify(path: str) -> tuple[bool, str]:
216
+ """Re-walk the hash chain in a JSONL file. Returns ``(ok, detail)``. docs/acttrace.md ยง5."""
217
+ lines = Path(path).read_text(encoding="utf-8").splitlines()
218
+ prev = GENESIS
219
+ seen = 0
220
+ for line in lines:
221
+ line = line.strip()
222
+ if not line:
223
+ continue
224
+ row = json.loads(line)
225
+ if "_meta" in row: # export header, not a chain entry
226
+ continue
227
+ expected = _chain_hash(prev, row["seq"], row["ts"], row["type"], row["payload"])
228
+ if row["prev_hash"] != prev:
229
+ return False, f"broken link at seq {row['seq']}: prev_hash mismatch"
230
+ if row["hash"] != expected:
231
+ return False, f"tampered entry at seq {row['seq']}: hash mismatch"
232
+ prev = row["hash"]
233
+ seen += 1
234
+ return True, f"ok: {seen} entries, head {prev[:12]}โ€ฆ"
@@ -0,0 +1,31 @@
1
+ """``acttrace`` CLI: an offline verifier for the hash chain. docs/acttrace.md ยง3.
2
+
3
+ acttrace verify evidence_q3.jsonl # exits non-zero if the chain is broken
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import sys
10
+ from collections.abc import Sequence
11
+
12
+ from . import verify
13
+
14
+
15
+ def main(argv: Sequence[str] | None = None) -> int:
16
+ """Entry point for the ``acttrace`` console script. Returns a process exit code."""
17
+ parser = argparse.ArgumentParser(prog="acttrace", description="Audit-log tools.")
18
+ sub = parser.add_subparsers(dest="command", required=True)
19
+ verify_cmd = sub.add_parser("verify", help="re-walk a JSONL log's hash chain")
20
+ verify_cmd.add_argument("path", help="path to the .jsonl audit/evidence file")
21
+
22
+ args = parser.parse_args(argv)
23
+ if args.command == "verify":
24
+ ok, detail = verify(args.path)
25
+ print(detail)
26
+ return 0 if ok else 1
27
+ return 2
28
+
29
+
30
+ if __name__ == "__main__": # pragma: no cover
31
+ sys.exit(main())
@@ -0,0 +1,118 @@
1
+ """Auto-populated, hash-chained, tamper-evident audit log. Offline; mock clients only."""
2
+
3
+ import json
4
+ from types import SimpleNamespace
5
+
6
+ import pytest
7
+ from powerailabs.acttrace import AuditLog, verify
8
+ from powerailabs.core import bus, instrument
9
+
10
+
11
+ @pytest.fixture(autouse=True)
12
+ def _clean_bus():
13
+ bus._reset()
14
+ yield
15
+ bus._reset()
16
+
17
+
18
+ def _client():
19
+ class Completions:
20
+ def create(self, **kwargs):
21
+ return SimpleNamespace(usage=SimpleNamespace(prompt_tokens=100, completion_tokens=50))
22
+
23
+ return instrument(SimpleNamespace(chat=SimpleNamespace(completions=Completions())))
24
+
25
+
26
+ def test_auto_populates_from_instrumented_calls(tmp_path):
27
+ log = AuditLog(system="loan_triage", risk_tier="high", path=str(tmp_path / "audit.jsonl"))
28
+ try:
29
+ client = _client()
30
+ with log.decision(input={"amount": 5000}, actor="agent") as d:
31
+ client.chat.completions.create(
32
+ model="gpt-4o", messages=[{"role": "user", "content": "x"}]
33
+ )
34
+ d.record(model="gpt-4o", prompt_id="triage@v3")
35
+ d.human_oversight(reviewer="ops@bank", action="approved", note="manual check")
36
+ finally:
37
+ log.detach()
38
+
39
+ types = [e.type for e in log.entries]
40
+ assert "decision" in types
41
+ assert "llm_call" in types # captured with zero per-call wiring
42
+ assert "human_oversight" in types
43
+ # the llm_call carries cost + is tagged with the active decision
44
+ llm = next(e for e in log.entries if e.type == "llm_call")
45
+ assert llm.payload["cost"] is not None
46
+ assert llm.payload["decision_id"] is not None
47
+
48
+
49
+ def test_chain_verifies_and_detects_tampering(tmp_path):
50
+ path = tmp_path / "audit.jsonl"
51
+ log = AuditLog(system="s", path=str(path))
52
+ try:
53
+ with log.decision(input="app") as d:
54
+ d.human_oversight(reviewer="r", action="approved")
55
+ finally:
56
+ log.detach()
57
+
58
+ ok, detail = verify(str(path))
59
+ assert ok, detail
60
+
61
+ # Tamper: edit a payload in the middle of the chain.
62
+ lines = path.read_text(encoding="utf-8").splitlines()
63
+ row = json.loads(lines[1])
64
+ row["payload"]["actor"] = "HACKED"
65
+ lines[1] = json.dumps(row)
66
+ path.write_text("\n".join(lines), encoding="utf-8")
67
+
68
+ ok, detail = verify(str(path))
69
+ assert not ok and "tampered" in detail
70
+
71
+
72
+ def test_context_assembly_auto_captured(tmp_path):
73
+ # contextkit emits an AssemblyReport on the bus; acttrace records it without importing it.
74
+ from powerailabs.contextkit import Block, Context
75
+
76
+ log = AuditLog(system="s", path=str(tmp_path / "a.jsonl"))
77
+ try:
78
+ with log.decision(input="q"):
79
+ ctx = Context(budget_tokens=1000, model="gpt-4o")
80
+ ctx.add(Block("system", priority=10, role="system"))
81
+ ctx.assemble()
82
+ finally:
83
+ log.detach()
84
+ assert any(e.type == "context_assembly" for e in log.entries)
85
+
86
+
87
+ def test_export_with_control_mapping(tmp_path):
88
+ log = AuditLog(system="s", risk_tier="high")
89
+ try:
90
+ with log.decision(input="x") as d:
91
+ d.human_oversight(reviewer="r", action="approved")
92
+ finally:
93
+ log.detach()
94
+ out = tmp_path / "evidence.jsonl"
95
+ log.export(str(out), framework="eu_ai_act")
96
+
97
+ rows = [json.loads(ln) for ln in out.read_text(encoding="utf-8").splitlines()]
98
+ assert "_meta" in rows[0]
99
+ assert "not legal advice" in rows[0]["_meta"]["disclaimer"].lower()
100
+ oversight = next(r for r in rows if r.get("type") == "human_oversight")
101
+ assert oversight["controls"] == ["Art.14 human oversight"]
102
+ # exported evidence still verifies
103
+ ok, _ = verify(str(out))
104
+ assert ok
105
+
106
+
107
+ def test_cli_verify(tmp_path, capsys):
108
+ from powerailabs.acttrace.cli import main
109
+
110
+ path = tmp_path / "audit.jsonl"
111
+ log = AuditLog(system="s", path=str(path))
112
+ log.detach()
113
+ assert main(["verify", str(path)]) == 0
114
+
115
+ path.write_text(
116
+ path.read_text(encoding="utf-8").replace('"system"', '"SYSTEM"'), encoding="utf-8"
117
+ )
118
+ assert main(["verify", str(path)]) == 1