openbox-sdk-python 1.0.1__tar.gz → 1.2.0__tar.gz

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 (94) hide show
  1. openbox_sdk_python-1.2.0/CHANGELOG.md +41 -0
  2. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/PKG-INFO +1 -1
  3. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/__init__.py +1 -1
  4. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/adapters/base.py +71 -13
  5. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/conformance/hook_preflight.py +5 -1
  6. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/contracts/results.py +131 -0
  7. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/hooks/preflight.py +15 -22
  8. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/runtime.py +35 -2
  9. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/pyproject.toml +1 -1
  10. openbox_sdk_python-1.2.0/tests/contracts/test_patch.py +207 -0
  11. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/runtime/test_runtime_delegation.py +61 -1
  12. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/uv.lock +1 -1
  13. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/.github/instructions/openbox-sdk-python.instructions.md +0 -0
  14. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/.github/workflows/ci.yml +0 -0
  15. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/.github/workflows/publish.yml +0 -0
  16. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/.gitignore +0 -0
  17. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/.python-version +0 -0
  18. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/README.md +0 -0
  19. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/adapters/__init__.py +0 -0
  20. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/approvals.py +0 -0
  21. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/client.py +0 -0
  22. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/config.py +0 -0
  23. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/conformance/__init__.py +0 -0
  24. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/conformance/fake_core.py +0 -0
  25. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/conformance/instrumentation.py +0 -0
  26. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/context.py +0 -0
  27. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/contracts/__init__.py +0 -0
  28. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/contracts/context.py +0 -0
  29. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/contracts/events.py +0 -0
  30. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/contracts/otel_spans.py +0 -0
  31. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/errors.py +0 -0
  32. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/gate.py +0 -0
  33. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/hooks/__init__.py +0 -0
  34. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/hooks/events.py +0 -0
  35. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/hooks/wrappers.py +0 -0
  36. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/identity.py +0 -0
  37. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/__init__.py +0 -0
  38. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/db.py +0 -0
  39. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/file.py +0 -0
  40. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/function.py +0 -0
  41. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/http.py +0 -0
  42. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/llm.py +0 -0
  43. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/manager.py +0 -0
  44. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/shared.py +0 -0
  45. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/otel/__init__.py +0 -0
  46. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/otel/propagation.py +0 -0
  47. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/otel/provider.py +0 -0
  48. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/otel/setup.py +0 -0
  49. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/otel/span_processor.py +0 -0
  50. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/otel/trace_context.py +0 -0
  51. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/py.typed +0 -0
  52. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/sdk_version.py +0 -0
  53. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/serialization.py +0 -0
  54. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/validation/__init__.py +0 -0
  55. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/validation/diagnostics.py +0 -0
  56. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/validation/event_rules.py +0 -0
  57. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/validation/registry.py +0 -0
  58. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/validation/span_normalization.py +0 -0
  59. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/wire/__init__.py +0 -0
  60. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/wire/core_span.py +0 -0
  61. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/openbox_core/wire/evaluate_payload.py +0 -0
  62. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/client/test_approval_poll.py +0 -0
  63. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/client/test_fail_modes.py +0 -0
  64. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/config/test_resolution_order.py +0 -0
  65. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/conformance/test_required_cases.py +0 -0
  66. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/context/test_bind_reset.py +0 -0
  67. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/context/test_trace_key.py +0 -0
  68. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/contracts/test_approval_parsing.py +0 -0
  69. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/contracts/test_event_classify.py +0 -0
  70. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/contracts/test_result_parsing.py +0 -0
  71. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/gate/test_diagnostics.py +0 -0
  72. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/gate/test_strict_failures.py +0 -0
  73. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/conftest.py +0 -0
  74. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/instrumented_env.py +0 -0
  75. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_db_block.py +0 -0
  76. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_db_redis_mongo.py +0 -0
  77. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_file_function_block.py +0 -0
  78. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_hook_failclosed_and_sync_approval.py +0 -0
  79. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_hook_runtime.py +0 -0
  80. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_http_preflight_block.py +0 -0
  81. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_http_urllib.py +0 -0
  82. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_manager_and_otel_lifecycle.py +0 -0
  83. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/signing/generate_golden_fixture_from_temporal_signer.py +0 -0
  84. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/signing/golden_temporal_signed_request.json +0 -0
  85. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/signing/test_golden_signing.py +0 -0
  86. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/test_import_safety.py +0 -0
  87. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/test_sdk_version.py +0 -0
  88. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/wire/go_spandata_compat/go.mod +0 -0
  89. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/wire/go_spandata_compat/main.go +0 -0
  90. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/wire/span_fixtures.py +0 -0
  91. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/wire/test_backend_compat.py +0 -0
  92. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/wire/test_core_span_projection.py +0 -0
  93. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/wire/test_flat_hook_contract.py +0 -0
  94. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.2.0}/tests/wire/test_hex_ids.py +0 -0
@@ -0,0 +1,41 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.2.0] - 2026-07-23
9
+
10
+ ### Changed
11
+ - **BREAKING:** Renamed the public BLOCK remediation contract from `retry_plan` to `patch`.
12
+ `RetryPlan` is now `Patch`, `RetryDirective` is now `PatchDirective`, and
13
+ `handle_retryable_block()` is now `handle_patch()`. `EvaluationResult.retry_plan` and
14
+ `ApprovalResult.retry_plan` are now `.patch`. The parser now reads only the canonical outer
15
+ wire key `patch`; the old `retry_plan` key is no longer recognized.
16
+ - The verdict gate is unchanged: a directive is still surfaced only for an exact `BLOCK` verdict
17
+ carrying a valid patch — never for HALT/`should_stop()`, and never for an expired approval
18
+ result.
19
+
20
+ ### Notes
21
+ - This is a breaking release: the old public names (`RetryPlan`, `RetryDirective`,
22
+ `handle_retryable_block`, `.retry_plan`) are removed, not aliased. Consumers still pinned to
23
+ `1.1.0` remain fail-safe — they ignore the unknown `patch` field but still enforce the `BLOCK`
24
+ verdict.
25
+
26
+ ## [1.1.0] - 2026-07-21
27
+
28
+ ### Added
29
+ - `RetryPlan` and `RetryDirective` dataclasses in `openbox_core.contracts.results`.
30
+ - Optional `retry_plan` directive parsing on both `EvaluationResult` and `ApprovalResult`.
31
+ A `_MISSING` sentinel keeps a present `new_input: null` distinct from an absent field; falsy
32
+ values (`null`, `""`, `0`, `[]`, `{}`) are preserved; a boolean `new_input` is rejected; and every
33
+ number (recursively) must be finite and, if integral, a JS-safe integer (`|n| <= 2^53 - 1`).
34
+ - `handle_retryable_block(result)` — an opt-in, pure inspector that returns a `RetryDirective` only
35
+ for a `BLOCK` verdict carrying a valid plan. Returns `None` for a plain BLOCK, every non-BLOCK
36
+ verdict (including HALT), a pending verdict, and an expired `ApprovalResult`.
37
+
38
+ ### Notes
39
+ - Default enforcement is unchanged: a `BLOCK` verdict still raises `GovernanceBlockedError`. The new
40
+ helper is opt-in and never triggers an automatic retry; malformed or ineligible plans are treated
41
+ as absent (never fail open).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbox-sdk-python
3
- Version: 1.0.1
3
+ Version: 1.2.0
4
4
  Summary: OpenBox base SDK - governance contracts, strict gate, identity/signing, evaluate client, context runtime, OTel span wire serialization, and generic instrumentation shared by every OpenBox framework SDK
5
5
  Author-email: OpenBox Team <tino@openbox.ai>
6
6
  License-Expression: MIT
@@ -37,7 +37,7 @@ from .errors import (
37
37
  # governance; eagerly it can deadlock package init as a circular import, and
38
38
  # lazily it can recurse unboundedly when a per-request header builder resolves
39
39
  # the version. Keep in sync with pyproject.toml on release.
40
- __version__ = "1.0.1"
40
+ __version__ = "1.2.0"
41
41
 
42
42
  __all__ = [
43
43
  "__version__",
@@ -8,7 +8,8 @@ core error types directly.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
- from typing import TYPE_CHECKING, Protocol, runtime_checkable
11
+ from collections.abc import Callable
12
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
12
13
 
13
14
  from ..contracts.results import EvaluationResult
14
15
  from ..errors import (
@@ -24,7 +25,7 @@ if TYPE_CHECKING:
24
25
  from ..approvals import ApprovalPoller
25
26
  from ..contracts.context import ActivityContext
26
27
 
27
- __all__ = ["FrameworkAdapter", "CoreAdapter"]
28
+ __all__ = ["FrameworkAdapter", "CoreAdapter", "adapter_accepts_context"]
28
29
 
29
30
 
30
31
  @runtime_checkable
@@ -33,16 +34,27 @@ class FrameworkAdapter(Protocol):
33
34
 
34
35
  name: str
35
36
 
36
- async def handle_approval(self, result: EvaluationResult) -> None:
37
+ async def handle_approval(
38
+ self, result: EvaluationResult, context: ActivityContext | None = None
39
+ ) -> None:
37
40
  """Drive the framework's approval flow for REQUIRE_APPROVAL.
38
41
 
39
42
  Return normally when approved; raise the framework's native rejection/
40
43
  expiry error otherwise. Called BEFORE the real operation runs.
41
44
 
42
- Adapters may ALSO define a plain-sync ``handle_approval_sync(result)``
43
- (not part of the required protocol): when present, sync hook paths
44
- delegate to it instead of driving the core inline poller. Frameworks
45
- with retry-based HITL can raise their native pending error there.
45
+ ``context`` carries the workflow/run/activity IDs the approval poll
46
+ needs: Core's evaluate response does NOT echo them, so they cannot be
47
+ recovered from ``result.raw``. The runtime passes the originating
48
+ ``ActivityContext`` (lifecycle events build one from the event).
49
+ Optional — an adapter defined as ``handle_approval(self, result)`` still
50
+ conforms; the runtime passes ``context`` only when the signature accepts
51
+ it (see :func:`adapter_accepts_context`).
52
+
53
+ Adapters may ALSO define a plain-sync ``handle_approval_sync(result,
54
+ context)`` (not part of the required protocol): when present, sync hook
55
+ paths delegate to it instead of driving the core inline poller.
56
+ Frameworks with retry-based HITL can raise their native pending error
57
+ there.
46
58
  """
47
59
  ...
48
60
 
@@ -82,17 +94,16 @@ class CoreAdapter:
82
94
  def __init__(self, approval_poller: ApprovalPoller | None = None):
83
95
  self._poller = approval_poller
84
96
 
85
- async def handle_approval(self, result: EvaluationResult) -> None:
97
+ async def handle_approval(
98
+ self, result: EvaluationResult, context: ActivityContext | None = None
99
+ ) -> None:
86
100
  if self._poller is None or not result.approval_id:
87
101
  raise ApprovalRejectedError(
88
102
  "REQUIRE_APPROVAL verdict but no approval flow is configured — "
89
103
  "failing safe (operation not run)"
90
104
  )
91
- approval = await self._poller.await_decision(
92
- result.raw.get("workflow_id", ""),
93
- result.raw.get("run_id", ""),
94
- result.raw.get("activity_id", ""),
95
- )
105
+ workflow_id, run_id, activity_id = _approval_poll_ids(result, context)
106
+ approval = await self._poller.await_decision(workflow_id, run_id, activity_id)
96
107
  if approval.allow_shaped:
97
108
  return
98
109
  if approval.expired:
@@ -121,3 +132,50 @@ class CoreAdapter:
121
132
  raise GovernanceBlockedError(
122
133
  result.verdict, result.reason or "Blocked by governance policy"
123
134
  )
135
+
136
+
137
+ def _approval_poll_ids(
138
+ result: EvaluationResult, context: ActivityContext | None
139
+ ) -> tuple[str, str, str]:
140
+ """Resolve the (workflow_id, run_id, activity_id) the approval poll sends.
141
+
142
+ Core's evaluate response does not echo them, so the originating
143
+ ``ActivityContext`` is authoritative; ``result.raw`` is only a fallback for
144
+ a caller that predates the ``context`` argument (it is empty in real Core
145
+ traffic).
146
+ """
147
+ raw = result.raw
148
+ if context is None:
149
+ return (
150
+ raw.get("workflow_id", ""),
151
+ raw.get("run_id", ""),
152
+ raw.get("activity_id", ""),
153
+ )
154
+ return (
155
+ context.workflow_id or raw.get("workflow_id", ""),
156
+ context.run_id or raw.get("run_id", ""),
157
+ context.activity_id or raw.get("activity_id", ""),
158
+ )
159
+
160
+
161
+ def adapter_accepts_context(callback: Callable[..., Any] | None) -> bool:
162
+ """True when an adapter callback accepts a ``context`` argument (an explicit
163
+ parameter or ``**kwargs``).
164
+
165
+ Checked by signature so a genuine ``TypeError`` raised inside the callback
166
+ body is never mistaken for an arity mismatch and silently dropped. Lets the
167
+ runtime stay backward-compatible with adapters written against the older
168
+ ``handle_approval(self, result)`` / ``on_completed_hook_result(self,
169
+ result)`` signatures.
170
+ """
171
+ import inspect
172
+
173
+ if callback is None:
174
+ return False
175
+ try:
176
+ params = inspect.signature(callback).parameters
177
+ except (TypeError, ValueError):
178
+ return False
179
+ return "context" in params or any(
180
+ p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()
181
+ )
@@ -39,10 +39,14 @@ class RecordingHookAdapter:
39
39
  self.completed_results: list[EvaluationResult] = []
40
40
  self.completed_contexts: list[ActivityContext | None] = []
41
41
  self.approvals: list[EvaluationResult] = []
42
+ self.approval_contexts: list[ActivityContext | None] = []
42
43
  self.approve_next = True
43
44
 
44
- async def handle_approval(self, result: EvaluationResult) -> None:
45
+ async def handle_approval(
46
+ self, result: EvaluationResult, context: ActivityContext | None = None
47
+ ) -> None:
45
48
  self.approvals.append(result)
49
+ self.approval_contexts.append(context)
46
50
  if not self.approve_next:
47
51
  raise ApprovalRejectedError("rejected by conformance adapter")
48
52
 
@@ -10,6 +10,7 @@ tolerant of unknown keys (field-shape drift from Core must not crash SDKs).
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import math
13
14
  from dataclasses import dataclass, field
14
15
  from enum import Enum
15
16
  from typing import Any
@@ -19,6 +20,9 @@ __all__ = [
19
20
  "GuardrailsResult",
20
21
  "EvaluationResult",
21
22
  "ApprovalResult",
23
+ "Patch",
24
+ "PatchDirective",
25
+ "handle_patch",
22
26
  ]
23
27
 
24
28
 
@@ -102,6 +106,87 @@ class GuardrailsResult:
102
106
  return [r.get("reason", "") for r in self.reasons if r.get("reason")]
103
107
 
104
108
 
109
+ # Sentinel distinguishing an absent ``patch`` field from a present JSON ``null``.
110
+ # ``dict.get("patch")`` would collapse both to None; membership via this sentinel does not.
111
+ _MISSING = object()
112
+
113
+ # JS Number.MAX_SAFE_INTEGER. Integral numbers must fit within +/- this bound so ``new_input`` can
114
+ # never silently change across FE, backend, Core, and Python (json.loads yields arbitrary-precision
115
+ # ``int`` and ``float('inf')``, both broader than JS).
116
+ _MAX_SAFE_INTEGER = 2**53 - 1
117
+
118
+
119
+ def _numbers_are_safe(value: Any) -> bool:
120
+ """Every number (recursively) must be finite and, if integral, within the JS-safe range.
121
+
122
+ ``bool`` is a subclass of ``int`` but is not a number here; a nested bool is a plain leaf
123
+ (top-level ``new_input`` booleans are rejected separately by :func:`_parse_patch`).
124
+ """
125
+ if isinstance(value, bool):
126
+ return True
127
+ if isinstance(value, int):
128
+ return abs(value) <= _MAX_SAFE_INTEGER
129
+ if isinstance(value, float):
130
+ if math.isinf(value) or math.isnan(value):
131
+ return False
132
+ if value.is_integer():
133
+ return abs(value) <= _MAX_SAFE_INTEGER
134
+ return True
135
+ if isinstance(value, dict):
136
+ return all(_numbers_are_safe(v) for v in value.values())
137
+ if isinstance(value, (list, tuple)):
138
+ return all(_numbers_are_safe(v) for v in value)
139
+ return True
140
+
141
+
142
+ @dataclass
143
+ class Patch:
144
+ """Optional remediation hint carried on a BLOCK verdict (policy-produced or admin-patch).
145
+
146
+ ``new_input`` may be null | str | number | list | dict (boolean rejected). A present
147
+ ``new_input`` of ``None`` is a valid patch (reuses the original input) and is distinct
148
+ from an absent ``patch`` field.
149
+ """
150
+
151
+ new_input: Any = None
152
+
153
+
154
+ @dataclass
155
+ class PatchDirective:
156
+ """Caller-facing view of a BLOCK with patch, surfaced only via :func:`handle_patch`.
157
+
158
+ It is a remediation hint, not proof that a patch was evaluated or executed.
159
+ """
160
+
161
+ new_input: Any = None
162
+ governance_event_id: str | None = None
163
+ reason: str | None = None
164
+
165
+
166
+ def _parse_patch(container: dict[str, Any]) -> Patch | None:
167
+ """Parse an optional ``patch`` from a result dict against the frozen wire contract.
168
+
169
+ Returns a patch only when ``patch`` is present, is a dict whose ONLY key is ``new_input``,
170
+ and ``new_input`` is null/str/number/list/dict (boolean rejected) with every number finite and,
171
+ if integral, within the JS-safe integer range. An absent field, a JSON ``null`` field, or any
172
+ contract violation yields ``None`` (treated as absent — never an error). A present
173
+ ``new_input`` of ``None`` is preserved as a valid patch.
174
+ """
175
+ patch = container.get("patch", _MISSING)
176
+ if patch is _MISSING or not isinstance(patch, dict):
177
+ return None
178
+ # Exactly the single key new_input (also guarantees new_input is present, not merely null).
179
+ if set(patch.keys()) != {"new_input"}:
180
+ return None
181
+ new_input = patch["new_input"]
182
+ # bool is a subclass of int — reject a boolean new_input BEFORE the number check.
183
+ if isinstance(new_input, bool):
184
+ return None
185
+ if not _numbers_are_safe(new_input):
186
+ return None
187
+ return Patch(new_input=new_input)
188
+
189
+
105
190
  @dataclass
106
191
  class EvaluationResult:
107
192
  """Response from a governance evaluation.
@@ -130,6 +215,7 @@ class EvaluationResult:
130
215
  fallback_used: bool = False # True when fail-open produced this result
131
216
  diagnostics: list[Any] = field(default_factory=list)
132
217
  raw: dict[str, Any] = field(default_factory=dict)
218
+ patch: Patch | None = None # Optional BLOCK remediation hint; None = absent
133
219
 
134
220
  @property
135
221
  def guardrails_result(self) -> GuardrailsResult | None:
@@ -180,6 +266,7 @@ class EvaluationResult:
180
266
  fallback_used=bool(data.get("fallback_used", False)),
181
267
  diagnostics=data.get("diagnostics") or [],
182
268
  raw=dict(data),
269
+ patch=_parse_patch(data),
183
270
  )
184
271
 
185
272
  @classmethod
@@ -212,6 +299,7 @@ class ApprovalResult:
212
299
  approval_expiration_time: str | None = None
213
300
  expired: bool = False
214
301
  raw: dict[str, Any] = field(default_factory=dict)
302
+ patch: Patch | None = None # Present only on an admin-patch BLOCK
215
303
 
216
304
  # Known decision vocabulary for approvals (current values + accepted aliases).
217
305
  # Anything OUTSIDE this set parses to None (pending) — the evaluate-path
@@ -257,6 +345,7 @@ class ApprovalResult:
257
345
  approval_expiration_time=data.get("approval_expiration_time"),
258
346
  expired=bool(data.get("expired", False)),
259
347
  raw=dict(data),
348
+ patch=_parse_patch(data),
260
349
  )
261
350
 
262
351
  @property
@@ -285,3 +374,45 @@ class ApprovalResult:
285
374
  if self.verdict is None:
286
375
  return True
287
376
  return self.verdict in (Verdict.REQUIRE_APPROVAL, Verdict.CONSTRAIN)
377
+
378
+
379
+ def handle_patch(
380
+ result: EvaluationResult | ApprovalResult,
381
+ ) -> PatchDirective | None:
382
+ """Opt-in, pure inspector: surface a patch directive from a BLOCK with patch.
383
+
384
+ Returns a :class:`PatchDirective` ONLY for ``verdict == Verdict.BLOCK`` with a present, valid
385
+ ``patch``. Returns ``None`` for plain BLOCK, every non-BLOCK verdict (including HALT),
386
+ a pending/``None`` verdict, and an expired :class:`ApprovalResult`.
387
+
388
+ This does NOT change enforcement: importing or exposing it never auto-applies the patch. A
389
+ caller must invoke it explicitly, and it makes no claim that a replacement was evaluated or
390
+ executed.
391
+
392
+ The gate is deliberately ``verdict == Verdict.BLOCK`` — NOT ``is_blocking()`` /
393
+ ``verdict.should_stop()``, which also match HALT (and, for approvals, expired results) and would
394
+ violate the wire-contract invariant that HALT always strips the patch.
395
+ """
396
+ # A stale/expired poll result must not surface a directive, even if verdict == BLOCK.
397
+ if isinstance(result, ApprovalResult) and result.expired:
398
+ return None
399
+ # ApprovalResult.verdict may be None (pending); the != BLOCK comparison handles that. Using
400
+ # getattr keeps this safe if a caller passes an unexpected object.
401
+ if getattr(result, "verdict", None) != Verdict.BLOCK:
402
+ return None
403
+
404
+ patch = result.patch
405
+ if patch is None:
406
+ return None
407
+
408
+ # governance_event_id is typed on EvaluationResult; ApprovalResult carries it only in raw.
409
+ if isinstance(result, EvaluationResult):
410
+ governance_event_id = result.governance_event_id
411
+ else:
412
+ governance_event_id = result.raw.get("governance_event_id") or result.raw.get("id")
413
+
414
+ return PatchDirective(
415
+ new_input=patch.new_input,
416
+ governance_event_id=governance_event_id,
417
+ reason=result.reason,
418
+ )
@@ -18,6 +18,7 @@ import logging
18
18
  from collections.abc import Mapping
19
19
  from typing import Any, NoReturn
20
20
 
21
+ from ..adapters.base import adapter_accepts_context
21
22
  from ..approvals import ApprovalPoller
22
23
  from ..contracts.events import EventEnvelope
23
24
  from ..contracts.otel_spans import HookType, Stage
@@ -39,10 +40,13 @@ class HookRuntime:
39
40
  self._store = runtime.context_store
40
41
  self._gate = runtime.gate
41
42
  self._adapter = runtime.adapter
42
- # Decide ONCE whether the adapter's completed callback takes ``context``,
43
- # by inspecting its signature — so a genuine TypeError raised inside the
43
+ # Decide ONCE whether the adapter's callbacks take ``context``, by
44
+ # inspecting their signatures — so a genuine TypeError raised inside a
44
45
  # callback body is never mistaken for an arity mismatch and swallowed.
45
- self._completed_accepts_context = self._adapter_accepts_context()
46
+ self._completed_accepts_context = adapter_accepts_context(
47
+ self._adapter.on_completed_hook_result
48
+ )
49
+ self._approval_accepts_context = adapter_accepts_context(self._adapter.handle_approval)
46
50
  hitl = runtime.config.hitl
47
51
  self._sync_poller: ApprovalPoller | None = None
48
52
  if hitl.enabled:
@@ -163,7 +167,14 @@ class HookRuntime:
163
167
  )
164
168
  if verdict.requires_approval():
165
169
  # Adapter drives its native approval flow; returning ⇒ approved.
166
- await self._adapter.handle_approval(result)
170
+ # Core omits the workflow/run/activity IDs from the evaluate
171
+ # response, so hand the span-resolved context for the poll.
172
+ if self._approval_accepts_context:
173
+ await self._adapter.handle_approval(
174
+ result, context=resolve_context(self._store, span)
175
+ )
176
+ else:
177
+ await self._adapter.handle_approval(result)
167
178
  return True
168
179
  return True
169
180
 
@@ -259,24 +270,6 @@ class HookRuntime:
259
270
  return
260
271
  self._after_completed(result, span)
261
272
 
262
- def _adapter_accepts_context(self) -> bool:
263
- """True when the adapter's ``on_completed_hook_result`` accepts a
264
- ``context`` argument (checked once, by signature — not by catching a
265
- TypeError from the call, which would mask real errors)."""
266
- import inspect
267
-
268
- callback = getattr(self._adapter, "on_completed_hook_result", None)
269
- if callback is None:
270
- return False
271
- try:
272
- params = inspect.signature(callback).parameters
273
- except (TypeError, ValueError):
274
- return False
275
- # Accepts context via an explicit param or **kwargs.
276
- return "context" in params or any(
277
- p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()
278
- )
279
-
280
273
  def _after_completed(self, result: EvaluationResult, span: Any) -> None:
281
274
  if result.verdict.should_stop():
282
275
  self._mark_stopped(result, span) # future execution only
@@ -17,10 +17,11 @@ from __future__ import annotations
17
17
 
18
18
  from typing import Any
19
19
 
20
- from .adapters.base import CoreAdapter, FrameworkAdapter
20
+ from .adapters.base import CoreAdapter, FrameworkAdapter, adapter_accepts_context
21
21
  from .client import EvaluationClient
22
22
  from .config import OpenBoxConfig
23
23
  from .context import ContextStore, default_context_store
24
+ from .contracts.context import ActivityContext
24
25
  from .contracts.events import EventEnvelope
25
26
  from .contracts.results import EvaluationResult, Verdict
26
27
  from .errors import GuardrailsValidationError
@@ -51,6 +52,9 @@ class OpenBoxRuntime:
51
52
  ):
52
53
  self.config = config
53
54
  self.adapter: FrameworkAdapter = adapter if adapter is not None else CoreAdapter()
55
+ # Decide ONCE whether the adapter's handle_approval accepts ``context``
56
+ # (older adapters take only ``result``) — see adapter_accepts_context.
57
+ self._approval_accepts_context = adapter_accepts_context(self.adapter.handle_approval)
54
58
  self.context_store = context_store if context_store is not None else default_context_store()
55
59
  self.client = client if client is not None else EvaluationClient(
56
60
  config.api_url,
@@ -104,7 +108,14 @@ class OpenBoxRuntime:
104
108
  result = await self.gate.aevaluate(event)
105
109
  if result.verdict.requires_approval():
106
110
  self._check_guardrails(result)
107
- await self.adapter.handle_approval(result)
111
+ # Core omits workflow/run/activity IDs from the evaluate response, so
112
+ # build the approval context from the originating event for the poll.
113
+ if self._approval_accepts_context:
114
+ await self.adapter.handle_approval(
115
+ result, context=_approval_context_from_event(event)
116
+ )
117
+ else:
118
+ await self.adapter.handle_approval(result)
108
119
  return result
109
120
  return self._enforce_lifecycle(result, drive_approval=False)
110
121
 
@@ -136,3 +147,25 @@ class OpenBoxRuntime:
136
147
  self.uninstall_instrumentation()
137
148
  self.context_store.clear()
138
149
  await self.client.aclose()
150
+
151
+
152
+ def _approval_context_from_event(event: EventEnvelope) -> ActivityContext:
153
+ """Build the approval context for a lifecycle event.
154
+
155
+ ``workflow_id`` / ``run_id`` live in the flat wire ``payload``;
156
+ ``activity_id`` is a first-class envelope field (a workflow-level approval
157
+ legitimately has none). Core's evaluate response omits all three, so the
158
+ poll must be built from the originating event — see
159
+ ``CoreAdapter.handle_approval``.
160
+ """
161
+ payload = event.payload
162
+
163
+ def _s(value: Any) -> str | None:
164
+ return value if isinstance(value, str) else None
165
+
166
+ activity_id = event.activity_id if event.activity_id is not None else _s(payload.get("activity_id"))
167
+ return ActivityContext(
168
+ workflow_id=_s(payload.get("workflow_id")),
169
+ run_id=_s(payload.get("run_id")),
170
+ activity_id=activity_id,
171
+ )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openbox-sdk-python"
3
- version = "1.0.1"
3
+ version = "1.2.0"
4
4
  description = "OpenBox base SDK - governance contracts, strict gate, identity/signing, evaluate client, context runtime, OTel span wire serialization, and generic instrumentation shared by every OpenBox framework SDK"
5
5
  authors = [
6
6
  { name = "OpenBox Team", email = "tino@openbox.ai" },
@@ -0,0 +1,207 @@
1
+ """patch parsing + handle_patch tests (frozen wire contract).
2
+
3
+ Covers: present-null vs absent, falsy-value preservation, boolean rejection, the finite/
4
+ safe-integer numeric rule (incl. nested), action="block" approvals, HALT/expired -> None,
5
+ the EvaluationResult path not raising AttributeError, and default BLOCK enforcement unchanged.
6
+ """
7
+
8
+ import pytest
9
+
10
+ from openbox_core.contracts.results import (
11
+ ApprovalResult,
12
+ EvaluationResult,
13
+ Patch,
14
+ PatchDirective,
15
+ Verdict,
16
+ handle_patch,
17
+ )
18
+ from openbox_core.errors import GovernanceBlockedError
19
+ from openbox_core.gate import raise_for_verdict
20
+
21
+ SAFE_MAX = 2**53 - 1
22
+
23
+
24
+ def _block(**extra):
25
+ return {"verdict": "block", **extra}
26
+
27
+
28
+ class TestPatchParsing:
29
+ def test_valid_patch_parses_on_evaluation(self):
30
+ r = EvaluationResult.from_dict(_block(patch={"new_input": "x"}))
31
+ assert isinstance(r.patch, Patch)
32
+ assert r.patch.new_input == "x"
33
+
34
+ def test_valid_patch_parses_on_approval_block_action(self):
35
+ r = ApprovalResult.from_dict(
36
+ {"action": "block", "patch": {"new_input": [1, 2]}}
37
+ )
38
+ assert r.verdict is Verdict.BLOCK
39
+ assert isinstance(r.patch, Patch)
40
+ assert r.patch.new_input == [1, 2]
41
+
42
+ def test_present_null_is_distinct_from_absent(self):
43
+ present = EvaluationResult.from_dict(_block(patch={"new_input": None}))
44
+ assert present.patch is not None
45
+ assert present.patch.new_input is None
46
+
47
+ absent = EvaluationResult.from_dict(_block())
48
+ assert absent.patch is None
49
+
50
+ def test_json_null_field_is_treated_as_absent(self):
51
+ assert EvaluationResult.from_dict(_block(patch=None)).patch is None
52
+
53
+ def test_falsy_values_preserved(self):
54
+ for val in (None, "", 0, [], {}):
55
+ r = EvaluationResult.from_dict(_block(patch={"new_input": val}))
56
+ assert r.patch is not None, val
57
+ assert r.patch.new_input == val
58
+
59
+ def test_boolean_new_input_rejected(self):
60
+ for val in (True, False):
61
+ r = EvaluationResult.from_dict(_block(patch={"new_input": val}))
62
+ assert r.patch is None
63
+
64
+ def test_extra_key_rejected(self):
65
+ r = EvaluationResult.from_dict(
66
+ _block(patch={"new_input": None, "x": 1})
67
+ )
68
+ assert r.patch is None
69
+
70
+ def test_missing_new_input_rejected(self):
71
+ r = EvaluationResult.from_dict(_block(patch={"other": 1}))
72
+ assert r.patch is None
73
+
74
+ def test_non_dict_patch_rejected(self):
75
+ for bad in ("str", 5, [1], True):
76
+ r = EvaluationResult.from_dict(_block(patch=bad))
77
+ assert r.patch is None
78
+
79
+ def test_numeric_safe_boundary(self):
80
+ assert (
81
+ EvaluationResult.from_dict(
82
+ _block(patch={"new_input": SAFE_MAX})
83
+ ).patch
84
+ is not None
85
+ )
86
+ assert (
87
+ EvaluationResult.from_dict(
88
+ _block(patch={"new_input": SAFE_MAX + 1})
89
+ ).patch
90
+ is None
91
+ )
92
+
93
+ def test_non_finite_and_nested_unsafe_rejected(self):
94
+ assert (
95
+ EvaluationResult.from_dict(
96
+ _block(patch={"new_input": float("inf")})
97
+ ).patch
98
+ is None
99
+ )
100
+ assert (
101
+ EvaluationResult.from_dict(
102
+ _block(patch={"new_input": float("nan")})
103
+ ).patch
104
+ is None
105
+ )
106
+ assert (
107
+ EvaluationResult.from_dict(
108
+ _block(patch={"new_input": {"a": [SAFE_MAX + 1]}})
109
+ ).patch
110
+ is None
111
+ )
112
+ assert (
113
+ EvaluationResult.from_dict(
114
+ _block(patch={"new_input": {"a": [SAFE_MAX]}})
115
+ ).patch
116
+ is not None
117
+ )
118
+
119
+ def test_raw_preserved(self):
120
+ data = _block(patch={"new_input": "x"}, extra="keep")
121
+ r = EvaluationResult.from_dict(data)
122
+ assert r.raw["extra"] == "keep"
123
+ assert r.raw["patch"] == {"new_input": "x"}
124
+
125
+
126
+ class TestHandlePatch:
127
+ def test_directive_from_evaluation_block(self):
128
+ r = EvaluationResult.from_dict(
129
+ _block(
130
+ patch={"new_input": "x"},
131
+ governance_event_id="ev-1",
132
+ reason="blocked",
133
+ )
134
+ )
135
+ d = handle_patch(r)
136
+ assert isinstance(d, PatchDirective)
137
+ assert d.new_input == "x"
138
+ assert d.governance_event_id == "ev-1"
139
+ assert d.reason == "blocked"
140
+
141
+ def test_directive_from_approval_reads_event_id_from_raw(self):
142
+ r = ApprovalResult.from_dict(
143
+ {
144
+ "action": "block",
145
+ "patch": {"new_input": None},
146
+ "governance_event_id": "ev-2",
147
+ "reason": "patch",
148
+ }
149
+ )
150
+ d = handle_patch(r)
151
+ assert d is not None
152
+ assert d.new_input is None
153
+ assert d.governance_event_id == "ev-2"
154
+
155
+ def test_approval_event_id_falls_back_to_id(self):
156
+ r = ApprovalResult.from_dict(
157
+ {"action": "block", "patch": {"new_input": 1}, "id": "ev-3"}
158
+ )
159
+ d = handle_patch(r)
160
+ assert d is not None
161
+ assert d.governance_event_id == "ev-3"
162
+
163
+ def test_plain_block_returns_none(self):
164
+ assert handle_patch(EvaluationResult.from_dict(_block())) is None
165
+
166
+ def test_non_block_verdicts_return_none(self):
167
+ for verdict in ("allow", "constrain", "require_approval", "halt"):
168
+ r = EvaluationResult.from_dict(
169
+ {"verdict": verdict, "patch": {"new_input": "x"}}
170
+ )
171
+ assert handle_patch(r) is None, verdict
172
+
173
+ def test_halt_approval_returns_none_even_with_patch(self):
174
+ r = ApprovalResult.from_dict(
175
+ {"action": "halt", "patch": {"new_input": "x"}}
176
+ )
177
+ assert handle_patch(r) is None
178
+
179
+ def test_expired_approval_returns_none_even_if_block(self):
180
+ r = ApprovalResult.from_dict(
181
+ {"action": "block", "patch": {"new_input": "x"}, "expired": True}
182
+ )
183
+ assert r.verdict is Verdict.BLOCK
184
+ assert handle_patch(r) is None
185
+
186
+ def test_pending_approval_returns_none(self):
187
+ r = ApprovalResult.from_dict({"patch": {"new_input": "x"}})
188
+ assert r.verdict is None
189
+ assert handle_patch(r) is None
190
+
191
+ def test_evaluation_result_never_raises_attributeerror(self):
192
+ # EvaluationResult has no is_blocking()/expired; the gate must not touch them.
193
+ r = EvaluationResult.from_dict(_block(patch={"new_input": "x"}))
194
+ assert handle_patch(r) is not None
195
+
196
+ def test_block_without_patch_returns_none(self):
197
+ assert handle_patch(EvaluationResult.from_dict(_block())) is None
198
+
199
+
200
+ class TestEnforcementUnchanged:
201
+ def test_block_still_raises_even_with_patch(self):
202
+ # Adding patch support must NOT suppress the default block; the helper is opt-in only.
203
+ r = EvaluationResult.from_dict(
204
+ _block(patch={"new_input": "x"}, reason="nope")
205
+ )
206
+ with pytest.raises(GovernanceBlockedError):
207
+ raise_for_verdict(r)
@@ -4,10 +4,12 @@ import httpx
4
4
  import pytest
5
5
 
6
6
  from openbox_core.adapters.base import CoreAdapter, FrameworkAdapter
7
+ from openbox_core.approvals import ApprovalPoller
7
8
  from openbox_core.client import EvaluationClient
8
9
  from openbox_core.config import OpenBoxConfig
10
+ from openbox_core.contracts.context import ActivityContext
9
11
  from openbox_core.contracts.events import workflow_started
10
- from openbox_core.contracts.results import EvaluationResult, Verdict
12
+ from openbox_core.contracts.results import ApprovalResult, EvaluationResult, Verdict
11
13
  from openbox_core.errors import (
12
14
  ApprovalRejectedError,
13
15
  GovernanceBlockedError,
@@ -45,6 +47,30 @@ class RecordingAdapter:
45
47
  self.completed.append(result)
46
48
 
47
49
 
50
+ class _IdRecordingClient:
51
+ """Fake client recording the (workflow_id, run_id, activity_id) each async
52
+ poll is called with, then returning an allow-shaped decision."""
53
+
54
+ def __init__(self, seen: list[tuple[str, str, str]]):
55
+ self._seen = seen
56
+
57
+ async def apoll_approval(self, workflow_id, run_id, activity_id):
58
+ self._seen.append((workflow_id, run_id, activity_id))
59
+ return ApprovalResult.from_dict({"action": "allow"})
60
+
61
+
62
+ class _ContextRecordingAdapter(RecordingAdapter):
63
+ """RecordingAdapter whose handle_approval ACCEPTS and records ``context``."""
64
+
65
+ def __init__(self):
66
+ super().__init__()
67
+ self.approval_contexts: list[ActivityContext | None] = []
68
+
69
+ async def handle_approval(self, result, context=None):
70
+ self.approvals.append(result)
71
+ self.approval_contexts.append(context)
72
+
73
+
48
74
  def make_runtime(response_json, adapter=None):
49
75
  transport = httpx.MockTransport(lambda r: httpx.Response(200, json=response_json))
50
76
  client = EvaluationClient(
@@ -91,6 +117,17 @@ class TestLifecycleDelegation:
91
117
  assert result.verdict is Verdict.REQUIRE_APPROVAL
92
118
  assert len(adapter.approvals) == 1
93
119
 
120
+ async def test_require_approval_threads_event_context(self):
121
+ # A context-accepting adapter receives the event's workflow/run IDs —
122
+ # Core's evaluate response never echoes them, so the runtime must supply
123
+ # them from the originating event.
124
+ adapter = _ContextRecordingAdapter()
125
+ runtime = make_runtime({"verdict": "require_approval", "approval_id": "app-1"}, adapter)
126
+ await runtime.aevaluate_lifecycle(workflow_started(**WF))
127
+ ctx = adapter.approval_contexts[0]
128
+ assert ctx is not None
129
+ assert (ctx.workflow_id, ctx.run_id) == ("wf-1", "r-1")
130
+
94
131
  def test_sync_require_approval_returned_to_caller(self):
95
132
  adapter = RecordingAdapter()
96
133
  runtime = make_runtime({"verdict": "require_approval"}, adapter)
@@ -117,6 +154,29 @@ class TestCoreAdapterDefaults:
117
154
  EvaluationResult(verdict=Verdict.REQUIRE_APPROVAL, approval_id="a")
118
155
  )
119
156
 
157
+ async def test_core_adapter_polls_with_context_ids_not_raw(self):
158
+ # raw is EMPTY, exactly as a real Core evaluate response is — the poll
159
+ # IDs must come from the context the runtime threads in.
160
+ seen: list[tuple[str, str, str]] = []
161
+ poller = ApprovalPoller(_IdRecordingClient(seen), poll_interval_seconds=0.001)
162
+ adapter = CoreAdapter(approval_poller=poller)
163
+ result = EvaluationResult(verdict=Verdict.REQUIRE_APPROVAL, approval_id="a")
164
+ ctx = ActivityContext(workflow_id="wf-9", run_id="run-9", activity_id="act-9")
165
+ await adapter.handle_approval(result, context=ctx)
166
+ assert seen == [("wf-9", "run-9", "act-9")]
167
+
168
+ async def test_core_adapter_approval_falls_back_to_raw_without_context(self):
169
+ seen: list[tuple[str, str, str]] = []
170
+ poller = ApprovalPoller(_IdRecordingClient(seen), poll_interval_seconds=0.001)
171
+ adapter = CoreAdapter(approval_poller=poller)
172
+ result = EvaluationResult(
173
+ verdict=Verdict.REQUIRE_APPROVAL,
174
+ approval_id="a",
175
+ raw={"workflow_id": "wf-raw", "run_id": "run-raw", "activity_id": "act-raw"},
176
+ )
177
+ await adapter.handle_approval(result)
178
+ assert seen == [("wf-raw", "run-raw", "act-raw")]
179
+
120
180
  def test_protocol_conformance(self):
121
181
  assert isinstance(CoreAdapter(), FrameworkAdapter)
122
182
  assert isinstance(RecordingAdapter(), FrameworkAdapter)
@@ -584,7 +584,7 @@ wheels = [
584
584
 
585
585
  [[package]]
586
586
  name = "openbox-sdk-python"
587
- version = "1.0.1"
587
+ version = "1.2.0"
588
588
  source = { editable = "." }
589
589
  dependencies = [
590
590
  { name = "cryptography" },