openbox-temporal-sdk-python 1.1.1__py3-none-any.whl → 1.1.2__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.
openbox/__init__.py CHANGED
@@ -68,7 +68,7 @@ from .span_processor import WorkflowSpanProcessor
68
68
  from .workflow_interceptor import GovernanceInterceptor
69
69
 
70
70
  # ═══════════════════════════════════════════════════════════════════════════════
71
- # Plugin (requires temporalio >= 1.24.0)
71
+ # Plugin (requires temporalio >= 1.23.0)
72
72
  # ═══════════════════════════════════════════════════════════════════════════════
73
73
 
74
74
  try:
@@ -76,7 +76,7 @@ try:
76
76
 
77
77
  from .plugin import OpenBoxPlugin
78
78
  except ImportError:
79
- pass # temporalio < 1.24.0, plugin not available
79
+ pass # temporalio < 1.23.0, plugin not available
80
80
 
81
81
  # ═══════════════════════════════════════════════════════════════════════════════
82
82
  # Verdict Handler
@@ -139,7 +139,7 @@ from .client import GovernanceClient
139
139
  __all__ = [
140
140
  # Simple Worker Factory (recommended)
141
141
  "create_openbox_worker",
142
- # Plugin (recommended for temporalio >= 1.24.0)
142
+ # Plugin (recommended for temporalio >= 1.23.0)
143
143
  "OpenBoxPlugin",
144
144
  # Configuration
145
145
  "initialize",
openbox/activities.py CHANGED
@@ -142,60 +142,109 @@ def _handle_api_error(event_type: str, error_msg: str, on_api_error: str) -> dic
142
142
  return {"success": False, "error": error_msg}
143
143
 
144
144
 
145
- @activity.defn(name="send_governance_event")
146
- async def send_governance_event(input: Dict[str, Any]) -> Optional[Dict[str, Any]]:
147
- """
148
- Activity that sends governance events to OpenBox Core.
145
+ class GovernanceActivities:
146
+ """Container for Temporal activities that need OpenBox credentials.
149
147
 
150
- Called from WorkflowInboundInterceptor via workflow.execute_activity()
151
- to maintain workflow determinism.
148
+ Credentials live on the instance (never in activity inputs → never in
149
+ workflow history → not visible to anyone with namespace read access).
150
+ The worker registers bound methods of a single instance, created at
151
+ worker-init time by the plugin / create_openbox_worker factory.
152
152
  """
153
- api_url = input.get("api_url", "")
154
- api_key = input.get("api_key", "")
155
- event_payload = input.get("payload", {})
156
- timeout = input.get("timeout", 30.0)
157
- on_api_error = input.get("on_api_error", "fail_open")
158
-
159
- # Add timestamp in activity context (non-deterministic code allowed)
160
- payload = {**event_payload, "timestamp": _rfc3339_now()}
161
- event_type = event_payload.get("event_type", "unknown")
162
-
163
- try:
164
- async with httpx.AsyncClient(timeout=timeout) as client:
165
- response = await client.post(
166
- f"{api_url}/api/v1/governance/evaluate",
167
- json=payload,
168
- headers=build_auth_headers(api_key),
169
- )
170
-
171
- if response.status_code != 200:
172
- return _handle_api_error(
173
- event_type,
174
- f"HTTP {response.status_code}: {response.text}",
175
- on_api_error,
153
+
154
+ def __init__(self, api_url: str, api_key: str):
155
+ self._api_url = api_url.rstrip("/")
156
+ self._api_key = api_key
157
+
158
+ @activity.defn(name="send_governance_event")
159
+ async def send_governance_event(
160
+ self, input: Dict[str, Any]
161
+ ) -> Optional[Dict[str, Any]]:
162
+ """Send a governance event to OpenBox Core.
163
+
164
+ Called from WorkflowInboundInterceptor via workflow.execute_activity()
165
+ to maintain workflow determinism. The `input` dict carries only the
166
+ event payload and per-call policy — never credentials.
167
+ """
168
+ event_payload = input.get("payload", {})
169
+ timeout = input.get("timeout", 30.0)
170
+ on_api_error = input.get("on_api_error", "fail_open")
171
+
172
+ # Add timestamp in activity context (non-deterministic code allowed)
173
+ payload = {**event_payload, "timestamp": _rfc3339_now()}
174
+ event_type = event_payload.get("event_type", "unknown")
175
+
176
+ try:
177
+ async with httpx.AsyncClient(timeout=timeout) as client:
178
+ response = await client.post(
179
+ f"{self._api_url}/api/v1/governance/evaluate",
180
+ json=payload,
181
+ headers=build_auth_headers(self._api_key),
176
182
  )
177
183
 
178
- data = response.json()
179
- verdict = Verdict.from_string(
180
- data.get("verdict") or data.get("action", "continue")
181
- )
182
- reason = data.get("reason")
183
- policy_id = data.get("policy_id")
184
- risk_score = data.get("risk_score", 0.0)
185
-
186
- if verdict.should_stop():
187
- result = await _handle_stop_verdict(
188
- verdict, reason, policy_id, risk_score, event_type, event_payload
184
+ if response.status_code != 200:
185
+ return _handle_api_error(
186
+ event_type,
187
+ f"HTTP {response.status_code}: {response.text}",
188
+ on_api_error,
189
+ )
190
+
191
+ data = response.json()
192
+ verdict = Verdict.from_string(
193
+ data.get("verdict") or data.get("action", "continue")
189
194
  )
190
- if result:
191
- return result
192
-
193
- return _build_verdict_result(verdict, reason, policy_id, risk_score)
194
-
195
- except (GovernanceAPIError, ApplicationError):
196
- raise
197
- except Exception as e:
198
- logger.warning(f"Failed to send {event_type} event: {e}")
199
- if on_api_error == "fail_closed":
200
- raise GovernanceAPIError(str(e))
201
- return {"success": False, "error": str(e)}
195
+ reason = data.get("reason")
196
+ policy_id = data.get("policy_id")
197
+ risk_score = data.get("risk_score", 0.0)
198
+
199
+ if verdict.should_stop():
200
+ result = await _handle_stop_verdict(
201
+ verdict,
202
+ reason,
203
+ policy_id,
204
+ risk_score,
205
+ event_type,
206
+ event_payload,
207
+ )
208
+ if result:
209
+ return result
210
+
211
+ return _build_verdict_result(verdict, reason, policy_id, risk_score)
212
+
213
+ except (GovernanceAPIError, ApplicationError):
214
+ raise
215
+ except Exception as e:
216
+ logger.warning(f"Failed to send {event_type} event: {e}")
217
+ if on_api_error == "fail_closed":
218
+ raise GovernanceAPIError(str(e))
219
+ return {"success": False, "error": str(e)}
220
+
221
+
222
+ def build_governance_activities(
223
+ api_url: str, api_key: str
224
+ ) -> GovernanceActivities:
225
+ """Factory used by plugin.py and worker.py to build the activities instance."""
226
+ return GovernanceActivities(api_url=api_url, api_key=api_key)
227
+
228
+
229
+ # ─────────────────────────────────────────────────────────────────────────────
230
+ # Backward-compat module-level helper.
231
+ #
232
+ # Not decorated with @activity.defn — worker/plugin register the class-based
233
+ # version above so credentials never flow through activity inputs. This shim
234
+ # exists for direct callers (tests, scripts) who already hold credentials and
235
+ # want to invoke the HTTP logic without constructing the class themselves.
236
+ # ─────────────────────────────────────────────────────────────────────────────
237
+ async def send_governance_event(input: Dict[str, Any]) -> Optional[Dict[str, Any]]:
238
+ """Backward-compat wrapper — delegates to GovernanceActivities.
239
+
240
+ Tests and direct callers can keep passing api_url/api_key in the input
241
+ dict. The class-based activity registered with the worker does NOT see
242
+ these fields (it reads credentials from self), so nothing written to
243
+ workflow history ever carries the API key.
244
+ """
245
+ instance = GovernanceActivities(
246
+ api_url=input.get("api_url", ""),
247
+ api_key=input.get("api_key", ""),
248
+ )
249
+ forwarded = {k: v for k, v in input.items() if k not in ("api_url", "api_key")}
250
+ return await instance.send_governance_event(forwarded)
@@ -68,9 +68,14 @@ def _classify_sql(query: Any) -> str:
68
68
 
69
69
 
70
70
  def _generate_span_id() -> str:
71
- """Generate a random 16-hex-char span ID for pymongo governance spans."""
72
- import random
73
- return format(random.getrandbits(64), "016x")
71
+ """Generate a random 16-hex-char span ID for pymongo governance spans.
72
+
73
+ Uses secrets (CSPRNG) rather than random — span IDs are not secrets, but
74
+ the stronger generator silences security scanners without runtime cost
75
+ and removes any chance of cross-trace ID collision under heavy load.
76
+ """
77
+ import secrets
78
+ return secrets.token_hex(8)
74
79
 
75
80
 
76
81
  def _build_db_span_data(
openbox/errors.py CHANGED
@@ -21,12 +21,26 @@ Hierarchy:
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
- from typing import TYPE_CHECKING, Union
24
+ from typing import TYPE_CHECKING, Final, Union
25
25
 
26
26
  if TYPE_CHECKING:
27
27
  from .types import Verdict
28
28
 
29
29
 
30
+ # ═══════════════════════════════════════════════════════════════════
31
+ # ApplicationError.type values raised by governance activities.
32
+ # Use these constants — never string-match on error messages or
33
+ # exception class names (brittle against locale / reformatting /
34
+ # Temporal's ActivityError wrapping).
35
+ # ═══════════════════════════════════════════════════════════════════
36
+
37
+ GOVERNANCE_HALT_ERROR_TYPE: Final[str] = "GovernanceHalt"
38
+ GOVERNANCE_BLOCK_ERROR_TYPE: Final[str] = "GovernanceBlock"
39
+ GOVERNANCE_API_ERROR_TYPE: Final[str] = "GovernanceAPIError"
40
+ # Legacy alias; kept for histories predating the rename.
41
+ GOVERNANCE_STOP_ERROR_TYPE: Final[str] = "GovernanceStop"
42
+
43
+
30
44
  # ═══════════════════════════════════════════════════════════════════
31
45
  # Base
32
46
  # ═══════════════════════════════════════════════════════════════════
@@ -16,6 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  import json
18
18
  import logging
19
+ import threading
19
20
  from typing import TYPE_CHECKING, Any, Dict, Optional
20
21
 
21
22
  import httpx
@@ -38,9 +39,14 @@ _max_body_size: Optional[int] = None
38
39
  _span_processor: Optional["WorkflowSpanProcessor"] = None
39
40
  _cached_auth_headers: Optional[dict] = None
40
41
 
41
- # Persistent HTTP clients (lazy-init, thread-safe for requests)
42
+ # Persistent HTTP clients. httpx Client/AsyncClient themselves are thread-safe
43
+ # for requests; the locks below only guard creation against concurrent activities
44
+ # racing to initialize a fresh client (which would leak connection pools when
45
+ # one of the losers gets garbage collected).
42
46
  _sync_client: Optional[httpx.Client] = None
43
47
  _async_client: Optional[httpx.AsyncClient] = None
48
+ _sync_client_lock = threading.Lock()
49
+ _async_client_lock = threading.Lock()
44
50
 
45
51
 
46
52
  def configure(
@@ -78,18 +84,22 @@ def configure(
78
84
 
79
85
 
80
86
  def _get_sync_client() -> httpx.Client:
81
- """Get or create persistent sync HTTP client."""
87
+ """Get or create persistent sync HTTP client (double-checked locking)."""
82
88
  global _sync_client
83
89
  if _sync_client is None or _sync_client.is_closed:
84
- _sync_client = httpx.Client(timeout=_api_timeout)
90
+ with _sync_client_lock:
91
+ if _sync_client is None or _sync_client.is_closed:
92
+ _sync_client = httpx.Client(timeout=_api_timeout)
85
93
  return _sync_client
86
94
 
87
95
 
88
96
  def _get_async_client() -> httpx.AsyncClient:
89
- """Get or create persistent async HTTP client."""
97
+ """Get or create persistent async HTTP client (double-checked locking)."""
90
98
  global _async_client
91
99
  if _async_client is None or _async_client.is_closed:
92
- _async_client = httpx.AsyncClient(timeout=_api_timeout)
100
+ with _async_client_lock:
101
+ if _async_client is None or _async_client.is_closed:
102
+ _async_client = httpx.AsyncClient(timeout=_api_timeout)
93
103
  return _async_client
94
104
 
95
105
 
openbox/plugin.py CHANGED
@@ -17,6 +17,7 @@ Usage:
17
17
  """
18
18
 
19
19
  import dataclasses
20
+ import logging
20
21
  from typing import Any, Optional, Set
21
22
 
22
23
  from temporalio.plugin import SimplePlugin
@@ -27,6 +28,8 @@ from .config import initialize as validate_api_key, GovernanceConfig
27
28
  from .span_processor import WorkflowSpanProcessor
28
29
  from .client import GovernanceClient
29
30
 
31
+ logger = logging.getLogger(__name__)
32
+
30
33
 
31
34
  class OpenBoxPlugin(SimplePlugin):
32
35
  """Temporal Plugin for OpenBox governance and observability.
@@ -62,6 +65,11 @@ class OpenBoxPlugin(SimplePlugin):
62
65
  db_libraries: Optional[Set[str]] = None,
63
66
  sqlalchemy_engine: Optional[Any] = None,
64
67
  instrument_file_io: bool = True,
68
+ # Propagate W3C traceparent/baggage through Temporal headers so spans
69
+ # started by the caller (e.g., an HTTP server) stitch to workflow and
70
+ # activity spans on the worker side. Uses Temporal's built-in
71
+ # TracingInterceptor under the hood.
72
+ enable_trace_propagation: bool = True,
65
73
  ):
66
74
  # 1. Validate API key (sync, uses urllib)
67
75
  validate_api_key(
@@ -115,7 +123,7 @@ class OpenBoxPlugin(SimplePlugin):
115
123
  on_api_error=governance_policy,
116
124
  )
117
125
 
118
- interceptors = [
126
+ interceptors: list = [
119
127
  GovernanceInterceptor(
120
128
  api_url=openbox_url,
121
129
  api_key=openbox_api_key,
@@ -131,8 +139,23 @@ class OpenBoxPlugin(SimplePlugin):
131
139
  ),
132
140
  ]
133
141
 
134
- # 6. Get governance activity
135
- from .activities import send_governance_event
142
+ # Header-based OTel trace propagation. Temporal's built-in
143
+ # TracingInterceptor implements both client.Interceptor and
144
+ # worker.Interceptor — SimplePlugin auto-routes it to both sides.
145
+ # Without it, trace IDs set by the caller don't reach workflow/
146
+ # activity spans, leaving disconnected trees in the backend.
147
+ if enable_trace_propagation:
148
+ from temporalio.contrib.opentelemetry import TracingInterceptor
149
+
150
+ interceptors.append(TracingInterceptor())
151
+
152
+ # 6. Build governance activity instance with credentials captured in self —
153
+ # avoids passing api_key through activity inputs / workflow history.
154
+ from .activities import build_governance_activities
155
+
156
+ governance_activities = build_governance_activities(
157
+ api_url=openbox_url, api_key=openbox_api_key
158
+ )
136
159
 
137
160
  # 7. Sandbox passthrough for opentelemetry
138
161
  def workflow_runner(runner: WorkflowRunner | None) -> WorkflowRunner | None:
@@ -157,7 +180,7 @@ class OpenBoxPlugin(SimplePlugin):
157
180
  super().__init__(
158
181
  "openbox.OpenBoxPlugin",
159
182
  interceptors=interceptors,
160
- activities=[send_governance_event],
183
+ activities=[governance_activities.send_governance_event],
161
184
  workflow_runner=workflow_runner,
162
185
  )
163
186
 
@@ -171,15 +194,17 @@ class OpenBoxPlugin(SimplePlugin):
171
194
 
172
195
  config = super().configure_worker(config)
173
196
 
174
- print("OpenBox Plugin initialized successfully")
175
- print(f" - Governance policy: {self._governance_policy}")
176
- print(f" - Governance timeout: {self._governance_timeout}s")
177
197
  db_status = "enabled" if self._instrument_databases else "disabled"
178
198
  file_status = "enabled" if self._instrument_file_io else "disabled"
179
199
  hitl_status = "enabled" if self._hitl_enabled else "disabled"
180
- print(f" - Database instrumentation: {db_status}")
181
- print(f" - File I/O instrumentation: {file_status}")
182
- print(f" - Approval polling: {hitl_status}")
183
- print(" - Hook governance: enabled")
200
+ logger.info(
201
+ "OpenBox Plugin initialized: policy=%s timeout=%ss "
202
+ "db=%s file=%s hitl=%s hook_governance=enabled",
203
+ self._governance_policy,
204
+ self._governance_timeout,
205
+ db_status,
206
+ file_status,
207
+ hitl_status,
208
+ )
184
209
 
185
210
  return config
openbox/worker.py CHANGED
@@ -18,6 +18,7 @@ Usage:
18
18
  await worker.run()
19
19
  """
20
20
 
21
+ import logging
21
22
  from datetime import timedelta
22
23
  from typing import (
23
24
  Any,
@@ -36,6 +37,8 @@ from temporalio.worker import Worker, Interceptor
36
37
  from .config import initialize as validate_api_key, GovernanceConfig
37
38
  from .span_processor import WorkflowSpanProcessor
38
39
 
40
+ logger = logging.getLogger(__name__)
41
+
39
42
 
40
43
  def create_openbox_worker(
41
44
  client: Client,
@@ -61,6 +64,9 @@ def create_openbox_worker(
61
64
  sqlalchemy_engine: Optional[Any] = None,
62
65
  # File I/O instrumentation
63
66
  instrument_file_io: bool = True,
67
+ # Header-based W3C trace propagation via Temporal's built-in TracingInterceptor.
68
+ # Without it, trace IDs set by the caller don't reach workflow/activity spans.
69
+ enable_trace_propagation: bool = True,
64
70
  # Standard Worker options
65
71
  activity_executor: Optional[Executor] = None,
66
72
  workflow_task_executor: Optional[ThreadPoolExecutor] = None,
@@ -156,8 +162,7 @@ def create_openbox_worker(
156
162
  await worker.run()
157
163
  ```
158
164
  """
159
- # Initialize OpenBox
160
- print(f"Initializing OpenBox SDK with URL: {openbox_url}")
165
+ logger.info("Initializing OpenBox SDK with URL: %s", openbox_url)
161
166
 
162
167
  # 0. Store Temporal client reference for HALT terminate calls
163
168
  from .activities import set_temporal_client
@@ -231,27 +236,35 @@ def create_openbox_worker(
231
236
  client=governance_client,
232
237
  )
233
238
 
234
- # 6. Get governance activities
235
- from .activities import send_governance_event
239
+ # 6. Build governance activities with credentials captured on the instance
240
+ # (so api_key never leaks through activity inputs into workflow history).
241
+ from .activities import build_governance_activities
236
242
 
237
- # Add OpenBox components
238
- all_interceptors = [workflow_interceptor, activity_interceptor, *interceptors]
239
- all_activities = [*activities, send_governance_event]
240
-
241
- print("OpenBox SDK initialized successfully")
242
- print(f" - Governance policy: {governance_policy}")
243
- print(f" - Governance timeout: {governance_timeout}s")
244
- print(
245
- " - Events: WorkflowStarted, WorkflowCompleted, WorkflowFailed, SignalReceived, ActivityStarted, ActivityCompleted"
246
- )
247
- print(
248
- f" - Database instrumentation: {'enabled' if instrument_databases else 'disabled'}"
243
+ governance_activities = build_governance_activities(
244
+ api_url=openbox_url, api_key=openbox_api_key
249
245
  )
250
- print(
251
- f" - File I/O instrumentation: {'enabled' if instrument_file_io else 'disabled'}"
246
+
247
+ # Add OpenBox components
248
+ all_interceptors: list = [workflow_interceptor, activity_interceptor, *interceptors]
249
+
250
+ # Header-based OTel trace propagation via Temporal's built-in interceptor.
251
+ if enable_trace_propagation:
252
+ from temporalio.contrib.opentelemetry import TracingInterceptor
253
+
254
+ all_interceptors.append(TracingInterceptor())
255
+
256
+ all_activities = [*activities, governance_activities.send_governance_event]
257
+
258
+ logger.info(
259
+ "OpenBox SDK initialized: policy=%s timeout=%ss db=%s file=%s hitl=%s "
260
+ "hook_governance=enabled events=WorkflowStarted,WorkflowCompleted,"
261
+ "WorkflowFailed,SignalReceived,ActivityStarted,ActivityCompleted",
262
+ governance_policy,
263
+ governance_timeout,
264
+ "enabled" if instrument_databases else "disabled",
265
+ "enabled" if instrument_file_io else "disabled",
266
+ "enabled" if hitl_enabled else "disabled",
252
267
  )
253
- print(f" - Approval polling: {'enabled' if hitl_enabled else 'disabled'}")
254
- print(" - Hook governance: enabled")
255
268
 
256
269
  # Create and return Worker
257
270
  return Worker(
@@ -20,6 +20,7 @@ from datetime import timedelta
20
20
  from typing import Any, Optional, Type
21
21
 
22
22
  from temporalio import workflow
23
+ from temporalio.exceptions import ActivityError, ApplicationError
23
24
  from temporalio.worker import (
24
25
  Interceptor,
25
26
  WorkflowInboundInterceptor,
@@ -28,9 +29,38 @@ from temporalio.worker import (
28
29
  HandleSignalInput,
29
30
  )
30
31
 
32
+ from .errors import (
33
+ GOVERNANCE_API_ERROR_TYPE,
34
+ GOVERNANCE_BLOCK_ERROR_TYPE,
35
+ GOVERNANCE_HALT_ERROR_TYPE,
36
+ GOVERNANCE_STOP_ERROR_TYPE,
37
+ )
31
38
  from .types import Verdict
32
39
 
33
40
 
41
+ def _application_error_type(exc: BaseException) -> Optional[str]:
42
+ """Walk exception chain and return the ApplicationError.type if present.
43
+
44
+ Temporal wraps activity failures as ActivityError(cause=ApplicationError).
45
+ We walk cause/__cause__/__context__ to find the first ApplicationError and
46
+ return its `type` field. Matching on this field is stable across message
47
+ reformatting, locale changes, and nested wrapping.
48
+ """
49
+ seen: set[int] = set()
50
+ current: Optional[BaseException] = exc
51
+ while current is not None and id(current) not in seen:
52
+ seen.add(id(current))
53
+ if isinstance(current, ApplicationError):
54
+ return getattr(current, "type", None)
55
+ next_exc = (
56
+ getattr(current, "cause", None)
57
+ or getattr(current, "__cause__", None)
58
+ or getattr(current, "__context__", None)
59
+ )
60
+ current = next_exc
61
+ return None
62
+
63
+
34
64
  def _safe_error_type(exc) -> Optional[str]:
35
65
  """Extract error type string from an exception, sanitized for JSON."""
36
66
  t = getattr(exc, "type", None)
@@ -117,8 +147,6 @@ from .errors import GovernanceHaltError # noqa: F401
117
147
 
118
148
 
119
149
  async def _send_governance_event(
120
- api_url: str,
121
- api_key: str,
122
150
  payload: dict,
123
151
  timeout: float,
124
152
  on_api_error: str = "fail_open",
@@ -130,17 +158,18 @@ async def _send_governance_event(
130
158
  on_api_error: "fail_open" (default) = continue on error
131
159
  "fail_closed" = halt workflow if governance API fails
132
160
 
133
- The on_api_error policy is passed to the activity, which handles logging
134
- (safe outside sandbox) and raises GovernanceAPIError if fail_closed.
135
- This interceptor catches that and re-raises as GovernanceHaltError.
161
+ Credentials (api_url, api_key) are held by the activity instance itself
162
+ never passed through activity inputs, so they never land in workflow
163
+ history. The on_api_error policy is passed to the activity, which handles
164
+ logging (safe outside sandbox) and raises GovernanceAPIError if
165
+ fail_closed. This interceptor catches that and re-raises as
166
+ GovernanceHaltError.
136
167
  """
137
168
  try:
138
169
  result = await workflow.execute_activity(
139
170
  "send_governance_event",
140
171
  args=[
141
172
  {
142
- "api_url": api_url,
143
- "api_key": api_key,
144
173
  "payload": payload,
145
174
  "timeout": timeout,
146
175
  "on_api_error": on_api_error,
@@ -150,27 +179,26 @@ async def _send_governance_event(
150
179
  )
151
180
  return result
152
181
  except Exception as e:
153
- error_str = str(e)
154
- error_type = type(e).__name__
155
-
156
- # GovernanceHalt: workflow should terminate (client.terminate() already called,
157
- # but re-raise to ensure workflow code path stops)
158
- if "GovernanceHalt" in error_str:
159
- raise GovernanceHaltError(error_str)
160
-
161
- # GovernanceBlock: activity blocked, workflow continues
162
- if "GovernanceBlock" in error_str:
182
+ app_error_type = _application_error_type(e)
183
+
184
+ # GovernanceHalt / legacy GovernanceStop: workflow should terminate
185
+ # (client.terminate() already called by the activity, but re-raise to
186
+ # ensure the workflow code path stops).
187
+ if app_error_type in (
188
+ GOVERNANCE_HALT_ERROR_TYPE,
189
+ GOVERNANCE_STOP_ERROR_TYPE,
190
+ ):
191
+ raise GovernanceHaltError(str(e))
192
+
193
+ # GovernanceBlock: the current activity is blocked; the workflow continues.
194
+ if app_error_type == GOVERNANCE_BLOCK_ERROR_TYPE:
163
195
  return None
164
196
 
165
- # Legacy GovernanceStop (backward compat) treat as halt
166
- if "GovernanceStop" in error_str:
167
- raise GovernanceHaltError(error_str)
197
+ # Activity raised GovernanceAPIError (fail_closed + API unreachable).
198
+ if app_error_type == GOVERNANCE_API_ERROR_TYPE:
199
+ raise GovernanceHaltError(str(e))
168
200
 
169
- # Activity raised GovernanceAPIError (fail_closed and API unreachable)
170
- if "GovernanceAPIError" in error_type or "GovernanceAPIError" in error_str:
171
- raise GovernanceHaltError(error_str)
172
-
173
- # Other errors with fail_open: silently continue
201
+ # Other errors with fail_open: silently continue.
174
202
  return None
175
203
 
176
204
 
@@ -179,12 +207,15 @@ class GovernanceInterceptor(Interceptor):
179
207
 
180
208
  def __init__(
181
209
  self,
182
- api_url: str,
183
- api_key: str,
210
+ api_url: str = "",
211
+ api_key: str = "",
184
212
  span_processor=None, # Shared with activity interceptor for HTTP spans
185
213
  config=None, # Optional GovernanceConfig
186
214
  ):
187
- self.api_url = api_url.rstrip("/")
215
+ # api_url / api_key are accepted for backward compatibility but no longer
216
+ # flow to the governance activity — credentials live on GovernanceActivities.
217
+ # Kept on self in case downstream callers introspect.
218
+ self.api_url = api_url.rstrip("/") if api_url else ""
188
219
  self.api_key = api_key
189
220
  self.span_processor = span_processor
190
221
  self.api_timeout = getattr(config, "api_timeout", 30.0) if config else 30.0
@@ -203,8 +234,6 @@ class GovernanceInterceptor(Interceptor):
203
234
  self, input: WorkflowInterceptorClassInput
204
235
  ) -> Optional[Type[WorkflowInboundInterceptor]]:
205
236
  # Capture via closure
206
- api_url = self.api_url
207
- api_key = self.api_key
208
237
  span_processor = self.span_processor
209
238
  timeout = self.api_timeout
210
239
  on_error = self.on_api_error
@@ -223,8 +252,6 @@ class GovernanceInterceptor(Interceptor):
223
252
  # WorkflowStarted event
224
253
  if send_start and workflow.patched("openbox-v2-start"):
225
254
  await _send_governance_event(
226
- api_url,
227
- api_key,
228
255
  {
229
256
  "source": "workflow-telemetry",
230
257
  "event_type": "WorkflowStarted",
@@ -254,8 +281,6 @@ class GovernanceInterceptor(Interceptor):
254
281
  )
255
282
 
256
283
  await _send_governance_event(
257
- api_url,
258
- api_key,
259
284
  {
260
285
  "source": "workflow-telemetry",
261
286
  "event_type": "WorkflowCompleted",
@@ -273,20 +298,24 @@ class GovernanceInterceptor(Interceptor):
273
298
  error = _build_error_dict(e)
274
299
 
275
300
  if workflow.patched("openbox-v2-failed"):
276
- await _send_governance_event(
277
- api_url,
278
- api_key,
279
- {
280
- "source": "workflow-telemetry",
281
- "event_type": "WorkflowFailed",
282
- "workflow_id": info.workflow_id,
283
- "run_id": info.run_id,
284
- "workflow_type": info.workflow_type,
285
- "error": error,
286
- },
287
- timeout,
288
- on_error,
289
- )
301
+ # Swallow failures from the failure-reporting activity itself
302
+ # (fail_closed + governance API down would otherwise raise
303
+ # GovernanceHaltError and shadow the real workflow exception).
304
+ try:
305
+ await _send_governance_event(
306
+ {
307
+ "source": "workflow-telemetry",
308
+ "event_type": "WorkflowFailed",
309
+ "workflow_id": info.workflow_id,
310
+ "run_id": info.run_id,
311
+ "workflow_type": info.workflow_type,
312
+ "error": error,
313
+ },
314
+ timeout,
315
+ on_error,
316
+ )
317
+ except Exception:
318
+ pass
290
319
 
291
320
  raise
292
321
 
@@ -300,8 +329,6 @@ class GovernanceInterceptor(Interceptor):
300
329
  # SignalReceived event - check verdict and store if "stop"
301
330
  if workflow.patched("openbox-v2-signal"):
302
331
  result = await _send_governance_event(
303
- api_url,
304
- api_key,
305
332
  {
306
333
  "source": "workflow-telemetry",
307
334
  "event_type": "SignalReceived",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbox-temporal-sdk-python
3
- Version: 1.1.1
3
+ Version: 1.1.2
4
4
  Summary: OpenBox SDK - Governance and observability for Temporal workflows
5
5
  Project-URL: Homepage, https://github.com/OpenBox-AI/temporal-sdk-python
6
6
  Project-URL: Documentation, https://github.com/OpenBox-AI/temporal-sdk-python#readme
@@ -100,7 +100,15 @@ await worker.run()
100
100
  ```
101
101
 
102
102
  The plugin automatically configures governance interceptors, OTel instrumentation,
103
- sandbox passthrough, and the `send_governance_event` activity.
103
+ sandbox passthrough, W3C trace propagation through Temporal headers (via
104
+ `temporalio.contrib.opentelemetry.TracingInterceptor`), and the
105
+ `send_governance_event` activity.
106
+
107
+ **Credentials never leave the plugin.** `openbox_api_key` is captured on the
108
+ governance activity instance itself — it does **not** flow through activity
109
+ inputs, so it is never written to workflow history. To opt out of trace
110
+ propagation (e.g., if you already wire `OpenTelemetryPlugin`), pass
111
+ `enable_trace_propagation=False`.
104
112
 
105
113
  ### Composing with Other Plugins
106
114
 
@@ -148,8 +156,10 @@ The factory automatically:
148
156
  1. Validates the API key
149
157
  2. Creates span processor
150
158
  3. Sets up OpenTelemetry instrumentation
151
- 4. Creates governance interceptors
152
- 5. Adds `send_governance_event` activity
159
+ 4. Creates governance interceptors (incl. W3C trace propagation)
160
+ 5. Builds the `GovernanceActivities` instance with credentials captured on
161
+ `self` and registers its `send_governance_event` method — the API key is
162
+ never passed through activity inputs / workflow history
153
163
  6. Returns fully configured Worker
154
164
 
155
165
  ---
@@ -195,6 +205,11 @@ worker = create_openbox_worker(
195
205
  # File I/O instrumentation
196
206
  instrument_file_io=False, # disabled by default
197
207
 
208
+ # Header-based W3C trace propagation (client → workflow → activities).
209
+ # Default True. Set False if you already wire OpenTelemetryPlugin or a
210
+ # custom propagator.
211
+ enable_trace_propagation=True,
212
+
198
213
  # Standard Worker options (all supported)
199
214
  activity_executor=my_executor,
200
215
  max_concurrent_activities=10,
@@ -471,7 +486,7 @@ from openbox import (
471
486
  )
472
487
  from openbox.otel_setup import setup_opentelemetry_for_governance
473
488
  from openbox.activity_interceptor import ActivityGovernanceInterceptor
474
- from openbox.activities import send_governance_event
489
+ from openbox.activities import build_governance_activities
475
490
  from openbox.hook_governance import FAIL_OPEN, FAIL_CLOSED # error policy constants
476
491
 
477
492
  # 1. Initialize SDK
@@ -511,14 +526,26 @@ activity_interceptor = ActivityGovernanceInterceptor(
511
526
  config=config,
512
527
  )
513
528
 
514
- # 6. Create worker
529
+ # 6. Build the governance activity instance (credentials captured on `self`
530
+ # so they never flow through workflow history).
531
+ governance_activities = build_governance_activities(
532
+ api_url="http://localhost:8086",
533
+ api_key="obx_test_key_1",
534
+ )
535
+
536
+ # 7. Create worker
515
537
  from temporalio.worker import Worker
538
+ from temporalio.contrib.opentelemetry import TracingInterceptor
516
539
  worker = Worker(
517
540
  client=client,
518
541
  task_queue="my-task-queue",
519
542
  workflows=[MyWorkflow],
520
- activities=[my_activity, send_governance_event],
521
- interceptors=[workflow_interceptor, activity_interceptor],
543
+ activities=[my_activity, governance_activities.send_governance_event],
544
+ interceptors=[
545
+ workflow_interceptor,
546
+ activity_interceptor,
547
+ TracingInterceptor(), # W3C header propagation for OTel spans
548
+ ],
522
549
  )
523
550
  ```
524
551
 
@@ -1,25 +1,25 @@
1
- openbox/__init__.py,sha256=-7RjQDWJfZNKWjl-1akKLtvjm-dj8I3kr-u-FvG_vUY,10451
2
- openbox/activities.py,sha256=g5Mz7WVoDmMwRS9bJcxb_Y2Qs_Q2CilqhtJoOETfqyk,7381
1
+ openbox/__init__.py,sha256=uyGpMtLwx7em2lapvIvkiTtmGcf5Og-CBWyHiY0jztA,10451
2
+ openbox/activities.py,sha256=mSOOQerWhF1KoRzw3jb24HBNZkAMeWjtvUUUmeUCK4M,10017
3
3
  openbox/activity_interceptor.py,sha256=WGpnREw_rxZe9pT_MfnD_snnpdXLA5WXG8SLdgPIueE,26181
4
4
  openbox/client.py,sha256=CKXhtZGmMeyIyZQpX1CTVGLj7iiGP_JcoOZ-0F1jEws,7135
5
5
  openbox/config.py,sha256=nUpKGV88LOsGcFThwBJM4rr79tecEmXZ0U7trf34hQo,11369
6
6
  openbox/context_propagation.py,sha256=hOEjqdKWgAw16ONppVI7B6YXD9SMufsdKP5ODmHCGCI,2105
7
- openbox/db_governance_hooks.py,sha256=qauqK9hqTOthmibeHBsTK_89Mz6y-7VY2vsxI90g8wU,31486
8
- openbox/errors.py,sha256=lZcXRtdEdtGBDAsCyXUU1WupCJ9w6gGXpL2J6xkVfi8,7731
7
+ openbox/db_governance_hooks.py,sha256=4g3i0bibYyBkSWl0U0dic3oBMUypiZU2xIBTtfcJfRk,31702
8
+ openbox/errors.py,sha256=je6Pp6Dim13cPfOphn4aJsSkHjoN_dzAlblPo_s-_mk,8676
9
9
  openbox/file_governance_hooks.py,sha256=i25n2CeENHsaYEwBnmxtZE8APoXo1gZFBMMzFOKPCFo,14221
10
10
  openbox/hitl.py,sha256=_sq_3DcvOOdFHi7uyNFG8FiN-q26YgkA9BEs9J8vyW4,4534
11
- openbox/hook_governance.py,sha256=GzZXPN_Z5jdeFZjb0DEKvit1DlZCgo2354xXOkVPOUo,14859
11
+ openbox/hook_governance.py,sha256=GjUlVblO7KyMet-mU2FtKeHTTrcUYYU4s4bgC5aCHfQ,15430
12
12
  openbox/http_governance_hooks.py,sha256=lKryJk3xwqmeXvtWtzVzXhZ3oNGV7o6C1zD3lbQpoiE,31241
13
13
  openbox/otel_setup.py,sha256=T3MK6GqLfVC3kLJC88VSYUZNbbhmskuS11DigxLWgdc,16843
14
- openbox/plugin.py,sha256=iDFMMCLhWJpEWdOIMg87lAP2C5tNzjKcsc2N3VTSURc,6640
14
+ openbox/plugin.py,sha256=WljrcJh_zuIxvgFTj_xcFjHoiarR6uXr4E2okMMu3Tw,7710
15
15
  openbox/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
16
  openbox/span_processor.py,sha256=k_tAT0sWudyVYuw3MALLXCqyNDyXTPwtPl4KVG4CscI,11913
17
17
  openbox/tracing.py,sha256=mPaf2Q3Vrhv-GIVmT56iTDF25IECYrp3GbdtOxPaxJY,10148
18
18
  openbox/types.py,sha256=2XD9aGdS4uNRUO2NzxluR5wOReIkmxCwfXtXrVQOhT4,6729
19
19
  openbox/verdict_handler.py,sha256=rUeiq1Qjrkp4yXfOzoH9Au0YWSKIyEsjdF7aUvI4PbY,4017
20
- openbox/worker.py,sha256=n0_2oPJnkaRpFyjLzNr0J4tMpkNmcMmbYzNzbnaWJxU,10924
21
- openbox/workflow_interceptor.py,sha256=sD-KKANZI2OAxEf1u7NtCqCGAqIzmX3GItPVgHnPxq8,12502
22
- openbox_temporal_sdk_python-1.1.1.dist-info/METADATA,sha256=ihnxZsXTWguotoBmqHU68T_Ip0U1jfW7_DORfTIWYTQ,20845
23
- openbox_temporal_sdk_python-1.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
24
- openbox_temporal_sdk_python-1.1.1.dist-info/licenses/LICENSE,sha256=J7UVgclAywF4uoxRJXhkMqYrPu0cjB_8ckiA_nMNpfI,1069
25
- openbox_temporal_sdk_python-1.1.1.dist-info/RECORD,,
20
+ openbox/worker.py,sha256=dPWNL-V2SLPzN-LM6AFpda3fclR83KXYVeMzDdN3EUw,11511
21
+ openbox/workflow_interceptor.py,sha256=cDeyAQhHREgll7l8ihJ2S3OszVo08DZyiNpeaJSpSKE,13985
22
+ openbox_temporal_sdk_python-1.1.2.dist-info/METADATA,sha256=hJY6QCHzukThYG-9kYQ9BbealZJzNXIzu1ocGJ1nfjE,22132
23
+ openbox_temporal_sdk_python-1.1.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
24
+ openbox_temporal_sdk_python-1.1.2.dist-info/licenses/LICENSE,sha256=J7UVgclAywF4uoxRJXhkMqYrPu0cjB_8ckiA_nMNpfI,1069
25
+ openbox_temporal_sdk_python-1.1.2.dist-info/RECORD,,