openbox-langgraph-sdk-python 0.1.2__py3-none-any.whl → 1.0.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.
@@ -17,6 +17,8 @@ Example:
17
17
  ... )
18
18
  """
19
19
 
20
+ __version__ = "1.0.0"
21
+
20
22
  from openbox_langgraph.client import GovernanceClient, build_auth_headers
21
23
  from openbox_langgraph.config import (
22
24
  GovernanceConfig,
@@ -32,11 +34,19 @@ from openbox_langgraph.errors import (
32
34
  GovernanceHaltError,
33
35
  GuardrailsValidationError,
34
36
  OpenBoxAuthError,
37
+ OpenBoxConfigError,
35
38
  OpenBoxError,
36
39
  OpenBoxInsecureURLError,
37
40
  OpenBoxNetworkError,
38
41
  )
39
42
  from openbox_langgraph.hitl import poll_until_decision
43
+ from openbox_langgraph.identity import (
44
+ AgentIdentityConfig,
45
+ build_agent_identity_canonical_request,
46
+ create_agent_identity_headers,
47
+ parse_optional_agent_identity_config,
48
+ validate_agent_identity_config,
49
+ )
40
50
  from openbox_langgraph.langgraph_handler import (
41
51
  OpenBoxLangGraphHandler,
42
52
  OpenBoxLangGraphHandlerOptions,
@@ -79,6 +89,7 @@ from openbox_langgraph.verdict_handler import (
79
89
 
80
90
  __all__ = [
81
91
  "DEFAULT_HITL_CONFIG",
92
+ "AgentIdentityConfig",
82
93
  "ApprovalExpiredError",
83
94
  "ApprovalRejectedError",
84
95
  "ApprovalResponse",
@@ -95,6 +106,7 @@ __all__ = [
95
106
  "LangChainGovernanceEvent",
96
107
  "LangGraphStreamEvent",
97
108
  "OpenBoxAuthError",
109
+ "OpenBoxConfigError",
98
110
  "OpenBoxError",
99
111
  "OpenBoxInsecureURLError",
100
112
  "OpenBoxLangGraphHandler",
@@ -105,7 +117,10 @@ __all__ = [
105
117
  "WorkflowEventType",
106
118
  "WorkflowSpanBuffer",
107
119
  "WorkflowSpanProcessor",
120
+ "__version__",
121
+ "build_agent_identity_canonical_request",
108
122
  "build_auth_headers",
123
+ "create_agent_identity_headers",
109
124
  "create_openbox_graph_handler",
110
125
  "create_span",
111
126
  "enforce_verdict",
@@ -117,12 +132,14 @@ __all__ = [
117
132
  "merge_config",
118
133
  "parse_approval_response",
119
134
  "parse_governance_response",
135
+ "parse_optional_agent_identity_config",
120
136
  "poll_until_decision",
121
137
  "rfc3339_now",
122
138
  "safe_serialize",
123
139
  "setup_opentelemetry_for_governance",
124
140
  "to_server_event_type",
125
141
  "traced",
142
+ "validate_agent_identity_config",
126
143
  "verdict_from_string",
127
144
  "verdict_priority",
128
145
  "verdict_requires_approval",
@@ -0,0 +1,126 @@
1
+ """Dual-write ActivityContext registration + per-turn cleanup for the opt-in
2
+ base-SDK core runtime.
3
+
4
+ `langgraph_handler.py` already registers trace/activity correlation with the
5
+ legacy `WorkflowSpanProcessor` at every tool/LLM start and clears it at
6
+ completion — that dual-write's counterpart lands here so the (opt-in, later
7
+ phase) core hook runtime can resolve the SAME activity via
8
+ `TraceContextRegistry` without the legacy processor's behavior changing by one
9
+ line. This module is INERT unless a handler has an actual core runtime — see
10
+ `should_dual_write`.
11
+
12
+ Trace-only, never a ContextVar bind: LangGraph tool/LLM execution runs inside
13
+ a spawned `asyncio.Task` (or, for sync tools, a `run_in_executor` worker
14
+ thread) that already snapshot its ContextVar chain before this module's
15
+ registration call can run — a bind here can never reach that code. See
16
+ `core_runtime.py`'s module docstring and `tests/test_contextvars_propagation.py`
17
+ for the empirical proof.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ from openbox_core.contracts.context import ActivityContext
25
+ from openbox_core.runtime import OpenBoxRuntime
26
+
27
+ from openbox_langgraph.config import GovernanceConfig
28
+ from openbox_langgraph.core_runtime import get_trace_registry
29
+
30
+ __all__ = [
31
+ "build_activity_context",
32
+ "register_activity",
33
+ "should_dual_write",
34
+ "unregister_activity",
35
+ ]
36
+
37
+
38
+ def should_dual_write(core_runtime: OpenBoxRuntime | None) -> bool:
39
+ """True only when the handler owns a real core runtime.
40
+
41
+ Handlers built with an injected `client` (e.g. a test double, or a
42
+ subclass overriding `evaluate_event`) have `core_runtime is None` by
43
+ construction (`langgraph_handler.py.__init__`) — this dual-write is
44
+ legacy-only for them, exactly like the gate-routing it mirrors.
45
+ """
46
+ return core_runtime is not None
47
+
48
+
49
+ def build_activity_context(
50
+ *,
51
+ config: GovernanceConfig,
52
+ workflow_id: str,
53
+ run_id: str,
54
+ activity_id: str,
55
+ activity_type: str,
56
+ activity_input: Any = None,
57
+ langgraph_node: str | None = None,
58
+ langgraph_step: int | None = None,
59
+ tool_type: str | None = None,
60
+ tool_name: str | None = None,
61
+ tool_call_id: str | None = None,
62
+ subagent_name: str | None = None,
63
+ parent_ids: list[str] | None = None,
64
+ ) -> ActivityContext:
65
+ """Map one LangGraph activity boundary onto the base SDK's `ActivityContext`.
66
+
67
+ `workflow_id`/`run_id` are the PER-TURN ids `langgraph_handler.py` mints in
68
+ `ainvoke`/`astream_governed`/`astream`/`astream_events` (NOT LangGraph's own
69
+ per-node `run_id`, which becomes `activity_id` instead) — matching the
70
+ legacy governance event's own workflow_id/run_id fields exactly, so a hook
71
+ resolving this context reports the same turn a dashboard operator sees
72
+ from the legacy path.
73
+ """
74
+ metadata: dict[str, Any] = {}
75
+ if langgraph_node is not None:
76
+ metadata["node"] = langgraph_node
77
+ if langgraph_step is not None:
78
+ metadata["step"] = langgraph_step
79
+ if tool_type is not None:
80
+ metadata["tool_type"] = tool_type
81
+ if tool_name is not None:
82
+ metadata["tool_name"] = tool_name
83
+ if tool_call_id is not None:
84
+ metadata["tool_call_id"] = tool_call_id
85
+ if subagent_name is not None:
86
+ metadata["subagent_name"] = subagent_name
87
+ if parent_ids:
88
+ metadata["parent_ids"] = list(parent_ids)
89
+
90
+ return ActivityContext(
91
+ workflow_id=workflow_id,
92
+ run_id=run_id,
93
+ workflow_type=config.agent_name or "LangGraphRun",
94
+ task_queue=config.task_queue or "langgraph",
95
+ activity_id=activity_id,
96
+ activity_type=activity_type,
97
+ activity_input=activity_input,
98
+ agent_name=config.agent_name,
99
+ session_id=config.session_id,
100
+ multi_agent_session_id=config.multi_agent_session_id,
101
+ metadata=metadata,
102
+ )
103
+
104
+
105
+ def register_activity(
106
+ core_runtime: OpenBoxRuntime | None,
107
+ trace_id: int,
108
+ ctx: ActivityContext,
109
+ ) -> None:
110
+ """Trace-only dual-write into the runtime's private `TraceContextRegistry`.
111
+
112
+ No-op when `core_runtime` is `None` (injected-client handlers) — see
113
+ `should_dual_write`. Skips a zero/falsy `trace_id` the same way the legacy
114
+ `if trace_id:` guard at the call site already does, so a degraded OTel
115
+ span (no active provider) never registers a bogus all-zero correlation.
116
+ """
117
+ if core_runtime is None or not trace_id:
118
+ return
119
+ get_trace_registry(core_runtime).register(trace_id, ctx)
120
+
121
+
122
+ def unregister_activity(core_runtime: OpenBoxRuntime | None, trace_id: int | None) -> None:
123
+ """Counterpart cleanup for `register_activity` — activity completion."""
124
+ if core_runtime is None or not trace_id:
125
+ return
126
+ get_trace_registry(core_runtime).unregister(trace_id)
@@ -2,14 +2,26 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import os
6
7
  from dataclasses import dataclass
7
- from datetime import UTC
8
- from typing import Any
8
+ from typing import TYPE_CHECKING, Any
9
9
 
10
10
  import httpx
11
-
12
- from openbox_langgraph.errors import OpenBoxNetworkError
11
+ from openbox_core.client import check_expiration
12
+ from openbox_core.contracts.results import EvaluationResult
13
+ from openbox_core.contracts.results import Verdict as _CoreVerdict
14
+ from openbox_core.errors import ContractError as _CoreContractError
15
+ from openbox_core.errors import GovernanceAPIError as _CoreGovernanceAPIError
16
+ from openbox_core.errors import OpenBoxNetworkError as _CoreOpenBoxNetworkError
17
+
18
+ from openbox_langgraph.core_events import to_envelope
19
+ from openbox_langgraph.errors import OpenBoxConfigError, OpenBoxNetworkError
20
+ from openbox_langgraph.identity import (
21
+ AgentIdentityConfig,
22
+ create_agent_identity_headers,
23
+ parse_optional_agent_identity_config,
24
+ )
13
25
  from openbox_langgraph.types import (
14
26
  ApprovalResponse,
15
27
  GovernanceVerdictResponse,
@@ -19,20 +31,141 @@ from openbox_langgraph.types import (
19
31
  to_server_event_type,
20
32
  )
21
33
 
22
- _SDK_VERSION = "0.1.0"
34
+ if TYPE_CHECKING:
35
+ from openbox_core.gate import GovernanceGate
36
+
37
+ _SDK_PACKAGE_VERSION = "1.0.0"
38
+ _SDK_IDENTIFIER = f"openbox-langgraph-python-v{_SDK_PACKAGE_VERSION}"
39
+
40
+
41
+ def _network_fallback_result(on_api_error: str, msg: str) -> GovernanceVerdictResponse | None:
42
+ """Apply the `on_api_error` policy to a NETWORK/transport failure.
43
+
44
+ This is the ONLY place a `GovernanceClient` verdict call may return
45
+ `None` — it corresponds exactly to a client-synthesized fallback
46
+ (`EvaluationResult.fallback_allow`): `verdict=ALLOW`, `fallback_used=True`,
47
+ and an EMPTY `raw` dict (nothing was ever parsed from a real body).
48
+
49
+ A response BODY that happens to be `{"verdict": "block",
50
+ "fallback_used": true}` never reaches this function — it is parsed by
51
+ `_verdict_from_response_data` instead, whose `raw` is always the
52
+ non-empty parsed dict, so the None-collapse below can never fire for it
53
+ and the BLOCK is enforced normally.
54
+
55
+ `msg` is the caller's fully-formatted message (kept caller-side so the
56
+ two distinct failure messages — "Governance API error: HTTP {status}"
57
+ for a bad response, "Governance API unreachable: {e}" for a raised
58
+ exception — stay exactly as they were before this translation layer).
59
+ """
60
+ if on_api_error == "fail_closed":
61
+ raise OpenBoxNetworkError(msg)
62
+ result = EvaluationResult.fallback_allow(msg)
63
+ return _collapse_client_synthesized_fallback(result)
64
+
65
+
66
+ def _verdict_from_response_data(data: dict[str, Any]) -> GovernanceVerdictResponse:
67
+ """Parse a real HTTP response body into a `GovernanceVerdictResponse`.
68
+
69
+ Routed through the base SDK's `EvaluationResult.from_dict` so `raw` is
70
+ always the full parsed body — the non-empty `raw` is exactly what keeps
71
+ `_collapse_client_synthesized_fallback` from ever mistaking a real
72
+ (even oddly-shaped) Core response for a client-side fallback.
73
+ """
74
+ return GovernanceVerdictResponse.from_result(EvaluationResult.from_dict(data))
75
+
23
76
 
77
+ def _collapse_client_synthesized_fallback(
78
+ result: EvaluationResult,
79
+ ) -> GovernanceVerdictResponse | None:
80
+ """Return `None` ONLY for a client-synthesized fail-open fallback.
24
81
 
25
- def build_auth_headers(api_key: str) -> dict[str, str]:
82
+ The three-part discriminator matches `EvaluationResult.fallback_allow`
83
+ exactly and nothing else: `fallback_used=True` AND `verdict is ALLOW` AND
84
+ `raw` is empty (no real body was ever parsed). A response body that
85
+ happens to carry `fallback_used: true` alongside a blocking verdict, or
86
+ alongside ALLOW but WITH a real (non-empty) body, is a real Core response
87
+ and must be returned as a `GovernanceVerdictResponse` so callers enforce
88
+ it — never silently collapsed to `None`/implicit-ALLOW.
89
+ """
90
+ if result.fallback_used and result.verdict is _CoreVerdict.ALLOW and not result.raw:
91
+ return None
92
+ return GovernanceVerdictResponse.from_result(result)
93
+
94
+
95
+ async def _gate_evaluate(
96
+ gate: GovernanceGate, event: LangChainGovernanceEvent, on_api_error: str
97
+ ) -> GovernanceVerdictResponse | None:
98
+ """Evaluate one lifecycle event through the base SDK's strict gate.
99
+
100
+ The single translation seam between `gate.aevaluate`'s base-SDK contract
101
+ (`EvaluationResult`, `openbox_core` exceptions) and this SDK's own
102
+ (`GovernanceVerdictResponse | None`, `openbox_langgraph.errors`) — every
103
+ gate-routed call site in `evaluate_event` goes through this function so
104
+ the translation is defined exactly once. Outcome policy (mirrors the legacy
105
+ httpx path so the wired transport is behaviourally interchangeable):
106
+
107
+ - `EvaluationResult.fallback_allow()` (client-synthesized fail-open on a
108
+ NETWORK error): collapsed to `None` via the same discriminator used for
109
+ the legacy path, so the pre-screen-`None` -> callback re-evaluation ->
110
+ PII-redaction flow keeps firing regardless of which transport produced it.
111
+ - `ContractError` (a malformed envelope — a bug in THIS SDK's own
112
+ event->envelope mapping, raised pre-network by the strict gate, never
113
+ from a Core response): ALWAYS a fail-open telemetry-drop (`None`),
114
+ independent of `on_api_error`. Enforcing fail_closed here would let an
115
+ SDK-side mapping defect block a user's graph for a reason their OWN policy
116
+ never produced — strictly worse than dropping one governance event.
117
+ - `GovernanceAPIError` / `OpenBoxNetworkError` (network-shaped failure) ->
118
+ this SDK's `OpenBoxNetworkError`, same public exception the legacy path
119
+ raises under fail_closed.
120
+ - Any OTHER exception (e.g. a malformed Core 200 body the base parser
121
+ cannot decode) is a transport-shaped fault, NOT a governance verdict:
122
+ routed through `_network_fallback_result` so fail_open returns `None`
123
+ (never crash the graph on a Core hiccup) and fail_closed raises
124
+ `OpenBoxNetworkError` — matching the legacy httpx catch-all exactly.
125
+ """
126
+ try:
127
+ result = await gate.aevaluate(to_envelope(event))
128
+ except _CoreContractError:
129
+ return None
130
+ except (_CoreGovernanceAPIError, _CoreOpenBoxNetworkError) as e:
131
+ raise OpenBoxNetworkError(str(e)) from e
132
+ except Exception as e:
133
+ return _network_fallback_result(on_api_error, f"Governance gate error: {e}")
134
+ return _collapse_client_synthesized_fallback(result)
135
+
136
+
137
+ def build_auth_headers(
138
+ api_key: str,
139
+ *,
140
+ method: str | None = None,
141
+ pathname: str | None = None,
142
+ body: bytes | str | None = None,
143
+ agent_identity: AgentIdentityConfig | None = None,
144
+ ) -> dict[str, str]:
26
145
  """Build standard auth headers for governance API calls.
27
146
 
28
- Single source of truth — used by GovernanceClient and hook_governance.
147
+ Single source of truth for the SDK's outbound governance requests.
29
148
  """
30
- return {
149
+ headers = {
31
150
  "Authorization": f"Bearer {api_key}",
32
151
  "Content-Type": "application/json",
33
- "User-Agent": f"OpenBox-LangGraph-SDK/{_SDK_VERSION}",
34
- "X-OpenBox-SDK-Version": _SDK_VERSION,
152
+ "User-Agent": f"OpenBox-LangGraph-SDK/{_SDK_IDENTIFIER}",
153
+ "X-OpenBox-SDK-Version": _SDK_IDENTIFIER,
35
154
  }
155
+ if agent_identity:
156
+ if method is None or pathname is None:
157
+ msg = "method and pathname are required when signing OpenBox requests."
158
+ raise OpenBoxConfigError(msg)
159
+ headers.update(
160
+ create_agent_identity_headers(
161
+ did=agent_identity.did,
162
+ private_key=agent_identity.private_key,
163
+ method=method,
164
+ pathname=pathname,
165
+ body=body,
166
+ )
167
+ )
168
+ return headers
36
169
 
37
170
 
38
171
  @dataclass
@@ -58,6 +191,9 @@ class GovernanceClient:
58
191
  api_key: str,
59
192
  timeout: float = 30.0, # seconds
60
193
  on_api_error: str = "fail_open",
194
+ agent_did: str | None = None,
195
+ agent_private_key: str | None = None,
196
+ gate: GovernanceGate | None = None,
61
197
  ) -> None:
62
198
  self._api_url = api_url.rstrip("/")
63
199
  self._api_key = api_key
@@ -65,10 +201,23 @@ class GovernanceClient:
65
201
  self._on_api_error = on_api_error
66
202
  self._client: httpx.AsyncClient | None = None
67
203
  self._sync_client: httpx.Client | None = None
68
- self._cached_headers = build_auth_headers(api_key)
204
+ self._agent_identity = parse_optional_agent_identity_config(
205
+ did=agent_did,
206
+ private_key=agent_private_key,
207
+ )
208
+ # Optional base-SDK gate. When wired (by the handler, from a core
209
+ # runtime built off the SAME api_url/api_key/timeout/on_api_error),
210
+ # `evaluate_event`'s ASYNC path routes lifecycle events through it
211
+ # instead of this client's own httpx transport — see `evaluate_event`.
212
+ # `None` (the default) preserves the exact legacy transport/serialization
213
+ # for every existing caller that constructs a bare `GovernanceClient()`.
214
+ # `evaluate_event_sync` (sync middleware hooks) is unaffected either way.
215
+ self._gate = gate
69
216
  # Deduplication: prevent sending the same (activity_id, event_type) twice
70
217
  # within the same workflow run. Keyed by (workflow_id, run_id) so it resets
71
- # automatically on each new ainvoke() call.
218
+ # automatically on each new ainvoke() call. Shared by every evaluate_event
219
+ # call site regardless of which transport (gate or legacy httpx) is active
220
+ # for a given call — dedup is a client-level concern, not a transport one.
72
221
  self._dedup_run: tuple[str, str] | None = None
73
222
  self._dedup_sent: set[tuple[str, str]] = set()
74
223
 
@@ -110,7 +259,11 @@ class GovernanceClient:
110
259
  client = self._get_client()
111
260
  response = await client.get(
112
261
  f"{self._api_url}/api/v1/auth/validate",
113
- headers=self._headers(),
262
+ headers=self._headers(
263
+ method="GET",
264
+ pathname="/api/v1/auth/validate",
265
+ body=b"",
266
+ ),
114
267
  )
115
268
  if response.status_code in (401, 403):
116
269
  msg = "Invalid API key. Check your API key at dashboard.openbox.ai"
@@ -150,13 +303,23 @@ class GovernanceClient:
150
303
  """Send a governance event to OpenBox Core and return the verdict.
151
304
 
152
305
  Returns `None` on network failure when `on_api_error` is `fail_open`.
153
- Silently drops duplicate (activity_id, event_type) pairs within the same run.
306
+ Silently drops duplicate (activity_id, event_type) pairs within the same run
307
+ — this de-dup pre-check runs BEFORE either transport below, so it applies
308
+ identically whether a `gate` is wired or not.
309
+
310
+ When a `gate` was supplied at construction (see `__init__`), the event is
311
+ routed through the base SDK's `EventEnvelope` + `GovernanceGate.aevaluate`
312
+ instead of this client's own httpx transport — see `_gate_evaluate`.
313
+ Overriding `evaluate_event` in a subclass (e.g. the golden-fixture
314
+ harness's `RecordingGovernanceClient`) still fully intercepts either way,
315
+ since the branch lives inside THIS method, never at a call site.
154
316
 
155
317
  Args:
156
318
  event: The governance event payload to evaluate.
157
319
 
158
320
  Raises:
159
- OpenBoxNetworkError: On network failure when `on_api_error` is `fail_closed`.
321
+ OpenBoxNetworkError: On network failure when `on_api_error` is `fail_closed`
322
+ (from either transport).
160
323
  """
161
324
  server_event_type = to_server_event_type(event.event_type)
162
325
  if event.activity_id and self._is_duplicate(
@@ -169,41 +332,49 @@ class GovernanceClient:
169
332
  )
170
333
  return None
171
334
 
172
- payload = event.to_dict()
173
- payload["event_type"] = server_event_type
174
- payload["task_queue"] = event.task_queue or "langgraph"
175
- payload["source"] = "workflow-telemetry"
176
-
177
335
  if os.environ.get("OPENBOX_DEBUG") == "1":
178
336
  import json
337
+
179
338
  print(
180
- f"[OpenBox Debug] governance request: {json.dumps(payload, indent=2, default=str)}"
339
+ f"[OpenBox Debug] governance request: "
340
+ f"{json.dumps(event.to_dict(), indent=2, default=str)}"
181
341
  )
182
342
 
343
+ if self._gate is not None:
344
+ return await _gate_evaluate(self._gate, event, self._on_api_error)
345
+
346
+ payload = event.to_dict()
347
+ payload["event_type"] = server_event_type
348
+ payload["task_queue"] = event.task_queue or "langgraph"
349
+ payload["source"] = "workflow-telemetry"
350
+
183
351
  try:
184
352
  client = self._get_client()
353
+ body = _json_body(payload)
185
354
  response = await client.post(
186
355
  f"{self._api_url}/api/v1/governance/evaluate",
187
- headers=self._headers(),
188
- json=payload,
356
+ headers=self._headers(
357
+ method="POST",
358
+ pathname="/api/v1/governance/evaluate",
359
+ body=body,
360
+ ),
361
+ content=body,
189
362
  )
190
363
 
191
364
  if not response.is_success:
192
- if self._on_api_error == "fail_closed":
193
- msg = f"Governance API error: HTTP {response.status_code}"
194
- raise OpenBoxNetworkError(msg)
195
- return None
365
+ return _network_fallback_result(
366
+ self._on_api_error, f"Governance API error: HTTP {response.status_code}"
367
+ )
196
368
 
197
369
  data = response.json()
198
- return GovernanceVerdictResponse.from_dict(data)
370
+ return _verdict_from_response_data(data)
199
371
 
200
372
  except OpenBoxNetworkError:
201
373
  raise
202
374
  except Exception as e:
203
- if self._on_api_error == "fail_closed":
204
- msg = f"Governance API unreachable: {e}"
205
- raise OpenBoxNetworkError(msg) from e
206
- return None
375
+ return _network_fallback_result(
376
+ self._on_api_error, f"Governance API unreachable: {e}"
377
+ )
207
378
 
208
379
  def evaluate_event_sync(
209
380
  self, event: LangChainGovernanceEvent
@@ -226,6 +397,7 @@ class GovernanceClient:
226
397
 
227
398
  if os.environ.get("OPENBOX_DEBUG") == "1":
228
399
  import json
400
+
229
401
  print(
230
402
  "[OpenBox Debug] sync governance request:"
231
403
  f" {json.dumps(payload, indent=2, default=str)}"
@@ -233,32 +405,33 @@ class GovernanceClient:
233
405
 
234
406
  try:
235
407
  client = self._get_sync_client()
408
+ body = _json_body(payload)
236
409
  response = client.post(
237
410
  f"{self._api_url}/api/v1/governance/evaluate",
238
- headers=self._headers(),
239
- json=payload,
411
+ headers=self._headers(
412
+ method="POST",
413
+ pathname="/api/v1/governance/evaluate",
414
+ body=body,
415
+ ),
416
+ content=body,
240
417
  )
241
418
 
242
419
  if not response.is_success:
243
- if self._on_api_error == "fail_closed":
244
- msg = f"Governance API error: HTTP {response.status_code}"
245
- raise OpenBoxNetworkError(msg)
246
- return None
420
+ return _network_fallback_result(
421
+ self._on_api_error, f"Governance API error: HTTP {response.status_code}"
422
+ )
247
423
 
248
424
  data = response.json()
249
- return GovernanceVerdictResponse.from_dict(data)
425
+ return _verdict_from_response_data(data)
250
426
 
251
427
  except OpenBoxNetworkError:
252
428
  raise
253
429
  except Exception as e:
254
- if self._on_api_error == "fail_closed":
255
- msg = f"Governance API unreachable: {e}"
256
- raise OpenBoxNetworkError(msg) from e
257
- return None
430
+ return _network_fallback_result(
431
+ self._on_api_error, f"Governance API unreachable: {e}"
432
+ )
258
433
 
259
- async def poll_approval(
260
- self, params: ApprovalPollParams
261
- ) -> ApprovalResponse | None:
434
+ async def poll_approval(self, params: ApprovalPollParams) -> ApprovalResponse | None:
262
435
  """Poll for HITL approval status.
263
436
 
264
437
  Returns `None` on network failure so the caller can retry.
@@ -268,39 +441,41 @@ class GovernanceClient:
268
441
  """
269
442
  try:
270
443
  client = self._get_client()
271
- response = await client.post(
272
- f"{self._api_url}/api/v1/governance/approval",
273
- headers=self._headers(),
274
- json={
444
+ body = _json_body(
445
+ {
275
446
  "workflow_id": params.workflow_id,
276
447
  "run_id": params.run_id,
277
448
  "activity_id": params.activity_id,
278
- },
449
+ }
450
+ )
451
+ response = await client.post(
452
+ f"{self._api_url}/api/v1/governance/approval",
453
+ headers=self._headers(
454
+ method="POST",
455
+ pathname="/api/v1/governance/approval",
456
+ body=body,
457
+ ),
458
+ content=body,
279
459
  )
280
460
 
281
461
  if not response.is_success:
282
462
  return None
283
463
 
284
464
  data = response.json()
285
- parsed = parse_approval_response(data)
286
-
287
- # SDK-side expiration check
288
- if parsed.approval_expiration_time and not parsed.expired:
289
- from datetime import datetime
290
- expiry = datetime.fromisoformat(
291
- parsed.approval_expiration_time.replace("Z", "+00:00")
292
- )
293
- if expiry < datetime.now(tz=UTC):
294
- parsed.expired = True
295
-
296
- return parsed
465
+ # SDK-side expiration check — run on the raw dict BEFORE parsing
466
+ # (matches openbox_core.client.check_expiration's own call order:
467
+ # check_expiration(data) then ApprovalResult.from_dict(data)).
468
+ # Handles ISO 'Z', ISO offset, and space-separated DB timestamp
469
+ # formats; a malformed timestamp is logged and left un-flagged
470
+ # rather than raised, so one bad timestamp string degrades to
471
+ # "expiration not confirmed" instead of aborting the whole poll.
472
+ check_expiration(data)
473
+ return parse_approval_response(data)
297
474
 
298
475
  except Exception:
299
476
  return None
300
477
 
301
- async def evaluate_raw(
302
- self, payload: dict[str, Any]
303
- ) -> dict[str, Any] | None:
478
+ async def evaluate_raw(self, payload: dict[str, Any]) -> dict[str, Any] | None:
304
479
  """Send a pre-built payload to the governance evaluate endpoint.
305
480
 
306
481
  Used by hook-level governance where the payload is fully assembled
@@ -311,16 +486,22 @@ class GovernanceClient:
311
486
  """
312
487
  if os.environ.get("OPENBOX_DEBUG") == "1":
313
488
  import json
489
+
314
490
  print(
315
491
  f"[OpenBox Debug] span hook request: {json.dumps(payload, indent=2, default=str)}"
316
492
  )
317
493
 
318
494
  try:
319
495
  client = self._get_client()
496
+ body = _json_body(payload)
320
497
  response = await client.post(
321
498
  f"{self._api_url}/api/v1/governance/evaluate",
322
- headers=self._headers(),
323
- json=payload,
499
+ headers=self._headers(
500
+ method="POST",
501
+ pathname="/api/v1/governance/evaluate",
502
+ body=body,
503
+ ),
504
+ content=body,
324
505
  )
325
506
 
326
507
  if not response.is_success:
@@ -354,5 +535,15 @@ class GovernanceClient:
354
535
  # Private helpers
355
536
  # ─────────────────────────────────────────────────────────────
356
537
 
357
- def _headers(self) -> dict[str, str]:
358
- return self._cached_headers
538
+ def _headers(self, *, method: str, pathname: str, body: bytes | str | None) -> dict[str, str]:
539
+ return build_auth_headers(
540
+ self._api_key,
541
+ method=method,
542
+ pathname=pathname,
543
+ body=body,
544
+ agent_identity=self._agent_identity,
545
+ )
546
+
547
+
548
+ def _json_body(payload: dict[str, Any]) -> bytes:
549
+ return json.dumps(payload, separators=(",", ":"), default=str).encode("utf-8")