openbox-sdk-python 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.
Files changed (52) hide show
  1. openbox_core/__init__.py +59 -0
  2. openbox_core/adapters/__init__.py +3 -0
  3. openbox_core/adapters/base.py +123 -0
  4. openbox_core/approvals.py +106 -0
  5. openbox_core/client.py +298 -0
  6. openbox_core/config.py +260 -0
  7. openbox_core/conformance/__init__.py +3 -0
  8. openbox_core/conformance/fake_core.py +169 -0
  9. openbox_core/conformance/hook_preflight.py +87 -0
  10. openbox_core/conformance/instrumentation.py +91 -0
  11. openbox_core/context.py +205 -0
  12. openbox_core/contracts/__init__.py +3 -0
  13. openbox_core/contracts/context.py +79 -0
  14. openbox_core/contracts/events.py +401 -0
  15. openbox_core/contracts/otel_spans.py +325 -0
  16. openbox_core/contracts/results.py +287 -0
  17. openbox_core/errors.py +287 -0
  18. openbox_core/gate.py +185 -0
  19. openbox_core/hooks/__init__.py +3 -0
  20. openbox_core/hooks/events.py +64 -0
  21. openbox_core/hooks/preflight.py +292 -0
  22. openbox_core/hooks/wrappers.py +105 -0
  23. openbox_core/identity.py +231 -0
  24. openbox_core/instrumentation/__init__.py +3 -0
  25. openbox_core/instrumentation/db.py +689 -0
  26. openbox_core/instrumentation/file.py +239 -0
  27. openbox_core/instrumentation/function.py +121 -0
  28. openbox_core/instrumentation/http.py +840 -0
  29. openbox_core/instrumentation/llm.py +3 -0
  30. openbox_core/instrumentation/manager.py +135 -0
  31. openbox_core/instrumentation/shared.py +27 -0
  32. openbox_core/otel/__init__.py +3 -0
  33. openbox_core/otel/propagation.py +45 -0
  34. openbox_core/otel/provider.py +35 -0
  35. openbox_core/otel/setup.py +36 -0
  36. openbox_core/otel/span_processor.py +62 -0
  37. openbox_core/otel/trace_context.py +71 -0
  38. openbox_core/py.typed +0 -0
  39. openbox_core/runtime.py +138 -0
  40. openbox_core/sdk_version.py +79 -0
  41. openbox_core/serialization.py +129 -0
  42. openbox_core/validation/__init__.py +3 -0
  43. openbox_core/validation/diagnostics.py +60 -0
  44. openbox_core/validation/event_rules.py +164 -0
  45. openbox_core/validation/registry.py +31 -0
  46. openbox_core/validation/span_normalization.py +107 -0
  47. openbox_core/wire/__init__.py +3 -0
  48. openbox_core/wire/core_span.py +130 -0
  49. openbox_core/wire/evaluate_payload.py +56 -0
  50. openbox_sdk_python-0.2.0.dist-info/METADATA +94 -0
  51. openbox_sdk_python-0.2.0.dist-info/RECORD +52 -0
  52. openbox_sdk_python-0.2.0.dist-info/WHEEL +4 -0
openbox_core/errors.py ADDED
@@ -0,0 +1,287 @@
1
+ """OpenBox base SDK — unified exception hierarchy.
2
+
3
+ Pure module: no network, crypto, OTel, logging, or wall-clock imports. Safe to
4
+ import from constrained framework paths.
5
+
6
+ Hierarchy:
7
+ OpenBoxError (base)
8
+ ├── ContractError # strict-gate event/runtime contract violation
9
+ ├── OpenBoxConfigError
10
+ │ ├── OpenBoxAuthError
11
+ │ │ └── OpenBoxSigningError # Core rejected a signed (AIP DID) request
12
+ │ ├── OpenBoxNetworkError
13
+ │ └── OpenBoxInsecureURLError
14
+ ├── GovernanceBlockedError # hook/activity verdict BLOCK
15
+ ├── GovernanceHaltError # verdict HALT (framework-level termination)
16
+ ├── GovernanceAPIError # governance API failure (fail_closed)
17
+ ├── GuardrailsValidationError # guardrails validation_passed=False
18
+ ├── ApprovalExpiredError # HITL approval window expired
19
+ ├── ApprovalRejectedError # HITL approval explicitly rejected
20
+ └── ApprovalTimeoutError # HITL polling exceeded max wait
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import TYPE_CHECKING
26
+
27
+ if TYPE_CHECKING:
28
+ from .contracts.results import Verdict
29
+
30
+ __all__ = [
31
+ "OpenBoxError",
32
+ "ContractError",
33
+ "OpenBoxConfigError",
34
+ "OpenBoxAuthError",
35
+ "OpenBoxNetworkError",
36
+ "OpenBoxInsecureURLError",
37
+ "OpenBoxSigningError",
38
+ "map_signing_error",
39
+ "GovernanceBlockedError",
40
+ "GovernanceHaltError",
41
+ "GovernanceAPIError",
42
+ "GuardrailsValidationError",
43
+ "ApprovalExpiredError",
44
+ "ApprovalRejectedError",
45
+ "ApprovalTimeoutError",
46
+ "extract_governance_error",
47
+ ]
48
+
49
+
50
+ # ═══════════════════════════════════════════════════════════════════
51
+ # Base
52
+ # ═══════════════════════════════════════════════════════════════════
53
+
54
+
55
+ class OpenBoxError(Exception):
56
+ """Base class for all OpenBox SDK errors."""
57
+
58
+
59
+ # ═══════════════════════════════════════════════════════════════════
60
+ # Strict-gate contract violations
61
+ # ═══════════════════════════════════════════════════════════════════
62
+
63
+
64
+ class ContractError(OpenBoxError):
65
+ """Raised by the always-strict gate on a malformed event/runtime contract.
66
+
67
+ Contract violations raise *before* any network send, regardless of the
68
+ ``on_api_error`` fail-open/fail-closed setting — fail-open applies only to
69
+ network errors, never to contract violations.
70
+
71
+ Attributes:
72
+ code: Machine-readable violation code (e.g. ``HOOK_TRIGGER_FALSE``).
73
+ detail: Optional structured context about the violation.
74
+ """
75
+
76
+ def __init__(self, message: str, code: str = "", detail: dict | None = None):
77
+ self.code = code
78
+ self.detail = detail or {}
79
+ super().__init__(message)
80
+
81
+
82
+ # ═══════════════════════════════════════════════════════════════════
83
+ # Configuration errors
84
+ # ═══════════════════════════════════════════════════════════════════
85
+
86
+
87
+ class OpenBoxConfigError(OpenBoxError):
88
+ """Raised when OpenBox configuration fails."""
89
+
90
+
91
+ class OpenBoxAuthError(OpenBoxConfigError):
92
+ """Raised when API key validation fails."""
93
+
94
+
95
+ class OpenBoxNetworkError(OpenBoxConfigError):
96
+ """Raised when network connectivity fails."""
97
+
98
+
99
+ class OpenBoxInsecureURLError(OpenBoxConfigError):
100
+ """Raised when HTTP is used for non-localhost URLs."""
101
+
102
+
103
+ class OpenBoxSigningError(OpenBoxAuthError):
104
+ """Raised when Core rejects a signed (AIP DID) request.
105
+
106
+ Attributes:
107
+ reason_code: Core's machine reason code (e.g. ``signature_invalid``).
108
+ """
109
+
110
+ def __init__(self, message: str, reason_code: str | None = None):
111
+ self.reason_code = reason_code
112
+ super().__init__(message)
113
+
114
+
115
+ # Core signed-request rejection reason codes → actionable SDK guidance.
116
+ # Forward-compatible: Core today often collapses identity failures into a
117
+ # generic "invalid token" body with no machine code; these richer messages
118
+ # activate once Core emits a machine reason code ("reason_code"/"code"/"reason").
119
+ _SIGNING_REASON_MESSAGES: dict[str, str] = {
120
+ "signature_invalid": (
121
+ "Request signature rejected (signature_invalid). The signed bytes did not "
122
+ "match — usually a body-hash mismatch (send content= bytes, never json=) or "
123
+ "a wrong/rotated private key."
124
+ ),
125
+ "nonce_replayed": (
126
+ "Request nonce was already used (nonce_replayed). Each request must carry a "
127
+ "fresh nonce; do not retry a fully-prepared request verbatim."
128
+ ),
129
+ "did_agent_mismatch": (
130
+ "DID does not match the authenticated agent (did_agent_mismatch). Check that "
131
+ "agent_did matches the agent the API key/private key were provisioned for."
132
+ ),
133
+ "verifier_not_configured": (
134
+ "Core has no verifier for this agent (verifier_not_configured). The agent's "
135
+ "public key may not be imported to KMS yet — re-provision the agent."
136
+ ),
137
+ # Core's code is "timestamp_outside_window"; "timestamp_skew" kept as an alias.
138
+ "timestamp_outside_window": (
139
+ "Request timestamp outside the allowed window (timestamp_outside_window). Sync "
140
+ "the host clock (NTP); signatures are valid only within ±300s."
141
+ ),
142
+ "timestamp_skew": (
143
+ "Request timestamp outside the allowed window (timestamp_skew). Sync the host "
144
+ "clock (NTP); signatures are valid only within ±300s."
145
+ ),
146
+ }
147
+
148
+
149
+ def map_signing_error(reason_code: str | None, fallback: str = "") -> OpenBoxSigningError:
150
+ """Map a Core signing reason code to an actionable OpenBoxSigningError.
151
+
152
+ Unknown/empty codes fall back to a generic message (optionally augmented with
153
+ ``fallback`` context). Never raises — always returns an exception to raise.
154
+ """
155
+ if reason_code and reason_code in _SIGNING_REASON_MESSAGES:
156
+ return OpenBoxSigningError(_SIGNING_REASON_MESSAGES[reason_code], reason_code)
157
+ msg = fallback or (
158
+ "Signed request rejected by OpenBox Core"
159
+ + (f" ({reason_code})" if reason_code else "")
160
+ + "."
161
+ )
162
+ return OpenBoxSigningError(msg, reason_code)
163
+
164
+
165
+ # ═══════════════════════════════════════════════════════════════════
166
+ # Governance verdict errors
167
+ # ═══════════════════════════════════════════════════════════════════
168
+
169
+
170
+ class GovernanceBlockedError(OpenBoxError):
171
+ """Raised when governance blocks an operation (default adapter behavior).
172
+
173
+ Framework adapters typically translate this into a native error type; the
174
+ base adapter raises it directly.
175
+
176
+ Attributes:
177
+ verdict: The Verdict enum value (normalized from string if needed).
178
+ reason: Human-readable explanation from the policy engine.
179
+ url: The URL or resource identifier that was blocked (optional).
180
+ """
181
+
182
+ def __init__(self, verdict: str | Verdict, reason: str, url: str = ""):
183
+ # Lazy import avoids a hard module-level dependency on contracts.
184
+ if isinstance(verdict, str):
185
+ from .contracts.results import Verdict
186
+
187
+ self.verdict = Verdict.from_string(verdict)
188
+ else:
189
+ self.verdict = verdict
190
+ self.reason = reason
191
+ self.url = url
192
+ super().__init__(f"Governance {self.verdict.value}: {reason}")
193
+
194
+
195
+ class GovernanceHaltError(OpenBoxError):
196
+ """Raised when governance halts execution (HALT verdict).
197
+
198
+ HALT is the nuclear option — the framework adapter decides how to stop
199
+ future work.
200
+ """
201
+
202
+ def __init__(self, message: str):
203
+ super().__init__(message)
204
+
205
+
206
+ class GovernanceAPIError(OpenBoxError):
207
+ """Raised when the governance API fails and policy is fail_closed."""
208
+
209
+
210
+ # ═══════════════════════════════════════════════════════════════════
211
+ # Guardrails errors
212
+ # ═══════════════════════════════════════════════════════════════════
213
+
214
+
215
+ class GuardrailsValidationError(OpenBoxError):
216
+ """Raised when guardrails validation_passed is False.
217
+
218
+ Attributes:
219
+ reasons: List of reason strings from the guardrails evaluation.
220
+ """
221
+
222
+ def __init__(self, reasons: list[str] | None = None):
223
+ self.reasons = reasons or []
224
+ reason_str = (
225
+ "; ".join(self.reasons) if self.reasons else "Guardrails validation failed"
226
+ )
227
+ super().__init__(reason_str)
228
+
229
+
230
+ # ═══════════════════════════════════════════════════════════════════
231
+ # HITL approval errors
232
+ # ═══════════════════════════════════════════════════════════════════
233
+
234
+
235
+ class ApprovalExpiredError(OpenBoxError):
236
+ """Raised when the HITL approval window expires (server-side deadline)."""
237
+
238
+
239
+ class ApprovalRejectedError(OpenBoxError):
240
+ """Raised when a HITL approval is explicitly rejected by a human."""
241
+
242
+
243
+ class ApprovalTimeoutError(OpenBoxError):
244
+ """Raised when HITL polling exceeds the configured max wait time."""
245
+
246
+ def __init__(self, max_wait_ms: int | None = None):
247
+ self.max_wait_ms = max_wait_ms
248
+ msg = (
249
+ f"Approval polling timed out after {max_wait_ms}ms"
250
+ if max_wait_ms
251
+ else "Approval polling timed out"
252
+ )
253
+ super().__init__(msg)
254
+
255
+
256
+ # ═══════════════════════════════════════════════════════════════════
257
+ # Utility: exception chain walker
258
+ # ═══════════════════════════════════════════════════════════════════
259
+
260
+
261
+ def extract_governance_error(exc: BaseException) -> GovernanceBlockedError | None:
262
+ """Walk an exception chain to find a wrapped GovernanceBlockedError.
263
+
264
+ Frameworks and client libraries often wrap errors. This utility recovers
265
+ the original GovernanceBlockedError for verdict inspection.
266
+
267
+ Args:
268
+ exc: Any exception, potentially wrapping a GovernanceBlockedError.
269
+
270
+ Returns:
271
+ The GovernanceBlockedError if found in the chain, None otherwise.
272
+ """
273
+ seen: set[int] = set()
274
+ current: BaseException | None = exc
275
+ while current is not None and id(current) not in seen:
276
+ seen.add(id(current))
277
+ if isinstance(current, GovernanceBlockedError):
278
+ return current
279
+ # Walk both explicit (__cause__) and implicit (__context__) chains
280
+ next_exc = getattr(current, "__cause__", None) or getattr(
281
+ current, "__context__", None
282
+ )
283
+ # Also check framework .cause properties.
284
+ if next_exc is None:
285
+ next_exc = getattr(current, "cause", None)
286
+ current = next_exc
287
+ return None
openbox_core/gate.py ADDED
@@ -0,0 +1,185 @@
1
+ """GovernanceGate — always-strict validation orchestrating evaluate calls.
2
+
3
+ The gate is ALWAYS STRICT for OpenBox event contracts and runtime invariants:
4
+ malformed contracts raise ``ContractError`` BEFORE any network send. There is
5
+ deliberately NO ``mode``/``OBSERVE``/``SANITIZE``/``STRICT`` toggle anywhere in
6
+ this API — fail-open policy applies only to network errors inside the client,
7
+ never to contract violations.
8
+
9
+ Paths:
10
+ - ``evaluate``/``aevaluate`` — lifecycle events (no spans); the gate
11
+ serializes the envelope dict directly.
12
+ - ``preflight``/``apreflight`` — hook STARTED-stage evaluation. Enforced by
13
+ the hook runtime/adapter (BLOCK/HALT/REQUIRE_APPROVAL stop the operation).
14
+ - ``completed``/``acompleted`` — hook COMPLETED-stage telemetry. Influences
15
+ *future* execution only; it never undoes the operation.
16
+
17
+ Hook body assembly is delegated to the injected ``payload_builder`` —
18
+ ``wire/evaluate_payload.build_evaluate_payload`` is the single owner of the
19
+ evaluate body shape. The gate never reimplements it.
20
+
21
+ Enforcement priority (for adapters interpreting results):
22
+ HALT > BLOCK > guardrails-fail > REQUIRE_APPROVAL > CONSTRAIN > ALLOW.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from collections.abc import Callable
28
+ from typing import Any
29
+
30
+ from .client import EvaluationClient
31
+ from .config import OpenBoxConfig
32
+ from .contracts.events import EventEnvelope
33
+ from .contracts.results import EvaluationResult, Verdict
34
+ from .errors import (
35
+ GovernanceBlockedError,
36
+ GovernanceHaltError,
37
+ GuardrailsValidationError,
38
+ OpenBoxConfigError,
39
+ )
40
+ from .serialization import apply_redaction, rfc3339_now, to_json_safe
41
+ from .validation.diagnostics import Diagnostic
42
+ from .validation.registry import validate_hook, validate_lifecycle
43
+ from .validation.span_normalization import redaction_diagnostics, strip_compat_noise
44
+
45
+ __all__ = ["GovernanceGate", "STAGE_STARTED", "STAGE_COMPLETED", "raise_for_verdict"]
46
+
47
+ STAGE_STARTED = "started"
48
+ STAGE_COMPLETED = "completed"
49
+
50
+ # payload_builder(event) -> (payload_dict, diagnostics)
51
+ PayloadBuilder = Callable[[EventEnvelope], tuple[dict[str, Any], list[Diagnostic]]]
52
+
53
+
54
+ class GovernanceGate:
55
+ """Validate → serialize → evaluate → parse, strictly.
56
+
57
+ Args:
58
+ client: The EvaluationClient used for network calls.
59
+ config: Resolved OpenBoxConfig (privacy settings are read here).
60
+ payload_builder: Hook evaluate-body assembler. Wired to
61
+ ``wire/evaluate_payload.build_evaluate_payload`` by the runtime;
62
+ hook paths raise until one is provided (lifecycle paths work
63
+ without it).
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ client: EvaluationClient,
69
+ config: OpenBoxConfig | None = None,
70
+ *,
71
+ payload_builder: PayloadBuilder | None = None,
72
+ ):
73
+ self._client = client
74
+ self._config = config or OpenBoxConfig()
75
+ self._payload_builder = payload_builder
76
+
77
+ # ── Lifecycle events ──────────────────────────────────────────────────
78
+
79
+ def evaluate(self, event: EventEnvelope) -> EvaluationResult:
80
+ """Evaluate a lifecycle/signal/handoff event (strictly validated)."""
81
+ payload, diagnostics = self._prepare_lifecycle(event)
82
+ result = self._client.evaluate(payload)
83
+ result.diagnostics.extend(d.to_dict() for d in diagnostics)
84
+ return result
85
+
86
+ async def aevaluate(self, event: EventEnvelope) -> EvaluationResult:
87
+ """Async :meth:`evaluate`."""
88
+ payload, diagnostics = self._prepare_lifecycle(event)
89
+ result = await self._client.aevaluate(payload)
90
+ result.diagnostics.extend(d.to_dict() for d in diagnostics)
91
+ return result
92
+
93
+ def _prepare_lifecycle(self, event: EventEnvelope) -> tuple[dict, list[Diagnostic]]:
94
+ diagnostics = validate_lifecycle(event)
95
+ payload = event.to_payload_dict()
96
+ payload.setdefault("timestamp", rfc3339_now())
97
+ # Empty span compatibility noise never reaches the wire.
98
+ payload, noise_diagnostics = strip_compat_noise(payload)
99
+ diagnostics.extend(noise_diagnostics)
100
+ return self._finalize_payload(payload, diagnostics)
101
+
102
+ # ── Hook events ───────────────────────────────────────────────────────
103
+
104
+ def preflight(self, event: EventEnvelope) -> EvaluationResult:
105
+ """Started-stage hook evaluation (validated; body via payload_builder)."""
106
+ payload, diagnostics = self._prepare_hook(event, STAGE_STARTED)
107
+ result = self._client.evaluate(payload)
108
+ result.diagnostics.extend(d.to_dict() for d in diagnostics)
109
+ return result
110
+
111
+ async def apreflight(self, event: EventEnvelope) -> EvaluationResult:
112
+ """Async :meth:`preflight`."""
113
+ payload, diagnostics = self._prepare_hook(event, STAGE_STARTED)
114
+ result = await self._client.aevaluate(payload)
115
+ result.diagnostics.extend(d.to_dict() for d in diagnostics)
116
+ return result
117
+
118
+ def completed(self, event: EventEnvelope) -> EvaluationResult:
119
+ """Completed-stage hook telemetry. Never undoes the operation — the
120
+ result may only influence FUTURE execution (adapter decides)."""
121
+ payload, diagnostics = self._prepare_hook(event, STAGE_COMPLETED)
122
+ result = self._client.evaluate(payload)
123
+ result.diagnostics.extend(d.to_dict() for d in diagnostics)
124
+ return result
125
+
126
+ async def acompleted(self, event: EventEnvelope) -> EvaluationResult:
127
+ """Async :meth:`completed`."""
128
+ payload, diagnostics = self._prepare_hook(event, STAGE_COMPLETED)
129
+ result = await self._client.aevaluate(payload)
130
+ result.diagnostics.extend(d.to_dict() for d in diagnostics)
131
+ return result
132
+
133
+ def _prepare_hook(self, event: EventEnvelope, stage: str) -> tuple[dict, list[Diagnostic]]:
134
+ diagnostics = validate_hook(event, stage)
135
+ if self._payload_builder is None:
136
+ raise OpenBoxConfigError(
137
+ "GovernanceGate has no payload_builder — hook evaluation requires "
138
+ "wire/evaluate_payload.build_evaluate_payload (wired by OpenBoxRuntime)"
139
+ )
140
+ payload, builder_diagnostics = self._payload_builder(event)
141
+ diagnostics.extend(builder_diagnostics)
142
+ payload.setdefault("timestamp", rfc3339_now())
143
+ return self._finalize_payload(payload, diagnostics)
144
+
145
+ # ── Shared payload finishing ──────────────────────────────────────────
146
+
147
+ def _finalize_payload(
148
+ self, payload: dict, diagnostics: list[Diagnostic]
149
+ ) -> tuple[dict, list[Diagnostic]]:
150
+ """JSON-safety + privacy redaction, applied BEFORE signing/sending.
151
+
152
+ ``exclude_none=False`` is deliberate: started-stage spans carry
153
+ EXPLICIT ``end_time: null`` / ``duration_ns: null`` (Core's non-pointer
154
+ int64 relies on them), so nulls must survive to the wire.
155
+ Omit-when-absent is handled where payloads are BUILT (keys left out),
156
+ not by dropping None here.
157
+ """
158
+ payload = to_json_safe(payload, exclude_none=False)
159
+ redact_keys = self._config.privacy.redact_keys
160
+ if redact_keys:
161
+ payload, changed = apply_redaction(payload, redact_keys)
162
+ diagnostics.extend(redaction_diagnostics(changed))
163
+ return payload, diagnostics
164
+
165
+
166
+ def raise_for_verdict(result: EvaluationResult) -> EvaluationResult | None:
167
+ """Default enforcement helper implementing the verdict priority order.
168
+
169
+ HALT > BLOCK > guardrails-fail > REQUIRE_APPROVAL > CONSTRAIN > ALLOW.
170
+
171
+ Raises the core error types for stop-shaped results; returns the result
172
+ for REQUIRE_APPROVAL (caller drives approval) and for ALLOW/CONSTRAIN
173
+ (caller proceeds). Framework adapters typically translate these into
174
+ native errors instead of calling this helper.
175
+ """
176
+ verdict = result.verdict
177
+ if verdict == Verdict.HALT:
178
+ raise GovernanceHaltError(result.reason or "Halted by governance policy")
179
+ if verdict == Verdict.BLOCK:
180
+ raise GovernanceBlockedError(verdict, result.reason or "Blocked by governance policy")
181
+ # Guardrails failure outranks approval so it is never swallowed by a HITL flow.
182
+ if result.guardrails and not result.guardrails.validation_passed:
183
+ reasons = result.guardrails.get_reason_strings()
184
+ raise GuardrailsValidationError(reasons or ["Guardrails validation failed"])
185
+ return result
@@ -0,0 +1,3 @@
1
+ """Started/completed hook runtime."""
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,64 @@
1
+ """Hook event assembly from the bound ActivityContext + serialized OTel span."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ from ..context import ContextStore
10
+ from ..contracts.context import ActivityContext
11
+ from ..contracts.events import EventEnvelope, hook
12
+ from ..contracts.otel_spans import HookType, Stage, from_otel_span
13
+ from ..otel.trace_context import raw_trace_id
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ __all__ = ["resolve_context", "build_hook_event"]
18
+
19
+
20
+ def resolve_context(store: ContextStore, span: Any) -> ActivityContext | None:
21
+ """Bound context via ContextVar first, trace-map fallback second.
22
+
23
+ The trace-map path serves code running where ContextVars don't propagate
24
+ (executor threads); both sides key by the canonical integer trace id.
25
+ """
26
+ ctx = store.current_activity_context()
27
+ if ctx is not None:
28
+ return ctx
29
+ trace_id = raw_trace_id(span)
30
+ if trace_id is not None and trace_id != 0:
31
+ return store.context_for_trace(trace_id)
32
+ return None
33
+
34
+
35
+ def build_hook_event(
36
+ store: ContextStore,
37
+ span: Any,
38
+ *,
39
+ stage: Stage,
40
+ hook_type: HookType,
41
+ fields: Mapping[str, Any] | None = None,
42
+ ) -> EventEnvelope | None:
43
+ """Build a hook EventEnvelope, or None when the hook must be SKIPPED.
44
+
45
+ No bound ActivityContext (or a context without an activity binding) means
46
+ this operation is not inside a governed activity — skipping is the
47
+ CORRECT behavior, not an error.
48
+ """
49
+ ctx = resolve_context(store, span)
50
+ if ctx is None:
51
+ logger.debug("hook skipped: no bound ActivityContext for span")
52
+ return None
53
+ if not ctx.activity_id or not ctx.activity_type:
54
+ logger.debug("hook skipped: bound context has no activity binding")
55
+ return None
56
+ span_data = from_otel_span(
57
+ span, stage=stage, hook_type=hook_type, activity_context=None, fields=fields
58
+ )
59
+ return hook(
60
+ activity_context=ctx.to_payload_fields(),
61
+ activity_id=ctx.activity_id,
62
+ activity_type=ctx.activity_type,
63
+ spans=[span_data],
64
+ )