From 1ffac4c5088eab4cf552c081edd1dced14dc0779 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:10:23 -0600 Subject: [PATCH 1/4] Tighten root cancellation successor evidence boundaries --- tests/RedriveSuccessorEvidenceTest.php | 4 +- tests/RedriveSuccessorStrictnessTest.php | 254 ++++++++++++++++++++++ tests/Support/RedriveSuccessorHistory.php | 94 +++++++- 3 files changed, 344 insertions(+), 8 deletions(-) create mode 100644 tests/RedriveSuccessorStrictnessTest.php diff --git a/tests/RedriveSuccessorEvidenceTest.php b/tests/RedriveSuccessorEvidenceTest.php index 7189bd74..94db4f0b 100644 --- a/tests/RedriveSuccessorEvidenceTest.php +++ b/tests/RedriveSuccessorEvidenceTest.php @@ -508,12 +508,12 @@ public static function invalidSuccessors(): array return $cases; } - public function testADeliveryToTheNextUnscheduledStepIsValid(): void + public function testADeliveryToTheNextUnscheduledStepNeedsThePriorActivityToResolve(): void { $fixture = RedriveFixtureRegistry::fixture(self::CANCELLATION); $fixture['history'][6]['payload']['sequence'] = 4; - self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::CANCELLATION])); + self::assertStringContainsString('earlier call is unresolved', (string) RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::CANCELLATION])); } /** @return array, string}> */ diff --git a/tests/RedriveSuccessorStrictnessTest.php b/tests/RedriveSuccessorStrictnessTest.php new file mode 100644 index 00000000..c22025a1 --- /dev/null +++ b/tests/RedriveSuccessorStrictnessTest.php @@ -0,0 +1,254 @@ +): array, string}> */ + public static function invalidBoundaries(): array + { + $cases = []; + $cases['root parent must be present'] = [static function (array $fixture): array { + unset($fixture['history'][5]['payload']['cancellation']['parent_request_id']); + $fixture['history'][6]['payload']['cancellation'] = $fixture['history'][5]['payload']['cancellation']; + + return $fixture; + }, 'parent_request_id']; + foreach (['scope_origin', 'scope_authority_deadline_at'] as $key) { + $cases["root excludes {$key} even when null"] = [static function (array $fixture) use ($key): array { + $fixture['history'][5]['payload']['cancellation'][$key] = null; + $fixture['history'][6]['payload']['cancellation'] = $fixture['history'][5]['payload']['cancellation']; + + return $fixture; + }, 'root context']; + } + $cases['lineage is exactly the own root address'] = [static function (array $fixture): array { + $fixture['history'][5]['payload']['cancellation']['lineage'][0]['unrelated_run_id'] = 'other-run'; + $fixture['history'][6]['payload']['cancellation'] = $fixture['history'][5]['payload']['cancellation']; + + return $fixture; + }, 'own root']; + $cases['request reason binds to context'] = [static function (array $fixture): array { + $fixture['history'][5]['payload']['reason'] = 'different reason'; + + return $fixture; + }, 'reason']; + $cases['reason is nullable text'] = [static function (array $fixture): array { + $fixture['history'][5]['payload']['reason'] = 42; + $fixture['history'][5]['payload']['cancellation']['reason'] = 42; + $fixture['history'][6]['payload']['cancellation']['reason'] = 42; + + return $fixture; + }, 'reason']; + foreach (['sequence_span', 'operation_sequence', 'operation_sequence_span'] as $key) { + $cases["single delivery excludes {$key} even when null"] = [static function (array $fixture) use ($key): array { + $fixture['history'][6]['payload'][$key] = null; + + return $fixture; + }, 'single-call']; + } + foreach (['sequence' => [null, 0, '3'], 'activity_type' => [null, ' '], 'activity_execution_id' => [null, ' ']] as $field => $values) { + foreach ($values as $index => $value) { + $cases["typed ActivityCancelled {$field} {$index}"] = [static function (array $fixture) use ($field, $value): array { + $terminal = self::terminal($fixture, 'ActivityCancelled'); + if ($value === null) { + unset($terminal['payload'][$field]); + } else { + $terminal['payload'][$field] = $value; + } + + return self::insert($fixture, 6, $terminal); + }, 'ActivityCancelled']; + } + } + foreach (['ActivityScheduled', 'TimerScheduled', 'SignalWaitOpened'] as $kind) { + $cases["next step waits for earlier {$kind}"] = [static function (array $fixture) use ($kind): array { + $fixture = self::withCall($fixture, $kind); + $fixture['history'][6]['payload']['sequence'] = 4; + + return $fixture; + }, 'earlier call is unresolved']; + $cases["{$kind} resolution must bind the same resource"] = [static function (array $fixture) use ($kind): array { + $fixture = self::withCall($fixture, $kind); + $fixture['history'][6]['payload']['sequence'] = 4; + $terminal = self::terminal($fixture, self::resolution($kind)); + $terminal['payload'][self::identity($kind)] = 'other-resource'; + + return self::insert($fixture, 6, $terminal); + }, 'earlier call is unresolved']; + } + $cases['resolution must follow its opener even at equal times'] = [static function (array $fixture): array { + $fixture['history'][6]['payload']['sequence'] = 4; + + return self::insert($fixture, 4, self::terminal($fixture, 'ActivityCompleted')); + }, 'earlier call is unresolved']; + $cases['resolution must use the opener step'] = [static function (array $fixture): array { + $fixture['history'][6]['payload']['sequence'] = 6; + $terminal = self::terminal($fixture, 'ActivityCompleted'); + $terminal['payload']['sequence'] = 5; + + return self::insert($fixture, 6, $terminal); + }, 'earlier call is unresolved']; + $cases['resolution must use the opener family'] = [static function (array $fixture): array { + $fixture['history'][6]['payload']['sequence'] = 4; + + return self::insert($fixture, 6, [ + 'event_type' => 'TimerFired', 'payload' => ['sequence' => 3, 'timer_id' => 'other-timer'], + ]); + }, 'earlier call is unresolved']; + $cases['resolution after delivery cannot advance the boundary'] = [static function (array $fixture): array { + $fixture['history'][6]['payload']['sequence'] = 4; + $terminal = self::terminal($fixture, 'ActivityCompleted'); + $terminal['recorded_at'] = $fixture['history'][6]['recorded_at']; + + return self::insert($fixture, 7, $terminal); + }, 'earlier call is unresolved']; + $cases['untyped request metadata cannot create a distant next step'] = [static function (array $fixture): array { + $fixture = self::insert($fixture, 5, self::terminal($fixture, 'ActivityCompleted')); + $fixture['history'][6]['payload']['sequence'] = 89; + $fixture['history'][7]['payload']['sequence'] = 90; + + return $fixture; + }, 'after the next step']; + + return $cases; + } + + #[DataProvider('resolvedCalls')] + public function testMatchedEarlierResolutionAllowsTheNextStep(string $kind): void + { + $fixture = self::withCall(RedriveFixtureRegistry::fixture(self::FIXTURE), $kind); + $fixture['history'][6]['payload']['sequence'] = 4; + $fixture = self::insert($fixture, 6, self::terminal($fixture, self::resolution($kind))); + self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE])); + } + + /** @return array */ + public static function resolvedCalls(): array + { + return [ + 'activity' => ['ActivityScheduled'], + 'timer' => ['TimerScheduled'], + 'signal' => ['SignalWaitOpened'], + ]; + } + + public function testTypedCancellationAfterRequestAtTheSameTimeIsValid(): void + { + $fixture = RedriveFixtureRegistry::fixture(self::FIXTURE); + $fixture['history'][5]['recorded_at'] = $fixture['history'][6]['recorded_at']; + $fixture = self::insert($fixture, 6, self::terminal($fixture, 'ActivityCancelled')); + self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE])); + } + + public function testTypedCancellationBeforeRequestAtTheSameTimeIsResolved(): void + { + $fixture = RedriveFixtureRegistry::fixture(self::FIXTURE); + $fixture = self::insert($fixture, 5, self::terminal($fixture, 'ActivityCancelled')); + self::assertStringContainsString('resolved before the request', (string) RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE])); + } + + public function testMatchedActivityCancellationAllowsTheNextStep(): void + { + $fixture = RedriveFixtureRegistry::fixture(self::FIXTURE); + $fixture['history'][6]['payload']['sequence'] = 4; + $fixture = self::insert($fixture, 6, self::terminal($fixture, 'ActivityCancelled')); + self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE])); + } + + public function testRootAddressKeyOrderAndMatchingTextReasonAreValid(): void + { + $fixture = RedriveFixtureRegistry::fixture(self::FIXTURE); + $fixture['history'][5]['payload']['reason'] = 'operator cancellation'; + $context = $fixture['history'][5]['payload']['cancellation']; + $context['reason'] = 'operator cancellation'; + $context['lineage'][0] = array_reverse($context['lineage'][0], preserve_keys: true); + $fixture['history'][5]['payload']['cancellation'] = $context; + $fixture['history'][6]['payload']['cancellation'] = $context; + self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE])); + } + + public function testUntypedMetadataCannotHideAReachableNextStep(): void + { + $fixture = RedriveFixtureRegistry::fixture(self::FIXTURE); + $fixture['history'][6]['payload']['sequence'] = 4; + $fixture['history'][5]['payload']['sequence'] = 99; + $fixture = self::insert($fixture, 5, self::terminal($fixture, 'ActivityCompleted')); + self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE])); + } + + /** @param array $fixture @return array */ + private static function withCall(array $fixture, string $kind): array + { + $fixture['history'][4] = [...$fixture['history'][4], 'event_type' => $kind, 'payload' => match ($kind) { + 'TimerScheduled' => ['sequence' => 3, 'timer_id' => 'successor-timer'], + 'SignalWaitOpened' => ['sequence' => 3, 'signal_wait_id' => 'successor-wait', 'signal_name' => 'approval'], + default => $fixture['history'][4]['payload'], + }]; + + return $fixture; + } + + private static function identity(string $kind): string + { + return match ($kind) { + 'TimerScheduled' => 'timer_id', + 'SignalWaitOpened' => 'signal_wait_id', + default => 'activity_execution_id', + }; + } + + private static function resolution(string $kind): string + { + return match ($kind) { + 'TimerScheduled' => 'TimerFired', + 'SignalWaitOpened' => 'SignalApplied', + default => 'ActivityCompleted', + }; + } + + /** @param array $fixture @return array */ + private static function terminal(array $fixture, string $kind): array + { + $payload = $fixture['history'][4]['payload']; + if ($kind === 'SignalApplied') { + $payload['workflow_command_id'] = 'signal-command'; + $payload['signal_id'] = 'signal-event'; + } + + return ['event_type' => $kind, 'payload' => $payload]; + } + + /** @param array $fixture @param array $event @return array */ + private static function insert(array $fixture, int $index, array $event): array + { + $event['recorded_at'] ??= $fixture['history'][$index]['recorded_at'] ?? $fixture['history'][count($fixture['history']) - 1]['recorded_at']; + array_splice($fixture['history'], $index, 0, [$event]); + foreach ($fixture['history'] as $offset => &$row) { + $row['sequence'] = $offset + 1; + } + + return $fixture; + } +} diff --git a/tests/Support/RedriveSuccessorHistory.php b/tests/Support/RedriveSuccessorHistory.php index 76cc99bc..78135a0f 100644 --- a/tests/Support/RedriveSuccessorHistory.php +++ b/tests/Support/RedriveSuccessorHistory.php @@ -7,7 +7,9 @@ use DateTimeImmutable; /** - * Checks that a replay fixture is a history Workflow 2.19 redrive can write. + * Checks the supported replay-fixture shapes against Workflow 2.19 redrive. + * This is a test evidence model. Scoped, grouped, selection and operation + * deliveries remain outside its qualified single-call boundary. * * Core's DefaultWorkflowControlPlane::redrive() records, in one transaction, * a WorkflowStarted carrying the successor identity and the redrive envelope, @@ -220,6 +222,16 @@ public static function violation(array $fixture, array $reusedEventTypes): ?stri if (array_key_exists('workflow_instance_id', $payload) && $payload['workflow_instance_id'] !== $instance) { return "successor {$type} must bind to the successor instance"; } + if ($type === 'ActivityCancelled') { + if (!is_int($payload['sequence'] ?? null) || $payload['sequence'] < 1) { + return 'ActivityCancelled must name a positive workflow step'; + } + foreach (['activity_type', 'activity_execution_id'] as $field) { + if (!self::nonBlank($payload[$field] ?? null)) { + return "ActivityCancelled {$field} must be a non-blank string"; + } + } + } $cancellation = is_array($payload['cancellation'] ?? null) ? $payload['cancellation'] : null; if ($cancellation !== null) { if (($cancellation['root_workflow_instance_id'] ?? null) !== $instance || ($cancellation['root_workflow_run_id'] ?? null) !== $run) { @@ -312,6 +324,10 @@ private static function cancellationViolation( if ($contextViolation !== null) { return $contextViolation; } + if ((($context['reason'] ?? null) !== null && !is_string($context['reason'])) + || ($request['reason'] ?? null) !== ($context['reason'] ?? null)) { + return "the cancellation request reason must be its context's nullable text reason"; + } $requestedAt = self::time($context['requested_at']); $requestRecordedAt = self::time($history[$requestIndex]['recorded_at'] ?? null); if ($requestedAt === null || $requestRecordedAt === null || $requestedAt < $startedAt || $requestedAt > $requestRecordedAt) { @@ -334,6 +350,9 @@ private static function contextViolation(array $context, string $command): ?stri if (($context['schema'] ?? null) !== 'durable-workflow.cancellation-context/v1') { return 'cancellation schema must be durable-workflow.cancellation-context/v1'; } + if (array_key_exists('scope_origin', $context) || array_key_exists('scope_authority_deadline_at', $context)) { + return 'a successor own cancellation must use a root context without scope fields'; + } foreach (['request_id', 'root_request_id', 'root_workflow_instance_id', 'root_workflow_run_id', 'source'] as $field) { if (!self::nonBlank($context[$field] ?? null)) { return "cancellation {$field} must be a non-blank string"; @@ -373,10 +392,16 @@ private static function contextViolation(array $context, string $command): ?stri if ($lineage[$last]['request_id'] !== $context['request_id']) { return 'cancellation lineage must end at this request'; } - $parent = $context['parent_request_id'] ?? null; + if (!array_key_exists('parent_request_id', $context)) { + return 'cancellation parent_request_id must be present and null for a root request'; + } + $parent = $context['parent_request_id']; if ($parent !== ($last === 0 ? null : $lineage[$last - 1]['request_id'])) { return 'cancellation parent_request_id must be the previous lineage request, or null for a root request'; } + if ($last !== 0 || count($lineage[0]) !== 3 || $context['root_request_id'] !== $command || $parent !== null) { + return 'a successor cancellation lineage must contain exactly its own root address'; + } $requestedAt = self::time($context['requested_at'] ?? null); $deadline = self::time($context['cleanup_deadline_at'] ?? null); if ($requestedAt === null || $deadline === null || $deadline <= $requestedAt) { @@ -389,8 +414,8 @@ private static function contextViolation(array $context, string $command): ?stri /** * Core CooperativeCancellationDelivery::validateCallBoundary() for a * single call: the step is a recorded call of the delivered kind that - * was not resolved before the request, or the next unrecorded step, - * which nothing may record after the delivery. + * was not resolved before the request, or the next reached unrecorded + * step, which nothing may record after the delivery. * * @param list> $history * @param array $delivery @@ -405,12 +430,17 @@ private static function deliveryStepViolation(array $history, int $requestIndex, if (!in_array($callKind, self::CALL_KINDS, true)) { return 'the cancellation delivery call_kind must be a single-call kind'; } + foreach (['sequence_span', 'operation_sequence', 'operation_sequence_span'] as $field) { + if (array_key_exists($field, $delivery)) { + return 'a single-call cancellation delivery must not carry span or operation operands'; + } + } $recordedKind = null; $nextStep = 1; foreach (array_slice($history, 0, $deliveryIndex) as $index => $event) { $payload = is_array($event['payload'] ?? null) ? $event['payload'] : []; $sequence = $payload['sequence'] ?? null; - if (!is_int($sequence)) { + if (!is_int($sequence) || !self::isStepEvent((string) ($event['event_type'] ?? ''), $payload)) { continue; } $nextStep = max($nextStep, $sequence + 1); @@ -430,10 +460,62 @@ private static function deliveryStepViolation(array $history, int $requestIndex, } if ($step === $nextStep) { foreach (array_slice($history, $deliveryIndex + 1) as $event) { - if (($event['payload']['sequence'] ?? null) === $step) { + $payload = is_array($event['payload'] ?? null) ? $event['payload'] : []; + if (($payload['sequence'] ?? null) === $step && self::isStepEvent((string) ($event['event_type'] ?? ''), $payload)) { return "step {$step} must not be recorded after a cancellation delivered before it was scheduled"; } } + + return self::priorCallViolation(array_slice($history, 0, $deliveryIndex)); + } + + return null; + } + + /** @param array $payload */ + private static function isStepEvent(string $type, array $payload): bool + { + return self::callKind($type, $payload) !== null + || in_array($type, self::RESOLUTIONS, true) + || in_array($type, ['MemoUpserted', 'SearchAttributesUpserted', 'SideEffectRecorded', 'VersionMarkerRecorded', 'CooperativeCancellationDelivered'], true); + } + + /** + * This single-call evidence model cannot infer parallel progress. A later + * unrecorded call is reachable only after each modeled earlier call has a + * matching later resolution. History row order also applies to equal times. + * + * @param list> $beforeDelivery + */ + private static function priorCallViolation(array $beforeDelivery): ?string + { + $resolutions = [ + 'ActivityScheduled' => ['activity_execution_id', ['ActivityCompleted', 'ActivityCancelled']], + 'TimerScheduled' => ['timer_id', ['TimerFired']], + 'SignalWaitOpened' => ['signal_wait_id', ['SignalApplied']], + ]; + foreach ($beforeDelivery as $index => $event) { + $shape = $resolutions[$event['event_type'] ?? ''] ?? null; + if ($shape === null) { + continue; + } + [$identity, $kinds] = $shape; + $payload = is_array($event['payload'] ?? null) ? $event['payload'] : []; + $resolved = false; + foreach (array_slice($beforeDelivery, $index + 1) as $terminal) { + $terminalPayload = is_array($terminal['payload'] ?? null) ? $terminal['payload'] : []; + if (self::nonBlank($payload[$identity] ?? null) + && is_int($payload['sequence'] ?? null) && $payload['sequence'] > 0 + && in_array($terminal['event_type'] ?? null, $kinds, true) + && ($terminalPayload['sequence'] ?? null) === $payload['sequence'] + && ($terminalPayload[$identity] ?? null) === $payload[$identity]) { + $resolved = true; + break; + } + } + if (!$resolved) { + return 'the cancellation delivery targets an unreachable next step: an earlier call is unresolved'; + } } return null; From 3c39d948153c11ec7ddae44ebbd8a349e4e9e59a Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:12:33 -0600 Subject: [PATCH 2/4] Annotate successor strictness control fixture shapes --- tests/RedriveSuccessorStrictnessTest.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/RedriveSuccessorStrictnessTest.php b/tests/RedriveSuccessorStrictnessTest.php index c22025a1..788667ab 100644 --- a/tests/RedriveSuccessorStrictnessTest.php +++ b/tests/RedriveSuccessorStrictnessTest.php @@ -198,7 +198,10 @@ public function testUntypedMetadataCannotHideAReachableNextStep(): void self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE])); } - /** @param array $fixture @return array */ + /** + * @param array $fixture + * @return array + */ private static function withCall(array $fixture, string $kind): array { $fixture['history'][4] = [...$fixture['history'][4], 'event_type' => $kind, 'payload' => match ($kind) { @@ -228,7 +231,10 @@ private static function resolution(string $kind): string }; } - /** @param array $fixture @return array */ + /** + * @param array $fixture + * @return array + */ private static function terminal(array $fixture, string $kind): array { $payload = $fixture['history'][4]['payload']; @@ -240,7 +246,11 @@ private static function terminal(array $fixture, string $kind): array return ['event_type' => $kind, 'payload' => $payload]; } - /** @param array $fixture @param array $event @return array */ + /** + * @param array $fixture + * @param array $event + * @return array + */ private static function insert(array $fixture, int $index, array $event): array { $event['recorded_at'] ??= $fixture['history'][$index]['recorded_at'] ?? $fixture['history'][count($fixture['history']) - 1]['recorded_at']; From d3eb3deb58b70db51a10fd8d644b3003b6e2e7d2 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:59:50 +0000 Subject: [PATCH 3/4] Complete admitted successor call resolution ledger Derive admitted calls and terminals from one closed family map, bind later resolutions to resource, step and activity mode, and explicitly reject unqualified service-operation histories. Add held-head countercontrols and preserve existing fixture verdicts. Refs #250. --- tests/RedriveSuccessorStrictnessTest.php | 155 ++++++++++++++++++++++ tests/Support/RedriveSuccessorHistory.php | 100 +++++++++----- 2 files changed, 221 insertions(+), 34 deletions(-) diff --git a/tests/RedriveSuccessorStrictnessTest.php b/tests/RedriveSuccessorStrictnessTest.php index 788667ab..c24d7cf4 100644 --- a/tests/RedriveSuccessorStrictnessTest.php +++ b/tests/RedriveSuccessorStrictnessTest.php @@ -198,6 +198,161 @@ public function testUntypedMetadataCannotHideAReachableNextStep(): void self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE])); } + /** @param array $opening */ + #[DataProvider('priorCallResolutions')] + public function testPriorCallAcceptsEverySupportedTerminal(array $opening, string $terminalType): void + { + $fixture = self::priorCallFixture($opening); + $terminal = ['event_type' => $terminalType, 'payload' => $opening['payload']]; + $fixture = self::insert($fixture, 6, $terminal); + + self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE])); + } + + /** @return iterable, string}> */ + public static function priorCallResolutions(): iterable + { + foreach (self::priorCallFamilies() as $name => [$opening, , $terminals]) { + foreach ($terminals as $terminal) { + yield $name.' / '.$terminal => [$opening, $terminal]; + } + } + } + + /** @param array $opening */ + #[DataProvider('priorCallRefusals')] + public function testPriorCallRequiresItsOwnLaterResolution(array $opening, string $identity, string $terminalType, string $fault): void + { + $fixture = self::priorCallFixture($opening); + $terminal = ['event_type' => $terminalType, 'payload' => $opening['payload']]; + $position = 6; + if ($fault === 'other resource') { + $terminal['payload'][$identity] = 'foreign-resource'; + } elseif ($fault === 'other step') { + $terminal['payload']['sequence'] = 4; + $fixture['history'][6]['payload']['sequence'] = 5; + } elseif ($fault === 'other family') { + $terminal['event_type'] = $terminalType === 'TimerFired' ? 'ActivityCompleted' : 'TimerFired'; + } elseif ($fault === 'before opener') { + $position = 4; + } elseif ($fault === 'after delivery') { + $position = 7; + } elseif ($fault === 'missing opener identity') { + unset($fixture['history'][4]['payload'][$identity]); + } elseif ($fault === 'missing terminal identity') { + unset($terminal['payload'][$identity]); + } + if ($fault !== 'unresolved') { + $fixture = self::insert($fixture, $position, $terminal); + } + + self::assertStringContainsString('earlier call is unresolved', (string) RedriveSuccessorHistory::violation( + $fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE], + )); + } + + /** @return iterable, string, string, string}> */ + public static function priorCallRefusals(): iterable + { + foreach (self::priorCallFamilies() as $name => [$opening, $identity, $terminals]) { + foreach (['unresolved', 'other resource', 'other step', 'other family', 'before opener', 'after delivery', + 'missing opener identity', 'missing terminal identity'] as $fault) { + yield $name.' / '.$fault => [$opening, $identity, $terminals[0], $fault]; + } + } + } + + public function testPriorCallDistinguishesLocalAndRemoteActivityResolutions(): void + { + foreach ([false, true] as $local) { + $opening = ['event_type' => 'ActivityScheduled', 'payload' => ['sequence' => 3, + 'activity_execution_id' => 'activity', 'activity_type' => 'effect', 'local_activity' => $local]]; + $fixture = self::priorCallFixture($opening); + $terminal = ['event_type' => 'ActivityCompleted', 'payload' => [...$opening['payload'], 'local_activity' => !$local]]; + $fixture = self::insert($fixture, 6, $terminal); + self::assertStringContainsString('earlier call is unresolved', (string) RedriveSuccessorHistory::violation( + $fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE], + )); + } + } + + public function testPriorCallChildIdentitySurvivesAChangedRunAndLink(): void + { + [$opening] = self::priorCallFamilies()['child']; + $fixture = self::priorCallFixture($opening); + $terminal = ['event_type' => 'ChildRunCompleted', 'payload' => [...$opening['payload'], + 'child_workflow_run_id' => 'continued-child-run', 'workflow_link_id' => 'continued-link']]; + $fixture = self::insert($fixture, 6, $terminal); + self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE])); + } + + #[DataProvider('serviceOperationEvents')] + public function testPriorCallExplicitlyExcludesServiceOperationHistories(string $type): void + { + $fixture = self::priorCallFixture(['event_type' => $type, + 'payload' => ['sequence' => 3, 'service_operation_id' => 'operation']]); + self::assertStringContainsString('service-operation histories are outside', (string) RedriveSuccessorHistory::violation( + $fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE], + )); + } + + /** @return iterable */ + public static function serviceOperationEvents(): iterable + { + foreach (['ServiceOperationScheduled', 'ServiceCallStarted', 'ServiceCallCompleted', 'ServiceCallFailed', 'ServiceCallCancelled'] as $type) { + yield $type => [$type]; + } + } + + public function testPriorCallExplicitlyExcludesServiceOperationDelivery(): void + { + $fixture = RedriveFixtureRegistry::fixture(self::FIXTURE); + $fixture['history'][6]['payload']['call_kind'] = 'service_operation'; + self::assertStringContainsString('single-call kind', (string) RedriveSuccessorHistory::violation( + $fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE], + )); + } + + /** @return array, string, list}> */ + private static function priorCallFamilies(): array + { + $activity = ['sequence' => 3, 'activity_execution_id' => 'activity', 'activity_type' => 'effect']; + $activityTerminals = ['ActivityCompleted', 'ActivityFailed', 'ActivityTimedOut', 'ActivityCancelled']; + + return [ + 'activity' => [['event_type' => 'ActivityScheduled', 'payload' => $activity], 'activity_execution_id', $activityTerminals], + 'local activity' => [['event_type' => 'ActivityScheduled', 'payload' => [...$activity, 'local_activity' => true]], + 'activity_execution_id', $activityTerminals], + 'timer' => [['event_type' => 'TimerScheduled', 'payload' => ['sequence' => 3, 'timer_id' => 'timer']], + 'timer_id', ['TimerFired', 'TimerCancelled']], + 'condition' => [['event_type' => 'ConditionWaitOpened', 'payload' => ['sequence' => 3, 'condition_wait_id' => 'condition']], + 'condition_wait_id', ['ConditionWaitSatisfied', 'ConditionWaitTimedOut']], + 'signal' => [['event_type' => 'SignalWaitOpened', 'payload' => ['sequence' => 3, 'signal_wait_id' => 'signal']], + 'signal_wait_id', ['SignalApplied']], + 'child' => [['event_type' => 'ChildWorkflowScheduled', 'payload' => ['sequence' => 3, 'child_call_id' => 'child-call', + 'child_workflow_instance_id' => 'child', 'child_workflow_run_id' => 'initial-child-run', 'workflow_link_id' => 'initial-link']], + 'child_call_id', ['ChildRunCompleted', 'ChildRunFailed', 'ChildRunCancelled', 'ChildRunTerminated']], + 'condition timer' => [['event_type' => 'TimerScheduled', 'payload' => ['sequence' => 3, 'timer_id' => 'timer', + 'timer_kind' => 'condition_timeout', 'condition_wait_id' => 'condition']], + 'condition_wait_id', ['ConditionWaitSatisfied', 'ConditionWaitTimedOut']], + 'signal timer' => [['event_type' => 'TimerScheduled', 'payload' => ['sequence' => 3, 'timer_id' => 'timer', + 'timer_kind' => 'signal_timeout', 'signal_wait_id' => 'signal']], 'signal_wait_id', ['SignalApplied']], + ]; + } + + /** + * @param array $opening + * @return array + */ + private static function priorCallFixture(array $opening): array + { + $fixture = RedriveFixtureRegistry::fixture(self::FIXTURE); + $fixture['history'][4] = [...$fixture['history'][4], ...$opening]; + $fixture['history'][6]['payload']['sequence'] = 4; + + return $fixture; + } + /** * @param array $fixture * @return array diff --git a/tests/Support/RedriveSuccessorHistory.php b/tests/Support/RedriveSuccessorHistory.php index 78135a0f..1fc4e69c 100644 --- a/tests/Support/RedriveSuccessorHistory.php +++ b/tests/Support/RedriveSuccessorHistory.php @@ -8,7 +8,7 @@ /** * Checks the supported replay-fixture shapes against Workflow 2.19 redrive. - * This is a test evidence model. Scoped, grouped, selection and operation + * This is a test evidence model. Scoped, grouped, selection and service-operation * deliveries remain outside its qualified single-call boundary. * * Core's DefaultWorkflowControlPlane::redrive() records, in one transaction, @@ -54,15 +54,25 @@ final class RedriveSuccessorHistory 'timer_kind', 'cancellation_cleanup', 'cancellation_scope_id', 'parallel_group_id', 'condition_wait_id', 'signal_wait_id', ]; - /** Core CooperativeCancellationDelivery::RESOLUTION_TYPES for the call kinds these fixtures use. */ - private const RESOLUTIONS = [ - 'ActivityCompleted', 'ActivityFailed', 'ActivityCancelled', 'ActivityTimedOut', 'TimerFired', 'TimerCancelled', - 'ConditionWaitSatisfied', 'ConditionWaitTimedOut', 'SignalApplied', 'ChildRunCompleted', 'ChildRunFailed', - 'ChildRunCancelled', 'ChildRunTerminated', 'ServiceCallCompleted', 'ServiceCallFailed', 'ServiceCallCancelled', + private const ACTIVITY_TERMINALS = ['ActivityCompleted', 'ActivityFailed', 'ActivityCancelled', 'ActivityTimedOut']; + + /** + * The admitted single-call families and their canonical resource/terminal + * pairs. Derive terminal and delivery admission from this same closed set. + * Service operations, groups and selection deliveries remain unqualified. + */ + private const CALL_RESOLUTIONS = [ + 'activity' => ['activity_execution_id', self::ACTIVITY_TERMINALS], + 'local_activity' => ['activity_execution_id', self::ACTIVITY_TERMINALS], + 'timer' => ['timer_id', ['TimerFired', 'TimerCancelled']], + 'condition' => ['condition_wait_id', ['ConditionWaitSatisfied', 'ConditionWaitTimedOut']], + 'signal' => ['signal_wait_id', ['SignalApplied']], + 'child' => ['child_call_id', ['ChildRunCompleted', 'ChildRunFailed', 'ChildRunCancelled', 'ChildRunTerminated']], ]; - /** Core CooperativeCancellationDelivery::CALL_SHAPES; parallel and selection deliveries are not covered here. */ - private const CALL_KINDS = ['activity', 'local_activity', 'timer', 'condition', 'signal', 'child', 'service_operation']; + private const SERVICE_OPERATION_EVENTS = [ + 'ServiceOperationScheduled', 'ServiceCallStarted', 'ServiceCallCompleted', 'ServiceCallFailed', 'ServiceCallCancelled', + ]; /** * @param array $fixture a replay-regression fixture with history, workflow and workflow_tasks @@ -130,6 +140,9 @@ public static function violation(array $fixture, array $reusedEventTypes): ?stri foreach (array_slice($history, 1) as $offset => $event) { $payload = is_array($event['payload'] ?? null) ? $event['payload'] : []; $type = (string) ($event['event_type'] ?? ''); + if (in_array($type, self::SERVICE_OPERATION_EVENTS, true)) { + return 'service-operation histories are outside the qualified successor evidence model'; + } if (($event['sequence'] ?? null) !== $offset + 2) { return "history order: {$type} must be history sequence ".($offset + 2); } @@ -427,7 +440,7 @@ private static function deliveryStepViolation(array $history, int $requestIndex, if (!is_int($step) || $step < 1) { return 'the cancellation delivery must name a positive workflow step'; } - if (!in_array($callKind, self::CALL_KINDS, true)) { + if (!is_string($callKind) || !array_key_exists($callKind, self::CALL_RESOLUTIONS)) { return 'the cancellation delivery call_kind must be a single-call kind'; } foreach (['sequence_span', 'operation_sequence', 'operation_sequence_span'] as $field) { @@ -448,7 +461,7 @@ private static function deliveryStepViolation(array $history, int $requestIndex, continue; } $recordedKind ??= self::callKind((string) ($event['event_type'] ?? ''), $payload); - if ($index < $requestIndex && in_array($event['event_type'] ?? null, self::RESOLUTIONS, true)) { + if ($index < $requestIndex && self::isResolution($event['event_type'] ?? null)) { return "the cancellation delivery targets step {$step}, which resolved before the request"; } } @@ -476,7 +489,7 @@ private static function deliveryStepViolation(array $history, int $requestIndex, private static function isStepEvent(string $type, array $payload): bool { return self::callKind($type, $payload) !== null - || in_array($type, self::RESOLUTIONS, true) + || self::isResolution($type) || in_array($type, ['MemoUpserted', 'SearchAttributesUpserted', 'SideEffectRecorded', 'VersionMarkerRecorded', 'CooperativeCancellationDelivered'], true); } @@ -489,31 +502,14 @@ private static function isStepEvent(string $type, array $payload): bool */ private static function priorCallViolation(array $beforeDelivery): ?string { - $resolutions = [ - 'ActivityScheduled' => ['activity_execution_id', ['ActivityCompleted', 'ActivityCancelled']], - 'TimerScheduled' => ['timer_id', ['TimerFired']], - 'SignalWaitOpened' => ['signal_wait_id', ['SignalApplied']], - ]; foreach ($beforeDelivery as $index => $event) { - $shape = $resolutions[$event['event_type'] ?? ''] ?? null; - if ($shape === null) { - continue; - } - [$identity, $kinds] = $shape; $payload = is_array($event['payload'] ?? null) ? $event['payload'] : []; - $resolved = false; - foreach (array_slice($beforeDelivery, $index + 1) as $terminal) { - $terminalPayload = is_array($terminal['payload'] ?? null) ? $terminal['payload'] : []; - if (self::nonBlank($payload[$identity] ?? null) - && is_int($payload['sequence'] ?? null) && $payload['sequence'] > 0 - && in_array($terminal['event_type'] ?? null, $kinds, true) - && ($terminalPayload['sequence'] ?? null) === $payload['sequence'] - && ($terminalPayload[$identity] ?? null) === $payload[$identity]) { - $resolved = true; - break; - } + $kind = self::callKind((string) ($event['event_type'] ?? ''), $payload); + $shape = self::CALL_RESOLUTIONS[$kind ?? ''] ?? null; + if ($kind === null || $shape === null) { + continue; } - if (!$resolved) { + if (!self::callIsResolved($kind, $payload, $shape, array_slice($beforeDelivery, $index + 1))) { return 'the cancellation delivery targets an unreachable next step: an earlier call is unresolved'; } } @@ -521,6 +517,43 @@ private static function priorCallViolation(array $beforeDelivery): ?string return null; } + /** + * @param array $payload + * @param array{string, list} $shape + * @param list> $afterOpening + */ + private static function callIsResolved(string $kind, array $payload, array $shape, array $afterOpening): bool + { + [$identity, $terminals] = $shape; + if (!self::nonBlank($payload[$identity] ?? null) + || !is_int($payload['sequence'] ?? null) || $payload['sequence'] < 1) { + return false; + } + foreach ($afterOpening as $terminal) { + $terminalPayload = is_array($terminal['payload'] ?? null) ? $terminal['payload'] : []; + $sameActivityMode = !in_array($kind, ['activity', 'local_activity'], true) + || (($terminalPayload['local_activity'] ?? false) === true) === ($kind === 'local_activity'); + if (in_array($terminal['event_type'] ?? null, $terminals, true) && $sameActivityMode + && ($terminalPayload['sequence'] ?? null) === $payload['sequence'] + && ($terminalPayload[$identity] ?? null) === $payload[$identity]) { + return true; + } + } + + return false; + } + + private static function isResolution(mixed $type): bool + { + foreach (self::CALL_RESOLUTIONS as [, $terminals]) { + if (in_array($type, $terminals, true)) { + return true; + } + } + + return false; + } + /** * Core CooperativeCancellationDelivery::callKindAt() for one event. * @@ -533,7 +566,6 @@ private static function callKind(string $type, array $payload): ?string 'ConditionWaitOpened' => 'condition', 'SignalWaitOpened' => 'signal', 'ChildWorkflowScheduled' => 'child', - 'ServiceOperationScheduled', 'ServiceCallStarted' => 'service_operation', 'TimerScheduled' => match ($payload['timer_kind'] ?? null) { 'condition_timeout' => 'condition', 'signal_timeout' => 'signal', From 6e801206e7d30d992b847e21de7a7f71329912b2 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:12:08 +0000 Subject: [PATCH 4/4] Match wait timeout resolutions by event shape Bind condition and signal TimerFired/TimerCancelled rows to the exact timeout kind, wait identity and workflow step. Keep internal timeout terminals out of plain timer resolution and correct the previously misclassified negative controls. Refs #250. --- tests/RedriveSuccessorStrictnessTest.php | 129 +++++++++++++++++++++- tests/Support/RedriveSuccessorHistory.php | 32 +++++- 2 files changed, 154 insertions(+), 7 deletions(-) diff --git a/tests/RedriveSuccessorStrictnessTest.php b/tests/RedriveSuccessorStrictnessTest.php index c24d7cf4..57ae264b 100644 --- a/tests/RedriveSuccessorStrictnessTest.php +++ b/tests/RedriveSuccessorStrictnessTest.php @@ -232,7 +232,8 @@ public function testPriorCallRequiresItsOwnLaterResolution(array $opening, strin $terminal['payload']['sequence'] = 4; $fixture['history'][6]['payload']['sequence'] = 5; } elseif ($fault === 'other family') { - $terminal['event_type'] = $terminalType === 'TimerFired' ? 'ActivityCompleted' : 'TimerFired'; + $terminal['event_type'] = in_array($identity, ['timer_id', 'condition_wait_id', 'signal_wait_id'], true) + ? 'ActivityCompleted' : 'TimerFired'; } elseif ($fault === 'before opener') { $position = 4; } elseif ($fault === 'after delivery') { @@ -313,6 +314,132 @@ public function testPriorCallExplicitlyExcludesServiceOperationDelivery(): void )); } + #[DataProvider('waitTimeoutResolutions')] + public function testWaitTimeoutAcceptsItsMatchingTimerTerminal(string $kind, string $layout, string $terminalType): void + { + $fixture = self::waitTimeoutFixture($kind, $layout); + $fixture = self::insert($fixture, self::deliveryIndex($fixture), self::waitTimeoutTerminal($kind, $terminalType)); + self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE])); + } + + /** @return iterable */ + public static function waitTimeoutResolutions(): iterable + { + foreach (['condition', 'signal'] as $kind) { + foreach (['wait', 'timer', 'both'] as $layout) { + foreach (['TimerFired', 'TimerCancelled'] as $terminal) { + yield $kind.' / '.$layout.' / '.$terminal => [$kind, $layout, $terminal]; + } + } + } + } + + #[DataProvider('waitTimeoutRefusals')] + public function testWaitTimeoutRejectsAnUnrelatedTimerTerminal(string $kind, string $terminalType, string $fault): void + { + $fixture = self::waitTimeoutFixture($kind, 'both'); + $terminal = self::waitTimeoutTerminal($kind, $terminalType); + $position = self::deliveryIndex($fixture); + if ($fault === 'missing kind') { + unset($terminal['payload']['timer_kind']); + } elseif ($fault === 'null kind') { + $terminal['payload']['timer_kind'] = null; + } elseif ($fault === 'other timeout kind') { + $terminal['payload']['timer_kind'] = $kind === 'condition' ? 'signal_timeout' : 'condition_timeout'; + } elseif ($fault === 'plain kind') { + $terminal['payload']['timer_kind'] = 'ordinary'; + } elseif ($fault === 'other wait') { + $terminal['payload'][$kind.'_wait_id'] = 'foreign-wait'; + } elseif ($fault === 'missing wait') { + unset($terminal['payload'][$kind.'_wait_id']); + } elseif ($fault === 'other step') { + $terminal['payload']['sequence'] = 4; + $fixture['history'][$position]['payload']['sequence'] = 5; + } elseif ($fault === 'after delivery') { + ++$position; + } elseif ($fault === 'before opener') { + $position = 4; + } + $fixture = self::insert($fixture, $position, $terminal); + self::assertStringContainsString('earlier call is unresolved', (string) RedriveSuccessorHistory::violation( + $fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE], + )); + } + + /** @return iterable */ + public static function waitTimeoutRefusals(): iterable + { + foreach (['condition', 'signal'] as $kind) { + foreach (['TimerFired', 'TimerCancelled'] as $terminal) { + foreach (['missing kind', 'null kind', 'other timeout kind', 'plain kind', 'other wait', 'missing wait', + 'other step', 'after delivery', 'before opener'] as $fault) { + yield $kind.' / '.$terminal.' / '.$fault => [$kind, $terminal, $fault]; + } + } + } + } + + #[DataProvider('plainTimerRefusals')] + public function testPlainTimerResolutionCannotUseAWaitTimeoutOrForeignTimer(string $terminalType, string $fault): void + { + [$opening] = self::priorCallFamilies()['timer']; + $fixture = self::priorCallFixture($opening); + $terminal = ['event_type' => $terminalType, 'payload' => $opening['payload']]; + if ($fault === 'other timer') { + $terminal['payload']['timer_id'] = 'foreign-timer'; + } elseif ($fault === 'missing timer') { + unset($terminal['payload']['timer_id']); + } else { + $terminal['payload']['timer_kind'] = $fault.'_timeout'; + $terminal['payload'][$fault.'_wait_id'] = $fault; + } + $fixture = self::insert($fixture, 6, $terminal); + self::assertStringContainsString('earlier call is unresolved', (string) RedriveSuccessorHistory::violation( + $fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE], + )); + } + + /** @return iterable */ + public static function plainTimerRefusals(): iterable + { + foreach (['TimerFired', 'TimerCancelled'] as $terminal) { + foreach (['condition', 'signal', 'other timer', 'missing timer'] as $fault) { + yield $terminal.' / '.$fault => [$terminal, $fault]; + } + } + } + + /** @return array */ + private static function waitTimeoutFixture(string $kind, string $layout): array + { + $families = self::priorCallFamilies(); + $fixture = self::priorCallFixture($families[$kind.($layout === 'timer' ? ' timer' : '')][0]); + if ($layout === 'both') { + $fixture = self::insert($fixture, 5, $families[$kind.' timer'][0]); + } + + return $fixture; + } + + /** @return array */ + private static function waitTimeoutTerminal(string $kind, string $type): array + { + return ['event_type' => $type, 'payload' => ['sequence' => 3, 'timer_id' => 'timer', + 'timer_kind' => $kind.'_timeout', $kind.'_wait_id' => $kind]]; + } + + /** @param array $fixture */ + private static function deliveryIndex(array $fixture): int + { + foreach ($fixture['history'] as $index => $event) { + if ($event['event_type'] === 'CooperativeCancellationDelivered') { + return (int) $index; + } + } + + throw new \LogicException('The source-control fixture requires its cancellation delivery.'); + } + /** @return array, string, list}> */ private static function priorCallFamilies(): array { diff --git a/tests/Support/RedriveSuccessorHistory.php b/tests/Support/RedriveSuccessorHistory.php index 1fc4e69c..3a1a5f56 100644 --- a/tests/Support/RedriveSuccessorHistory.php +++ b/tests/Support/RedriveSuccessorHistory.php @@ -56,6 +56,8 @@ final class RedriveSuccessorHistory private const ACTIVITY_TERMINALS = ['ActivityCompleted', 'ActivityFailed', 'ActivityCancelled', 'ActivityTimedOut']; + private const TIMER_TERMINALS = ['TimerFired', 'TimerCancelled']; + /** * The admitted single-call families and their canonical resource/terminal * pairs. Derive terminal and delivery admission from this same closed set. @@ -64,9 +66,9 @@ final class RedriveSuccessorHistory private const CALL_RESOLUTIONS = [ 'activity' => ['activity_execution_id', self::ACTIVITY_TERMINALS], 'local_activity' => ['activity_execution_id', self::ACTIVITY_TERMINALS], - 'timer' => ['timer_id', ['TimerFired', 'TimerCancelled']], - 'condition' => ['condition_wait_id', ['ConditionWaitSatisfied', 'ConditionWaitTimedOut']], - 'signal' => ['signal_wait_id', ['SignalApplied']], + 'timer' => ['timer_id', self::TIMER_TERMINALS], + 'condition' => ['condition_wait_id', ['ConditionWaitSatisfied', 'ConditionWaitTimedOut', ...self::TIMER_TERMINALS]], + 'signal' => ['signal_wait_id', ['SignalApplied', ...self::TIMER_TERMINALS]], 'child' => ['child_call_id', ['ChildRunCompleted', 'ChildRunFailed', 'ChildRunCancelled', 'ChildRunTerminated']], ]; @@ -531,9 +533,9 @@ private static function callIsResolved(string $kind, array $payload, array $shap } foreach ($afterOpening as $terminal) { $terminalPayload = is_array($terminal['payload'] ?? null) ? $terminal['payload'] : []; - $sameActivityMode = !in_array($kind, ['activity', 'local_activity'], true) - || (($terminalPayload['local_activity'] ?? false) === true) === ($kind === 'local_activity'); - if (in_array($terminal['event_type'] ?? null, $terminals, true) && $sameActivityMode + $type = $terminal['event_type'] ?? null; + if (in_array($type, $terminals, true) + && self::terminalFamilyMatches($kind, $type, $terminalPayload) && ($terminalPayload['sequence'] ?? null) === $payload['sequence'] && ($terminalPayload[$identity] ?? null) === $payload[$identity]) { return true; @@ -543,6 +545,24 @@ private static function callIsResolved(string $kind, array $payload, array $shap return false; } + /** @param array $payload */ + private static function terminalFamilyMatches(string $kind, mixed $type, array $payload): bool + { + if (in_array($type, self::TIMER_TERMINALS, true)) { + $timerKind = $payload['timer_kind'] ?? null; + + return match ($kind) { + 'condition' => $timerKind === 'condition_timeout', + 'signal' => $timerKind === 'signal_timeout', + 'timer' => !in_array($timerKind, ['condition_timeout', 'signal_timeout'], true), + default => false, + }; + } + + return !in_array($kind, ['activity', 'local_activity'], true) + || (($payload['local_activity'] ?? false) === true) === ($kind === 'local_activity'); + } + private static function isResolution(mixed $type): bool { foreach (self::CALL_RESOLUTIONS as [, $terminals]) {