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
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blocklog.managers.decision
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
The ``decision()`` context manager — the highest-priority surface of the SDK.
|
|
5
|
+
|
|
6
|
+
Usage (Layer 1)::
|
|
7
|
+
|
|
8
|
+
import blocklog
|
|
9
|
+
|
|
10
|
+
blocklog.init(api_key="blk_...")
|
|
11
|
+
|
|
12
|
+
with blocklog.decision(type="BUY", asset="TSLA", confidence=0.87) as d:
|
|
13
|
+
price = fetch_price("TSLA")
|
|
14
|
+
d.record_input(price=price, signal="momentum_crossover")
|
|
15
|
+
|
|
16
|
+
order = place_order("TSLA", qty=100)
|
|
17
|
+
d.record_output(order_id=order.id, filled_at=order.price)
|
|
18
|
+
|
|
19
|
+
if order.value > 500_000:
|
|
20
|
+
d.request_approval(reason="Trade exceeds threshold")
|
|
21
|
+
|
|
22
|
+
# After the block:
|
|
23
|
+
print(d.id) # UUID of the recorded decision
|
|
24
|
+
print(d.verified) # True if blockchain-anchored
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
import traceback as _traceback
|
|
30
|
+
from contextlib import contextmanager
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from typing import Any, Generator
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DecisionContext:
|
|
38
|
+
"""Live handle to a decision being recorded.
|
|
39
|
+
|
|
40
|
+
Obtained from the ``decision()`` context manager. Do not instantiate
|
|
41
|
+
directly.
|
|
42
|
+
|
|
43
|
+
Attributes
|
|
44
|
+
----------
|
|
45
|
+
id : str | None
|
|
46
|
+
The backend-assigned UUID for this decision. Available after the
|
|
47
|
+
context body starts executing (set during ``__enter__``).
|
|
48
|
+
decision_type : str
|
|
49
|
+
The decision type you passed in (e.g. ``"BUY"``, ``"TRADE"``,
|
|
50
|
+
``"APPROVE"``).
|
|
51
|
+
status : str
|
|
52
|
+
``"open"`` while inside the ``with`` block, ``"complete"`` on
|
|
53
|
+
clean exit, ``"error"`` on exception.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
decision_type: str,
|
|
60
|
+
asset: str | None = None,
|
|
61
|
+
confidence: float | None = None,
|
|
62
|
+
metadata: dict[str, Any] | None = None,
|
|
63
|
+
agent_id: str | None = None,
|
|
64
|
+
trace_id: str | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.decision_type = decision_type
|
|
67
|
+
self.asset = asset
|
|
68
|
+
self.confidence = confidence
|
|
69
|
+
self.metadata: dict[str, Any] = metadata or {}
|
|
70
|
+
self.agent_id = agent_id
|
|
71
|
+
self._trace_id = trace_id
|
|
72
|
+
|
|
73
|
+
self.id: str | None = None
|
|
74
|
+
self.status: str = "open"
|
|
75
|
+
self._inputs: list[dict] = []
|
|
76
|
+
self._outputs: list[dict] = []
|
|
77
|
+
self._tags: list[str] = []
|
|
78
|
+
self._started_at: datetime = datetime.now(timezone.utc)
|
|
79
|
+
self._approval_requested: bool = False
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
# Public methods available inside the ``with`` block
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def record_input(self, **kwargs: Any) -> "DecisionContext":
|
|
86
|
+
"""Record structured inputs that fed into this decision.
|
|
87
|
+
|
|
88
|
+
Call this before the AI model / logic runs.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
**kwargs
|
|
93
|
+
Arbitrary key-value pairs describing the inputs. Will be
|
|
94
|
+
stored verbatim and appear in replays and forensic timelines.
|
|
95
|
+
|
|
96
|
+
Examples
|
|
97
|
+
--------
|
|
98
|
+
>>> d.record_input(price=412.50, volume=1_200_000, signal="rsi_oversold")
|
|
99
|
+
"""
|
|
100
|
+
self._inputs.append({"recorded_at": _now_iso(), **kwargs})
|
|
101
|
+
self._send_event("DECISION_INPUT", kwargs)
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
def record_output(self, **kwargs: Any) -> "DecisionContext":
|
|
105
|
+
"""Record structured outputs / results of this decision.
|
|
106
|
+
|
|
107
|
+
Call this after the AI model / logic produces a result.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
**kwargs
|
|
112
|
+
Arbitrary key-value pairs describing the outputs.
|
|
113
|
+
|
|
114
|
+
Examples
|
|
115
|
+
--------
|
|
116
|
+
>>> d.record_output(order_id="ord_88", filled_at=413.10, qty=100)
|
|
117
|
+
"""
|
|
118
|
+
self._outputs.append({"recorded_at": _now_iso(), **kwargs})
|
|
119
|
+
self._send_event("DECISION_OUTPUT", kwargs)
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def tag(self, *tags: str) -> "DecisionContext":
|
|
123
|
+
"""Attach one or more string labels to this decision.
|
|
124
|
+
|
|
125
|
+
Tags appear in the dashboard and can be used to filter/search.
|
|
126
|
+
|
|
127
|
+
Examples
|
|
128
|
+
--------
|
|
129
|
+
>>> d.tag("high-value", "requires-review", "momentum-strategy")
|
|
130
|
+
"""
|
|
131
|
+
self._tags.extend(tags)
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def request_approval(
|
|
135
|
+
self,
|
|
136
|
+
reason: str,
|
|
137
|
+
reviewer: str | None = None,
|
|
138
|
+
) -> "DecisionContext":
|
|
139
|
+
"""Request human approval for this decision (HITL).
|
|
140
|
+
|
|
141
|
+
This is a **non-blocking** call. It records the approval request
|
|
142
|
+
against the decision and notifies the reviewer (via backend
|
|
143
|
+
webhooks / email, as configured in your Blocklog workspace).
|
|
144
|
+
Execution continues; it is the caller's responsibility to gate
|
|
145
|
+
further actions on approval status.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
reason:
|
|
150
|
+
Human-readable explanation for why approval is needed.
|
|
151
|
+
reviewer:
|
|
152
|
+
Optional email / identifier of the intended reviewer.
|
|
153
|
+
|
|
154
|
+
Examples
|
|
155
|
+
--------
|
|
156
|
+
>>> if order_value > 500_000:
|
|
157
|
+
... d.request_approval(
|
|
158
|
+
... reason="Trade exceeds $500k threshold",
|
|
159
|
+
... reviewer="risk-team@fund.com",
|
|
160
|
+
... )
|
|
161
|
+
"""
|
|
162
|
+
self._approval_requested = True
|
|
163
|
+
try:
|
|
164
|
+
from blocklog._global import get_client
|
|
165
|
+
client = get_client()
|
|
166
|
+
client.approval.request(
|
|
167
|
+
decision_id=self.id,
|
|
168
|
+
reason=reason,
|
|
169
|
+
reviewer=reviewer,
|
|
170
|
+
)
|
|
171
|
+
except Exception as exc: # noqa: BLE001
|
|
172
|
+
logger.warning("blocklog: approval.request() failed: %s", exc)
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
def verify(self) -> dict[str, Any]:
|
|
176
|
+
"""Immediately verify this decision against the blockchain anchor.
|
|
177
|
+
|
|
178
|
+
Returns
|
|
179
|
+
-------
|
|
180
|
+
dict
|
|
181
|
+
Verification result from ``GET /decisions/{id}/verify``.
|
|
182
|
+
"""
|
|
183
|
+
if not self.id:
|
|
184
|
+
raise RuntimeError("Decision has not been committed yet.")
|
|
185
|
+
from blocklog._global import get_client
|
|
186
|
+
return get_client().decisions.verify(self.id)
|
|
187
|
+
|
|
188
|
+
# ------------------------------------------------------------------
|
|
189
|
+
# Internal helpers
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def _send_event(self, event_type: str, payload: dict) -> None:
|
|
193
|
+
"""Fire an event log to the ingest endpoint (best-effort)."""
|
|
194
|
+
try:
|
|
195
|
+
from blocklog._global import get_client
|
|
196
|
+
from blocklog.context.vars import get_context
|
|
197
|
+
|
|
198
|
+
ctx = get_context()
|
|
199
|
+
client = get_client()
|
|
200
|
+
client.event(
|
|
201
|
+
event_type,
|
|
202
|
+
payload={
|
|
203
|
+
"decision_id": self.id,
|
|
204
|
+
"decision_type": self.decision_type,
|
|
205
|
+
"asset": self.asset,
|
|
206
|
+
**payload,
|
|
207
|
+
},
|
|
208
|
+
trace_id=str(ctx.trace_id) if ctx else self._trace_id,
|
|
209
|
+
session_id=str(ctx.session_id) if ctx else None,
|
|
210
|
+
actor_id=self.agent_id or (ctx.agent_id if ctx else None),
|
|
211
|
+
actor_type="agent",
|
|
212
|
+
)
|
|
213
|
+
except Exception as exc: # noqa: BLE001
|
|
214
|
+
logger.debug("blocklog: event send failed (%s): %s", event_type, exc)
|
|
215
|
+
|
|
216
|
+
def _commit(self) -> None:
|
|
217
|
+
"""Create the decision record in the backend."""
|
|
218
|
+
try:
|
|
219
|
+
from blocklog._global import get_client
|
|
220
|
+
from blocklog.context.vars import get_context
|
|
221
|
+
|
|
222
|
+
ctx = get_context()
|
|
223
|
+
client = get_client()
|
|
224
|
+
result = client.decisions.create(
|
|
225
|
+
decision_type=self.decision_type,
|
|
226
|
+
asset=self.asset,
|
|
227
|
+
confidence=self.confidence,
|
|
228
|
+
metadata={
|
|
229
|
+
**self.metadata,
|
|
230
|
+
"tags": self._tags,
|
|
231
|
+
"started_at": self._started_at.isoformat(),
|
|
232
|
+
},
|
|
233
|
+
trace_id=str(ctx.trace_id) if ctx else self._trace_id,
|
|
234
|
+
session_id=str(ctx.session_id) if ctx else None,
|
|
235
|
+
agent_id=self.agent_id or (ctx.agent_id if ctx else None),
|
|
236
|
+
)
|
|
237
|
+
self.id = str(result.get("id", result.get("decision_id", "")))
|
|
238
|
+
except Exception as exc: # noqa: BLE001
|
|
239
|
+
logger.warning("blocklog: decision.create() failed: %s", exc)
|
|
240
|
+
|
|
241
|
+
def _complete(self) -> None:
|
|
242
|
+
self.status = "complete"
|
|
243
|
+
self._send_event("DECISION_COMPLETE", {
|
|
244
|
+
"inputs": self._inputs,
|
|
245
|
+
"outputs": self._outputs,
|
|
246
|
+
"tags": self._tags,
|
|
247
|
+
"approval_requested": self._approval_requested,
|
|
248
|
+
"completed_at": _now_iso(),
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
def _error(self, exc: BaseException) -> None:
|
|
252
|
+
self.status = "error"
|
|
253
|
+
self._send_event("DECISION_ERROR", {
|
|
254
|
+
"error_type": type(exc).__name__,
|
|
255
|
+
"error_message": str(exc),
|
|
256
|
+
"traceback": _traceback.format_exc(),
|
|
257
|
+
"tags": self._tags,
|
|
258
|
+
"failed_at": _now_iso(),
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# Public factory
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
@contextmanager
|
|
267
|
+
def decision(
|
|
268
|
+
*,
|
|
269
|
+
type: str, # noqa: A002
|
|
270
|
+
asset: str | None = None,
|
|
271
|
+
confidence: float | None = None,
|
|
272
|
+
metadata: dict[str, Any] | None = None,
|
|
273
|
+
agent_id: str | None = None,
|
|
274
|
+
trace_id: str | None = None,
|
|
275
|
+
) -> Generator[DecisionContext, None, None]:
|
|
276
|
+
"""Context manager for recording an AI decision.
|
|
277
|
+
|
|
278
|
+
Creates a decision record in Blocklog, lets you annotate it with
|
|
279
|
+
inputs/outputs, then automatically closes it on exit — cleanly or
|
|
280
|
+
with an error event if an exception is raised.
|
|
281
|
+
|
|
282
|
+
Parameters
|
|
283
|
+
----------
|
|
284
|
+
type:
|
|
285
|
+
Decision type identifier. Use a short, uppercase string that is
|
|
286
|
+
meaningful in your domain (``"BUY"``, ``"SELL"``, ``"APPROVE"``,
|
|
287
|
+
``"REJECT"``, ``"ROUTE"``, ``"SUMMARISE"``…).
|
|
288
|
+
asset:
|
|
289
|
+
Optional asset or resource this decision is about
|
|
290
|
+
(``"TSLA"``, ``"customer_123"``, ``"invoice_456"``…).
|
|
291
|
+
confidence:
|
|
292
|
+
Optional model confidence score between 0 and 1.
|
|
293
|
+
metadata:
|
|
294
|
+
Arbitrary extra fields stored with the decision record.
|
|
295
|
+
agent_id:
|
|
296
|
+
Override the agent identity for this decision. Normally
|
|
297
|
+
inherited from the surrounding ``@agent`` context.
|
|
298
|
+
trace_id:
|
|
299
|
+
Override the trace ID. Normally inherited automatically.
|
|
300
|
+
|
|
301
|
+
Yields
|
|
302
|
+
------
|
|
303
|
+
DecisionContext
|
|
304
|
+
A live handle you can use to call ``record_input()``,
|
|
305
|
+
``record_output()``, ``tag()``, ``request_approval()``, etc.
|
|
306
|
+
|
|
307
|
+
Examples
|
|
308
|
+
--------
|
|
309
|
+
>>> with blocklog.decision(type="BUY", asset="TSLA", confidence=0.87) as d:
|
|
310
|
+
... d.record_input(price=412.50, signal="momentum")
|
|
311
|
+
... order = place_order("TSLA", qty=100)
|
|
312
|
+
... d.record_output(order_id=order.id)
|
|
313
|
+
"""
|
|
314
|
+
ctx = DecisionContext(
|
|
315
|
+
decision_type=type,
|
|
316
|
+
asset=asset,
|
|
317
|
+
confidence=confidence,
|
|
318
|
+
metadata=metadata,
|
|
319
|
+
agent_id=agent_id,
|
|
320
|
+
trace_id=trace_id,
|
|
321
|
+
)
|
|
322
|
+
ctx._commit()
|
|
323
|
+
try:
|
|
324
|
+
yield ctx
|
|
325
|
+
ctx._complete()
|
|
326
|
+
except BaseException as exc:
|
|
327
|
+
ctx._error(exc)
|
|
328
|
+
raise
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
# Helpers
|
|
333
|
+
# ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
def _now_iso() -> str:
|
|
336
|
+
return datetime.now(timezone.utc).isoformat()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from uuid import UUID, uuid4
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class EventEnvelope(BaseModel):
|
|
8
|
+
event_type: str
|
|
9
|
+
payload: dict
|
|
10
|
+
source: str = "python-sdk"
|
|
11
|
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
12
|
+
idempotency_key: str | None = None
|
|
13
|
+
trace_id: UUID | None = None
|
|
14
|
+
session_id: UUID | None = None
|
|
15
|
+
workflow_id: UUID | None = None
|
|
16
|
+
parent_event_id: UUID | None = None
|
|
17
|
+
root_event_id: UUID | None = None
|
|
18
|
+
span_id: str | None = None
|
|
19
|
+
attempt_no: int = 1
|
|
20
|
+
causality_type: str | None = None
|
|
21
|
+
schema_version: str = "1.0"
|
|
22
|
+
event_version: str = "1.0"
|
|
23
|
+
actor_type: str | None = None
|
|
24
|
+
actor_id: str | None = None
|
|
25
|
+
agent_metadata: dict = Field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SessionContext(BaseModel):
|
|
29
|
+
trace_id: UUID = Field(default_factory=uuid4)
|
|
30
|
+
session_id: UUID = Field(default_factory=uuid4)
|
|
31
|
+
workflow_id: UUID | None = None
|
|
32
|
+
agent_id: str | None = None
|
|
33
|
+
source: str = "python-sdk"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class IngestResponse(BaseModel):
|
|
8
|
+
log_id: str
|
|
9
|
+
company_id: str
|
|
10
|
+
event_type: str
|
|
11
|
+
source: str
|
|
12
|
+
idempotency_key: str | None = None
|
|
13
|
+
timestamp: datetime | None = None
|
|
14
|
+
created_at: datetime
|
|
15
|
+
trace_id: UUID | None = None
|
|
16
|
+
session_id: UUID | None = None
|
|
17
|
+
workflow_id: UUID | None = None
|
|
18
|
+
parent_event_id: UUID | None = None
|
blocklog/replay.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blocklog.replay
|
|
3
|
+
~~~~~~~~~~~~~~~
|
|
4
|
+
Module-level factory for forensic replay sessions.
|
|
5
|
+
|
|
6
|
+
Usage (Layer 1)::
|
|
7
|
+
|
|
8
|
+
import blocklog
|
|
9
|
+
|
|
10
|
+
session = blocklog.replay("trace-abc-123")
|
|
11
|
+
|
|
12
|
+
# Explore what happened
|
|
13
|
+
timeline = session.timeline()
|
|
14
|
+
root_cause = session.root_cause()
|
|
15
|
+
graph = session.causal_graph()
|
|
16
|
+
stale = session.staleness()
|
|
17
|
+
|
|
18
|
+
# What would have happened differently?
|
|
19
|
+
cf = session.counterfactual(token_id="tok_x", modified_inputs={"price": 400})
|
|
20
|
+
|
|
21
|
+
# Compare against another run
|
|
22
|
+
diff = session.compare(other_trace_id="trace-def-456")
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from blocklog.api.replay import ReplaySession
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def replay(
|
|
33
|
+
trace_id: str,
|
|
34
|
+
*,
|
|
35
|
+
token_id: str | None = None,
|
|
36
|
+
metadata: dict[str, Any] | None = None,
|
|
37
|
+
) -> "ReplaySession":
|
|
38
|
+
"""Create a forensic replay session for a trace.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
trace_id:
|
|
43
|
+
The trace to replay. All events associated with this trace will
|
|
44
|
+
be reconstructed into a timeline, causal graph, and analysis.
|
|
45
|
+
token_id:
|
|
46
|
+
Execution token ID to bind to the session (required for
|
|
47
|
+
counterfactual simulations).
|
|
48
|
+
metadata:
|
|
49
|
+
Optional extra metadata for the replay session.
|
|
50
|
+
|
|
51
|
+
Returns
|
|
52
|
+
-------
|
|
53
|
+
ReplaySession
|
|
54
|
+
A session handle with methods for timeline, root_cause,
|
|
55
|
+
causal_graph, staleness, divergence, counterfactual, and compare.
|
|
56
|
+
|
|
57
|
+
Examples
|
|
58
|
+
--------
|
|
59
|
+
>>> session = blocklog.replay("trace-abc")
|
|
60
|
+
>>> cause = session.root_cause()
|
|
61
|
+
>>> print(cause["description"])
|
|
62
|
+
"""
|
|
63
|
+
from blocklog._global import get_client
|
|
64
|
+
return get_client().replay.create(trace_id=trace_id, token_id=token_id, metadata=metadata)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from hashlib import sha256
|
|
2
|
+
|
|
3
|
+
from .canonical import canonical_json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def hash_sign(payload: dict, private_key: str | None = None) -> str:
|
|
7
|
+
"""Generate a deterministic hash signature for a payload.
|
|
8
|
+
|
|
9
|
+
This is NOT cryptographic Ed25519 signing. It generates a deterministic
|
|
10
|
+
SHA256 hash of the payload combined with a seed/key for tamper-evidence
|
|
11
|
+
purposes. For true cryptographic signing, use the cryptography library.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
payload: The payload to hash
|
|
15
|
+
private_key: Optional seed/key for the hash (defaults to "blocklog")
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Hexadecimal SHA256 hash string
|
|
19
|
+
"""
|
|
20
|
+
seed = private_key or "blocklog"
|
|
21
|
+
return sha256(seed.encode("utf-8") + canonical_json(payload)).hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Backward compatibility alias
|
|
25
|
+
pseudo_sign = hash_sign
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
import httpx
|
|
5
|
+
except ModuleNotFoundError: # pragma: no cover - exercised in local fallback mode
|
|
6
|
+
httpx = None
|
|
7
|
+
import requests
|
|
8
|
+
else:
|
|
9
|
+
requests = None
|
|
10
|
+
|
|
11
|
+
from .auth import build_headers
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AsyncTransport:
|
|
15
|
+
def __init__(self, *, base_url: str, api_key: str, timeout: float) -> None:
|
|
16
|
+
self.base_url = base_url.rstrip("/")
|
|
17
|
+
self.api_key = api_key
|
|
18
|
+
self.timeout = timeout
|
|
19
|
+
self.client = httpx.AsyncClient(timeout=timeout) if httpx is not None else None
|
|
20
|
+
|
|
21
|
+
async def request(self, method: str, path: str, *, json: dict | None = None, headers: dict[str, str] | None = None):
|
|
22
|
+
if self.client is not None:
|
|
23
|
+
response = await self.client.request(
|
|
24
|
+
method,
|
|
25
|
+
f"{self.base_url}{path}",
|
|
26
|
+
json=json,
|
|
27
|
+
headers=build_headers(self.api_key, headers),
|
|
28
|
+
)
|
|
29
|
+
else:
|
|
30
|
+
response = await asyncio.to_thread(
|
|
31
|
+
requests.request,
|
|
32
|
+
method,
|
|
33
|
+
f"{self.base_url}{path}",
|
|
34
|
+
json=json,
|
|
35
|
+
headers=build_headers(self.api_key, headers),
|
|
36
|
+
timeout=self.timeout,
|
|
37
|
+
)
|
|
38
|
+
response.raise_for_status()
|
|
39
|
+
return response.json()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
try:
|
|
2
|
+
import httpx
|
|
3
|
+
except ModuleNotFoundError: # pragma: no cover - exercised in local fallback mode
|
|
4
|
+
httpx = None
|
|
5
|
+
import requests
|
|
6
|
+
else:
|
|
7
|
+
requests = None
|
|
8
|
+
|
|
9
|
+
from .auth import build_headers
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SyncTransport:
|
|
13
|
+
def __init__(self, *, base_url: str, api_key: str, timeout: float) -> None:
|
|
14
|
+
self.base_url = base_url.rstrip("/")
|
|
15
|
+
self.api_key = api_key
|
|
16
|
+
self.timeout = timeout
|
|
17
|
+
self.client = httpx.Client(timeout=timeout) if httpx is not None else None
|
|
18
|
+
|
|
19
|
+
def request(self, method: str, path: str, *, json: dict | None = None, headers: dict[str, str] | None = None):
|
|
20
|
+
if self.client is not None:
|
|
21
|
+
response = self.client.request(
|
|
22
|
+
method,
|
|
23
|
+
f"{self.base_url}{path}",
|
|
24
|
+
json=json,
|
|
25
|
+
headers=build_headers(self.api_key, headers),
|
|
26
|
+
)
|
|
27
|
+
else:
|
|
28
|
+
response = requests.request(
|
|
29
|
+
method,
|
|
30
|
+
f"{self.base_url}{path}",
|
|
31
|
+
json=json,
|
|
32
|
+
headers=build_headers(self.api_key, headers),
|
|
33
|
+
timeout=self.timeout,
|
|
34
|
+
)
|
|
35
|
+
response.raise_for_status()
|
|
36
|
+
return response.json()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from random import random
|
|
5
|
+
from time import sleep
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class RetryPolicy:
|
|
10
|
+
max_retries: int = 3
|
|
11
|
+
base_delay: float = 0.25
|
|
12
|
+
|
|
13
|
+
def backoff(self, attempt: int) -> float:
|
|
14
|
+
return self.base_delay * (2 ** attempt) + random() * 0.1
|
|
15
|
+
|
|
16
|
+
def run(self, fn):
|
|
17
|
+
last_error = None
|
|
18
|
+
for attempt in range(self.max_retries):
|
|
19
|
+
try:
|
|
20
|
+
return fn()
|
|
21
|
+
except Exception as exc: # noqa: BLE001
|
|
22
|
+
last_error = exc
|
|
23
|
+
if attempt == self.max_retries - 1:
|
|
24
|
+
raise
|
|
25
|
+
sleep(self.backoff(attempt))
|
|
26
|
+
raise RuntimeError("retry failed") from last_error
|
blocklog/verify.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blocklog.verify
|
|
3
|
+
~~~~~~~~~~~~~~~
|
|
4
|
+
Module-level namespace for cryptographic verification.
|
|
5
|
+
|
|
6
|
+
Usage (Layer 1)::
|
|
7
|
+
|
|
8
|
+
import blocklog
|
|
9
|
+
|
|
10
|
+
result = blocklog.verify.log("log-uuid-here")
|
|
11
|
+
result = blocklog.verify.batch("batch-uuid-here")
|
|
12
|
+
result = blocklog.verify.decision("dec-uuid-here")
|
|
13
|
+
|
|
14
|
+
print(result["status"]) # "verified"
|
|
15
|
+
print(result["time_attestation"])
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def log(log_id: str) -> dict[str, Any]:
|
|
23
|
+
"""Verify a single log entry against its Merkle proof.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
log_id:
|
|
28
|
+
UUID of the log entry to verify.
|
|
29
|
+
|
|
30
|
+
Returns
|
|
31
|
+
-------
|
|
32
|
+
dict
|
|
33
|
+
Keys: ``status``, ``merkle_proof``, ``batch_anchor``,
|
|
34
|
+
``time_attestation``, ``details``.
|
|
35
|
+
"""
|
|
36
|
+
from blocklog._global import get_client
|
|
37
|
+
return get_client().verify.log(log_id)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def batch(batch_id: str) -> dict[str, Any]:
|
|
41
|
+
"""Verify an entire batch against its blockchain anchor.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
batch_id:
|
|
46
|
+
ID of the batch to verify.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
dict
|
|
51
|
+
Keys: ``status``, ``anchor_tx``, ``timestamp``,
|
|
52
|
+
``time_attestation``, ``details``.
|
|
53
|
+
"""
|
|
54
|
+
from blocklog._global import get_client
|
|
55
|
+
return get_client().verify.batch(batch_id)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def decision(decision_id: str) -> dict[str, Any]:
|
|
59
|
+
"""Verify all evidence attached to a specific decision.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
decision_id:
|
|
64
|
+
UUID of the decision to verify.
|
|
65
|
+
|
|
66
|
+
Returns
|
|
67
|
+
-------
|
|
68
|
+
dict
|
|
69
|
+
Verification summary including Merkle and blockchain evidence.
|
|
70
|
+
"""
|
|
71
|
+
from blocklog._global import get_client
|
|
72
|
+
return get_client().verify.decision(decision_id)
|