durable-workflow 2.5.9__tar.gz → 2.5.11__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 (129) hide show
  1. {durable_workflow-2.5.9/src/durable_workflow.egg-info → durable_workflow-2.5.11}/PKG-INFO +9 -1
  2. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/README.md +8 -0
  3. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/pyproject.toml +3 -3
  4. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/replay_conformance.py +35 -16
  5. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/workflow.py +9 -13
  6. {durable_workflow-2.5.9 → durable_workflow-2.5.11/src/durable_workflow.egg-info}/PKG-INFO +9 -1
  7. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_patch_history.py +74 -1
  8. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_replay.py +18 -6
  9. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_replay_conformance.py +44 -3
  10. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_replay_regression_corpus.py +33 -1
  11. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/LICENSE +0 -0
  12. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/setup.cfg +0 -0
  13. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/__init__.py +0 -0
  14. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/_activity_process.py +0 -0
  15. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/_avro.py +0 -0
  16. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/_cancellation_scope.py +0 -0
  17. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  18. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  19. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/_prepared_local_activity.py +0 -0
  20. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  21. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/activity.py +0 -0
  22. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/auth_composition.py +0 -0
  23. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/cancellation.py +0 -0
  24. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/client.py +0 -0
  25. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/errors.py +0 -0
  26. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/external_storage.py +0 -0
  27. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/external_task_input.py +0 -0
  28. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/external_task_result.py +0 -0
  29. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/history_bundle_verify.py +0 -0
  30. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/interceptors.py +0 -0
  31. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/invocable.py +0 -0
  32. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/metrics.py +0 -0
  33. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/nexus.py +0 -0
  34. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/py.typed +0 -0
  35. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/python_conformance.py +0 -0
  36. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/replay_verify.py +0 -0
  37. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/retry_policy.py +0 -0
  38. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/serializer.py +0 -0
  39. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/sync.py +0 -0
  40. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/testing.py +0 -0
  41. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/worker.py +0 -0
  42. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/worker_session.py +0 -0
  43. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  44. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
  45. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  46. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  47. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow.egg-info/requires.txt +0 -0
  48. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/src/durable_workflow.egg-info/top_level.txt +0 -0
  49. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_activity_cancellation_policies.py +0 -0
  50. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_activity_context.py +0 -0
  51. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_activity_process.py +0 -0
  52. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_auth_composition.py +0 -0
  53. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_avro_value_benchmark.py +0 -0
  54. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_avro_value_protocol.py +0 -0
  55. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_cancellation_context.py +0 -0
  56. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_cancellation_remaining_time.py +0 -0
  57. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_cancellation_scope_admission.py +0 -0
  58. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_cancellation_scope_authoring.py +0 -0
  59. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_cancellation_scope_boundary.py +0 -0
  60. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_cancellation_scope_opening.py +0 -0
  61. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_child_workflow_policies.py +0 -0
  62. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_ci_checkout.py +0 -0
  63. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_ci_integration_endpoint.py +0 -0
  64. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_client.py +0 -0
  65. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_client_timeouts.py +0 -0
  66. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_codec_regression_corpus.py +0 -0
  67. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  68. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_committed_cancellation_scope_groups.py +0 -0
  69. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_committed_cancellation_scope_history.py +0 -0
  70. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_committed_cancellation_scope_replay.py +0 -0
  71. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_condition_replay_drift.py +0 -0
  72. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_control_plane_parity_fixtures.py +0 -0
  73. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_cooperative_cancellation.py +0 -0
  74. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_cooperative_cancellation_client.py +0 -0
  75. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_cooperative_cancellation_worker.py +0 -0
  76. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_cooperative_remote_worker.py +0 -0
  77. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_docstring_cross_references.py +0 -0
  78. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_durable_selection.py +0 -0
  79. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_errors.py +0 -0
  80. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_external_storage.py +0 -0
  81. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_external_task_input.py +0 -0
  82. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_external_task_result.py +0 -0
  83. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_golden_history_replay.py +0 -0
  84. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_history_bundle_verify.py +0 -0
  85. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_history_event_contract.py +0 -0
  86. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_invocable.py +0 -0
  87. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_message_streams.py +0 -0
  88. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_metrics.py +0 -0
  89. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_nexus_namespace.py +0 -0
  90. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_nexus_wire.py +0 -0
  91. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_order_processing_example.py +0 -0
  92. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_parallel_completion_order_replay.py +0 -0
  93. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_parallel_saga_authoring.py +0 -0
  94. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_pending_operation_replay.py +0 -0
  95. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_prepared_local_activity_client.py +0 -0
  96. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_prepared_local_activity_groups.py +0 -0
  97. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_prepared_local_activity_policies.py +0 -0
  98. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_prepared_local_activity_worker.py +0 -0
  99. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_public_boundary_scanner.py +0 -0
  100. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_pypi_project_surface.py +0 -0
  101. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_python_conformance.py +0 -0
  102. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_queries.py +0 -0
  103. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_readme_quickstart.py +0 -0
  104. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_redrive_replay.py +0 -0
  105. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_registration_ambiguity.py +0 -0
  106. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_release_metadata.py +0 -0
  107. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_replay_verify.py +0 -0
  108. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_retry_policy.py +0 -0
  109. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_run_cancellation_scope_replay.py +0 -0
  110. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_runtime_external_payload_transport.py +0 -0
  111. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_schedules.py +0 -0
  112. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_scoped_run_cancellation_context.py +0 -0
  113. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_serializer.py +0 -0
  114. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_signals.py +0 -0
  115. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_sleep.py +0 -0
  116. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_standalone_activity_client.py +0 -0
  117. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_sticky_execution.py +0 -0
  118. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_storage_admission.py +0 -0
  119. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_sync.py +0 -0
  120. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_testing_harness.py +0 -0
  121. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_update_signal_condition_replay.py +0 -0
  122. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_updates.py +0 -0
  123. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_wait_condition.py +0 -0
  124. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_worker.py +0 -0
  125. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_worker_session.py +0 -0
  126. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_workflow_entrypoint_validation.py +0 -0
  127. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_workflow_result_timeout.py +0 -0
  128. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_workflow_streams.py +0 -0
  129. {durable_workflow-2.5.9 → durable_workflow-2.5.11}/tests/test_workflow_updates_conformance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.5.9
3
+ Version: 2.5.11
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -175,6 +175,14 @@ for immutable context, operation policies, shielded cleanup and recovery.
175
175
 
176
176
  ## Development
177
177
 
178
+ The installed `durable-workflow-replay-conformance` command runs an offline
179
+ Python replay shard. Its `sdk-python` artifact pin must match the installed
180
+ distribution version reported by `importlib.metadata.version("durable-workflow")`.
181
+ The report records that installed identity separately from the declared platform
182
+ tuple and fails if metadata is missing or the SDK pin differs. Replay success
183
+ does not prove installation or execution of the other declared artifacts; the
184
+ aggregate runner supplies its own verified installation receipts.
185
+
178
186
  ```bash
179
187
  pip install -e '.[dev]'
180
188
  ruff check src/ tests/
@@ -135,6 +135,14 @@ for immutable context, operation policies, shielded cleanup and recovery.
135
135
 
136
136
  ## Development
137
137
 
138
+ The installed `durable-workflow-replay-conformance` command runs an offline
139
+ Python replay shard. Its `sdk-python` artifact pin must match the installed
140
+ distribution version reported by `importlib.metadata.version("durable-workflow")`.
141
+ The report records that installed identity separately from the declared platform
142
+ tuple and fails if metadata is missing or the SDK pin differs. Replay success
143
+ does not prove installation or execution of the other declared artifacts; the
144
+ aggregate runner supplies its own verified installation receipts.
145
+
138
146
  ```bash
139
147
  pip install -e '.[dev]'
140
148
  ruff check src/ tests/
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.5.9"
7
+ version = "2.5.11"
8
8
  description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -71,8 +71,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
71
71
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
72
72
 
73
73
  [tool.durable-workflow]
74
- product-train = "2.5.9"
75
- registry-version = "2.5.9"
74
+ product-train = "2.5.11"
75
+ registry-version = "2.5.11"
76
76
  supported-server-versions = "2.5.16"
77
77
  worker-protocol-version = "1.20"
78
78
  control-plane-version = "2"
@@ -15,6 +15,7 @@ import json
15
15
  import sys
16
16
  from collections.abc import Mapping, Sequence
17
17
  from datetime import datetime, timezone
18
+ from importlib import metadata
18
19
  from pathlib import Path
19
20
  from typing import Any
20
21
 
@@ -204,6 +205,17 @@ def compose_report(
204
205
  started_at = _timestamp()
205
206
  versions = _canonical_artifact_metadata(dict(artifact_versions or {}))
206
207
  sources = _canonical_artifact_metadata(dict(artifact_sources or {}))
208
+ try:
209
+ installed_version = metadata.version("durable-workflow")
210
+ except metadata.PackageNotFoundError:
211
+ installed_version = None
212
+ identity = {
213
+ "runtime": "sdk-python",
214
+ "package": "durable-workflow",
215
+ "version": installed_version,
216
+ "version_source": "installed_distribution_metadata" if installed_version else "unavailable",
217
+ "declared_version": _artifact_metadata(versions, "sdk-python"),
218
+ }
207
219
  scenario_results: dict[str, dict[str, Any]] = {}
208
220
  findings: list[dict[str, Any]] = []
209
221
  finding_links: dict[str, list[dict[str, Any]]] = {}
@@ -212,7 +224,7 @@ def compose_report(
212
224
  scenario_results,
213
225
  findings,
214
226
  finding_links,
215
- _published_artifact_scenario(versions, sources),
227
+ _published_artifact_scenario(versions, sources, installed_version),
216
228
  )
217
229
 
218
230
  for family, scenario_id in COMPLETED_SCENARIOS.items():
@@ -242,6 +254,9 @@ def compose_report(
242
254
  has_failures = any(
243
255
  scenario.get("status") != "pass" for scenario in scenario_results.values()
244
256
  )
257
+ for scenario in scenario_results.values():
258
+ scenario["implementation_identity"] = dict(identity)
259
+ scenario["artifact_tuple_evidence"] = "declared"
245
260
 
246
261
  return {
247
262
  "schema": RESULT_SCHEMA,
@@ -252,6 +267,8 @@ def compose_report(
252
267
  "finished_at": _timestamp(),
253
268
  "artifact_versions": versions,
254
269
  "artifact_sources": sources,
270
+ "artifact_tuple_evidence": "declared",
271
+ "implementation_identity": identity,
255
272
  "runtime_matrix": {
256
273
  "runtimes": ["sdk-python"],
257
274
  },
@@ -307,6 +324,7 @@ def _append_scenario(
307
324
  def _published_artifact_scenario(
308
325
  artifact_versions: Mapping[str, str],
309
326
  artifact_sources: Mapping[str, str],
327
+ installed_version: str | None,
310
328
  ) -> dict[str, Any]:
311
329
  missing_versions: list[str] = []
312
330
  missing_sources: list[str] = []
@@ -338,14 +356,14 @@ def _published_artifact_scenario(
338
356
  untrusted_sources[artifact] = source
339
357
 
340
358
  missing = sorted(set(missing_versions + missing_sources))
341
- status = (
342
- "pass"
343
- if not missing
344
- and not rejected_versions
345
- and not forbidden_sources
346
- and not untrusted_sources
347
- else "fail"
348
- )
359
+ declarations_valid = not missing and not rejected_versions and not forbidden_sources and not untrusted_sources
360
+ declared_version = _artifact_metadata(artifact_versions, "sdk-python")
361
+ identity_error = None
362
+ if not installed_version:
363
+ identity_error = "installed_sdk_metadata_missing"
364
+ elif declared_version != installed_version:
365
+ identity_error = "installed_sdk_version_mismatch"
366
+ status = "pass" if declarations_valid and identity_error is None else "fail"
349
367
 
350
368
  return _scenario(
351
369
  "published_artifact_install_only",
@@ -361,8 +379,14 @@ def _published_artifact_scenario(
361
379
  "rejected_versions": rejected_versions,
362
380
  "forbidden_sources": forbidden_sources,
363
381
  "untrusted_sources": untrusted_sources,
364
- "published_artifacts_only": status == "pass",
365
- "published_install_tuple_proven": status == "pass",
382
+ "declared_artifacts_published_only": bool(declarations_valid),
383
+ "declared_sdk_version": declared_version,
384
+ "installed_sdk_version": installed_version,
385
+ "sdk_identity_error": identity_error,
386
+ "sdk_version_matches": bool(installed_version and declared_version == installed_version),
387
+ "artifact_tuple_evidence": "declared",
388
+ "published_artifacts_only": False,
389
+ "published_install_tuple_proven": False,
366
390
  },
367
391
  )
368
392
 
@@ -589,11 +613,6 @@ def _scenario(
589
613
  "scenario_id": scenario_id,
590
614
  "status": status,
591
615
  "published_artifact_versions": dict(artifact_versions),
592
- "implementation_identity": {
593
- "runtime": "sdk-python",
594
- "package": "durable-workflow",
595
- "version": _artifact_metadata(artifact_versions, "sdk-python"),
596
- },
597
616
  "runtime_matrix": {
598
617
  "runtimes": ["sdk-python"],
599
618
  },
@@ -5238,7 +5238,7 @@ def _replay_state(
5238
5238
  pending: list[Command] = []
5239
5239
  advanced_cmd: Any = None
5240
5240
  terminal_condition_reopen_cmd: WaitCondition | None = None
5241
- version_decisions: dict[str, tuple[int, str]] = {}
5241
+ version_decisions: dict[str, int] = {}
5242
5242
 
5243
5243
  def _version_error(detail: str, step: _RecordedStep | None = None) -> NonDeterministicReplayError:
5244
5244
  return NonDeterministicReplayError(
@@ -5256,13 +5256,12 @@ def _replay_state(
5256
5256
  if type(version) is not int:
5257
5257
  raise _version_error("Version marker must contain an integer version.", step)
5258
5258
  change_id = payload.get("change_id")
5259
- if "change_id" in payload and (not isinstance(change_id, str) or not change_id.strip()):
5259
+ if not isinstance(change_id, str) or not change_id.strip():
5260
5260
  raise _version_error("Version marker must retain a non-empty change ID.", step)
5261
- if "min_supported" in payload or "max_supported" in payload:
5262
- minimum, maximum = payload.get("min_supported"), payload.get("max_supported")
5263
- if (type(minimum) is not int or type(maximum) is not int
5264
- or not minimum <= version <= maximum):
5265
- raise _version_error("Recorded version marker has an invalid supported range.", step)
5261
+ minimum, maximum = payload.get("min_supported"), payload.get("max_supported")
5262
+ if (type(minimum) is not int or type(maximum) is not int
5263
+ or not minimum <= version <= maximum):
5264
+ raise _version_error("Recorded version marker has an invalid supported range.", step)
5266
5265
  return version
5267
5266
 
5268
5267
  def _consume_duplicate_version_markers() -> None:
@@ -5277,7 +5276,7 @@ def _replay_state(
5277
5276
  if _next_unconsumed_recorded_step() is not step:
5278
5277
  return
5279
5278
  version = _recorded_version(step)
5280
- if version != version_decisions[change_id][0]:
5279
+ if version != version_decisions[change_id]:
5281
5280
  raise _version_error(f"Conflicting recorded decisions for change ID {change_id!r}.", step)
5282
5281
  # Earlier SDKs emitted a marker for every invocation. Consume these
5283
5282
  # aliases while retaining their physical positions for cancellation
@@ -5293,13 +5292,10 @@ def _replay_state(
5293
5292
  if (type(command.min_supported) is not int or type(command.max_supported) is not int
5294
5293
  or command.min_supported > command.max_supported):
5295
5294
  raise _version_error(f"Invalid supported range for change ID {change_id!r}.")
5296
- family = "version" if command.result_kind == "version" else "patch"
5297
5295
  decision = version_decisions.get(change_id)
5298
5296
  step = _next_unconsumed_recorded_step()
5299
5297
  if decision is not None:
5300
- version, original_family = decision
5301
- if original_family != family:
5302
- raise _version_error(f"Change ID {change_id!r} cannot switch between version and patch helpers.", step)
5298
+ version = decision
5303
5299
  elif step is not None and step.shape == "version marker":
5304
5300
  _assert_step_matches(command, step)
5305
5301
  version = _recorded_version(step)
@@ -5326,7 +5322,7 @@ def _replay_state(
5326
5322
  f"Recorded version {version} for change ID {change_id!r} is outside "
5327
5323
  f"the supported range {command.min_supported}..{command.max_supported}.", step,
5328
5324
  )
5329
- version_decisions.setdefault(change_id, (version, family))
5325
+ version_decisions.setdefault(change_id, version)
5330
5326
  return _version_marker_result(command, version)
5331
5327
 
5332
5328
  def _parallel_leaf_kind(command: Any) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.5.9
3
+ Version: 2.5.11
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -175,6 +175,14 @@ for immutable context, operation policies, shielded cleanup and recovery.
175
175
 
176
176
  ## Development
177
177
 
178
+ The installed `durable-workflow-replay-conformance` command runs an offline
179
+ Python replay shard. Its `sdk-python` artifact pin must match the installed
180
+ distribution version reported by `importlib.metadata.version("durable-workflow")`.
181
+ The report records that installed identity separately from the declared platform
182
+ tuple and fails if metadata is missing or the SDK pin differs. Replay success
183
+ does not prove installation or execution of the other declared artifacts; the
184
+ aggregate runner supplies its own verified installation receipts.
185
+
178
186
  ```bash
179
187
  pip install -e '.[dev]'
180
188
  ruff check src/ tests/
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from itertools import permutations
3
4
  from typing import Any
4
5
 
5
6
  import pytest
@@ -74,6 +75,43 @@ class PatchCancellationWorkflow:
74
75
  return [decisions, "cancelled"]
75
76
 
76
77
 
78
+ @workflow.defn(name="tests.patch.helpers")
79
+ class MixedPatchHelpersWorkflow:
80
+ def run(self, ctx, helpers): # type: ignore[no-untyped-def]
81
+ decisions = []
82
+ for helper in helpers:
83
+ command = ctx.get_version("same", -1, 1) if helper == "get_version" else getattr(ctx, helper)("same")
84
+ decisions.append((yield command))
85
+ result = yield ctx.schedule_activity("old", [])
86
+ return [decisions, result]
87
+
88
+
89
+ @pytest.mark.parametrize("helpers", list(permutations(["get_version", "patched", "deprecate_patch"])))
90
+ @pytest.mark.parametrize("marker_count", [0, 1, 2])
91
+ @pytest.mark.parametrize("completed", [False, True])
92
+ def test_helpers_share_one_decision_on_old_history(
93
+ helpers: tuple[str, ...], marker_count: int, completed: bool,
94
+ ) -> None:
95
+ history = [version_marker(sequence) for sequence in range(1, marker_count + 1)]
96
+ history += old_activity(marker_count + 1, completed)
97
+ commands = replay(MixedPatchHelpersWorkflow, history, [helpers]).commands
98
+ if not completed:
99
+ assert commands == []
100
+ else:
101
+ assert len(commands) == 1
102
+ assert isinstance(commands[0], CompleteWorkflow)
103
+ version = 1 if marker_count else -1
104
+ values = {"get_version": version, "patched": version == 1, "deprecate_patch": None}
105
+ assert commands[0].result == [[values[helper] for helper in helpers], "recorded"]
106
+
107
+
108
+ @pytest.mark.parametrize("helpers", list(permutations(["get_version", "patched", "deprecate_patch"])))
109
+ def test_helpers_record_one_decision_on_fresh_history(helpers: tuple[str, ...]) -> None:
110
+ commands = replay(MixedPatchHelpersWorkflow, [], [helpers]).commands
111
+ assert [type(command) for command in commands] == [RecordVersionMarker, ScheduleActivity]
112
+ assert commands[0].version == 1
113
+
114
+
77
115
  @pytest.mark.parametrize("completed", [False, True])
78
116
  def test_inserted_patch_preserves_unmarked_activity(completed: bool) -> None:
79
117
  outcome = replay(PatchInsertionWorkflow, old_activity(1, completed), [])
@@ -122,7 +160,7 @@ def test_repeated_version_reuses_first_decision() -> None:
122
160
 
123
161
 
124
162
  @pytest.mark.parametrize("arguments", [[3], [1, True]])
125
- def test_repeated_version_rejects_incompatible_range_or_family(arguments: list[Any]) -> None:
163
+ def test_repeated_version_rejects_incompatible_range(arguments: list[Any]) -> None:
126
164
  with pytest.raises(NonDeterministicReplayError):
127
165
  replay(VersionRangeWorkflow, [], arguments)
128
166
 
@@ -134,6 +172,41 @@ def test_conflicting_duplicate_marker_is_rejected(version: Any) -> None:
134
172
  replay(RepeatedPatchWorkflow, history, [])
135
173
 
136
174
 
175
+ @pytest.mark.parametrize("sequence", [1, 2])
176
+ @pytest.mark.parametrize("bounds", [
177
+ {}, {"min_supported": -1}, {"max_supported": 1},
178
+ {"min_supported": True, "max_supported": 1},
179
+ {"min_supported": -1, "max_supported": "1"},
180
+ {"min_supported": 2, "max_supported": 1},
181
+ {"min_supported": -1, "max_supported": 0},
182
+ ])
183
+ def test_canonical_markers_require_valid_recorded_ranges(sequence: int, bounds: dict[str, Any]) -> None:
184
+ markers = [version_marker(1), version_marker(2)]
185
+ markers[sequence - 1]["payload"].pop("min_supported")
186
+ markers[sequence - 1]["payload"].pop("max_supported")
187
+ markers[sequence - 1]["payload"].update(bounds)
188
+ with pytest.raises(NonDeterministicReplayError, match="invalid supported range"):
189
+ replay(RepeatedPatchWorkflow, markers + old_activity(3), [])
190
+
191
+
192
+ @pytest.mark.parametrize("identity", [{}, {"change_id": None}, {"change_id": ""}, {"change_id": True}])
193
+ def test_canonical_marker_requires_a_change_id(identity: dict[str, Any]) -> None:
194
+ marker = version_marker(1)
195
+ marker["payload"].pop("change_id")
196
+ marker["payload"].update(identity)
197
+ with pytest.raises(NonDeterministicReplayError, match="non-empty change ID"):
198
+ replay(RepeatedPatchWorkflow, [marker] + old_activity(2), [])
199
+
200
+
201
+ def test_duplicate_ranges_may_differ_around_the_same_version() -> None:
202
+ marker = version_marker(2)
203
+ marker["payload"].update(min_supported=0, max_supported=3)
204
+ commands = replay(RepeatedPatchWorkflow, [version_marker(1), marker] + old_activity(3), []).commands
205
+ assert len(commands) == 1
206
+ assert isinstance(commands[0], CompleteWorkflow)
207
+ assert commands[0].result == [True, True, "recorded"]
208
+
209
+
137
210
  @pytest.mark.parametrize("marker_count", [0, 1, 2])
138
211
  def test_patch_decisions_preserve_committed_cancellation_sequence(marker_count: int) -> None:
139
212
  history = [version_marker(sequence) for sequence in range(1, marker_count + 1)]
@@ -1949,7 +1949,9 @@ class TestVersionMarker:
1949
1949
 
1950
1950
  def test_version_from_history(self) -> None:
1951
1951
  history = [
1952
- {"event_type": "VersionMarkerRecorded", "payload": {"version": 2}},
1952
+ {"event_type": "VersionMarkerRecorded", "payload": {
1953
+ "sequence": 1, "change_id": "change-1", "version": 2, "min_supported": 1, "max_supported": 2,
1954
+ }},
1953
1955
  ]
1954
1956
  outcome = replay(VersionWorkflow, history, [])
1955
1957
  assert len(outcome.commands) == 1
@@ -1959,7 +1961,9 @@ class TestVersionMarker:
1959
1961
 
1960
1962
  def test_old_version_from_history(self) -> None:
1961
1963
  history = [
1962
- {"event_type": "VersionMarkerRecorded", "payload": {"version": 1}},
1964
+ {"event_type": "VersionMarkerRecorded", "payload": {
1965
+ "sequence": 1, "change_id": "change-1", "version": 1, "min_supported": 1, "max_supported": 2,
1966
+ }},
1963
1967
  ]
1964
1968
  outcome = replay(VersionWorkflow, history, [])
1965
1969
  assert len(outcome.commands) == 1
@@ -1969,7 +1973,9 @@ class TestVersionMarker:
1969
1973
 
1970
1974
  def test_full_replay(self) -> None:
1971
1975
  history = [
1972
- {"event_type": "VersionMarkerRecorded", "payload": {"version": 2}},
1976
+ {"event_type": "VersionMarkerRecorded", "payload": {
1977
+ "sequence": 1, "change_id": "change-1", "version": 2, "min_supported": 1, "max_supported": 2,
1978
+ }},
1973
1979
  {"event_type": "ActivityCompleted", "payload": {"result": _avro("done")}},
1974
1980
  ]
1975
1981
  outcome = replay(VersionWorkflow, history, [])
@@ -2006,7 +2012,9 @@ class TestVersionMarker:
2006
2012
 
2007
2013
  def test_patched_uses_recorded_marker_for_existing_patched_runs(self) -> None:
2008
2014
  history = [
2009
- {"event_type": "VersionMarkerRecorded", "payload": {"version": 1}},
2015
+ {"event_type": "VersionMarkerRecorded", "payload": {
2016
+ "sequence": 1, "change_id": "patch-1", "version": 1, "min_supported": -1, "max_supported": 1,
2017
+ }},
2010
2018
  ]
2011
2019
 
2012
2020
  outcome = replay(PatchedWorkflow, history, [])
@@ -2017,7 +2025,9 @@ class TestVersionMarker:
2017
2025
 
2018
2026
  def test_patched_uses_legacy_default_for_existing_unpatched_runs(self) -> None:
2019
2027
  history = [
2020
- {"event_type": "VersionMarkerRecorded", "payload": {"version": -1}},
2028
+ {"event_type": "VersionMarkerRecorded", "payload": {
2029
+ "sequence": 1, "change_id": "patch-1", "version": -1, "min_supported": -1, "max_supported": 1,
2030
+ }},
2021
2031
  ]
2022
2032
 
2023
2033
  outcome = replay(PatchedWorkflow, history, [])
@@ -2040,7 +2050,9 @@ class TestVersionMarker:
2040
2050
 
2041
2051
  replayed = replay(
2042
2052
  DeprecatedPatchWorkflow,
2043
- [{"event_type": "VersionMarkerRecorded", "payload": {"version": 1}}],
2053
+ [{"event_type": "VersionMarkerRecorded", "payload": {
2054
+ "sequence": 1, "change_id": "patch-1", "version": 1, "min_supported": -1, "max_supported": 1,
2055
+ }}],
2044
2056
  [],
2045
2057
  )
2046
2058
  assert len(replayed.commands) == 1
@@ -1,9 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ from importlib import metadata
4
5
  from pathlib import Path
5
6
  from typing import Any
6
7
 
8
+ import pytest
9
+
7
10
  from durable_workflow.replay_conformance import (
8
11
  COVERAGE_SCOPE,
9
12
  RESULT_SCHEMA,
@@ -25,6 +28,11 @@ def artifact_versions() -> dict[str, str]:
25
28
  }
26
29
 
27
30
 
31
+ @pytest.fixture(autouse=True)
32
+ def installed_sdk_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
33
+ monkeypatch.setattr(metadata, "version", lambda package: "0.4.73")
34
+
35
+
28
36
  def artifact_sources() -> dict[str, str]:
29
37
  return {
30
38
  "server": "docker_image",
@@ -81,7 +89,7 @@ def test_compose_report_emits_python_replay_conformance_shard() -> None:
81
89
  assert indexed[scenario_id]["status"] == "pass"
82
90
 
83
91
 
84
- def test_report_proves_published_artifact_install_policy() -> None:
92
+ def test_report_validates_declared_tuple_without_claiming_installation_proof() -> None:
85
93
  report = compose_report(
86
94
  artifact_versions=artifact_versions(),
87
95
  artifact_sources=artifact_sources(),
@@ -90,13 +98,46 @@ def test_report_proves_published_artifact_install_policy() -> None:
90
98
  observed = published["observed_outputs"]
91
99
 
92
100
  assert published["status"] == "pass"
93
- assert observed["published_artifacts_only"] is True
94
- assert observed["published_install_tuple_proven"] is True
101
+ assert observed["published_artifacts_only"] is False
102
+ assert observed["published_install_tuple_proven"] is False
103
+ assert observed["declared_artifacts_published_only"] is True
104
+ assert observed["artifact_tuple_evidence"] == "declared"
105
+ assert observed["installed_sdk_version"] == "0.4.73"
106
+ assert observed["sdk_version_matches"] is True
107
+ assert observed["sdk_identity_error"] is None
108
+ assert report["implementation_identity"]["version"] == "0.4.73"
95
109
  assert observed["missing_artifacts"] == []
96
110
  assert observed["artifact_sources"]["server"] == "docker_image"
97
111
  assert observed["artifact_sources"]["sdk-python"] == "pypi_package"
98
112
 
99
113
 
114
+ def test_report_rejects_declared_sdk_version_mismatch(monkeypatch: pytest.MonkeyPatch) -> None:
115
+ monkeypatch.setattr(metadata, "version", lambda package: "2.5.10")
116
+ report = compose_report(artifact_versions=artifact_versions(), artifact_sources=artifact_sources())
117
+ install = scenarios(report)["published_artifact_install_only"]
118
+ assert report["outcome"] == "fail"
119
+ assert install["status"] == "fail"
120
+ assert install["observed_outputs"]["installed_sdk_version"] == "2.5.10"
121
+ assert install["observed_outputs"]["declared_sdk_version"] == "0.4.73"
122
+ assert install["observed_outputs"]["sdk_identity_error"] == "installed_sdk_version_mismatch"
123
+ assert install["observed_outputs"]["published_install_tuple_proven"] is False
124
+ assert all(scenario["implementation_identity"]["version"] == "2.5.10" for scenario in report["scenario_results"])
125
+ assert report["finding_links"]["published_artifact_install_only"]
126
+
127
+
128
+ def test_report_rejects_missing_installed_sdk_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
129
+ def missing_metadata(package: str) -> str:
130
+ raise metadata.PackageNotFoundError(package)
131
+
132
+ monkeypatch.setattr(metadata, "version", missing_metadata)
133
+ report = compose_report(artifact_versions=artifact_versions(), artifact_sources=artifact_sources())
134
+ install = scenarios(report)["published_artifact_install_only"]
135
+ assert report["outcome"] == "fail"
136
+ assert install["observed_outputs"]["sdk_identity_error"] == "installed_sdk_metadata_missing"
137
+ assert install["implementation_identity"]["version"] is None
138
+ assert install["observed_outputs"]["published_install_tuple_proven"] is False
139
+
140
+
100
141
  def test_report_rejects_missing_versions_and_sources() -> None:
101
142
  report = compose_report(
102
143
  artifact_versions={"sdk-python": "0.4.73"},
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  from collections.abc import Mapping, Sequence
5
+ from importlib import metadata
5
6
  from pathlib import Path
6
7
  from typing import Any
7
8
  from unittest.mock import AsyncMock
@@ -11,6 +12,7 @@ import pytest
11
12
  from durable_workflow import Replayer, Worker, serializer, workflow
12
13
  from durable_workflow.client import PROTOCOL_VERSION, Client, WorkflowExecution, WorkflowStreamAppendItem
13
14
  from durable_workflow.errors import NonDeterministicReplayError, WorkflowCancelled, WorkflowPayloadDecodeError
15
+ from durable_workflow.replay_conformance import compose_report
14
16
  from durable_workflow.workflow import (
15
17
  LocalActivityExecutionAborted,
16
18
  WorkflowContext,
@@ -25,7 +27,12 @@ from tests.test_golden_history_replay import (
25
27
  GoldenTimeoutWaitWorkflow,
26
28
  GoldenVersionMarkerWorkflow,
27
29
  )
28
- from tests.test_patch_history import PatchCancellationWorkflow, PatchInsertionWorkflow, RepeatedPatchWorkflow
30
+ from tests.test_patch_history import (
31
+ MixedPatchHelpersWorkflow,
32
+ PatchCancellationWorkflow,
33
+ PatchInsertionWorkflow,
34
+ RepeatedPatchWorkflow,
35
+ )
29
36
  from tests.test_pending_operation_replay import PendingOperation
30
37
  from tests.test_update_signal_condition_replay import (
31
38
  PostConditionReceiversWorkflow,
@@ -290,6 +297,7 @@ WORKFLOWS = [
290
297
  ParallelResultBindingWorkflow,
291
298
  PatchCancellationWorkflow,
292
299
  PatchInsertionWorkflow,
300
+ MixedPatchHelpersWorkflow,
293
301
  RepeatedPatchWorkflow,
294
302
  PendingOperation,
295
303
  PagedRecordedSideEffectsWorkflow,
@@ -365,6 +373,27 @@ def _execute_fixture(fixture: dict[str, Any]) -> list[dict[str, Any]]:
365
373
  assert isinstance(workflow, dict)
366
374
  workflow_type = workflow.get("type")
367
375
  assert isinstance(workflow_type, str) and workflow_type
376
+ if workflow_type == "sdk.replay-conformance-report":
377
+ # This public command executes the SDK's offline replay scenarios. Its
378
+ # regression input is artifact declarations, not an invented history.
379
+ assert "history" not in fixture
380
+ inputs = workflow.get("input")
381
+ assert isinstance(inputs, list) and len(inputs) == 1 and isinstance(inputs[0], dict)
382
+ report = compose_report(**inputs[0])
383
+ install = next(scenario for scenario in report["scenario_results"]
384
+ if scenario["scenario_id"] == "published_artifact_install_only")
385
+ observed = install["observed_outputs"]
386
+ commands = [{
387
+ "type": "replay_conformance_report",
388
+ "outcome": report["outcome"],
389
+ "sdk_identity_error": observed.get("sdk_identity_error"),
390
+ "declared_sdk_version": observed.get("declared_sdk_version"),
391
+ "published_install_tuple_proven": observed["published_install_tuple_proven"],
392
+ }]
393
+ _assert_matches(fixture["command_sequence"], commands, f"{fixture['id']}.command_sequence")
394
+ _assert_matches(fixture["expected"], {"command_sequence": commands}, f"{fixture['id']}.expected")
395
+ assert report["implementation_identity"]["version"] == metadata.version("durable-workflow")
396
+ return commands
368
397
  assert workflow_type in WORKFLOW_TYPES, (
369
398
  f"replay fixture workflow {workflow_type!r} has no Python implementation; "
370
399
  "register its reproducer workflow in WORKFLOWS"
@@ -456,6 +485,9 @@ def test_checked_in_replay_regression_corpus_uses_official_replayer(
456
485
  assert isinstance(workflow, dict)
457
486
  expected = fixture.get("expected")
458
487
  assert isinstance(expected, dict) and expected
488
+ if workflow.get("type") == "sdk.replay-conformance-report":
489
+ _execute_fixture(fixture)
490
+ return
459
491
  expected_error = expected.get("error")
460
492
  expected_replay_error = fixture.get("expected_replay_error")
461
493
  if isinstance(expected_replay_error, dict):