openbox-langgraph-sdk-python 0.2.0__py3-none-any.whl → 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,8 @@ Example:
17
17
  ... )
18
18
  """
19
19
 
20
+ __version__ = "1.1.0"
21
+
20
22
  from openbox_langgraph.client import GovernanceClient, build_auth_headers
21
23
  from openbox_langgraph.config import (
22
24
  GovernanceConfig,
@@ -36,6 +38,7 @@ from openbox_langgraph.errors import (
36
38
  OpenBoxError,
37
39
  OpenBoxInsecureURLError,
38
40
  OpenBoxNetworkError,
41
+ OpenBoxSigningError,
39
42
  )
40
43
  from openbox_langgraph.hitl import poll_until_decision
41
44
  from openbox_langgraph.identity import (
@@ -110,11 +113,13 @@ __all__ = [
110
113
  "OpenBoxLangGraphHandler",
111
114
  "OpenBoxLangGraphHandlerOptions",
112
115
  "OpenBoxNetworkError",
116
+ "OpenBoxSigningError",
113
117
  "Verdict",
114
118
  "VerdictContext",
115
119
  "WorkflowEventType",
116
120
  "WorkflowSpanBuffer",
117
121
  "WorkflowSpanProcessor",
122
+ "__version__",
118
123
  "build_agent_identity_canonical_request",
119
124
  "build_auth_headers",
120
125
  "create_agent_identity_headers",
@@ -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)
@@ -5,16 +5,28 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
  from dataclasses import dataclass
8
- from datetime import UTC
9
- from typing import Any
8
+ from typing import TYPE_CHECKING, Any
10
9
 
11
10
  import httpx
12
-
13
- from openbox_langgraph.errors import OpenBoxConfigError, 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 OpenBoxConfigError as _CoreOpenBoxConfigError
17
+
18
+ from openbox_langgraph.core_events import to_envelope
19
+ from openbox_langgraph.errors import (
20
+ OpenBoxConfigError,
21
+ OpenBoxError,
22
+ OpenBoxNetworkError,
23
+ _raise_core_error,
24
+ )
14
25
  from openbox_langgraph.identity import (
15
26
  AgentIdentityConfig,
16
27
  create_agent_identity_headers,
17
28
  parse_optional_agent_identity_config,
29
+ parse_optional_workload_private_key,
18
30
  )
19
31
  from openbox_langgraph.types import (
20
32
  ApprovalResponse,
@@ -25,7 +37,110 @@ from openbox_langgraph.types import (
25
37
  to_server_event_type,
26
38
  )
27
39
 
28
- _SDK_VERSION = "0.2.0"
40
+ if TYPE_CHECKING:
41
+ from openbox_core.client import EvaluationClient
42
+ from openbox_core.gate import GovernanceGate
43
+
44
+ _SDK_PACKAGE_VERSION = "1.1.0"
45
+ _SDK_IDENTIFIER = f"openbox-langgraph-python-v{_SDK_PACKAGE_VERSION}"
46
+
47
+
48
+ def _network_fallback_result(on_api_error: str, msg: str) -> GovernanceVerdictResponse | None:
49
+ """Apply the `on_api_error` policy to a NETWORK/transport failure.
50
+
51
+ This is the ONLY place a `GovernanceClient` verdict call may return
52
+ `None` — it corresponds exactly to a client-synthesized fallback
53
+ (`EvaluationResult.fallback_allow`): `verdict=ALLOW`, `fallback_used=True`,
54
+ and an EMPTY `raw` dict (nothing was ever parsed from a real body).
55
+
56
+ A response BODY that happens to be `{"verdict": "block",
57
+ "fallback_used": true}` never reaches this function — it is parsed by
58
+ `_verdict_from_response_data` instead, whose `raw` is always the
59
+ non-empty parsed dict, so the None-collapse below can never fire for it
60
+ and the BLOCK is enforced normally.
61
+
62
+ `msg` is the caller's fully-formatted message (kept caller-side so the
63
+ two distinct failure messages — "Governance API error: HTTP {status}"
64
+ for a bad response, "Governance API unreachable: {e}" for a raised
65
+ exception — stay exactly as they were before this translation layer).
66
+ """
67
+ if on_api_error == "fail_closed":
68
+ raise OpenBoxNetworkError(msg)
69
+ result = EvaluationResult.fallback_allow(msg)
70
+ return _collapse_client_synthesized_fallback(result)
71
+
72
+
73
+ def _verdict_from_response_data(data: dict[str, Any]) -> GovernanceVerdictResponse:
74
+ """Parse a real HTTP response body into a `GovernanceVerdictResponse`.
75
+
76
+ Routed through the base SDK's `EvaluationResult.from_dict` so `raw` is
77
+ always the full parsed body — the non-empty `raw` is exactly what keeps
78
+ `_collapse_client_synthesized_fallback` from ever mistaking a real
79
+ (even oddly-shaped) Core response for a client-side fallback.
80
+ """
81
+ return GovernanceVerdictResponse.from_result(EvaluationResult.from_dict(data))
82
+
83
+
84
+ def _collapse_client_synthesized_fallback(
85
+ result: EvaluationResult,
86
+ ) -> GovernanceVerdictResponse | None:
87
+ """Return `None` ONLY for a client-synthesized fail-open fallback.
88
+
89
+ The three-part discriminator matches `EvaluationResult.fallback_allow`
90
+ exactly and nothing else: `fallback_used=True` AND `verdict is ALLOW` AND
91
+ `raw` is empty (no real body was ever parsed). A response body that
92
+ happens to carry `fallback_used: true` alongside a blocking verdict, or
93
+ alongside ALLOW but WITH a real (non-empty) body, is a real Core response
94
+ and must be returned as a `GovernanceVerdictResponse` so callers enforce
95
+ it — never silently collapsed to `None`/implicit-ALLOW.
96
+ """
97
+ if result.fallback_used and result.verdict is _CoreVerdict.ALLOW and not result.raw:
98
+ return None
99
+ return GovernanceVerdictResponse.from_result(result)
100
+
101
+
102
+ async def _gate_evaluate(
103
+ gate: GovernanceGate, event: LangChainGovernanceEvent, on_api_error: str
104
+ ) -> GovernanceVerdictResponse | None:
105
+ """Evaluate one lifecycle event through the base SDK's strict gate.
106
+
107
+ The single translation seam between `gate.aevaluate`'s base-SDK contract
108
+ (`EvaluationResult`, `openbox_core` exceptions) and this SDK's own
109
+ (`GovernanceVerdictResponse | None`, `openbox_langgraph.errors`) — every
110
+ gate-routed call site in `evaluate_event` goes through this function so
111
+ the translation is defined exactly once. Outcome policy (mirrors the legacy
112
+ httpx path so the wired transport is behaviourally interchangeable):
113
+
114
+ - `EvaluationResult.fallback_allow()` (client-synthesized fail-open on a
115
+ NETWORK error): collapsed to `None` via the same discriminator used for
116
+ the legacy path, so the pre-screen-`None` -> callback re-evaluation ->
117
+ PII-redaction flow keeps firing regardless of which transport produced it.
118
+ - `ContractError` (a malformed envelope — a bug in THIS SDK's own
119
+ event->envelope mapping, raised pre-network by the strict gate, never
120
+ from a Core response): ALWAYS a fail-open telemetry-drop (`None`),
121
+ independent of `on_api_error`. Enforcing fail_closed here would let an
122
+ SDK-side mapping defect block a user's graph for a reason their OWN policy
123
+ never produced — strictly worse than dropping one governance event.
124
+ - Base authentication, signing, and configuration errors always propagate
125
+ through this SDK's public errors, including under fail_open.
126
+ - `GovernanceAPIError` / `OpenBoxNetworkError` -> this SDK's
127
+ `OpenBoxNetworkError`. Bootstrap/token-exchange failures must propagate;
128
+ the base client applies fail_open only to ordinary governance transport errors.
129
+ - Any OTHER exception (e.g. a malformed Core 200 body the base parser
130
+ cannot decode) is a transport-shaped fault, NOT a governance verdict:
131
+ routed through `_network_fallback_result` so fail_open returns `None`
132
+ (never crash the graph on a Core hiccup) and fail_closed raises
133
+ `OpenBoxNetworkError` — matching the legacy httpx catch-all exactly.
134
+ """
135
+ try:
136
+ result = await gate.aevaluate(to_envelope(event))
137
+ except _CoreContractError:
138
+ return None
139
+ except (_CoreGovernanceAPIError, _CoreOpenBoxConfigError) as e:
140
+ _raise_core_error(e)
141
+ except Exception as e:
142
+ return _network_fallback_result(on_api_error, f"Governance gate error: {e}")
143
+ return _collapse_client_synthesized_fallback(result)
29
144
 
30
145
 
31
146
  def build_auth_headers(
@@ -38,13 +153,14 @@ def build_auth_headers(
38
153
  ) -> dict[str, str]:
39
154
  """Build standard auth headers for governance API calls.
40
155
 
41
- Single source of truth — used by GovernanceClient and hook_governance.
156
+ Compatibility helper for API-key/DID requests. Workload headers are composed
157
+ by the base SDK after bootstrap and token exchange.
42
158
  """
43
159
  headers = {
44
160
  "Authorization": f"Bearer {api_key}",
45
161
  "Content-Type": "application/json",
46
- "User-Agent": f"OpenBox-LangGraph-SDK/{_SDK_VERSION}",
47
- "X-OpenBox-SDK-Version": _SDK_VERSION,
162
+ "User-Agent": f"OpenBox-LangGraph-SDK/{_SDK_IDENTIFIER}",
163
+ "X-OpenBox-SDK-Version": _SDK_IDENTIFIER,
48
164
  }
49
165
  if agent_identity:
50
166
  if method is None or pathname is None:
@@ -87,6 +203,9 @@ class GovernanceClient:
87
203
  on_api_error: str = "fail_open",
88
204
  agent_did: str | None = None,
89
205
  agent_private_key: str | None = None,
206
+ gate: GovernanceGate | None = None,
207
+ workload_private_key: str | None = None,
208
+ core_client: EvaluationClient | None = None,
90
209
  ) -> None:
91
210
  self._api_url = api_url.rstrip("/")
92
211
  self._api_key = api_key
@@ -98,9 +217,43 @@ class GovernanceClient:
98
217
  did=agent_did,
99
218
  private_key=agent_private_key,
100
219
  )
220
+ workload_private_key = parse_optional_workload_private_key(workload_private_key)
221
+ self._core_client = core_client
222
+ self._owns_core_client = core_client is None and workload_private_key is not None
223
+ if self._owns_core_client:
224
+ from openbox_core.client import EvaluationClient
225
+ from openbox_core.identity import AgentIdentity
226
+
227
+ self._core_client = EvaluationClient(
228
+ self._api_url,
229
+ self._api_key,
230
+ timeout_seconds=timeout,
231
+ on_api_error=on_api_error,
232
+ identity=(
233
+ AgentIdentity.from_private_key(
234
+ self._agent_identity.did, self._agent_identity.private_key
235
+ )
236
+ if self._agent_identity
237
+ else None
238
+ ),
239
+ workload_private_key=workload_private_key,
240
+ sdk_version=_SDK_PACKAGE_VERSION,
241
+ sdk_engine="langgraph",
242
+ )
243
+ # Optional base-SDK gate. When wired (by the handler, from a core
244
+ # runtime built off the SAME api_url/api_key/timeout/on_api_error),
245
+ # `evaluate_event`'s ASYNC path routes lifecycle events through it
246
+ # instead of this client's own httpx transport — see `evaluate_event`.
247
+ # `None` (the default) preserves the exact legacy transport/serialization
248
+ # for every existing caller that constructs a bare `GovernanceClient()`.
249
+ # The handler also lends its runtime client for sync/raw calls and
250
+ # approval polling, so every path shares the same workload token cache.
251
+ self._gate = gate
101
252
  # Deduplication: prevent sending the same (activity_id, event_type) twice
102
253
  # within the same workflow run. Keyed by (workflow_id, run_id) so it resets
103
- # automatically on each new ainvoke() call.
254
+ # automatically on each new ainvoke() call. Shared by every evaluate_event
255
+ # call site regardless of which transport (gate or legacy httpx) is active
256
+ # for a given call — dedup is a client-level concern, not a transport one.
104
257
  self._dedup_run: tuple[str, str] | None = None
105
258
  self._dedup_sent: set[tuple[str, str]] = set()
106
259
 
@@ -117,7 +270,9 @@ class GovernanceClient:
117
270
  return self._sync_client
118
271
 
119
272
  async def close(self) -> None:
120
- """Close the underlying HTTP clients."""
273
+ """Close owned HTTP clients; a borrowed runtime client belongs to its runtime."""
274
+ if self._owns_core_client and self._core_client is not None:
275
+ await self._core_client.aclose()
121
276
  if self._client and not self._client.is_closed:
122
277
  await self._client.aclose()
123
278
  self._client = None
@@ -138,6 +293,13 @@ class GovernanceClient:
138
293
  """
139
294
  from openbox_langgraph.errors import OpenBoxAuthError
140
295
 
296
+ if self._core_client is not None:
297
+ try:
298
+ await self._core_client.avalidate_api_key()
299
+ except _CoreOpenBoxConfigError as exc:
300
+ _raise_core_error(exc)
301
+ return
302
+
141
303
  try:
142
304
  client = self._get_client()
143
305
  response = await client.get(
@@ -186,13 +348,23 @@ class GovernanceClient:
186
348
  """Send a governance event to OpenBox Core and return the verdict.
187
349
 
188
350
  Returns `None` on network failure when `on_api_error` is `fail_open`.
189
- Silently drops duplicate (activity_id, event_type) pairs within the same run.
351
+ Silently drops duplicate (activity_id, event_type) pairs within the same run
352
+ — this de-dup pre-check runs BEFORE either transport below, so it applies
353
+ identically whether a `gate` is wired or not.
354
+
355
+ When a `gate` was supplied at construction (see `__init__`), the event is
356
+ routed through the base SDK's `EventEnvelope` + `GovernanceGate.aevaluate`
357
+ instead of this client's own httpx transport — see `_gate_evaluate`.
358
+ Overriding `evaluate_event` in a subclass (e.g. the golden-fixture
359
+ harness's `RecordingGovernanceClient`) still fully intercepts either way,
360
+ since the branch lives inside THIS method, never at a call site.
190
361
 
191
362
  Args:
192
363
  event: The governance event payload to evaluate.
193
364
 
194
365
  Raises:
195
- OpenBoxNetworkError: On network failure when `on_api_error` is `fail_closed`.
366
+ OpenBoxNetworkError: On network failure when `on_api_error` is `fail_closed`
367
+ (from either transport).
196
368
  """
197
369
  server_event_type = to_server_event_type(event.event_type)
198
370
  if event.activity_id and self._is_duplicate(
@@ -205,18 +377,34 @@ class GovernanceClient:
205
377
  )
206
378
  return None
207
379
 
208
- payload = event.to_dict()
209
- payload["event_type"] = server_event_type
210
- payload["task_queue"] = event.task_queue or "langgraph"
211
- payload["source"] = "workflow-telemetry"
212
-
213
380
  if os.environ.get("OPENBOX_DEBUG") == "1":
214
381
  import json
215
382
 
216
383
  print(
217
- f"[OpenBox Debug] governance request: {json.dumps(payload, indent=2, default=str)}"
384
+ f"[OpenBox Debug] governance request: "
385
+ f"{json.dumps(event.to_dict(), indent=2, default=str)}"
218
386
  )
219
387
 
388
+ if self._gate is not None:
389
+ try:
390
+ return await _gate_evaluate(self._gate, event, self._on_api_error)
391
+ except OpenBoxError:
392
+ self._forget_failed_event(event, server_event_type)
393
+ raise
394
+
395
+ payload = event.to_dict()
396
+ payload["event_type"] = server_event_type
397
+ payload["task_queue"] = event.task_queue or "langgraph"
398
+ payload["source"] = "workflow-telemetry"
399
+
400
+ if self._core_client is not None:
401
+ try:
402
+ result = await self._core_client.aevaluate(payload)
403
+ except (_CoreOpenBoxConfigError, _CoreGovernanceAPIError) as exc:
404
+ self._forget_failed_event(event, server_event_type)
405
+ _raise_core_error(exc)
406
+ return _collapse_client_synthesized_fallback(result)
407
+
220
408
  try:
221
409
  client = self._get_client()
222
410
  body = _json_body(payload)
@@ -231,21 +419,17 @@ class GovernanceClient:
231
419
  )
232
420
 
233
421
  if not response.is_success:
234
- if self._on_api_error == "fail_closed":
235
- msg = f"Governance API error: HTTP {response.status_code}"
236
- raise OpenBoxNetworkError(msg)
237
- return None
422
+ return _network_fallback_result(
423
+ self._on_api_error, f"Governance API error: HTTP {response.status_code}"
424
+ )
238
425
 
239
426
  data = response.json()
240
- return GovernanceVerdictResponse.from_dict(data)
427
+ return _verdict_from_response_data(data)
241
428
 
242
429
  except OpenBoxNetworkError:
243
430
  raise
244
431
  except Exception as e:
245
- if self._on_api_error == "fail_closed":
246
- msg = f"Governance API unreachable: {e}"
247
- raise OpenBoxNetworkError(msg) from e
248
- return None
432
+ return _network_fallback_result(self._on_api_error, f"Governance API unreachable: {e}")
249
433
 
250
434
  def evaluate_event_sync(
251
435
  self, event: LangChainGovernanceEvent
@@ -266,6 +450,14 @@ class GovernanceClient:
266
450
  payload["task_queue"] = event.task_queue or "langgraph"
267
451
  payload["source"] = "workflow-telemetry"
268
452
 
453
+ if self._core_client is not None:
454
+ try:
455
+ result = self._core_client.evaluate(payload)
456
+ except (_CoreOpenBoxConfigError, _CoreGovernanceAPIError) as exc:
457
+ self._forget_failed_event(event, server_event_type)
458
+ _raise_core_error(exc)
459
+ return _collapse_client_synthesized_fallback(result)
460
+
269
461
  if os.environ.get("OPENBOX_DEBUG") == "1":
270
462
  import json
271
463
 
@@ -288,30 +480,36 @@ class GovernanceClient:
288
480
  )
289
481
 
290
482
  if not response.is_success:
291
- if self._on_api_error == "fail_closed":
292
- msg = f"Governance API error: HTTP {response.status_code}"
293
- raise OpenBoxNetworkError(msg)
294
- return None
483
+ return _network_fallback_result(
484
+ self._on_api_error, f"Governance API error: HTTP {response.status_code}"
485
+ )
295
486
 
296
487
  data = response.json()
297
- return GovernanceVerdictResponse.from_dict(data)
488
+ return _verdict_from_response_data(data)
298
489
 
299
490
  except OpenBoxNetworkError:
300
491
  raise
301
492
  except Exception as e:
302
- if self._on_api_error == "fail_closed":
303
- msg = f"Governance API unreachable: {e}"
304
- raise OpenBoxNetworkError(msg) from e
305
- return None
493
+ return _network_fallback_result(self._on_api_error, f"Governance API unreachable: {e}")
306
494
 
307
495
  async def poll_approval(self, params: ApprovalPollParams) -> ApprovalResponse | None:
308
496
  """Poll for HITL approval status.
309
497
 
310
- Returns `None` on network failure so the caller can retry.
498
+ Returns `None` on ordinary polling transport failure so the caller can retry.
499
+ Workload authentication and bootstrap failures always raise.
311
500
 
312
501
  Args:
313
502
  params: Identifiers for the pending approval.
314
503
  """
504
+ if self._core_client is not None:
505
+ try:
506
+ result = await self._core_client.apoll_approval(
507
+ params.workflow_id, params.run_id, params.activity_id
508
+ )
509
+ except _CoreOpenBoxConfigError as exc:
510
+ _raise_core_error(exc)
511
+ return ApprovalResponse.from_result(result) if result is not None else None
512
+
315
513
  try:
316
514
  client = self._get_client()
317
515
  body = _json_body(
@@ -335,19 +533,15 @@ class GovernanceClient:
335
533
  return None
336
534
 
337
535
  data = response.json()
338
- parsed = parse_approval_response(data)
339
-
340
- # SDK-side expiration check
341
- if parsed.approval_expiration_time and not parsed.expired:
342
- from datetime import datetime
343
-
344
- expiry = datetime.fromisoformat(
345
- parsed.approval_expiration_time.replace("Z", "+00:00")
346
- )
347
- if expiry < datetime.now(tz=UTC):
348
- parsed.expired = True
349
-
350
- return parsed
536
+ # SDK-side expiration check — run on the raw dict BEFORE parsing
537
+ # (matches openbox_core.client.check_expiration's own call order:
538
+ # check_expiration(data) then ApprovalResult.from_dict(data)).
539
+ # Handles ISO 'Z', ISO offset, and space-separated DB timestamp
540
+ # formats; a malformed timestamp is logged and left un-flagged
541
+ # rather than raised, so one bad timestamp string degrades to
542
+ # "expiration not confirmed" instead of aborting the whole poll.
543
+ check_expiration(data)
544
+ return parse_approval_response(data)
351
545
 
352
546
  except Exception:
353
547
  return None
@@ -359,8 +553,17 @@ class GovernanceClient:
359
553
  by the caller (no event_type translation needed).
360
554
 
361
555
  Args:
362
- payload: The raw dict to POST to `/api/v1/governance/evaluate`.
556
+ payload: The raw dict to POST to the identity-appropriate evaluate route.
363
557
  """
558
+ if self._core_client is not None:
559
+ try:
560
+ result = await self._core_client.aevaluate(payload)
561
+ except (_CoreOpenBoxConfigError, _CoreGovernanceAPIError) as exc:
562
+ _raise_core_error(exc)
563
+ if _collapse_client_synthesized_fallback(result) is None:
564
+ return None
565
+ return dict(result.raw)
566
+
364
567
  if os.environ.get("OPENBOX_DEBUG") == "1":
365
568
  import json
366
569
 
@@ -412,6 +615,11 @@ class GovernanceClient:
412
615
  # Private helpers
413
616
  # ─────────────────────────────────────────────────────────────
414
617
 
618
+ def _forget_failed_event(self, event: LangChainGovernanceEvent, event_type: str) -> None:
619
+ """A caller retrying a rejected event must authenticate again, never dedup to ALLOW."""
620
+ if event.activity_id and self._dedup_run == (event.workflow_id, event.run_id):
621
+ self._dedup_sent.discard((event.activity_id, event_type))
622
+
415
623
  def _headers(self, *, method: str, pathname: str, body: bytes | str | None) -> dict[str, str]:
416
624
  return build_auth_headers(
417
625
  self._api_key,