openbox-sdk-python 1.1.0__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 (95) hide show
  1. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/CHANGELOG.md +18 -0
  2. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/PKG-INFO +1 -1
  3. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/__init__.py +1 -1
  4. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/contracts/results.py +39 -38
  5. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/pyproject.toml +1 -1
  6. openbox_sdk_python-1.2.0/tests/contracts/test_patch.py +207 -0
  7. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/uv.lock +1 -1
  8. openbox_sdk_python-1.1.0/tests/contracts/test_retry_plan.py +0 -207
  9. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/.github/instructions/openbox-sdk-python.instructions.md +0 -0
  10. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/.github/workflows/ci.yml +0 -0
  11. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/.github/workflows/publish.yml +0 -0
  12. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/.gitignore +0 -0
  13. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/.python-version +0 -0
  14. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/README.md +0 -0
  15. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/adapters/__init__.py +0 -0
  16. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/adapters/base.py +0 -0
  17. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/approvals.py +0 -0
  18. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/client.py +0 -0
  19. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/config.py +0 -0
  20. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/conformance/__init__.py +0 -0
  21. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/conformance/fake_core.py +0 -0
  22. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/conformance/hook_preflight.py +0 -0
  23. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/conformance/instrumentation.py +0 -0
  24. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/context.py +0 -0
  25. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/contracts/__init__.py +0 -0
  26. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/contracts/context.py +0 -0
  27. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/contracts/events.py +0 -0
  28. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/contracts/otel_spans.py +0 -0
  29. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/errors.py +0 -0
  30. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/gate.py +0 -0
  31. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/hooks/__init__.py +0 -0
  32. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/hooks/events.py +0 -0
  33. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/hooks/preflight.py +0 -0
  34. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/hooks/wrappers.py +0 -0
  35. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/identity.py +0 -0
  36. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/__init__.py +0 -0
  37. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/db.py +0 -0
  38. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/file.py +0 -0
  39. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/function.py +0 -0
  40. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/http.py +0 -0
  41. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/llm.py +0 -0
  42. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/manager.py +0 -0
  43. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/instrumentation/shared.py +0 -0
  44. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/otel/__init__.py +0 -0
  45. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/otel/propagation.py +0 -0
  46. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/otel/provider.py +0 -0
  47. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/otel/setup.py +0 -0
  48. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/otel/span_processor.py +0 -0
  49. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/otel/trace_context.py +0 -0
  50. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/py.typed +0 -0
  51. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/runtime.py +0 -0
  52. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/sdk_version.py +0 -0
  53. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/serialization.py +0 -0
  54. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/validation/__init__.py +0 -0
  55. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/validation/diagnostics.py +0 -0
  56. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/validation/event_rules.py +0 -0
  57. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/validation/registry.py +0 -0
  58. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/validation/span_normalization.py +0 -0
  59. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/wire/__init__.py +0 -0
  60. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/wire/core_span.py +0 -0
  61. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/openbox_core/wire/evaluate_payload.py +0 -0
  62. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/client/test_approval_poll.py +0 -0
  63. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/client/test_fail_modes.py +0 -0
  64. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/config/test_resolution_order.py +0 -0
  65. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/conformance/test_required_cases.py +0 -0
  66. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/context/test_bind_reset.py +0 -0
  67. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/context/test_trace_key.py +0 -0
  68. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/contracts/test_approval_parsing.py +0 -0
  69. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/contracts/test_event_classify.py +0 -0
  70. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/contracts/test_result_parsing.py +0 -0
  71. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/gate/test_diagnostics.py +0 -0
  72. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/gate/test_strict_failures.py +0 -0
  73. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/instrumentation/conftest.py +0 -0
  74. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/instrumentation/instrumented_env.py +0 -0
  75. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_db_block.py +0 -0
  76. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_db_redis_mongo.py +0 -0
  77. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_file_function_block.py +0 -0
  78. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_hook_failclosed_and_sync_approval.py +0 -0
  79. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_hook_runtime.py +0 -0
  80. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_http_preflight_block.py +0 -0
  81. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_http_urllib.py +0 -0
  82. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/instrumentation/test_manager_and_otel_lifecycle.py +0 -0
  83. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/runtime/test_runtime_delegation.py +0 -0
  84. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/signing/generate_golden_fixture_from_temporal_signer.py +0 -0
  85. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/signing/golden_temporal_signed_request.json +0 -0
  86. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/signing/test_golden_signing.py +0 -0
  87. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/test_import_safety.py +0 -0
  88. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/test_sdk_version.py +0 -0
  89. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/wire/go_spandata_compat/go.mod +0 -0
  90. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/wire/go_spandata_compat/main.go +0 -0
  91. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/wire/span_fixtures.py +0 -0
  92. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/wire/test_backend_compat.py +0 -0
  93. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/wire/test_core_span_projection.py +0 -0
  94. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/wire/test_flat_hook_contract.py +0 -0
  95. {openbox_sdk_python-1.1.0 → openbox_sdk_python-1.2.0}/tests/wire/test_hex_ids.py +0 -0
@@ -5,6 +5,24 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
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
+
8
26
  ## [1.1.0] - 2026-07-21
9
27
 
10
28
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbox-sdk-python
3
- Version: 1.1.0
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.1.0"
40
+ __version__ = "1.2.0"
41
41
 
42
42
  __all__ = [
43
43
  "__version__",
@@ -20,9 +20,9 @@ __all__ = [
20
20
  "GuardrailsResult",
21
21
  "EvaluationResult",
22
22
  "ApprovalResult",
23
- "RetryPlan",
24
- "RetryDirective",
25
- "handle_retryable_block",
23
+ "Patch",
24
+ "PatchDirective",
25
+ "handle_patch",
26
26
  ]
27
27
 
28
28
 
@@ -106,8 +106,8 @@ class GuardrailsResult:
106
106
  return [r.get("reason", "") for r in self.reasons if r.get("reason")]
107
107
 
108
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.
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
111
  _MISSING = object()
112
112
 
113
113
  # JS Number.MAX_SAFE_INTEGER. Integral numbers must fit within +/- this bound so ``new_input`` can
@@ -120,7 +120,7 @@ def _numbers_are_safe(value: Any) -> bool:
120
120
  """Every number (recursively) must be finite and, if integral, within the JS-safe range.
121
121
 
122
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`).
123
+ (top-level ``new_input`` booleans are rejected separately by :func:`_parse_patch`).
124
124
  """
125
125
  if isinstance(value, bool):
126
126
  return True
@@ -140,22 +140,22 @@ def _numbers_are_safe(value: Any) -> bool:
140
140
 
141
141
 
142
142
  @dataclass
143
- class RetryPlan:
144
- """Optional remediation hint carried on a BLOCK verdict (policy-produced or admin-retry).
143
+ class Patch:
144
+ """Optional remediation hint carried on a BLOCK verdict (policy-produced or admin-patch).
145
145
 
146
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.
147
+ ``new_input`` of ``None`` is a valid patch (reuses the original input) and is distinct
148
+ from an absent ``patch`` field.
149
149
  """
150
150
 
151
151
  new_input: Any = None
152
152
 
153
153
 
154
154
  @dataclass
155
- class RetryDirective:
156
- """Caller-facing view of a retryable BLOCK, surfaced only via :func:`handle_retryable_block`.
155
+ class PatchDirective:
156
+ """Caller-facing view of a BLOCK with patch, surfaced only via :func:`handle_patch`.
157
157
 
158
- It is a remediation hint, not proof that a retry was evaluated or executed.
158
+ It is a remediation hint, not proof that a patch was evaluated or executed.
159
159
  """
160
160
 
161
161
  new_input: Any = None
@@ -163,28 +163,28 @@ class RetryDirective:
163
163
  reason: str | None = None
164
164
 
165
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.
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
168
 
169
- Returns a plan only when ``retry_plan`` is present, is a dict whose ONLY key is ``new_input``,
169
+ Returns a patch only when ``patch`` is present, is a dict whose ONLY key is ``new_input``,
170
170
  and ``new_input`` is null/str/number/list/dict (boolean rejected) with every number finite and,
171
171
  if integral, within the JS-safe integer range. An absent field, a JSON ``null`` field, or any
172
172
  contract violation yields ``None`` (treated as absent — never an error). A present
173
- ``new_input`` of ``None`` is preserved as a valid plan.
173
+ ``new_input`` of ``None`` is preserved as a valid patch.
174
174
  """
175
- plan = container.get("retry_plan", _MISSING)
176
- if plan is _MISSING or not isinstance(plan, dict):
175
+ patch = container.get("patch", _MISSING)
176
+ if patch is _MISSING or not isinstance(patch, dict):
177
177
  return None
178
178
  # Exactly the single key new_input (also guarantees new_input is present, not merely null).
179
- if set(plan.keys()) != {"new_input"}:
179
+ if set(patch.keys()) != {"new_input"}:
180
180
  return None
181
- new_input = plan["new_input"]
181
+ new_input = patch["new_input"]
182
182
  # bool is a subclass of int — reject a boolean new_input BEFORE the number check.
183
183
  if isinstance(new_input, bool):
184
184
  return None
185
185
  if not _numbers_are_safe(new_input):
186
186
  return None
187
- return RetryPlan(new_input=new_input)
187
+ return Patch(new_input=new_input)
188
188
 
189
189
 
190
190
  @dataclass
@@ -215,7 +215,7 @@ class EvaluationResult:
215
215
  fallback_used: bool = False # True when fail-open produced this result
216
216
  diagnostics: list[Any] = field(default_factory=list)
217
217
  raw: dict[str, Any] = field(default_factory=dict)
218
- retry_plan: RetryPlan | None = None # Optional BLOCK remediation hint; None = absent
218
+ patch: Patch | None = None # Optional BLOCK remediation hint; None = absent
219
219
 
220
220
  @property
221
221
  def guardrails_result(self) -> GuardrailsResult | None:
@@ -266,7 +266,7 @@ class EvaluationResult:
266
266
  fallback_used=bool(data.get("fallback_used", False)),
267
267
  diagnostics=data.get("diagnostics") or [],
268
268
  raw=dict(data),
269
- retry_plan=_parse_retry_plan(data),
269
+ patch=_parse_patch(data),
270
270
  )
271
271
 
272
272
  @classmethod
@@ -299,7 +299,7 @@ class ApprovalResult:
299
299
  approval_expiration_time: str | None = None
300
300
  expired: bool = False
301
301
  raw: dict[str, Any] = field(default_factory=dict)
302
- retry_plan: RetryPlan | None = None # Present only on an admin-retry BLOCK
302
+ patch: Patch | None = None # Present only on an admin-patch BLOCK
303
303
 
304
304
  # Known decision vocabulary for approvals (current values + accepted aliases).
305
305
  # Anything OUTSIDE this set parses to None (pending) — the evaluate-path
@@ -345,7 +345,7 @@ class ApprovalResult:
345
345
  approval_expiration_time=data.get("approval_expiration_time"),
346
346
  expired=bool(data.get("expired", False)),
347
347
  raw=dict(data),
348
- retry_plan=_parse_retry_plan(data),
348
+ patch=_parse_patch(data),
349
349
  )
350
350
 
351
351
  @property
@@ -376,21 +376,22 @@ class ApprovalResult:
376
376
  return self.verdict in (Verdict.REQUIRE_APPROVAL, Verdict.CONSTRAIN)
377
377
 
378
378
 
379
- def handle_retryable_block(
379
+ def handle_patch(
380
380
  result: EvaluationResult | ApprovalResult,
381
- ) -> RetryDirective | None:
382
- """Opt-in, pure inspector: surface a retry directive from a retryable BLOCK.
381
+ ) -> PatchDirective | None:
382
+ """Opt-in, pure inspector: surface a patch directive from a BLOCK with patch.
383
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),
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
386
  a pending/``None`` verdict, and an expired :class:`ApprovalResult`.
387
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.
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.
390
391
 
391
392
  The gate is deliberately ``verdict == Verdict.BLOCK`` — NOT ``is_blocking()`` /
392
393
  ``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
+ violate the wire-contract invariant that HALT always strips the patch.
394
395
  """
395
396
  # A stale/expired poll result must not surface a directive, even if verdict == BLOCK.
396
397
  if isinstance(result, ApprovalResult) and result.expired:
@@ -400,8 +401,8 @@ def handle_retryable_block(
400
401
  if getattr(result, "verdict", None) != Verdict.BLOCK:
401
402
  return None
402
403
 
403
- plan = result.retry_plan
404
- if plan is None:
404
+ patch = result.patch
405
+ if patch is None:
405
406
  return None
406
407
 
407
408
  # governance_event_id is typed on EvaluationResult; ApprovalResult carries it only in raw.
@@ -410,8 +411,8 @@ def handle_retryable_block(
410
411
  else:
411
412
  governance_event_id = result.raw.get("governance_event_id") or result.raw.get("id")
412
413
 
413
- return RetryDirective(
414
- new_input=plan.new_input,
414
+ return PatchDirective(
415
+ new_input=patch.new_input,
415
416
  governance_event_id=governance_event_id,
416
417
  reason=result.reason,
417
418
  )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openbox-sdk-python"
3
- version = "1.1.0"
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)
@@ -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" },
@@ -1,207 +0,0 @@
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)