blocklog 0.2.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.
- blocklog/__init__.py +78 -0
- blocklog/_global.py +20 -0
- blocklog/_init_fn.py +95 -0
- blocklog/api/approval.py +182 -0
- blocklog/api/compliance.py +162 -0
- blocklog/api/decisions.py +177 -0
- blocklog/api/incidents.py +306 -0
- blocklog/api/replay.py +285 -0
- blocklog/api/traces.py +137 -0
- blocklog/api/verify.py +100 -0
- blocklog/approval.py +119 -0
- blocklog/async_client.py +28 -0
- blocklog/batching/buffer.py +22 -0
- blocklog/client.py +194 -0
- blocklog/compliance.py +95 -0
- blocklog/config.py +17 -0
- blocklog/context/managers.py +15 -0
- blocklog/context/vars.py +13 -0
- blocklog/decorators/__init__.py +4 -0
- blocklog/decorators/agent.py +219 -0
- blocklog/decorators/tool.py +206 -0
- blocklog/incident.py +89 -0
- blocklog/integrations/langchain.py +129 -0
- blocklog/integrations/langgraph.py +3 -0
- blocklog/integrations/openai_agents.py +3 -0
- blocklog/managers/__init__.py +3 -0
- blocklog/managers/decision.py +336 -0
- blocklog/middleware/hooks.py +11 -0
- blocklog/models/events.py +33 -0
- blocklog/models/responses.py +18 -0
- blocklog/replay.py +64 -0
- blocklog/signing/canonical.py +5 -0
- blocklog/signing/ed25519.py +25 -0
- blocklog/transport/auth.py +8 -0
- blocklog/transport/httpx_async.py +39 -0
- blocklog/transport/httpx_sync.py +36 -0
- blocklog/transport/retry.py +26 -0
- blocklog/verify.py +72 -0
- blocklog-0.2.0.dist-info/METADATA +272 -0
- blocklog-0.2.0.dist-info/RECORD +43 -0
- blocklog-0.2.0.dist-info/WHEEL +5 -0
- blocklog-0.2.0.dist-info/licenses/LICENSE +21 -0
- blocklog-0.2.0.dist-info/top_level.txt +1 -0
blocklog/api/replay.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blocklog.api.replay
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Layer 2 client for forensic replay sessions.
|
|
5
|
+
|
|
6
|
+
Available via ``client.replay.*``.
|
|
7
|
+
|
|
8
|
+
Backend endpoints
|
|
9
|
+
-----------------
|
|
10
|
+
- POST /api/v1/forensics/replays
|
|
11
|
+
- GET /api/v1/forensics/replays/{id}
|
|
12
|
+
- GET /api/v1/forensics/replays/{id}/timeline
|
|
13
|
+
- GET /api/v1/forensics/replays/{id}/root-cause
|
|
14
|
+
- GET /api/v1/forensics/replays/{id}/causal-graph
|
|
15
|
+
- GET /api/v1/forensics/replays/{id}/staleness
|
|
16
|
+
- GET /api/v1/forensics/replays/{id}/divergence
|
|
17
|
+
- POST /api/v1/forensics/replays/{id}/counterfactuals
|
|
18
|
+
- POST /api/v1/forensics/compare
|
|
19
|
+
- GET /api/v1/forensics/compare/{id}
|
|
20
|
+
- POST /api/v1/replay/sessions (simple replay)
|
|
21
|
+
- GET /api/v1/replay/sessions/{id}
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import TYPE_CHECKING, Any
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from blocklog.client import BlocklogClient
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ReplaySession:
|
|
32
|
+
"""A forensic replay session.
|
|
33
|
+
|
|
34
|
+
Wraps a backend replay session and provides lazy-loaded access to
|
|
35
|
+
all sub-resources: timeline, root cause, causal graph, staleness
|
|
36
|
+
heatmap, divergences, and counterfactuals.
|
|
37
|
+
|
|
38
|
+
Obtained from ``client.replay.create()`` or ``blocklog.replay()``.
|
|
39
|
+
|
|
40
|
+
Examples
|
|
41
|
+
--------
|
|
42
|
+
>>> session = client.replay.create(trace_id="trace-abc")
|
|
43
|
+
>>> session.timeline()
|
|
44
|
+
>>> session.root_cause()
|
|
45
|
+
>>> session.compare(other_trace_id="trace-def")
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, data: dict[str, Any], client: "ReplayClient") -> None:
|
|
49
|
+
self._data = data
|
|
50
|
+
self._client = client
|
|
51
|
+
self.id: str = str(data.get("id", data.get("replay_session_id", "")))
|
|
52
|
+
|
|
53
|
+
# -- Raw data --
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def raw(self) -> dict[str, Any]:
|
|
57
|
+
"""The full raw session data from the backend."""
|
|
58
|
+
return self._data
|
|
59
|
+
|
|
60
|
+
# -- Sub-resource accessors --
|
|
61
|
+
|
|
62
|
+
def timeline(self) -> list[dict[str, Any]]:
|
|
63
|
+
"""Return the chronological event timeline for this replay.
|
|
64
|
+
|
|
65
|
+
Returns
|
|
66
|
+
-------
|
|
67
|
+
list[dict]
|
|
68
|
+
Ordered list of timeline items.
|
|
69
|
+
"""
|
|
70
|
+
return self._client.timeline(self.id)
|
|
71
|
+
|
|
72
|
+
def root_cause(self) -> dict[str, Any]:
|
|
73
|
+
"""Perform root-cause analysis on this replay.
|
|
74
|
+
|
|
75
|
+
The backend applies heuristics to detect:
|
|
76
|
+
- Stale context / data freshness violations
|
|
77
|
+
- Policy violations (authorization denied)
|
|
78
|
+
- Integrity failures (receipt status anomalies)
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
dict
|
|
83
|
+
Keys: ``detected``, ``root_cause_type``, ``description``,
|
|
84
|
+
``confidence``, ``remediation``.
|
|
85
|
+
"""
|
|
86
|
+
return self._client.root_cause(self.id)
|
|
87
|
+
|
|
88
|
+
def causal_graph(self) -> dict[str, Any]:
|
|
89
|
+
"""Return the causal graph (nodes + edges) for this replay.
|
|
90
|
+
|
|
91
|
+
Useful for visualising the chain of causality across agents,
|
|
92
|
+
tools, decisions, and execution receipts.
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
dict
|
|
97
|
+
Keys: ``nodes``, ``edges``.
|
|
98
|
+
"""
|
|
99
|
+
return self._client.causal_graph(self.id)
|
|
100
|
+
|
|
101
|
+
def staleness(self) -> dict[str, Any]:
|
|
102
|
+
"""Return the staleness heatmap for data sources used in this replay.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
dict
|
|
107
|
+
Keys: ``overall_staleness_rating``, ``findings``.
|
|
108
|
+
"""
|
|
109
|
+
return self._client.staleness(self.id)
|
|
110
|
+
|
|
111
|
+
def divergence(self) -> list[dict[str, Any]]:
|
|
112
|
+
"""Return divergence analysis results for this replay.
|
|
113
|
+
|
|
114
|
+
Returns
|
|
115
|
+
-------
|
|
116
|
+
list[dict]
|
|
117
|
+
List of detected divergence events.
|
|
118
|
+
"""
|
|
119
|
+
return self._client.divergence(self.id)
|
|
120
|
+
|
|
121
|
+
def counterfactual(
|
|
122
|
+
self,
|
|
123
|
+
token_id: str,
|
|
124
|
+
*,
|
|
125
|
+
modified_inputs: dict[str, Any],
|
|
126
|
+
) -> dict[str, Any]:
|
|
127
|
+
"""Run a counterfactual (what-if) simulation on this replay.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
token_id:
|
|
132
|
+
The execution token ID to simulate against.
|
|
133
|
+
modified_inputs:
|
|
134
|
+
Dict of input fields to override in the simulation.
|
|
135
|
+
|
|
136
|
+
Returns
|
|
137
|
+
-------
|
|
138
|
+
dict
|
|
139
|
+
Counterfactual analysis result.
|
|
140
|
+
"""
|
|
141
|
+
return self._client.counterfactual(self.id, token_id=token_id, modified_inputs=modified_inputs)
|
|
142
|
+
|
|
143
|
+
def compare(self, other_trace_id: str) -> dict[str, Any]:
|
|
144
|
+
"""Compare this replay session against another trace.
|
|
145
|
+
|
|
146
|
+
Creates a new replay session for ``other_trace_id``, then runs a
|
|
147
|
+
forensic comparison against this session.
|
|
148
|
+
|
|
149
|
+
Parameters
|
|
150
|
+
----------
|
|
151
|
+
other_trace_id:
|
|
152
|
+
Trace ID of the session to compare against.
|
|
153
|
+
|
|
154
|
+
Returns
|
|
155
|
+
-------
|
|
156
|
+
dict
|
|
157
|
+
Comparison result including ``differences`` list.
|
|
158
|
+
"""
|
|
159
|
+
other = self._client.create(trace_id=other_trace_id)
|
|
160
|
+
return self._client.compare(self.id, other.id)
|
|
161
|
+
|
|
162
|
+
def __repr__(self) -> str:
|
|
163
|
+
return f"<ReplaySession id={self.id!r}>"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class ReplayClient:
|
|
167
|
+
"""Manage forensic replay sessions.
|
|
168
|
+
|
|
169
|
+
Accessed as ``client.replay``.
|
|
170
|
+
|
|
171
|
+
Examples
|
|
172
|
+
--------
|
|
173
|
+
>>> session = client.replay.create(trace_id="trace-abc")
|
|
174
|
+
>>> session.root_cause()
|
|
175
|
+
>>> session.compare("trace-def")
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def __init__(self, client: "BlocklogClient") -> None:
|
|
179
|
+
self._client = client
|
|
180
|
+
|
|
181
|
+
def create(
|
|
182
|
+
self,
|
|
183
|
+
trace_id: str,
|
|
184
|
+
*,
|
|
185
|
+
token_id: str | None = None,
|
|
186
|
+
metadata: dict[str, Any] | None = None,
|
|
187
|
+
) -> ReplaySession:
|
|
188
|
+
"""Create a forensic replay session for a given trace.
|
|
189
|
+
|
|
190
|
+
Parameters
|
|
191
|
+
----------
|
|
192
|
+
trace_id:
|
|
193
|
+
The trace to replay.
|
|
194
|
+
token_id:
|
|
195
|
+
Execution token ID to bind to this session.
|
|
196
|
+
metadata:
|
|
197
|
+
Extra metadata for the replay session.
|
|
198
|
+
|
|
199
|
+
Returns
|
|
200
|
+
-------
|
|
201
|
+
ReplaySession
|
|
202
|
+
A session handle with access to all forensic sub-resources.
|
|
203
|
+
"""
|
|
204
|
+
payload: dict[str, Any] = {"trace_id": trace_id}
|
|
205
|
+
if token_id is not None:
|
|
206
|
+
payload["token_id"] = token_id
|
|
207
|
+
if metadata is not None:
|
|
208
|
+
payload["metadata"] = metadata
|
|
209
|
+
|
|
210
|
+
data = self._client.retry.run(
|
|
211
|
+
lambda: self._client.transport.request("POST", "/forensics/replays", json=payload)
|
|
212
|
+
)
|
|
213
|
+
return ReplaySession(data, self)
|
|
214
|
+
|
|
215
|
+
def get(self, replay_session_id: str) -> ReplaySession:
|
|
216
|
+
"""Fetch an existing replay session by ID."""
|
|
217
|
+
data = self._client.retry.run(
|
|
218
|
+
lambda: self._client.transport.request("GET", f"/forensics/replays/{replay_session_id}")
|
|
219
|
+
)
|
|
220
|
+
return ReplaySession(data, self)
|
|
221
|
+
|
|
222
|
+
def timeline(self, replay_session_id: str) -> list[dict[str, Any]]:
|
|
223
|
+
"""Return the event timeline for a replay session."""
|
|
224
|
+
return self._client.retry.run(
|
|
225
|
+
lambda: self._client.transport.request("GET", f"/forensics/replays/{replay_session_id}/timeline")
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def root_cause(self, replay_session_id: str) -> dict[str, Any]:
|
|
229
|
+
"""Run root-cause analysis on a replay session."""
|
|
230
|
+
return self._client.retry.run(
|
|
231
|
+
lambda: self._client.transport.request("GET", f"/forensics/replays/{replay_session_id}/root-cause")
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def causal_graph(self, replay_session_id: str) -> dict[str, Any]:
|
|
235
|
+
"""Return the causal graph for a replay session."""
|
|
236
|
+
return self._client.retry.run(
|
|
237
|
+
lambda: self._client.transport.request("GET", f"/forensics/replays/{replay_session_id}/causal-graph")
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def staleness(self, replay_session_id: str) -> dict[str, Any]:
|
|
241
|
+
"""Return staleness heatmap for a replay session."""
|
|
242
|
+
return self._client.retry.run(
|
|
243
|
+
lambda: self._client.transport.request("GET", f"/forensics/replays/{replay_session_id}/staleness")
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def divergence(self, replay_session_id: str) -> list[dict[str, Any]]:
|
|
247
|
+
"""Return divergence analysis for a replay session."""
|
|
248
|
+
return self._client.retry.run(
|
|
249
|
+
lambda: self._client.transport.request("GET", f"/forensics/replays/{replay_session_id}/divergence")
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def counterfactual(
|
|
253
|
+
self,
|
|
254
|
+
replay_session_id: str,
|
|
255
|
+
*,
|
|
256
|
+
token_id: str,
|
|
257
|
+
modified_inputs: dict[str, Any],
|
|
258
|
+
) -> dict[str, Any]:
|
|
259
|
+
"""Run a counterfactual simulation."""
|
|
260
|
+
return self._client.retry.run(
|
|
261
|
+
lambda: self._client.transport.request(
|
|
262
|
+
"POST",
|
|
263
|
+
f"/forensics/replays/{replay_session_id}/counterfactuals",
|
|
264
|
+
json={"token_id": token_id, "modified_inputs": modified_inputs},
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def compare(
|
|
269
|
+
self,
|
|
270
|
+
baseline_session_id: str,
|
|
271
|
+
candidate_session_id: str,
|
|
272
|
+
) -> dict[str, Any]:
|
|
273
|
+
"""Compare two replay sessions and return a diff of differences."""
|
|
274
|
+
return self._client.retry.run(
|
|
275
|
+
lambda: self._client.transport.request("POST", "/forensics/compare", json={
|
|
276
|
+
"baseline_session_id": baseline_session_id,
|
|
277
|
+
"candidate_session_id": candidate_session_id,
|
|
278
|
+
})
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def get_comparison(self, comparison_id: str) -> dict[str, Any]:
|
|
282
|
+
"""Retrieve a previously computed replay comparison."""
|
|
283
|
+
return self._client.retry.run(
|
|
284
|
+
lambda: self._client.transport.request("GET", f"/forensics/compare/{comparison_id}")
|
|
285
|
+
)
|
blocklog/api/traces.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blocklog.api.traces
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Layer 2 client for trace and session queries.
|
|
5
|
+
|
|
6
|
+
Available via ``client.traces.*``.
|
|
7
|
+
|
|
8
|
+
Backend endpoints
|
|
9
|
+
-----------------
|
|
10
|
+
- GET /api/v1/traces
|
|
11
|
+
- GET /api/v1/traces/{trace_id}
|
|
12
|
+
- GET /api/v1/sessions/{session_id}/timeline
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from blocklog.client import BlocklogClient
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TracesClient:
|
|
23
|
+
"""Query traces and session timelines.
|
|
24
|
+
|
|
25
|
+
Accessed as ``client.traces``.
|
|
26
|
+
|
|
27
|
+
Examples
|
|
28
|
+
--------
|
|
29
|
+
>>> traces = client.traces.list(event_type="DECISION_COMPLETE", limit=20)
|
|
30
|
+
>>> detail = client.traces.get("trace-uuid")
|
|
31
|
+
>>> timeline = client.traces.session_timeline("session-uuid")
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, client: "BlocklogClient") -> None:
|
|
35
|
+
self._client = client
|
|
36
|
+
|
|
37
|
+
def list(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
trace_id: str | None = None,
|
|
41
|
+
session_id: str | None = None,
|
|
42
|
+
workflow_id: str | None = None,
|
|
43
|
+
source: str | None = None,
|
|
44
|
+
event_type: str | None = None,
|
|
45
|
+
from_ts: str | None = None,
|
|
46
|
+
to_ts: str | None = None,
|
|
47
|
+
limit: int = 50,
|
|
48
|
+
) -> list[dict[str, Any]]:
|
|
49
|
+
"""List traces with optional filters.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
trace_id:
|
|
54
|
+
Filter to a specific trace.
|
|
55
|
+
session_id:
|
|
56
|
+
Filter to a specific session.
|
|
57
|
+
workflow_id:
|
|
58
|
+
Filter to a specific workflow.
|
|
59
|
+
source:
|
|
60
|
+
Filter by event source string.
|
|
61
|
+
event_type:
|
|
62
|
+
Filter by event type (e.g. ``"DECISION_COMPLETE"``).
|
|
63
|
+
from_ts:
|
|
64
|
+
ISO-8601 lower bound for event timestamp.
|
|
65
|
+
to_ts:
|
|
66
|
+
ISO-8601 upper bound for event timestamp.
|
|
67
|
+
limit:
|
|
68
|
+
Maximum number of results (1–200, default 50).
|
|
69
|
+
|
|
70
|
+
Returns
|
|
71
|
+
-------
|
|
72
|
+
list[dict]
|
|
73
|
+
List of trace/log records.
|
|
74
|
+
"""
|
|
75
|
+
params: dict[str, Any] = {"limit": limit}
|
|
76
|
+
if trace_id:
|
|
77
|
+
params["trace_id"] = trace_id
|
|
78
|
+
if session_id:
|
|
79
|
+
params["session_id"] = session_id
|
|
80
|
+
if workflow_id:
|
|
81
|
+
params["workflow_id"] = workflow_id
|
|
82
|
+
if source:
|
|
83
|
+
params["source"] = source
|
|
84
|
+
if event_type:
|
|
85
|
+
params["event_type"] = event_type
|
|
86
|
+
if from_ts:
|
|
87
|
+
params["from"] = from_ts
|
|
88
|
+
if to_ts:
|
|
89
|
+
params["to"] = to_ts
|
|
90
|
+
|
|
91
|
+
result = self._client.retry.run(
|
|
92
|
+
lambda: self._client.transport.request("GET", "/traces", params=params)
|
|
93
|
+
)
|
|
94
|
+
return result.get("items", result) if isinstance(result, dict) else result
|
|
95
|
+
|
|
96
|
+
def get(self, trace_id: str) -> dict[str, Any]:
|
|
97
|
+
"""Fetch detailed information about a specific trace.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
trace_id:
|
|
102
|
+
UUID of the trace.
|
|
103
|
+
"""
|
|
104
|
+
return self._client.retry.run(
|
|
105
|
+
lambda: self._client.transport.request("GET", f"/traces/{trace_id}")
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def session_timeline(
|
|
109
|
+
self,
|
|
110
|
+
session_id: str,
|
|
111
|
+
*,
|
|
112
|
+
cursor: str | None = None,
|
|
113
|
+
limit: int = 100,
|
|
114
|
+
) -> dict[str, Any]:
|
|
115
|
+
"""Return the paginated event timeline for a session.
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
session_id:
|
|
120
|
+
UUID of the session.
|
|
121
|
+
cursor:
|
|
122
|
+
Pagination cursor from a previous response.
|
|
123
|
+
limit:
|
|
124
|
+
Max events to return (1–500, default 100).
|
|
125
|
+
|
|
126
|
+
Returns
|
|
127
|
+
-------
|
|
128
|
+
dict
|
|
129
|
+
Paginated response with ``items`` and optional ``next_cursor``.
|
|
130
|
+
"""
|
|
131
|
+
params: dict[str, Any] = {"limit": limit}
|
|
132
|
+
if cursor:
|
|
133
|
+
params["cursor"] = cursor
|
|
134
|
+
|
|
135
|
+
return self._client.retry.run(
|
|
136
|
+
lambda: self._client.transport.request("GET", f"/sessions/{session_id}/timeline", params=params)
|
|
137
|
+
)
|
blocklog/api/verify.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blocklog.api.verify
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Layer 2 client for cryptographic verification.
|
|
5
|
+
|
|
6
|
+
Available via ``client.verify.*``.
|
|
7
|
+
|
|
8
|
+
Backend endpoints
|
|
9
|
+
-----------------
|
|
10
|
+
- GET /api/v1/verify/log/{log_id}
|
|
11
|
+
- GET /api/v1/verify/batch/{batch_id}
|
|
12
|
+
- GET /api/v1/decisions/{id}/verify
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from blocklog.client import BlocklogClient
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class VerifyClient:
|
|
23
|
+
"""Cryptographically verify logs, batches, and decisions.
|
|
24
|
+
|
|
25
|
+
Every item recorded by Blocklog is anchored to a blockchain via a
|
|
26
|
+
Merkle tree. These methods let you prove that a specific log entry
|
|
27
|
+
or batch has not been tampered with since it was anchored.
|
|
28
|
+
|
|
29
|
+
Accessed as ``client.verify``.
|
|
30
|
+
|
|
31
|
+
Examples
|
|
32
|
+
--------
|
|
33
|
+
>>> result = client.verify.log("log-uuid-here")
|
|
34
|
+
>>> print(result["status"]) # "verified"
|
|
35
|
+
>>> print(result["merkle_proof"])
|
|
36
|
+
|
|
37
|
+
>>> result = client.verify.decision("dec-uuid-here")
|
|
38
|
+
>>> print(result["status"]) # "verified"
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, client: "BlocklogClient") -> None:
|
|
42
|
+
self._client = client
|
|
43
|
+
|
|
44
|
+
def log(self, log_id: str) -> dict[str, Any]:
|
|
45
|
+
"""Verify a single log entry against its Merkle proof.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
log_id:
|
|
50
|
+
UUID of the log entry to verify.
|
|
51
|
+
|
|
52
|
+
Returns
|
|
53
|
+
-------
|
|
54
|
+
dict
|
|
55
|
+
Keys: ``status``, ``merkle_proof``, ``batch_anchor``,
|
|
56
|
+
``time_attestation``, ``details``.
|
|
57
|
+
|
|
58
|
+
Raises
|
|
59
|
+
------
|
|
60
|
+
httpx.HTTPStatusError
|
|
61
|
+
If the log is not found or verification fails.
|
|
62
|
+
"""
|
|
63
|
+
return self._client.retry.run(
|
|
64
|
+
lambda: self._client.transport.request("GET", f"/verify/log/{log_id}")
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def batch(self, batch_id: str) -> dict[str, Any]:
|
|
68
|
+
"""Verify an entire batch against its blockchain anchor.
|
|
69
|
+
|
|
70
|
+
Parameters
|
|
71
|
+
----------
|
|
72
|
+
batch_id:
|
|
73
|
+
ID of the batch to verify.
|
|
74
|
+
|
|
75
|
+
Returns
|
|
76
|
+
-------
|
|
77
|
+
dict
|
|
78
|
+
Keys: ``status``, ``anchor_tx``, ``timestamp``,
|
|
79
|
+
``time_attestation``, ``details``.
|
|
80
|
+
"""
|
|
81
|
+
return self._client.retry.run(
|
|
82
|
+
lambda: self._client.transport.request("GET", f"/verify/batch/{batch_id}")
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def decision(self, decision_id: str) -> dict[str, Any]:
|
|
86
|
+
"""Verify all evidence attached to a decision.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
decision_id:
|
|
91
|
+
UUID of the decision to verify.
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
dict
|
|
96
|
+
Verification summary including Merkle and blockchain evidence.
|
|
97
|
+
"""
|
|
98
|
+
return self._client.retry.run(
|
|
99
|
+
lambda: self._client.transport.request("GET", f"/decisions/{decision_id}/verify")
|
|
100
|
+
)
|
blocklog/approval.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blocklog.approval
|
|
3
|
+
~~~~~~~~~~~~~~~~~
|
|
4
|
+
Module-level namespace for human approval workflows.
|
|
5
|
+
|
|
6
|
+
Usage (Layer 1)::
|
|
7
|
+
|
|
8
|
+
from blocklog import approval
|
|
9
|
+
|
|
10
|
+
approval.request(
|
|
11
|
+
decision_id="dec_abc123",
|
|
12
|
+
reason="Trade exceeds $500k threshold",
|
|
13
|
+
reviewer="risk-team@fund.com",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
approval.reject(reviewer="alice@fund.com", reason="Insufficient data")
|
|
17
|
+
|
|
18
|
+
approval.escalate(
|
|
19
|
+
from_reviewer="alice@fund.com",
|
|
20
|
+
to_reviewer="head-of-risk@fund.com",
|
|
21
|
+
reason="Requires executive sign-off",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
trail = approval.audit_trail()
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def request(
|
|
32
|
+
decision_id: str | None = None,
|
|
33
|
+
*,
|
|
34
|
+
reason: str,
|
|
35
|
+
reviewer: str | None = None,
|
|
36
|
+
log_id: str | None = None,
|
|
37
|
+
metadata: dict[str, Any] | None = None,
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
"""Request human approval for a decision.
|
|
40
|
+
|
|
41
|
+
This is a **non-blocking** call — it registers the approval request
|
|
42
|
+
and triggers configured webhooks/notifications, then returns
|
|
43
|
+
immediately.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
decision_id:
|
|
48
|
+
UUID of the decision requiring approval.
|
|
49
|
+
reason:
|
|
50
|
+
Human-readable explanation of why approval is needed.
|
|
51
|
+
reviewer:
|
|
52
|
+
Email / identifier of the intended reviewer.
|
|
53
|
+
log_id:
|
|
54
|
+
UUID of a specific log entry, if approval is on a log rather
|
|
55
|
+
than a top-level decision.
|
|
56
|
+
metadata:
|
|
57
|
+
Extra context to store with the approval request.
|
|
58
|
+
"""
|
|
59
|
+
from blocklog._global import get_client
|
|
60
|
+
return get_client().approval.request(
|
|
61
|
+
decision_id=decision_id,
|
|
62
|
+
reason=reason,
|
|
63
|
+
reviewer=reviewer,
|
|
64
|
+
log_id=log_id,
|
|
65
|
+
metadata=metadata,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def reject(reviewer: str, *, reason: str, decision_id: str | None = None) -> dict[str, Any]:
|
|
70
|
+
"""Record that a reviewer has rejected a decision.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
reviewer:
|
|
75
|
+
Identity of the person rejecting.
|
|
76
|
+
reason:
|
|
77
|
+
Explanation for the rejection.
|
|
78
|
+
decision_id:
|
|
79
|
+
Optional reference to the decision being rejected.
|
|
80
|
+
"""
|
|
81
|
+
from blocklog._global import get_client
|
|
82
|
+
return get_client().approval.reject(reviewer=reviewer, reason=reason, decision_id=decision_id)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def escalate(
|
|
86
|
+
from_reviewer: str,
|
|
87
|
+
to_reviewer: str,
|
|
88
|
+
*,
|
|
89
|
+
reason: str,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
"""Escalate an approval request to a different reviewer.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
from_reviewer:
|
|
96
|
+
Current reviewer escalating the decision.
|
|
97
|
+
to_reviewer:
|
|
98
|
+
Target reviewer who should take over.
|
|
99
|
+
reason:
|
|
100
|
+
Explanation for the escalation.
|
|
101
|
+
"""
|
|
102
|
+
from blocklog._global import get_client
|
|
103
|
+
return get_client().approval.escalate(
|
|
104
|
+
from_reviewer=from_reviewer,
|
|
105
|
+
to_reviewer=to_reviewer,
|
|
106
|
+
reason=reason,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def list_overrides() -> list[dict[str, Any]]:
|
|
111
|
+
"""Return all HITL override records for the company."""
|
|
112
|
+
from blocklog._global import get_client
|
|
113
|
+
return get_client().approval.list_overrides()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def audit_trail() -> list[dict[str, Any]]:
|
|
117
|
+
"""Return the full HITL audit trail in reverse-chronological order."""
|
|
118
|
+
from blocklog._global import get_client
|
|
119
|
+
return get_client().approval.audit_trail()
|
blocklog/async_client.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from blocklog.client import BlocklogClient
|
|
4
|
+
from blocklog.config import BlocklogConfig
|
|
5
|
+
from blocklog.models.responses import IngestResponse
|
|
6
|
+
from blocklog.transport.httpx_async import AsyncTransport
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AsyncBlocklogClient(BlocklogClient):
|
|
10
|
+
def __init__(self, config: BlocklogConfig) -> None:
|
|
11
|
+
super().__init__(config)
|
|
12
|
+
self.transport = AsyncTransport(
|
|
13
|
+
base_url=config.base_url,
|
|
14
|
+
api_key=config.api_key,
|
|
15
|
+
timeout=config.timeout,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
async def event(self, event_type: str, payload: dict, **kwargs) -> IngestResponse:
|
|
19
|
+
envelope = self._build_event(event_type=event_type, payload=payload, **kwargs)
|
|
20
|
+
result = await self.transport.request("POST", "/logs", json=self._serialize(envelope))
|
|
21
|
+
return IngestResponse.model_validate(result)
|
|
22
|
+
|
|
23
|
+
async def flush(self, *, batch=None):
|
|
24
|
+
batch = batch or self.buffer.flush()
|
|
25
|
+
if not batch:
|
|
26
|
+
return {"ingested": 0, "log_ids": []}
|
|
27
|
+
payload = {"logs": [self._serialize(item) for item in batch]}
|
|
28
|
+
return await self.transport.request("POST", "/logs/batch", json=payload)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from blocklog.models.events import EventEnvelope
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class EventBuffer:
|
|
10
|
+
batch_size: int
|
|
11
|
+
items: list[EventEnvelope] = field(default_factory=list)
|
|
12
|
+
|
|
13
|
+
def add(self, event: EventEnvelope) -> list[EventEnvelope] | None:
|
|
14
|
+
self.items.append(event)
|
|
15
|
+
if len(self.items) >= self.batch_size:
|
|
16
|
+
return self.flush()
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
def flush(self) -> list[EventEnvelope]:
|
|
20
|
+
drained = list(self.items)
|
|
21
|
+
self.items.clear()
|
|
22
|
+
return drained
|