openbox-sdk-python 1.0.1__tar.gz → 1.1.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.1.0/CHANGELOG.md +23 -0
  2. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/PKG-INFO +1 -1
  3. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/__init__.py +1 -1
  4. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/adapters/base.py +71 -13
  5. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/conformance/hook_preflight.py +5 -1
  6. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/contracts/results.py +130 -0
  7. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/hooks/preflight.py +15 -22
  8. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/runtime.py +35 -2
  9. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/pyproject.toml +1 -1
  10. openbox_sdk_python-1.1.0/tests/contracts/test_retry_plan.py +207 -0
  11. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/runtime/test_runtime_delegation.py +61 -1
  12. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/.github/instructions/openbox-sdk-python.instructions.md +0 -0
  13. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/.github/workflows/ci.yml +0 -0
  14. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/.github/workflows/publish.yml +0 -0
  15. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/.gitignore +0 -0
  16. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/.python-version +0 -0
  17. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/README.md +0 -0
  18. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/adapters/__init__.py +0 -0
  19. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/approvals.py +0 -0
  20. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/client.py +0 -0
  21. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/config.py +0 -0
  22. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/conformance/__init__.py +0 -0
  23. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/conformance/fake_core.py +0 -0
  24. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/conformance/instrumentation.py +0 -0
  25. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/context.py +0 -0
  26. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/contracts/__init__.py +0 -0
  27. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/contracts/context.py +0 -0
  28. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/contracts/events.py +0 -0
  29. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/contracts/otel_spans.py +0 -0
  30. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/errors.py +0 -0
  31. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/gate.py +0 -0
  32. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/hooks/__init__.py +0 -0
  33. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/hooks/events.py +0 -0
  34. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/hooks/wrappers.py +0 -0
  35. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/identity.py +0 -0
  36. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/instrumentation/__init__.py +0 -0
  37. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/instrumentation/db.py +0 -0
  38. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/instrumentation/file.py +0 -0
  39. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/instrumentation/function.py +0 -0
  40. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/instrumentation/http.py +0 -0
  41. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/instrumentation/llm.py +0 -0
  42. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/instrumentation/manager.py +0 -0
  43. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/instrumentation/shared.py +0 -0
  44. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/otel/__init__.py +0 -0
  45. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/otel/propagation.py +0 -0
  46. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/otel/provider.py +0 -0
  47. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/otel/setup.py +0 -0
  48. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/otel/span_processor.py +0 -0
  49. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/otel/trace_context.py +0 -0
  50. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/py.typed +0 -0
  51. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/sdk_version.py +0 -0
  52. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/serialization.py +0 -0
  53. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/validation/__init__.py +0 -0
  54. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/validation/diagnostics.py +0 -0
  55. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/validation/event_rules.py +0 -0
  56. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/validation/registry.py +0 -0
  57. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/validation/span_normalization.py +0 -0
  58. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/wire/__init__.py +0 -0
  59. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/wire/core_span.py +0 -0
  60. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/openbox_core/wire/evaluate_payload.py +0 -0
  61. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/client/test_approval_poll.py +0 -0
  62. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/client/test_fail_modes.py +0 -0
  63. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/config/test_resolution_order.py +0 -0
  64. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/conformance/test_required_cases.py +0 -0
  65. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/context/test_bind_reset.py +0 -0
  66. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/context/test_trace_key.py +0 -0
  67. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/contracts/test_approval_parsing.py +0 -0
  68. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/contracts/test_event_classify.py +0 -0
  69. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/contracts/test_result_parsing.py +0 -0
  70. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/gate/test_diagnostics.py +0 -0
  71. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/gate/test_strict_failures.py +0 -0
  72. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/instrumentation/conftest.py +0 -0
  73. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/instrumentation/instrumented_env.py +0 -0
  74. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/instrumentation/test_db_block.py +0 -0
  75. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/instrumentation/test_db_redis_mongo.py +0 -0
  76. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/instrumentation/test_file_function_block.py +0 -0
  77. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/instrumentation/test_hook_failclosed_and_sync_approval.py +0 -0
  78. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/instrumentation/test_hook_runtime.py +0 -0
  79. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/instrumentation/test_http_preflight_block.py +0 -0
  80. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/instrumentation/test_http_urllib.py +0 -0
  81. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/instrumentation/test_manager_and_otel_lifecycle.py +0 -0
  82. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/signing/generate_golden_fixture_from_temporal_signer.py +0 -0
  83. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/signing/golden_temporal_signed_request.json +0 -0
  84. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/signing/test_golden_signing.py +0 -0
  85. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/test_import_safety.py +0 -0
  86. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/test_sdk_version.py +0 -0
  87. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/wire/go_spandata_compat/go.mod +0 -0
  88. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/wire/go_spandata_compat/main.go +0 -0
  89. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/wire/span_fixtures.py +0 -0
  90. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/wire/test_backend_compat.py +0 -0
  91. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/wire/test_core_span_projection.py +0 -0
  92. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/wire/test_flat_hook_contract.py +0 -0
  93. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/tests/wire/test_hex_ids.py +0 -0
  94. {openbox_sdk_python-1.0.1 → openbox_sdk_python-1.1.0}/uv.lock +0 -0
@@ -0,0 +1,23 @@
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.1.0] - 2026-07-21
9
+
10
+ ### Added
11
+ - `RetryPlan` and `RetryDirective` dataclasses in `openbox_core.contracts.results`.
12
+ - Optional `retry_plan` directive parsing on both `EvaluationResult` and `ApprovalResult`.
13
+ A `_MISSING` sentinel keeps a present `new_input: null` distinct from an absent field; falsy
14
+ values (`null`, `""`, `0`, `[]`, `{}`) are preserved; a boolean `new_input` is rejected; and every
15
+ number (recursively) must be finite and, if integral, a JS-safe integer (`|n| <= 2^53 - 1`).
16
+ - `handle_retryable_block(result)` — an opt-in, pure inspector that returns a `RetryDirective` only
17
+ for a `BLOCK` verdict carrying a valid plan. Returns `None` for a plain BLOCK, every non-BLOCK
18
+ verdict (including HALT), a pending verdict, and an expired `ApprovalResult`.
19
+
20
+ ### Notes
21
+ - Default enforcement is unchanged: a `BLOCK` verdict still raises `GovernanceBlockedError`. The new
22
+ helper is opt-in and never triggers an automatic retry; malformed or ineligible plans are treated
23
+ 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.1.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.1.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
+ "RetryPlan",
24
+ "RetryDirective",
25
+ "handle_retryable_block",
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 ``retry_plan`` field from a present JSON ``null``.
110
+ # ``dict.get("retry_plan")`` 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_retry_plan`).
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 RetryPlan:
144
+ """Optional remediation hint carried on a BLOCK verdict (policy-produced or admin-retry).
145
+
146
+ ``new_input`` may be null | str | number | list | dict (boolean rejected). A present
147
+ ``new_input`` of ``None`` is a valid plan (retry with the original input) and is distinct
148
+ from an absent ``retry_plan`` field.
149
+ """
150
+
151
+ new_input: Any = None
152
+
153
+
154
+ @dataclass
155
+ class RetryDirective:
156
+ """Caller-facing view of a retryable BLOCK, surfaced only via :func:`handle_retryable_block`.
157
+
158
+ It is a remediation hint, not proof that a retry 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_retry_plan(container: dict[str, Any]) -> RetryPlan | None:
167
+ """Parse an optional ``retry_plan`` from a result dict against the frozen wire contract.
168
+
169
+ Returns a plan only when ``retry_plan`` 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 plan.
174
+ """
175
+ plan = container.get("retry_plan", _MISSING)
176
+ if plan is _MISSING or not isinstance(plan, dict):
177
+ return None
178
+ # Exactly the single key new_input (also guarantees new_input is present, not merely null).
179
+ if set(plan.keys()) != {"new_input"}:
180
+ return None
181
+ new_input = plan["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 RetryPlan(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
+ retry_plan: RetryPlan | 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
+ retry_plan=_parse_retry_plan(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
+ retry_plan: RetryPlan | None = None # Present only on an admin-retry 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
+ retry_plan=_parse_retry_plan(data),
260
349
  )
261
350
 
262
351
  @property
@@ -285,3 +374,44 @@ 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_retryable_block(
380
+ result: EvaluationResult | ApprovalResult,
381
+ ) -> RetryDirective | None:
382
+ """Opt-in, pure inspector: surface a retry directive from a retryable BLOCK.
383
+
384
+ Returns a :class:`RetryDirective` ONLY for ``verdict == Verdict.BLOCK`` with a present, valid
385
+ ``retry_plan``. 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-retries. A caller must
389
+ invoke it explicitly, and it makes no claim that a replacement was evaluated or executed.
390
+
391
+ The gate is deliberately ``verdict == Verdict.BLOCK`` — NOT ``is_blocking()`` /
392
+ ``verdict.should_stop()``, which also match HALT (and, for approvals, expired results) and would
393
+ violate the wire-contract invariant that HALT always strips the plan.
394
+ """
395
+ # A stale/expired poll result must not surface a directive, even if verdict == BLOCK.
396
+ if isinstance(result, ApprovalResult) and result.expired:
397
+ return None
398
+ # ApprovalResult.verdict may be None (pending); the != BLOCK comparison handles that. Using
399
+ # getattr keeps this safe if a caller passes an unexpected object.
400
+ if getattr(result, "verdict", None) != Verdict.BLOCK:
401
+ return None
402
+
403
+ plan = result.retry_plan
404
+ if plan is None:
405
+ return None
406
+
407
+ # governance_event_id is typed on EvaluationResult; ApprovalResult carries it only in raw.
408
+ if isinstance(result, EvaluationResult):
409
+ governance_event_id = result.governance_event_id
410
+ else:
411
+ governance_event_id = result.raw.get("governance_event_id") or result.raw.get("id")
412
+
413
+ return RetryDirective(
414
+ new_input=plan.new_input,
415
+ governance_event_id=governance_event_id,
416
+ reason=result.reason,
417
+ )
@@ -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.1.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
+ """retry_plan parsing + handle_retryable_block 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
+ RetryDirective,
14
+ RetryPlan,
15
+ Verdict,
16
+ handle_retryable_block,
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 TestRetryPlanParsing:
29
+ def test_valid_plan_parses_on_evaluation(self):
30
+ r = EvaluationResult.from_dict(_block(retry_plan={"new_input": "x"}))
31
+ assert isinstance(r.retry_plan, RetryPlan)
32
+ assert r.retry_plan.new_input == "x"
33
+
34
+ def test_valid_plan_parses_on_approval_block_action(self):
35
+ r = ApprovalResult.from_dict(
36
+ {"action": "block", "retry_plan": {"new_input": [1, 2]}}
37
+ )
38
+ assert r.verdict is Verdict.BLOCK
39
+ assert isinstance(r.retry_plan, RetryPlan)
40
+ assert r.retry_plan.new_input == [1, 2]
41
+
42
+ def test_present_null_is_distinct_from_absent(self):
43
+ present = EvaluationResult.from_dict(_block(retry_plan={"new_input": None}))
44
+ assert present.retry_plan is not None
45
+ assert present.retry_plan.new_input is None
46
+
47
+ absent = EvaluationResult.from_dict(_block())
48
+ assert absent.retry_plan is None
49
+
50
+ def test_json_null_field_is_treated_as_absent(self):
51
+ assert EvaluationResult.from_dict(_block(retry_plan=None)).retry_plan is None
52
+
53
+ def test_falsy_values_preserved(self):
54
+ for val in (None, "", 0, [], {}):
55
+ r = EvaluationResult.from_dict(_block(retry_plan={"new_input": val}))
56
+ assert r.retry_plan is not None, val
57
+ assert r.retry_plan.new_input == val
58
+
59
+ def test_boolean_new_input_rejected(self):
60
+ for val in (True, False):
61
+ r = EvaluationResult.from_dict(_block(retry_plan={"new_input": val}))
62
+ assert r.retry_plan is None
63
+
64
+ def test_extra_key_rejected(self):
65
+ r = EvaluationResult.from_dict(
66
+ _block(retry_plan={"new_input": None, "x": 1})
67
+ )
68
+ assert r.retry_plan is None
69
+
70
+ def test_missing_new_input_rejected(self):
71
+ r = EvaluationResult.from_dict(_block(retry_plan={"other": 1}))
72
+ assert r.retry_plan is None
73
+
74
+ def test_non_dict_plan_rejected(self):
75
+ for bad in ("str", 5, [1], True):
76
+ r = EvaluationResult.from_dict(_block(retry_plan=bad))
77
+ assert r.retry_plan is None
78
+
79
+ def test_numeric_safe_boundary(self):
80
+ assert (
81
+ EvaluationResult.from_dict(
82
+ _block(retry_plan={"new_input": SAFE_MAX})
83
+ ).retry_plan
84
+ is not None
85
+ )
86
+ assert (
87
+ EvaluationResult.from_dict(
88
+ _block(retry_plan={"new_input": SAFE_MAX + 1})
89
+ ).retry_plan
90
+ is None
91
+ )
92
+
93
+ def test_non_finite_and_nested_unsafe_rejected(self):
94
+ assert (
95
+ EvaluationResult.from_dict(
96
+ _block(retry_plan={"new_input": float("inf")})
97
+ ).retry_plan
98
+ is None
99
+ )
100
+ assert (
101
+ EvaluationResult.from_dict(
102
+ _block(retry_plan={"new_input": float("nan")})
103
+ ).retry_plan
104
+ is None
105
+ )
106
+ assert (
107
+ EvaluationResult.from_dict(
108
+ _block(retry_plan={"new_input": {"a": [SAFE_MAX + 1]}})
109
+ ).retry_plan
110
+ is None
111
+ )
112
+ assert (
113
+ EvaluationResult.from_dict(
114
+ _block(retry_plan={"new_input": {"a": [SAFE_MAX]}})
115
+ ).retry_plan
116
+ is not None
117
+ )
118
+
119
+ def test_raw_preserved(self):
120
+ data = _block(retry_plan={"new_input": "x"}, extra="keep")
121
+ r = EvaluationResult.from_dict(data)
122
+ assert r.raw["extra"] == "keep"
123
+ assert r.raw["retry_plan"] == {"new_input": "x"}
124
+
125
+
126
+ class TestHandleRetryableBlock:
127
+ def test_directive_from_evaluation_block(self):
128
+ r = EvaluationResult.from_dict(
129
+ _block(
130
+ retry_plan={"new_input": "x"},
131
+ governance_event_id="ev-1",
132
+ reason="blocked",
133
+ )
134
+ )
135
+ d = handle_retryable_block(r)
136
+ assert isinstance(d, RetryDirective)
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
+ "retry_plan": {"new_input": None},
146
+ "governance_event_id": "ev-2",
147
+ "reason": "retry",
148
+ }
149
+ )
150
+ d = handle_retryable_block(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", "retry_plan": {"new_input": 1}, "id": "ev-3"}
158
+ )
159
+ d = handle_retryable_block(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_retryable_block(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, "retry_plan": {"new_input": "x"}}
170
+ )
171
+ assert handle_retryable_block(r) is None, verdict
172
+
173
+ def test_halt_approval_returns_none_even_with_plan(self):
174
+ r = ApprovalResult.from_dict(
175
+ {"action": "halt", "retry_plan": {"new_input": "x"}}
176
+ )
177
+ assert handle_retryable_block(r) is None
178
+
179
+ def test_expired_approval_returns_none_even_if_block(self):
180
+ r = ApprovalResult.from_dict(
181
+ {"action": "block", "retry_plan": {"new_input": "x"}, "expired": True}
182
+ )
183
+ assert r.verdict is Verdict.BLOCK
184
+ assert handle_retryable_block(r) is None
185
+
186
+ def test_pending_approval_returns_none(self):
187
+ r = ApprovalResult.from_dict({"retry_plan": {"new_input": "x"}})
188
+ assert r.verdict is None
189
+ assert handle_retryable_block(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(retry_plan={"new_input": "x"}))
194
+ assert handle_retryable_block(r) is not None
195
+
196
+ def test_block_without_plan_returns_none(self):
197
+ assert handle_retryable_block(EvaluationResult.from_dict(_block())) is None
198
+
199
+
200
+ class TestEnforcementUnchanged:
201
+ def test_block_still_raises_even_with_retry_plan(self):
202
+ # Adding retry_plan support must NOT suppress the default block; the helper is opt-in only.
203
+ r = EvaluationResult.from_dict(
204
+ _block(retry_plan={"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)