Skip to content

Commit d9ab083

Browse files
[cross-repo from server#312] Conformance blocker: expand replay coverage beyond current smoke (#632)
1 parent 9e2f7de commit d9ab083

8 files changed

Lines changed: 372 additions & 29 deletions

File tree

docs/architecture/platform-conformance-suite.md

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ The machine-readable mirror of this document is
1717
`Workflow\V2\Support\PlatformConformanceSuite`, exported by the
1818
standalone `workflow-server` from `GET /api/cluster/info` under
1919
`platform_conformance_suite`. Schema:
20-
`durable-workflow.v2.platform-conformance.suite`, version `2`.
20+
`durable-workflow.v2.platform-conformance.suite`, version `3`.
2121

2222
## Why one suite
2323

@@ -73,7 +73,7 @@ them from the declared locations.
7373
| `control_plane_request_response` | `cli`, `sdk-python` | `tests/fixtures/control-plane/` | Frozen request bodies and response shapes for `workflow.start`, `signal`, `query`, `update`, `cancel`, `task-history`, namespace storage. |
7474
| `worker_task_lifecycle` | `cli`, `sdk-python`, `server` | `tests/fixtures/external-task-input/`, `tests/fixtures/external-task-result/` | Task input envelopes (poll → claim → run) and task result envelopes (complete, fail, cancel, heartbeat) used by every conforming worker. |
7575
| `signal_query_runtime_contract` | `workflow`, `server`, `cli`, `sdk-python`, `waterline` | `docs/architecture/platform-conformance-suite.md`, `docs/architecture/query-and-live-debug.md`, `src/V2/Client/ControlPlaneClient.php`, `tests/Unit/V2/ControlPlaneClientTest.php`, `tests/Feature/SignalReplayTest.php`, `tests/Feature/V2/V2QueryWorkflowTest.php`, `tests/Feature/WorkflowControlPlaneTest.php`, `tests/Feature/WorkflowQueryTaskBrokerTest.php`, `tests/Commands/`, `tests/test_signals.py`, `tests/test_queries.py`, `tests/test_worker.py`, `CONFORMANCE.md` | Live published-artifact scenarios for signal delivery and query consistency across PHP and Python workers, CLI and SDK clients, replay timing, terminal runs, malformed payloads, and operator visibility. |
76-
| `history_replay_bundles` | `workflow`, `sdk-python` | `tests/Fixtures/V2/GoldenHistory/`, `tests/fixtures/golden_history/` | Frozen history event bundles. A conforming SDK must replay each bundle and reproduce the documented final command sequence. |
76+
| `history_replay_bundles` | `durable-workflow.github.io`, `workflow`, `sdk-python` | `static/platform-conformance/replay-runtime-scenarios.json`, `tests/Fixtures/V2/GoldenHistory/`, `tests/fixtures/golden_history/` | Deterministic replay coverage for frozen history bundles, worker restart replay, adversarial refusal, and in-flight signal timing across the official PHP and Python runtimes. |
7777
| `failure_repair_actionability` | `server`, `workflow` | `docs/contracts/external-task-result.md`, `docs/contracts/replay-verification.md`, fixture pointers therein | Failure objects and repair / actionability shapes for stuck tasks, deterministic failure, and replay-mismatch surfaces. |
7878
| `cli_json_envelopes` | `cli` | `tests/fixtures/control-plane/`, `schemas/` | The `--output=json` and `--output=jsonl` envelopes that automation depends on. Diagnostic-only fields are listed and excluded from the contract diff. |
7979
| `waterline_observer_envelopes` | `waterline` | (TBD: `tests/fixtures/observer/`) | The `/waterline/api/v2/*` shapes and operator dashboard JSON envelopes. Status: provisional — fixtures land alongside the next Waterline contract slice. |
@@ -127,6 +127,31 @@ Required scenarios:
127127
stable reason when live query values are intentionally not materialized
128128
in read-only detail responses.
129129

130+
### History replay runtime contract
131+
132+
The `history_replay_bundles` category is also stable and load-bearing.
133+
It must run against published install channels only, pin the resolved
134+
artifact versions in the result, and name every required replay scenario
135+
as passed, failed, or unsupported with a linked finding. A smoke-only
136+
run is nonconforming even when the smoke path passes.
137+
138+
Required scenarios are published in the public replay scenario manifest
139+
at `static/platform-conformance/replay-runtime-scenarios.json` and
140+
include:
141+
142+
- published-artifact install-only evidence for server, CLI, PHP runtime,
143+
and Python SDK;
144+
- PHP and Python completed-history replay for activity, signal/update,
145+
wait-condition, version-marker, and saga-compensation families;
146+
- PHP and Python worker-restart replay for completed-query, activity,
147+
signal/update, wait-condition, version-marker, and saga-compensation
148+
state;
149+
- PHP and Python divergent-code refusal with actionable
150+
non-determinism diagnostics;
151+
- server history mutation and malformed-history refusal through the
152+
documented replay verification surface;
153+
- PHP and Python in-flight signal restart timing.
154+
130155
## Pass / fail rules
131156

132157
The harness runs each fixture against the implementation under test and
@@ -154,10 +179,11 @@ emits a structured result. The rules below are normative.
154179
release does not conform.
155180

156181
5. **Stable runtime scenario coverage.** A stable runtime category such
157-
as `signal_query_runtime_contract` passes only when every scenario it
158-
declares records a pass, fail, or unsupported result with resolved
159-
artifact versions and linked findings. A smoke-only subset or omitted
160-
scenario is nonconforming, not provisional.
182+
as `signal_query_runtime_contract` or `history_replay_bundles` passes
183+
only when every scenario it declares records a pass, fail, or
184+
unsupported result with resolved artifact versions and linked
185+
findings. A smoke-only subset or omitted scenario is nonconforming,
186+
not provisional.
161187

162188
6. **Provisional categories warn but do not fail.** A failed fixture in
163189
a provisional category emits a warning in the harness output. A
@@ -276,10 +302,11 @@ docs. It indexes them under one normative declaration so a single
276302
`sdk-python/tests/fixtures/control-plane/` are the existing parity
277303
fixtures. The suite cites them as the
278304
`control_plane_request_response` source-of-truth.
279-
- `tests/Fixtures/V2/GoldenHistory/` (this repo) and
280-
`sdk-python/tests/fixtures/golden_history/` are the existing replay
281-
bundles. The suite cites them as the `history_replay_bundles`
282-
source-of-truth.
305+
- `static/platform-conformance/replay-runtime-scenarios.json`,
306+
`tests/Fixtures/V2/GoldenHistory/` (this repo), and
307+
`sdk-python/tests/fixtures/golden_history/` are the replay scenario and
308+
bundle authorities. The suite cites them as the
309+
`history_replay_bundles` source-of-truth.
283310

284311
## Changing this document
285312

src/V2/Exceptions/HistoryEventShapeMismatchException.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,18 @@ public function __construct(
1515
public readonly int $workflowSequence,
1616
public readonly string $expectedHistoryShape,
1717
public readonly array $recordedEventTypes,
18+
?string $detail = null,
1819
) {
20+
$detail = is_string($detail) && $detail !== ''
21+
? " {$detail}"
22+
: '';
23+
1924
parent::__construct(sprintf(
20-
'Workflow history at workflow sequence %d recorded [%s], but the current workflow yielded %s. Keep yielded workflow steps stable across deployments or run this workflow on a compatible build.',
25+
'Workflow history at workflow sequence %d recorded [%s], but the current workflow yielded %s.%s Keep yielded workflow steps stable across deployments or run this workflow on a compatible build.',
2126
$workflowSequence,
2227
implode(', ', $recordedEventTypes),
2328
$expectedHistoryShape,
29+
$detail,
2430
));
2531
}
2632
}

src/V2/Support/PlatformConformanceSuite.php

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ final class PlatformConformanceSuite
2929
{
3030
public const SCHEMA = 'durable-workflow.v2.platform-conformance.suite';
3131

32-
public const VERSION = 2;
32+
public const VERSION = 3;
3333

3434
public const RESULT_SCHEMA = 'durable-workflow.v2.platform-conformance.result';
3535

@@ -302,8 +302,12 @@ private static function fixtureCatalog(): array
302302
],
303303
'history_replay_bundles' => [
304304
'status' => self::CATEGORY_STATUS_STABLE,
305-
'description' => 'Frozen history event bundles. A conforming SDK must replay each bundle and reproduce the documented final command sequence.',
305+
'description' => 'Deterministic replay coverage for frozen history bundles, worker restart replay, adversarial refusal, and in-flight signal timing across the official PHP and Python runtimes.',
306306
'sources' => [
307+
[
308+
'repository' => 'durable-workflow.github.io',
309+
'path' => 'static/platform-conformance/replay-runtime-scenarios.json',
310+
],
307311
[
308312
'repository' => 'workflow',
309313
'path' => 'tests/Fixtures/V2/GoldenHistory/',
@@ -313,7 +317,8 @@ private static function fixtureCatalog(): array
313317
'path' => 'tests/fixtures/golden_history/',
314318
],
315319
],
316-
'authority_doc' => 'https://github.com/durable-workflow/workflow/blob/v2/docs/api-stability.md',
320+
'authority_doc' => 'https://durable-workflow.github.io/docs/2.0/platform-conformance, https://github.com/durable-workflow/server/blob/main/docs/contracts/replay-verification.md, https://github.com/durable-workflow/workflow/blob/v2/docs/api-stability.md',
321+
'required_scenarios' => self::replayRequiredScenarios(),
317322
],
318323
'failure_repair_actionability' => [
319324
'status' => self::CATEGORY_STATUS_STABLE,
@@ -400,7 +405,10 @@ private static function passFailRules(): array
400405
],
401406
'stable_runtime_scenario_coverage' => [
402407
'rule' => 'A stable runtime fixture category passes only when every required scenario it declares records a pass, fail, or unsupported result with artifact versions and linked findings. A smoke-only subset or omitted scenario is nonconforming, not provisional.',
403-
'applies_to_categories' => ['signal_query_runtime_contract'],
408+
'applies_to_categories' => [
409+
'signal_query_runtime_contract',
410+
'history_replay_bundles',
411+
],
404412
],
405413
'provisional_categories_warn_only' => [
406414
'rule' => 'A failed fixture in a provisional category emits a warning in the harness output and does not block the release. The category becomes load-bearing when promoted to stable in a later suite version.',
@@ -411,6 +419,44 @@ private static function passFailRules(): array
411419
];
412420
}
413421

422+
/**
423+
* @return list<string>
424+
*/
425+
private static function replayRequiredScenarios(): array
426+
{
427+
return [
428+
'published_artifact_install_only',
429+
'python_completed_history_activity_replay',
430+
'python_completed_history_signal_update_replay',
431+
'python_completed_history_wait_condition_replay',
432+
'python_completed_history_version_marker_replay',
433+
'python_completed_history_saga_compensation_replay',
434+
'php_completed_history_activity_replay',
435+
'php_completed_history_signal_update_replay',
436+
'php_completed_history_wait_condition_replay',
437+
'php_completed_history_version_marker_replay',
438+
'php_completed_history_saga_compensation_replay',
439+
'python_worker_restart_completed_query',
440+
'python_worker_restart_activity_state',
441+
'python_worker_restart_signal_update_state',
442+
'python_worker_restart_wait_condition_state',
443+
'python_worker_restart_version_marker_state',
444+
'python_worker_restart_saga_compensation_state',
445+
'php_worker_restart_completed_query',
446+
'php_worker_restart_activity_state',
447+
'php_worker_restart_signal_update_state',
448+
'php_worker_restart_wait_condition_state',
449+
'php_worker_restart_version_marker_state',
450+
'php_worker_restart_saga_compensation_state',
451+
'python_code_divergence_refusal',
452+
'php_code_divergence_refusal',
453+
'server_history_mutation_refusal',
454+
'malformed_history_refusal',
455+
'python_in_flight_signal_restart_timing',
456+
'php_in_flight_signal_restart_timing',
457+
];
458+
}
459+
414460
/**
415461
* @return array<string, mixed>
416462
*/

src/V2/Support/QueryStateReplayer.php

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ public function replayState(WorkflowRun $run): ReplayState
7676
}
7777

7878
if ($current instanceof LocalActivityCall) {
79-
WorkflowStepHistory::assertCompatible($run, $sequence, WorkflowStepHistory::LOCAL_ACTIVITY);
79+
WorkflowStepHistory::assertCompatible($run, $sequence, WorkflowStepHistory::LOCAL_ACTIVITY, [
80+
'activity_type' => $current->activity,
81+
]);
8082

8183
$activityCompletion = $this->activityCompletionEvent($run, $sequence);
8284

@@ -113,7 +115,9 @@ public function replayState(WorkflowRun $run): ReplayState
113115
}
114116

115117
if ($current instanceof ActivityCall) {
116-
WorkflowStepHistory::assertCompatible($run, $sequence, WorkflowStepHistory::ACTIVITY);
118+
WorkflowStepHistory::assertCompatible($run, $sequence, WorkflowStepHistory::ACTIVITY, [
119+
'activity_type' => $current->activity,
120+
]);
117121

118122
$activityCompletion = $this->activityCompletionEvent($run, $sequence);
119123

@@ -268,7 +272,9 @@ public function replayState(WorkflowRun $run): ReplayState
268272

269273
if ($current instanceof VersionCall) {
270274
$this->applyRecordedUpdates($run, $workflow, $sequence);
271-
WorkflowStepHistory::assertCompatible($run, $sequence, WorkflowStepHistory::VERSION_MARKER);
275+
WorkflowStepHistory::assertCompatible($run, $sequence, WorkflowStepHistory::VERSION_MARKER, [
276+
'change_id' => $current->changeId,
277+
]);
272278

273279
$versionEvent = $this->versionMarkerEvent($run, $sequence);
274280
$resolution = VersionResolver::resolve($run, $versionEvent, $current, $sequence);
@@ -307,7 +313,9 @@ public function replayState(WorkflowRun $run): ReplayState
307313

308314
if ($current instanceof SignalCall) {
309315
$this->applyRecordedUpdates($run, $workflow, $sequence);
310-
WorkflowStepHistory::assertCompatible($run, $sequence, WorkflowStepHistory::SIGNAL_WAIT);
316+
WorkflowStepHistory::assertCompatible($run, $sequence, WorkflowStepHistory::SIGNAL_WAIT, [
317+
'signal_name' => $current->name,
318+
]);
311319

312320
$signalEvent = $this->appliedSignalEvent($run, $sequence, $current);
313321

@@ -342,7 +350,9 @@ public function replayState(WorkflowRun $run): ReplayState
342350

343351
if ($current instanceof ChildWorkflowCall) {
344352
$this->applyRecordedUpdates($run, $workflow, $sequence);
345-
WorkflowStepHistory::assertCompatible($run, $sequence, WorkflowStepHistory::CHILD_WORKFLOW);
353+
WorkflowStepHistory::assertCompatible($run, $sequence, WorkflowStepHistory::CHILD_WORKFLOW, [
354+
'child_workflow_type' => $current->workflow,
355+
]);
346356

347357
$resolutionEvent = ChildRunHistory::resolutionEventForSequence($run, $sequence);
348358
$childRun = ChildRunHistory::childRunForSequence($run, $sequence);
@@ -453,6 +463,9 @@ public function replayState(WorkflowRun $run): ReplayState
453463
$call instanceof ActivityCall
454464
? WorkflowStepHistory::ACTIVITY
455465
: WorkflowStepHistory::CHILD_WORKFLOW,
466+
$call instanceof ActivityCall
467+
? ['activity_type' => $call->activity]
468+
: ['child_workflow_type' => $call->workflow],
456469
);
457470

458471
if ($call instanceof ActivityCall) {

src/V2/Support/WorkflowExecutor.php

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,13 @@ public function run(WorkflowRun $run, WorkflowTask $task): ?WorkflowTask
137137
return $this->restartAfterPendingUpdateFailure($run, $task);
138138
}
139139

140-
if (! $this->ensureStepHistoryCompatible($run, $task, $sequence, WorkflowStepHistory::LOCAL_ACTIVITY)) {
140+
if (! $this->ensureStepHistoryCompatible(
141+
$run,
142+
$task,
143+
$sequence,
144+
WorkflowStepHistory::LOCAL_ACTIVITY,
145+
['activity_type' => $current->activity],
146+
)) {
141147
return null;
142148
}
143149

@@ -211,7 +217,13 @@ public function run(WorkflowRun $run, WorkflowTask $task): ?WorkflowTask
211217
return $this->restartAfterPendingUpdateFailure($run, $task);
212218
}
213219

214-
if (! $this->ensureStepHistoryCompatible($run, $task, $sequence, WorkflowStepHistory::ACTIVITY)) {
220+
if (! $this->ensureStepHistoryCompatible(
221+
$run,
222+
$task,
223+
$sequence,
224+
WorkflowStepHistory::ACTIVITY,
225+
['activity_type' => $current->activity],
226+
)) {
215227
return null;
216228
}
217229

@@ -548,7 +560,13 @@ public function run(WorkflowRun $run, WorkflowTask $task): ?WorkflowTask
548560
// legacy replays that have since produced ACTIVITY/TIMER
549561
// events at the same sequence.
550562
if ($resolution->advancesSequence
551-
&& ! $this->ensureStepHistoryCompatible($run, $task, $sequence, WorkflowStepHistory::VERSION_MARKER)
563+
&& ! $this->ensureStepHistoryCompatible(
564+
$run,
565+
$task,
566+
$sequence,
567+
WorkflowStepHistory::VERSION_MARKER,
568+
['change_id' => $current->changeId],
569+
)
552570
) {
553571
return null;
554572
}
@@ -734,7 +752,13 @@ public function run(WorkflowRun $run, WorkflowTask $task): ?WorkflowTask
734752
return $this->restartAfterPendingUpdateFailure($run, $task);
735753
}
736754

737-
if (! $this->ensureStepHistoryCompatible($run, $task, $sequence, WorkflowStepHistory::SIGNAL_WAIT)) {
755+
if (! $this->ensureStepHistoryCompatible(
756+
$run,
757+
$task,
758+
$sequence,
759+
WorkflowStepHistory::SIGNAL_WAIT,
760+
['signal_name' => $current->name],
761+
)) {
738762
return null;
739763
}
740764

@@ -884,7 +908,13 @@ public function run(WorkflowRun $run, WorkflowTask $task): ?WorkflowTask
884908
return $this->restartAfterPendingUpdateFailure($run, $task);
885909
}
886910

887-
if (! $this->ensureStepHistoryCompatible($run, $task, $sequence, WorkflowStepHistory::CHILD_WORKFLOW)) {
911+
if (! $this->ensureStepHistoryCompatible(
912+
$run,
913+
$task,
914+
$sequence,
915+
WorkflowStepHistory::CHILD_WORKFLOW,
916+
['child_workflow_type' => $current->workflow],
917+
)) {
888918
return null;
889919
}
890920

@@ -1036,6 +1066,9 @@ public function run(WorkflowRun $run, WorkflowTask $task): ?WorkflowTask
10361066
$call instanceof ActivityCall
10371067
? WorkflowStepHistory::ACTIVITY
10381068
: WorkflowStepHistory::CHILD_WORKFLOW,
1069+
$call instanceof ActivityCall
1070+
? ['activity_type' => $call->activity]
1071+
: ['child_workflow_type' => $call->workflow],
10391072
)) {
10401073
return null;
10411074
}
@@ -3107,9 +3140,10 @@ private function ensureStepHistoryCompatible(
31073140
WorkflowTask $task,
31083141
int $sequence,
31093142
string $expectedShape,
3143+
array $expectedDetails = [],
31103144
): bool {
31113145
try {
3112-
WorkflowStepHistory::assertCompatible($run, $sequence, $expectedShape);
3146+
WorkflowStepHistory::assertCompatible($run, $sequence, $expectedShape, $expectedDetails);
31133147

31143148
return true;
31153149
} catch (Throwable $throwable) {

0 commit comments

Comments
 (0)