openbox-temporal-sdk-python 1.2.1__tar.gz → 1.3.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 (67) hide show
  1. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/CHANGELOG.md +12 -0
  2. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/PKG-INFO +5 -3
  3. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/README.md +3 -1
  4. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/docs/configuration.md +49 -0
  5. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/docs/system-architecture.md +70 -20
  6. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/openbox/__init__.py +28 -30
  7. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/openbox/activities.py +24 -12
  8. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/openbox/activity_interceptor.py +108 -35
  9. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/openbox/config.py +41 -13
  10. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/openbox/core_adapter.py +55 -5
  11. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/openbox/errors.py +12 -1
  12. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/openbox/governance_state.py +46 -12
  13. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/openbox/hitl.py +25 -0
  14. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/openbox/multi_agent.py +38 -8
  15. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/openbox/plugin.py +5 -2
  16. openbox_temporal_sdk_python-1.3.0/openbox/retry_coordinator.py +136 -0
  17. openbox_temporal_sdk_python-1.3.0/openbox/retryable_block.py +179 -0
  18. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/openbox/worker.py +8 -13
  19. openbox_temporal_sdk_python-1.3.0/openbox/workflow_interceptor.py +719 -0
  20. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/pyproject.toml +3 -2
  21. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/tests/test_activities.py +162 -5
  22. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/tests/test_activity_interceptor.py +390 -38
  23. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/tests/test_config.py +41 -8
  24. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/tests/test_multi_agent.py +83 -12
  25. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/tests/test_plugin.py +26 -6
  26. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/tests/test_public_api_compatibility.py +9 -2
  27. openbox_temporal_sdk_python-1.3.0/tests/test_retry_coordinator_and_budget.py +151 -0
  28. openbox_temporal_sdk_python-1.3.0/tests/test_retryable_block_e2e.py +292 -0
  29. openbox_temporal_sdk_python-1.3.0/tests/test_retryable_block_envelope.py +298 -0
  30. openbox_temporal_sdk_python-1.3.0/tests/test_retryable_block_replay.py +227 -0
  31. openbox_temporal_sdk_python-1.3.0/tests/test_temporal_hook_parity.py +236 -0
  32. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/tests/test_worker.py +27 -4
  33. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/tests/test_workflow_interceptor.py +744 -41
  34. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/tests/test_workflow_sandbox_import_safety.py +2 -0
  35. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/uv.lock +5 -5
  36. openbox_temporal_sdk_python-1.2.1/openbox/workflow_interceptor.py +0 -372
  37. openbox_temporal_sdk_python-1.2.1/tests/test_temporal_hook_parity.py +0 -129
  38. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/.github/workflows/publish.yml +0 -0
  39. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/.github/workflows/sonarqube.yaml +0 -0
  40. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/.gitignore +0 -0
  41. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/.python-version +0 -0
  42. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/.repomixignore +0 -0
  43. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/LICENSE +0 -0
  44. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/docs/changelog-hook-level-governance.md +0 -0
  45. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/docs/code-standards.md +0 -0
  46. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/docs/codebase-summary.md +0 -0
  47. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/docs/project-overview-pdr.md +0 -0
  48. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/docs/temporal-plugin-integration-guide.md +0 -0
  49. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/openbox/client.py +0 -0
  50. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/openbox/py.typed +0 -0
  51. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/openbox/request_signing.py +0 -0
  52. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/openbox/tracing.py +0 -0
  53. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/openbox/types.py +0 -0
  54. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/openbox/verdict_handler.py +0 -0
  55. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/release-manifest.json +0 -0
  56. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/repomix-output.xml +0 -0
  57. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/sonar-project.properties +0 -0
  58. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/tests/__init__.py +0 -0
  59. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/tests/conftest.py +0 -0
  60. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/tests/test_approval_action_precedence.py +0 -0
  61. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/tests/test_base_sdk_signing_parity.py +0 -0
  62. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/tests/test_core_conformance_suite.py +0 -0
  63. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/tests/test_no_import_machinery_in_governed_open_paths.py +0 -0
  64. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/tests/test_plugin_integration.py +0 -0
  65. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/tests/test_request_signing.py +0 -0
  66. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/tests/test_signing_config.py +0 -0
  67. {openbox_temporal_sdk_python-1.2.1 → openbox_temporal_sdk_python-1.3.0}/tests/test_types.py +0 -0
@@ -4,6 +4,18 @@ All notable changes to OpenBox SDK for Temporal Workflows.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.3.0] - 2026-07-21
8
+
9
+ ### Added
10
+
11
+ - **Retryable BLOCK workflow restart:** A governance response carrying `BLOCK` with a valid `retry_plan` (containing `new_input`) now triggers a Continue-As-New restart of the workflow using the replacement input. The restart is event-agnostic (supports WorkflowStarted, WorkflowCompleted, WorkflowFailed, SignalReceived, ActivityStarted, ActivityCompleted, Handoff, hook evaluations, and approval polling) and is bounded by the `max_retryable_block_restarts` configuration (default: 3, minimum: 1). When the bound is exceeded, the workflow fails with a non-retryable `GovernanceRetryLimitExceeded` error. The restart count is tracked across the entire workflow chain via a durable memo counter. Plain BLOCK (without a retry plan), HALT, expired approvals, and malformed plans preserve existing behavior and do not restart.
12
+
13
+ - **Workflow input capture in `WorkflowStarted`:** The `WorkflowStarted` governance event now carries the Temporal workflow arguments in the `activity_input` field, so OpenBox stores and displays workflow input the same way as activity input. The outer argument list is preserved verbatim (`workflow(a, b)` → `[a, b]`; a single list argument stays nested as `[[...]]`; no arguments → `[]`). Because this changes the `send_governance_event` activity input, it is version-gated by a dedicated patch marker (`openbox-workflow-start-input-v1`): new workflow histories include `activity_input`, while histories created before this change replay their original payload unchanged.
14
+
15
+ ### Changed
16
+
17
+ - **`openbox-sdk-python` dependency raised to `>=1.1.0`, installed from PyPI.** The retryable-BLOCK restart requires `handle_retryable_block` from base SDK 1.1.0; now that 1.1.0 is published, the dependency resolves from PyPI and the temporary local editable source is removed.
18
+
7
19
  ## [1.2.1] - 2026-07-21
8
20
 
9
21
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openbox-temporal-sdk-python
3
- Version: 1.2.1
3
+ Version: 1.3.0
4
4
  Summary: OpenBox SDK - Governance and observability for Temporal workflows
5
5
  Project-URL: Homepage, https://github.com/OpenBox-AI/temporal-sdk-python
6
6
  Project-URL: Documentation, https://github.com/OpenBox-AI/temporal-sdk-python#readme
@@ -24,7 +24,7 @@ Requires-Python: >=3.11
24
24
  Requires-Dist: asyncpg>=0.29.0
25
25
  Requires-Dist: httpx<1,>=0.28.0
26
26
  Requires-Dist: mysql-connector-python>=8.0.0
27
- Requires-Dist: openbox-sdk-python>=1.0.1
27
+ Requires-Dist: openbox-sdk-python>=1.1.0
28
28
  Requires-Dist: opentelemetry-api<1.40.0,>=1.38.0
29
29
  Requires-Dist: opentelemetry-instrumentation-asyncpg<0.61b0,>=0.59b0
30
30
  Requires-Dist: opentelemetry-instrumentation-httpx<0.61b0,>=0.59b0
@@ -227,9 +227,11 @@ OpenBox Core returns a verdict indicating what action the SDK should take.
227
227
  | `ALLOW` | Continue execution normally |
228
228
  | `CONSTRAIN` | Log constraints, continue |
229
229
  | `REQUIRE_APPROVAL` | Pause, poll for human approval |
230
- | `BLOCK` | Raise error, stop activity |
230
+ | `BLOCK` | Raise error, stop activity; if carrying a `retry_plan`, restart the workflow with replacement input via Continue-As-New (see "Retryable BLOCK" below) |
231
231
  | `HALT` | Raise error, terminate workflow |
232
232
 
233
+ **Retryable BLOCK:** When a `BLOCK` verdict includes a `retry_plan` with `new_input`, the SDK restarts the workflow using the corrected input instead of failing permanently. The restart chain is bounded by `max_retryable_block_restarts` (default: 3). See [Retryable BLOCK Restart Configuration](./docs/configuration.md#retryable-block-restart) for semantics and **critical idempotency requirements**.
234
+
233
235
  **v1.0 Backward Compatibility:**
234
236
  - `"continue"` → `ALLOW`
235
237
  - `"stop"` → `HALT`
@@ -178,9 +178,11 @@ OpenBox Core returns a verdict indicating what action the SDK should take.
178
178
  | `ALLOW` | Continue execution normally |
179
179
  | `CONSTRAIN` | Log constraints, continue |
180
180
  | `REQUIRE_APPROVAL` | Pause, poll for human approval |
181
- | `BLOCK` | Raise error, stop activity |
181
+ | `BLOCK` | Raise error, stop activity; if carrying a `retry_plan`, restart the workflow with replacement input via Continue-As-New (see "Retryable BLOCK" below) |
182
182
  | `HALT` | Raise error, terminate workflow |
183
183
 
184
+ **Retryable BLOCK:** When a `BLOCK` verdict includes a `retry_plan` with `new_input`, the SDK restarts the workflow using the corrected input instead of failing permanently. The restart chain is bounded by `max_retryable_block_restarts` (default: 3). See [Retryable BLOCK Restart Configuration](./docs/configuration.md#retryable-block-restart) for semantics and **critical idempotency requirements**.
185
+
184
186
  **v1.0 Backward Compatibility:**
185
187
  - `"continue"` → `ALLOW`
186
188
  - `"stop"` → `HALT`
@@ -31,6 +31,55 @@
31
31
  | `hitl_enabled` | `bool` | `True` | Enable approval polling for `REQUIRE_APPROVAL` |
32
32
  | `hitl_poll_interval_ms` | `int` | `5000` | Polling interval in milliseconds for approval status |
33
33
 
34
+ ## Retryable BLOCK Restart
35
+
36
+ | Parameter | Type | Default | Description |
37
+ |-----------|------|---------|-------------|
38
+ | `max_retryable_block_restarts` | `int` | `3` | Maximum Continue-As-New restarts triggered by a BLOCK with `retry_plan`. Minimum value: `1`. Applies uniformly across all governance event origins. When the count would exceed this limit, the workflow fails with a non-retryable `GovernanceRetryLimitExceeded` error. |
39
+
40
+ ### Semantics
41
+
42
+ A governance response carrying `BLOCK` with a valid `retry_plan` (containing `new_input`) will:
43
+
44
+ 1. **Stop the current execution path** with a non-retryable error signal
45
+ 2. **Not retry** the blocked activity with the same input (Temporal activity retry is bypassed)
46
+ 3. **Restart the workflow via Continue-As-New** using `retry_plan.new_input` as replacement input
47
+ - If `new_input` is `null`, all arguments of the current run are reused exactly
48
+ - If `new_input` is a non-null value, it is passed as one workflow argument
49
+ 4. **Keep the same Workflow ID** — the previous run closes as `ContinuedAsNew`; a new Run ID is issued for the restarted execution
50
+ 5. **Track restarts across the chain** — a global counter (per workflow ID) increments with each restart and is bounded by `max_retryable_block_restarts` to prevent infinite loops
51
+
52
+ This behavior is **event-agnostic**. The following governance event types all support retryable BLOCK:
53
+
54
+ - `WorkflowStarted`
55
+ - `WorkflowCompleted`
56
+ - `WorkflowFailed`
57
+ - `SignalReceived`
58
+ - `ActivityStarted`
59
+ - `ActivityCompleted`
60
+ - `Handoff`
61
+ - Started and completed hooks
62
+ - Approval polling responses
63
+
64
+ **Origins that do NOT restart:** `HALT`, expired approvals, malformed plans, and plain BLOCK (without a valid `retry_plan`) use existing behavior.
65
+
66
+ ### Critical: Idempotency and Side Effects
67
+
68
+ **This is the most important user-facing hazard.** A retry plan may be produced *after* side effects have already occurred:
69
+
70
+ - A policy may return a retry plan in response to `ActivityCompleted` — after your activity already ran and may have modified external state
71
+ - A completed hook may return a retry plan — after the operation has occurred
72
+ - `WorkflowCompleted` or `WorkflowFailed` may return a retry plan — after your workflow has executed and potentially caused side effects
73
+
74
+ **You are responsible for ensuring safety:**
75
+
76
+ - **Activities must remain idempotent.** If an activity is executed, rolls back or completes, and then the workflow restarts with corrected input, the activity must be safe to re-execute (or be designed to avoid re-execution for the same business key).
77
+ - **External writes should use stable business idempotency keys**, not the Temporal Run ID (which changes across Continue-As-New). For example, if writing to an external system, use your application's stable resource identifier, not `workflow.get_info().run_id`.
78
+ - **Policy authors and governance integrators own emitting safe replacement input.** The SDK does not validate that `new_input` is safe; it assumes the governance policy has vetted the input and the consequences of re-execution.
79
+ - **The SDK adds no source-specific restrictions.** You have full control and full responsibility.
80
+
81
+ Log any external state changes with identifiers your team can use to trace and reconcile retries. Monitor governance restart counts to detect policy bugs that cause repeated retries.
82
+
34
83
  ## Identity & Signing (AIP DID)
35
84
 
36
85
  When provided, every request to OpenBox Core is signed locally with Ed25519 and
@@ -289,32 +289,17 @@ OpenBoxError (base)
289
289
 
290
290
  #### TemporalGovernanceState
291
291
 
292
- **Responsibility:** Hold the small amount of Temporal semantics that must survive past a base-SDK hook callback — signal verdicts, HITL pending markers, and the completed-hook stop bridge. Workflow-safe, thread-safe, and shared by both interceptors.
292
+ **Responsibility:** Hold the small amount of Temporal semantics that must survive past a base-SDK hook callback — signal verdicts, HITL pending markers, completed-hook stop bridge, and retryable-BLOCK requests. Workflow-safe, thread-safe, and shared by both interceptors.
293
293
 
294
294
  The base SDK (`openbox_core`) owns hook context, hook payload building, hook evaluation, and the within-activity abort short-circuit (its `ContextStore`). This object holds only the effects the base runtime cannot express itself. All keys are **run-scoped**: state from a prior run with the same `workflow_id` is ignored and cleared.
295
295
 
296
- **Methods:**
297
- ```python
298
- class TemporalGovernanceState:
299
- # Signal verdicts (workflow interceptor → next activity)
300
- def set_signal_verdict(self, workflow_id, run_id, verdict, reason=None) -> None
301
- def get_signal_verdict(self, workflow_id, run_id) -> Optional[(Verdict, reason)] # clears stale run
302
- def clear_signal_verdict(self, workflow_id) -> None
303
-
304
- # HITL pending-approval markers
305
- def mark_pending_approval(self, workflow_id, run_id, activity_id) -> None
306
- def has_pending_approval(self, workflow_id, run_id, activity_id) -> bool
307
- def clear_pending_approval(self, workflow_id, run_id, activity_id) -> None
296
+ **Code Location:** `openbox/governance_state.py`
308
297
 
309
- # Completed-hook stop bridge (adapter activity interceptor)
310
- def record_completed_stop(self, workflow_id, run_id, activity_id, verdict, reason=None) -> None
311
- def take_completed_stop(self, workflow_id, run_id, activity_id) -> Optional[(Verdict, reason)]
298
+ **Retryable BLOCK Restart Bridge:** The workflow interceptor receives retryable-BLOCK requests from governance responses (activity results, signals, hooks, approval polls) and submits them to a run-local `RetryableBlockCoordinator`. The coordinator stores the first request (first-wins semantics); the inbound workflow boundary later reads it, validates the restart budget (incrementing a memo counter bounded by `max_retryable_block_restarts`), and calls `continue_as_new(new_input, memo=...)` with the replacement input and updated counter.
312
299
 
313
- # Cleanup
314
- def cleanup_run(self, workflow_id, run_id) -> None
315
- ```
300
+ **Versioned Error Transport:** Activity-side retry requests use a stable, non-retryable `ApplicationError` with type `"GovernanceRetryableBlock"` carrying a versioned request envelope. The workflow boundary extracts and validates the envelope before attempting a restart. Unknown versions fail safely as plain BLOCK; Temporal-level failures (replay, malformed detail) do not trigger a Workflow Task loop.
316
301
 
317
- **Code Location:** `openbox/governance_state.py`
302
+ **Replay Safety (Patch Marker `"openbox-retryable-block-v1"`):** The Continue-As-New command is protected by a Temporal patch marker so old histories (pre-feature) skip the new branch and replay unchanged. New histories take the retryable-BLOCK path deterministically.
318
303
 
319
304
  ---
320
305
 
@@ -635,6 +620,71 @@ Authorization: Bearer {api_key}
635
620
  └────────────┘ └──────────────┘ └──────────────┘
636
621
  ```
637
622
 
623
+ ### Retryable BLOCK Restart Flow
624
+
625
+ ```
626
+ ┌──────────────────────────────────────────┐
627
+ │ Governance response from any event type │
628
+ │ (activity, signal, hook, approval, etc) │
629
+ └────────────┬─────────────────────────────┘
630
+
631
+
632
+ ┌──────────────────────────────────────────┐
633
+ │ Base SDK handle_retryable_block() helper │
634
+ │ (checks for valid BLOCK + retry_plan) │
635
+ └────────────┬─────────────────────────────┘
636
+
637
+ ┌──────┴──────────┐
638
+ │ │
639
+ ▼ ▼
640
+ ┌────────┐ ┌─────────────────┐
641
+ │No plan │ │RetryDirective │
642
+ └────────┘ │(new_input) │
643
+ │ └────────┬────────┘
644
+ ▼ │
645
+ Existing BLOCK/HALT ┌─────┴────────────────┐
646
+ behavior │ │
647
+ ▼ ▼
648
+ Activity/hook Workflow event/signal
649
+ context context
650
+ │ │
651
+ ▼ ▼
652
+ Non-retryable Submit to run-local
653
+ ApplicationError RetryableBlockCoordinator
654
+ (transport signal) (first-wins)
655
+ │ │
656
+ └──────┬───────────────┘
657
+
658
+
659
+ Workflow inbound boundary
660
+
661
+ ┌─────────┴────────────┐
662
+ │ │
663
+ ┌─────▼──────┐ ┌─────────────┐
664
+ │Read memo │ │Read request │
665
+ │counter │ │from coord │
666
+ └─────┬──────┘ └──────┬──────┘
667
+ │ │
668
+ ┌─────────┴───────────────────┤
669
+ │ Increment and validate cap │
670
+ └─────────┬───────────────────┘
671
+
672
+ ┌──────────┴──────────┐
673
+ │ │
674
+ ┌────▼────┐ ┌──────▼──────┐
675
+ │ Over │ │ Under limit │
676
+ │ limit │ │ │
677
+ └────┬────┘ └──────┬───────┘
678
+ │ │
679
+ ▼ ▼
680
+ ApplicationError Continue-As-New
681
+ (GovernanceRetry (same Workflow ID,
682
+ LimitExceeded) new Run ID, fresh
683
+ history)
684
+ ```
685
+
686
+ **Code Location:** `openbox/workflow_interceptor.py`, `openbox/retry_coordinator.py`
687
+
638
688
  ### Hook-Level Governance Flow
639
689
 
640
690
  Hook detection, payload building, and evaluation are owned by the `openbox_core` base runtime. The Temporal SDK provides the activity-context bridge (`core_activity_scope`) and maps the resulting verdict onto Temporal-native effects (`TemporalFrameworkAdapter`).
@@ -6,45 +6,43 @@
6
6
  # it crashes the workflow sandbox as a circular import, lazily it recurses
7
7
  # unboundedly from build_auth_headers on every evaluate. Keep in sync with
8
8
  # pyproject.toml on release.
9
- __version__ = "1.2.1"
10
-
11
- from .worker import create_openbox_worker
12
-
13
- from .config import (
14
- initialize,
15
- get_global_config,
16
- GovernanceConfig,
17
- )
9
+ __version__ = "1.3.0"
18
10
 
11
+ from .config import GovernanceConfig, get_global_config, initialize
19
12
  from .errors import (
20
- OpenBoxError,
21
- OpenBoxConfigError,
22
- OpenBoxAuthError,
23
- OpenBoxNetworkError,
24
- OpenBoxInsecureURLError,
25
- OpenBoxSigningError,
26
- GovernanceBlockedError,
27
- GovernanceHaltError,
28
- GovernanceAPIError,
29
- GuardrailsValidationError,
30
13
  ApprovalExpiredError,
31
14
  ApprovalRejectedError,
32
15
  ApprovalTimeoutError,
16
+ GovernanceAPIError,
17
+ GovernanceBlockedError,
18
+ GovernanceHaltError,
19
+ GuardrailsValidationError,
20
+ OpenBoxAuthError,
21
+ OpenBoxConfigError,
22
+ OpenBoxError,
23
+ OpenBoxInsecureURLError,
24
+ OpenBoxNetworkError,
25
+ OpenBoxSigningError,
33
26
  extract_governance_error,
34
27
  map_signing_error,
35
28
  )
36
29
 
37
- from .types import (
38
- Verdict,
39
- WorkflowEventType,
40
- GovernanceVerdictResponse,
41
- GuardrailsCheckResult,
42
- )
43
-
44
30
  # Multi-agent primitives (sandbox-safe — only imports temporalio.workflow eagerly;
45
31
  # signing/HTTP routed lazily through the governance activity).
46
32
  from .multi_agent import emit_handoff
47
33
 
34
+ # Retryable-BLOCK restart envelope (sandbox-safe: pure stdlib + base contracts).
35
+ from .retryable_block import (
36
+ GOVERNANCE_RETRYABLE_BLOCK_SCHEMA_VERSION,
37
+ RetryableBlockRequest,
38
+ )
39
+ from .types import (
40
+ GovernanceVerdictResponse,
41
+ GuardrailsCheckResult,
42
+ Verdict,
43
+ WorkflowEventType,
44
+ )
45
+ from .worker import create_openbox_worker
48
46
  from .workflow_interceptor import GovernanceInterceptor
49
47
 
50
48
  try:
@@ -54,11 +52,9 @@ try:
54
52
  except ImportError:
55
53
  pass # temporalio < 1.23.0, plugin not available
56
54
 
57
- from .verdict_handler import enforce_verdict, VerdictEnforcementResult
58
-
59
- from .hitl import handle_approval_response, raise_approval_pending, should_skip_hitl
60
-
61
55
  from .client import GovernanceClient
56
+ from .hitl import handle_approval_response, raise_approval_pending, should_skip_hitl
57
+ from .verdict_handler import VerdictEnforcementResult, enforce_verdict
62
58
 
63
59
  # NOTE: ActivityGovernanceInterceptor is NOT imported here because it imports
64
60
  # OpenTelemetry which uses importlib_metadata -> os.stat, causing sandbox issues.
@@ -114,6 +110,8 @@ __all__ = [
114
110
  "WorkflowEventType",
115
111
  "GovernanceVerdictResponse",
116
112
  "GuardrailsCheckResult",
113
+ "RetryableBlockRequest",
114
+ "GOVERNANCE_RETRYABLE_BLOCK_SCHEMA_VERSION",
117
115
  "emit_handoff",
118
116
  "GovernanceInterceptor",
119
117
  "enforce_verdict",
@@ -21,18 +21,16 @@ executes. This ensures timestamps are generated in activity context (non-determi
21
21
  code allowed) rather than workflow context (must be deterministic).
22
22
  """
23
23
 
24
- import httpx
25
24
  import logging
26
25
  from datetime import datetime, timezone
27
- from typing import Dict, Any, Optional
28
-
29
-
30
- from .types import rfc3339_now as _rfc3339_now
26
+ from typing import Any, Dict, Optional
31
27
 
28
+ import httpx
32
29
  from temporalio import activity
33
30
  from temporalio.exceptions import ApplicationError
34
31
 
35
- from .types import Verdict
32
+ from .types import GovernanceVerdictResponse, Verdict
33
+ from .types import rfc3339_now as _rfc3339_now
36
34
 
37
35
  logger = logging.getLogger(__name__)
38
36
 
@@ -201,12 +199,26 @@ class GovernanceActivities:
201
199
  )
202
200
 
203
201
  data = response.json()
204
- verdict = Verdict.from_string(
205
- data.get("verdict") or data.get("action", "continue")
206
- )
207
- reason = data.get("reason")
208
- policy_id = data.get("policy_id")
209
- risk_score = data.get("risk_score", 0.0)
202
+ parsed = GovernanceVerdictResponse.from_dict(data)
203
+ verdict = parsed.verdict
204
+ reason = parsed.reason
205
+ policy_id = parsed.policy_id
206
+ risk_score = parsed.risk_score
207
+
208
+ # A BLOCK carrying a valid retry plan restarts the workflow run
209
+ # instead of merely failing this activity — check for it before
210
+ # the plain BLOCK/HALT stop handling below ever sees the verdict.
211
+ from .errors import GOVERNANCE_RETRYABLE_BLOCK_ERROR_TYPE
212
+ from .retryable_block import retryable_block_request
213
+
214
+ retry_req = retryable_block_request(parsed, event_type=event_type)
215
+ if retry_req is not None:
216
+ raise ApplicationError(
217
+ "Governance requested workflow restart",
218
+ retry_req.to_dict(),
219
+ type=GOVERNANCE_RETRYABLE_BLOCK_ERROR_TYPE,
220
+ non_retryable=True,
221
+ )
210
222
 
211
223
  if verdict.should_stop():
212
224
  result = await _handle_stop_verdict(
@@ -11,12 +11,11 @@ IMPORTANT: Activities CAN use datetime/time and make HTTP calls directly.
11
11
  This is different from workflow interceptors which must maintain determinism.
12
12
  """
13
13
 
14
- from typing import Optional, Any, List
15
14
  import dataclasses
16
- from dataclasses import asdict, is_dataclass, fields
17
- import time
18
15
  import json
19
-
16
+ import time
17
+ from dataclasses import asdict, fields, is_dataclass
18
+ from typing import Any, List, NoReturn, Optional
20
19
 
21
20
  from .types import rfc3339_now as _rfc3339_now
22
21
 
@@ -63,27 +62,44 @@ def _update_list_items(current_list: list, new_list: list, _logger=None) -> None
63
62
  current_list[i] = new_item
64
63
 
65
64
 
65
+ from opentelemetry import trace
66
66
  from temporalio import activity
67
67
  from temporalio.worker import (
68
- Interceptor,
69
68
  ActivityInboundInterceptor,
70
69
  ExecuteActivityInput,
70
+ Interceptor,
71
71
  )
72
- from opentelemetry import trace
73
72
 
73
+ from .activities import _terminate_workflow_for_halt
74
+ from .client import GovernanceClient
74
75
  from .config import GovernanceConfig
75
76
  from .core_adapter import core_activity_scope, get_core_context_store
76
- from .governance_state import TemporalGovernanceState
77
- from .types import (
78
- WorkflowEventType,
79
- GovernanceVerdictResponse,
80
- Verdict,
77
+ from .errors import (
78
+ GOVERNANCE_RETRYABLE_BLOCK_ERROR_TYPE,
79
+ GovernanceBlockedError,
80
+ GovernanceHaltError,
81
+ GuardrailsValidationError,
81
82
  )
83
+ from .governance_state import TemporalGovernanceState
82
84
  from .multi_agent import read_session_from_header
83
- from .activities import _terminate_workflow_for_halt
85
+ from .retryable_block import RetryableBlockRequest, retryable_block_request
86
+ from .types import GovernanceVerdictResponse, Verdict, WorkflowEventType
84
87
  from .verdict_handler import enforce_verdict
85
- from .errors import GovernanceBlockedError, GovernanceHaltError, GuardrailsValidationError
86
- from .client import GovernanceClient
88
+
89
+
90
+ def _raise_retryable_block(req: RetryableBlockRequest) -> NoReturn:
91
+ """Raise the stable, versioned ``GovernanceRetryableBlock`` ApplicationError
92
+ for a valid retryable-BLOCK directive. Non-retryable — the workflow
93
+ interceptor catches this stable type and Continue-As-News with the
94
+ replacement input."""
95
+ from temporalio.exceptions import ApplicationError
96
+
97
+ raise ApplicationError(
98
+ "Governance requested workflow restart",
99
+ req.to_dict(),
100
+ type=GOVERNANCE_RETRYABLE_BLOCK_ERROR_TYPE,
101
+ non_retryable=True,
102
+ )
87
103
 
88
104
 
89
105
  def _serialize_value(value: Any) -> Any:
@@ -94,6 +110,12 @@ def _serialize_value(value: Any) -> Any:
94
110
  return value
95
111
  if isinstance(value, bytes):
96
112
  return _serialize_bytes(value)
113
+ model_dump = getattr(value, "model_dump", None)
114
+ if callable(model_dump):
115
+ try:
116
+ return model_dump(mode="json")
117
+ except Exception:
118
+ pass
97
119
  if is_dataclass(value) and not isinstance(value, type):
98
120
  return asdict(value)
99
121
  if isinstance(value, (list, tuple)):
@@ -111,6 +133,7 @@ def _serialize_bytes(value: bytes) -> str:
111
133
  return value.decode("utf-8")
112
134
  except Exception:
113
135
  import base64
136
+
114
137
  return base64.b64encode(value).decode("ascii")
115
138
 
116
139
 
@@ -238,35 +261,56 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
238
261
  )
239
262
  except Exception:
240
263
  # The activity (or a hook) raised, so _handle_completion is skipped. A
241
- # completed-hook HALT recorded during user code is a kill-switch and
242
- # must STILL reach the terminate path failing the activity does not
243
- # halt the workflow. Consume the completed-stop here too (also clears
244
- # it, so it can never strand/leak on the exception path).
264
+ # completed-hook stop recorded during user code must still be enforced:
265
+ # HALT reaches the terminate path and raises GovernanceHalt, and a
266
+ # retryable request raises GovernanceRetryableBlock either REPLACES
267
+ # the original exception (completed-hook priority HALT > retryable >
268
+ # original exception). A plain completed BLOCK is a no-op, so the
269
+ # `raise` below re-propagates the original exception unchanged. Consume
270
+ # the completed-stop either way (also clears it, so it can never
271
+ # strand/leak on the exception path).
245
272
  await self._consume_completed_halt(info)
246
273
  raise
247
274
 
248
275
  result = await self._handle_completion(
249
- info, status, error, start_time, end_time,
250
- activity_input, activity_output, result,
276
+ info,
277
+ status,
278
+ error,
279
+ start_time,
280
+ end_time,
281
+ activity_input,
282
+ activity_output,
283
+ result,
251
284
  session_id,
252
285
  )
253
286
 
254
287
  return result
255
288
 
256
289
  async def _consume_completed_halt(self, info) -> None:
257
- """Exception-path safety net: a completed-hook HALT recorded during user
258
- code must terminate the workflow even when the activity itself raised.
259
- Clears the completed-stop and the base abort flag so nothing strands."""
290
+ """Exception-path safety net: a completed-hook stop recorded during user
291
+ code must still be enforced even though the activity itself raised, so
292
+ _handle_completion (the success path) never runs. Priority: HALT raises
293
+ GovernanceHalt (terminating the workflow and REPLACING the original
294
+ exception); a retryable request raises GovernanceRetryableBlock (also
295
+ replacing the original exception with the restart request); a plain
296
+ completed BLOCK is a no-op here, so the caller's re-raise propagates the
297
+ original exception unchanged. Clears the completed-stop and the base abort
298
+ flag so nothing strands."""
260
299
  stop = self._state.take_completed_stop(
261
300
  info.workflow_id, info.workflow_run_id, info.activity_id
262
301
  )
263
302
  get_core_context_store().clear_activity_aborted(
264
303
  info.workflow_id, info.activity_id
265
304
  )
266
- if stop is not None and stop[0] is Verdict.HALT:
305
+ if stop is None:
306
+ return
307
+ if stop.verdict is Verdict.HALT:
267
308
  await _terminate_workflow_for_halt(
268
- info.workflow_id, stop[1] or "Governance halt"
309
+ info.workflow_id, stop.reason or "Governance halt"
269
310
  )
311
+ return
312
+ if stop.request is not None:
313
+ _raise_retryable_block(stop.request)
270
314
 
271
315
  async def _check_pending_verdicts(self, info) -> None:
272
316
  """Enforce a SignalReceived BLOCK/HALT recorded for this run by the
@@ -292,6 +336,7 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
292
336
  await _terminate_workflow_for_halt(workflow_id, reason)
293
337
  else:
294
338
  from temporalio.exceptions import ApplicationError
339
+
295
340
  raise ApplicationError(
296
341
  f"Governance blocked: {reason}",
297
342
  type="GovernanceBlock",
@@ -329,9 +374,7 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
329
374
  info.activity_id,
330
375
  )
331
376
  if approved:
332
- activity.logger.info(
333
- f"Approval granted for workflow_id={info.workflow_id}"
334
- )
377
+ activity.logger.info(f"Approval granted for workflow_id={info.workflow_id}")
335
378
  self._state.clear_pending_approval(
336
379
  info.workflow_id, info.workflow_run_id, info.activity_id
337
380
  )
@@ -362,7 +405,21 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
362
405
  self, verdict_response: GovernanceVerdictResponse, info, context: str
363
406
  ) -> None:
364
407
  """Enforce a governance verdict (HITL, BLOCK, HALT, guardrails)."""
365
- from .hitl import should_skip_hitl, raise_approval_pending
408
+ from .hitl import raise_approval_pending, should_skip_hitl
409
+
410
+ # An exact BLOCK with a valid retry plan requests a workflow restart and
411
+ # takes priority over the generic BLOCK/HALT/guardrails mapping below —
412
+ # checked before enforce_verdict() so it is never first converted to a
413
+ # plain GovernanceBlockedError. These are REAL activity-lifecycle events
414
+ # (not hooks), so hook_trigger stays the default False.
415
+ event_type = (
416
+ WorkflowEventType.ACTIVITY_STARTED.value
417
+ if context == "activity_start"
418
+ else WorkflowEventType.ACTIVITY_COMPLETED.value
419
+ )
420
+ req = retryable_block_request(verdict_response, event_type=event_type)
421
+ if req is not None:
422
+ _raise_retryable_block(req)
366
423
 
367
424
  try:
368
425
  verdict_result = enforce_verdict(verdict_response, context)
@@ -384,6 +441,7 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
384
441
  await _terminate_workflow_for_halt(info.workflow_id, str(e))
385
442
  except GovernanceBlockedError as e:
386
443
  from temporalio.exceptions import ApplicationError
444
+
387
445
  raise ApplicationError(
388
446
  f"Governance blocked: {e.reason}",
389
447
  type="GovernanceBlock",
@@ -391,6 +449,7 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
391
449
  )
392
450
  except GuardrailsValidationError as e:
393
451
  from temporalio.exceptions import ApplicationError
452
+
394
453
  activity.logger.info(f"Guardrails validation failed: {e}")
395
454
  raise ApplicationError(
396
455
  f"Guardrails validation failed: {e}",
@@ -510,8 +569,16 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
510
569
  return result, status, error, activity_output, end_time
511
570
 
512
571
  async def _handle_completion(
513
- self, info, status, error, start_time, end_time,
514
- activity_input, activity_output, result, multi_agent_session_id=None,
572
+ self,
573
+ info,
574
+ status,
575
+ error,
576
+ start_time,
577
+ end_time,
578
+ activity_input,
579
+ activity_output,
580
+ result,
581
+ multi_agent_session_id=None,
515
582
  ) -> Any:
516
583
  """Send ActivityCompleted, enforce verdict, apply output redaction."""
517
584
  store = get_core_context_store()
@@ -525,10 +592,16 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
525
592
  base_aborted = store.is_activity_aborted(info.workflow_id, info.activity_id)
526
593
  store.clear_activity_aborted(info.workflow_id, info.activity_id)
527
594
 
528
- if completed_stop is not None and completed_stop[0] is Verdict.HALT:
529
- await _terminate_workflow_for_halt(
530
- info.workflow_id, completed_stop[1] or "Governance halt"
531
- )
595
+ if completed_stop is not None:
596
+ if completed_stop.verdict is Verdict.HALT:
597
+ await _terminate_workflow_for_halt(
598
+ info.workflow_id, completed_stop.reason or "Governance halt"
599
+ )
600
+ elif completed_stop.request is not None:
601
+ # A retryable request outranks the plain skip-completed-event
602
+ # behavior below — raise the restart request now that user code
603
+ # has already returned.
604
+ _raise_retryable_block(completed_stop.request)
532
605
 
533
606
  was_aborted = base_aborted or completed_stop is not None
534
607