Skip to content

Commit 19ba333

Browse files
authored
Merge pull request #768 from durable-workflow/fix/744-recorded-version-bounds-stable
Backport recorded version marker validation to stable
2 parents 19a7a1c + 5d37bd1 commit 19ba333

11 files changed

Lines changed: 230 additions & 1 deletion

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
## 2.5.13 - 2026-10-13
4+
5+
- Reject version markers with missing or non-integer canonical bounds,
6+
inverted recorded ranges or a recorded version outside those bounds.
7+
Validate retained duplicates before replay can skip them.
8+
- Preserve one numeric decision per change ID across version, patch and
9+
deprecation helpers. Valid historical duplicates retain their physical
10+
positions and clocks, including when their recorded ranges differ.
11+
- This compatible backport starts from 2.5.12 and does not include later
12+
main/Nexus changes. Marker and command wire shapes are unchanged.
13+
314
## 2.5.12 - 2026-10-11
415

516
- Enforce a Nexus service call's original deadline during admission, background

‎composer.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@
7979
"dev-main": "2.0.x-dev"
8080
},
8181
"durable-workflow": {
82-
"product-train": "2.5.12",
82+
"product-train": "2.5.13",
8383
"laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json",
8484
"laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json"
8585
},

‎docs/api-stability.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,15 @@ site before the patch marker existed.
633633

634634
**Evolution rules:**
635635

636+
The numeric version is frozen once per change ID across all three helpers.
637+
Each invocation validates its own supported range and returns its integer,
638+
boolean or void projection. Consistent historical duplicate markers keep
639+
their physical positions and recorded clocks. Their bounds may differ from
640+
each other and from current code, provided each recorded range contains the
641+
same frozen version. Canonical markers require both integer bounds;
642+
inverted ranges, versions outside those bounds and malformed fields are
643+
invalid history rather than supported version decisions.
644+
636645
An existing recorded version, or the legacy default for a pre-marker run,
637646
outside the current authored supported range is a replay compatibility fault.
638647
The embedded engine blocks the affected workflow task with

‎scripts/ci/test-regression-corpus-policy.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,6 +1532,38 @@ def test_expected_malformed_service_response_failure_grows_replay_corpus(self) -
15321532
self.assertEqual(1, counts["new_fixture_evidence"])
15331533
self.assertEqual(1, counts["counterfactual_fixture_paths"])
15341534

1535+
def test_invalid_recorded_version_range_is_distinct_replay_evidence(self) -> None:
1536+
fixture = self.official_history_replay_fixture()
1537+
fixture["id"] = "invalid-recorded-version-range-requires-change"
1538+
fixture["history"][1]["event_type"] = "VersionMarkerRecorded"
1539+
fixture["history"][1]["payload"] = {
1540+
"sequence": 7,
1541+
"change_id": "same",
1542+
"version": 1,
1543+
"min_supported": 2,
1544+
"max_supported": 5,
1545+
}
1546+
del fixture["expected"]
1547+
fixture["expected_failure"] = {
1548+
"type": "version_marker_history_range_invalid",
1549+
"exception": "LogicException",
1550+
}
1551+
self.write_json(
1552+
"tests/Fixtures/V2/ReplayRegression/invalid-recorded-version-range.json",
1553+
fixture,
1554+
)
1555+
(self.root / "src/V2/Support/WorkflowFiberRunner.php").write_text(
1556+
"<?php\nreturn 'changed';\n",
1557+
encoding="utf-8",
1558+
)
1559+
1560+
result = self.validate()
1561+
1562+
self.assertEqual(0, result.returncode, result.stderr)
1563+
counts = json.loads(result.stdout)["counts"]["replay"]
1564+
self.assertEqual(1, counts["new_fixture_evidence"])
1565+
self.assertEqual(1, counts["counterfactual_fixture_paths"])
1566+
15351567
def test_malformed_payload_envelopes_are_rejected(self) -> None:
15361568
base = self.official_history_replay_fixture()
15371569
self.write_json(

‎scripts/ci/validate-regression-corpus.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
SEARCH_ATTRIBUTE_TYPE_IDENTITY_MISMATCH = "search_attribute_type_identity_mismatch"
3232
WORKFLOW_HISTORY_SHAPE_MISMATCH = "workflow_history_shape_mismatch"
3333
VERSION_MARKER_INCOMPATIBLE_RANGE = "version_marker_incompatible_range"
34+
VERSION_MARKER_HISTORY_RANGE_INVALID = "version_marker_history_range_invalid"
3435
UNSUPPORTED_PAYLOAD_CODEC = "unsupported_payload_codec"
3536
PHP_GOLDEN_REPLAY_WORKFLOW = "Tests\\Fixtures\\V2\\TestGoldenReplayWorkflow"
3637
PHP_REPLAY_CONSUMERS = {
@@ -1278,6 +1279,7 @@ def _replay_expected_failure(value: Any, context: str) -> Mapping[str, str]:
12781279
SEARCH_ATTRIBUTE_TYPE_IDENTITY_MISMATCH,
12791280
WORKFLOW_HISTORY_SHAPE_MISMATCH,
12801281
VERSION_MARKER_INCOMPATIBLE_RANGE,
1282+
VERSION_MARKER_HISTORY_RANGE_INVALID,
12811283
UNSUPPORTED_PAYLOAD_CODEC,
12821284
}:
12831285
raise CorpusError(f"{context}.type is unsupported")

‎src/V2/Support/VersionDecisions.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public function replaceMarkers(WorkflowRun|array $history): void
5151
if (! is_string($changeId) || $changeId === '' || ! is_int($version)) {
5252
throw new LogicException('Workflow version markers require a change ID and integer version.');
5353
}
54+
VersionResolver::assertRecordedRange($event, $sequence);
5455
$original = $this->decisions[$changeId]['version'] ?? $versions[$changeId] ?? $version;
5556
if ($version !== $original) {
5657
throw new LogicException(sprintf(

‎src/V2/Support/VersionResolver.php‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,29 @@
1212

1313
final class VersionResolver
1414
{
15+
public static function assertRecordedRange(WorkflowHistoryEvent $event, int $sequence): void
16+
{
17+
$version = $event->payload['version'] ?? null;
18+
$minSupported = $event->payload['min_supported'] ?? null;
19+
$maxSupported = $event->payload['max_supported'] ?? null;
20+
if (! is_int($version) || ! is_int($minSupported) || ! is_int($maxSupported)) {
21+
throw new LogicException(sprintf(
22+
'Workflow version marker at workflow sequence [%d] requires integer version, min_supported and max_supported.',
23+
$sequence,
24+
));
25+
}
26+
27+
if ($minSupported > $maxSupported || $version < $minSupported || $version > $maxSupported) {
28+
throw new LogicException(sprintf(
29+
'Workflow version marker at workflow sequence [%d] has invalid recorded range [%d, %d] for version [%d].',
30+
$sequence,
31+
$minSupported,
32+
$maxSupported,
33+
$version,
34+
));
35+
}
36+
}
37+
1538
public static function reuse(
1639
int $version,
1740
VersionCall $versionCall,
@@ -75,6 +98,7 @@ private static function recordedVersion(
7598
));
7699
}
77100

101+
self::assertRecordedRange($event, $sequence);
78102
self::assertSupported($version, $versionCall, $sequence);
79103

80104
return $version;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"$schema": "https://raw.githubusercontent.com/durable-workflow/.github/main/regression-corpus/evidence-schema.json",
3+
"fixture_schema": "durable-workflow.replay-regression/v1",
4+
"id": "version-marker-invalid-recorded-duplicate-range",
5+
"protocol_version": "1.19",
6+
"bindings": ["php"],
7+
"consumers": ["workflow-fiber-runner", "query-state-replayer", "workflow-executor"],
8+
"workflow": {"type": "Tests\\Fixtures\\V2\\TestSinglePatchReplayWorkflow", "arguments": [], "payload_codec": "avro"},
9+
"history": [
10+
{"sequence": 1, "event_type": "WorkflowStarted", "payload": {}, "recorded_at": "2026-10-13T01:00:00+00:00"},
11+
{"sequence": 2, "event_type": "VersionMarkerRecorded", "payload": {"sequence": 1, "change_id": "same", "version": 1, "min_supported": -1, "max_supported": 1}, "recorded_at": "2026-10-13T01:00:01+00:00"},
12+
{"sequence": 3, "event_type": "VersionMarkerRecorded", "payload": {"sequence": 2, "change_id": "same", "version": 1, "min_supported": 2, "max_supported": 5}, "recorded_at": "2026-10-13T01:00:02+00:00"}
13+
],
14+
"expected_failure": {"type": "version_marker_history_range_invalid", "exception": "LogicException"}
15+
}

‎tests/Unit/V2/LegacyVersionOfflineReplayTest.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ public function testFallbackDoesNotHideMalformedMarkersOrStepDrift(string $case)
109109
'sequence' => 1,
110110
'change_id' => 'legacy-upgrade',
111111
'version' => 1,
112+
'min_supported' => -1,
113+
'max_supported' => 2,
112114
];
113115
if ($case === 'missing-change-id') {
114116
unset($payload['change_id']);

‎tests/Unit/V2/VersionDecisionsTest.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ private function marker(int $sequence, int $version): WorkflowHistoryEvent
8686
'sequence' => $sequence,
8787
'change_id' => 'same',
8888
'version' => $version,
89+
'min_supported' => -1,
90+
'max_supported' => 2,
8991
],
9092
]);
9193
}

0 commit comments

Comments
 (0)