From d36f085691e1d443af5d25fe7db132c157a9cb9f Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:53:12 +0000 Subject: [PATCH 1/2] Reject repeated primary-call terminals in redrive evidence --- tests/RedriveSuccessorStrictnessTest.php | 140 ++++++++++++++++++++++ tests/Support/RedriveSuccessorHistory.php | 35 +++++- 2 files changed, 169 insertions(+), 6 deletions(-) diff --git a/tests/RedriveSuccessorStrictnessTest.php b/tests/RedriveSuccessorStrictnessTest.php index 57ae264b..174b4c70 100644 --- a/tests/RedriveSuccessorStrictnessTest.php +++ b/tests/RedriveSuccessorStrictnessTest.php @@ -219,6 +219,146 @@ public static function priorCallResolutions(): iterable } } + /** @param array $opening */ + #[DataProvider('terminalLedgerCases')] + public function testPriorCallTerminalLedgerScansItsWholeInterval( + array $opening, + string $firstType, + string $secondType, + string $scenario, + ): void { + $fixture = self::priorCallFixture($opening); + $first = self::terminal($fixture, $firstType); + $second = self::terminal($fixture, $secondType); + $foreign = $second; + $kind = $opening['event_type']; + $foreign['payload'][self::identity($kind)] = 'unrelated-resource'; + if ($scenario !== 'foreign-only' && $scenario !== 'later-only') { + if ($scenario === 'foreign-before') { + $fixture = self::insert($fixture, self::deliveryIndex($fixture), $foreign); + } + $fixture = self::insert($fixture, self::deliveryIndex($fixture), $first); + self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE]), + 'Each multiplicity probe first proves its valid one-terminal control.'); + } + if (in_array($scenario, ['foreign-only', 'foreign-after', 'duplicate-after-foreign'], true)) { + $fixture = self::insert($fixture, self::deliveryIndex($fixture), $foreign); + } + if (in_array($scenario, ['duplicate', 'duplicate-after-foreign'], true)) { + $fixture = self::insert($fixture, self::deliveryIndex($fixture), $second); + } elseif (in_array($scenario, ['later-duplicate', 'later-only'], true)) { + $fixture = self::insert($fixture, self::deliveryIndex($fixture) + 1, $second); + } + $violation = RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE]); + + if (in_array($scenario, ['duplicate', 'duplicate-after-foreign'], true)) { + self::assertNotNull($violation); + self::assertStringContainsString('more than one terminal', $violation); + } elseif (in_array($scenario, ['foreign-only', 'later-only'], true)) { + self::assertNotNull($violation); + self::assertStringContainsString('an earlier call is unresolved', $violation); + } else { + self::assertNull($violation); + } + } + + /** @return iterable, string, string, string}> */ + public static function terminalLedgerCases(): iterable + { + $families = self::priorCallFamilies(); + $pairs = [ + ['activity', 'ActivityFailed', 'ActivityCompleted'], + ['activity', 'ActivityCompleted', 'ActivityCompleted'], + ['local activity', 'ActivityFailed', 'ActivityCompleted'], + ['local activity', 'ActivityCompleted', 'ActivityCompleted'], + ['timer', 'TimerCancelled', 'TimerFired'], + ['timer', 'TimerFired', 'TimerFired'], + ['signal', 'SignalApplied', 'SignalApplied'], + ]; + foreach ($pairs as [$name, $first, $second]) { + foreach (['duplicate', 'foreign-before', 'foreign-after', 'duplicate-after-foreign', + 'foreign-only', 'later-duplicate', 'later-only'] as $scenario) { + yield $name.' / '.$first.' / '.$second.' / '.$scenario => [$families[$name][0], $first, $second, $scenario]; + } + } + } + + #[DataProvider('pairedWaitTerminals')] + public function testCanonicalWaitAndBackingTimerRowsAreNotTwoLogicalTerminals( + string $kind, + string $layout, + string $waitTerminal, + string $timerTerminal, + ): void { + $fixture = self::waitTimeoutFixture($kind, $layout); + $fixture = self::insert($fixture, self::deliveryIndex($fixture), [ + 'event_type' => $waitTerminal, + 'payload' => ['sequence' => 3, $kind.'_wait_id' => $kind, + 'workflow_command_id' => 'signal-command', 'signal_id' => 'signal-event'], + ]); + $fixture = self::insert($fixture, self::deliveryIndex($fixture), self::waitTimeoutTerminal($kind, $timerTerminal)); + + self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE])); + } + + /** @return iterable */ + public static function pairedWaitTerminals(): iterable + { + foreach (['wait', 'timer', 'both'] as $layout) { + yield 'condition satisfied / '.$layout => ['condition', $layout, 'ConditionWaitSatisfied', 'TimerCancelled']; + yield 'condition timed out / '.$layout => ['condition', $layout, 'ConditionWaitTimedOut', 'TimerFired']; + yield 'signal applied / '.$layout => ['signal', $layout, 'SignalApplied', 'TimerCancelled']; + } + } + + /** + * @param array $opening + * @param array $foreignFields + */ + #[DataProvider('terminalModeAndKindCases')] + public function testForeignTerminalModeAndTimerKindCannotChangeTheClosedState( + array $opening, + string $type, + array $foreignFields, + string $scenario, + ): void { + $fixture = self::priorCallFixture($opening); + $valid = self::terminal($fixture, $type); + $foreign = $valid; + $foreign['payload'] = [...$foreign['payload'], ...$foreignFields]; + $fixture = self::insert($fixture, self::deliveryIndex($fixture), $valid); + self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE])); + $fixture = self::insert($fixture, $scenario === 'before' ? 6 : self::deliveryIndex($fixture), $foreign); + if ($scenario === 'duplicate-after') { + $fixture = self::insert($fixture, self::deliveryIndex($fixture), $valid); + } + $violation = RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE]); + + if ($scenario === 'duplicate-after') { + self::assertNotNull($violation); + self::assertStringContainsString('more than one terminal', $violation); + } else { + self::assertNull($violation); + } + } + + /** @return iterable, string, array, string}> */ + public static function terminalModeAndKindCases(): iterable + { + $families = self::priorCallFamilies(); + $cases = [ + ['activity', 'ActivityCompleted', ['local_activity' => true]], + ['local activity', 'ActivityCompleted', ['local_activity' => false]], + ['timer', 'TimerFired', ['timer_kind' => 'condition_timeout', 'condition_wait_id' => 'condition']], + ['timer', 'TimerFired', ['timer_kind' => 'signal_timeout', 'signal_wait_id' => 'signal']], + ]; + foreach ($cases as $index => [$family, $type, $fields]) { + foreach (['before', 'after', 'duplicate-after'] as $scenario) { + yield $family.' / '.$index.' / '.$scenario => [$families[$family][0], $type, $fields, $scenario]; + } + } + } + /** @param array $opening */ #[DataProvider('priorCallRefusals')] public function testPriorCallRequiresItsOwnLaterResolution(array $opening, string $identity, string $terminalType, string $fault): void diff --git a/tests/Support/RedriveSuccessorHistory.php b/tests/Support/RedriveSuccessorHistory.php index 3a1a5f56..6bf91c50 100644 --- a/tests/Support/RedriveSuccessorHistory.php +++ b/tests/Support/RedriveSuccessorHistory.php @@ -10,6 +10,10 @@ * Checks the supported replay-fixture shapes against Workflow 2.19 redrive. * This is a test evidence model. Scoped, grouped, selection and service-operation * deliveries remain outside its qualified single-call boundary. + * Terminal multiplicity is checked for remote/local activities, plain timers + * and SignalApplied markers. Backing-timer/wait outcome combinations and child + * closure semantics retain their existing presence/identity bounds; this is + * not a generalized logical-outcome interpreter for those shapes. * * Core's DefaultWorkflowControlPlane::redrive() records, in one transaction, * a WorkflowStarted carrying the successor identity and the redrive envelope, @@ -511,8 +515,9 @@ private static function priorCallViolation(array $beforeDelivery): ?string if ($kind === null || $shape === null) { continue; } - 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'; + $violation = self::callResolutionViolation($kind, $payload, $shape, array_slice($beforeDelivery, $index + 1)); + if ($violation !== null) { + return $violation; } } @@ -524,13 +529,16 @@ private static function priorCallViolation(array $beforeDelivery): ?string * @param array{string, list} $shape * @param list> $afterOpening */ - private static function callIsResolved(string $kind, array $payload, array $shape, array $afterOpening): bool + private static function callResolutionViolation(string $kind, array $payload, array $shape, array $afterOpening): ?string { [$identity, $terminals] = $shape; + $unresolved = 'the cancellation delivery targets an unreachable next step: an earlier call is unresolved'; if (!self::nonBlank($payload[$identity] ?? null) || !is_int($payload['sequence'] ?? null) || $payload['sequence'] < 1) { - return false; + return $unresolved; } + $resolved = false; + $closed = false; foreach ($afterOpening as $terminal) { $terminalPayload = is_array($terminal['payload'] ?? null) ? $terminal['payload'] : []; $type = $terminal['event_type'] ?? null; @@ -538,11 +546,26 @@ private static function callIsResolved(string $kind, array $payload, array $shap && self::terminalFamilyMatches($kind, $type, $terminalPayload) && ($terminalPayload['sequence'] ?? null) === $payload['sequence'] && ($terminalPayload[$identity] ?? null) === $payload[$identity]) { - return true; + $resolved = true; + if (!self::closesModeledCall($kind, $type)) { + continue; + } + if ($closed) { + return 'the cancellation delivery targets an unreachable next step: an earlier call has more than one terminal'; + } + $closed = true; } } - return false; + return $resolved ? null : $unresolved; + } + + private static function closesModeledCall(string $kind, mixed $type): bool + { + // A canonical timeout timer and wait-specific event can describe one + // outcome. Do not count those physical rows as independent terminals. + return in_array($kind, ['activity', 'local_activity', 'timer'], true) + || ($kind === 'signal' && $type === 'SignalApplied'); } /** @param array $payload */ From 657c80667d83019529e6e256f445f1c49c5dfcf1 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:08:19 +0000 Subject: [PATCH 2/2] Check primary terminal uniqueness across the full successor --- tests/RedriveSuccessorStrictnessTest.php | 89 ++++++++++++++++++++--- tests/Support/RedriveSuccessorHistory.php | 66 +++++++++++++---- 2 files changed, 129 insertions(+), 26 deletions(-) diff --git a/tests/RedriveSuccessorStrictnessTest.php b/tests/RedriveSuccessorStrictnessTest.php index 174b4c70..f78b443c 100644 --- a/tests/RedriveSuccessorStrictnessTest.php +++ b/tests/RedriveSuccessorStrictnessTest.php @@ -233,6 +233,10 @@ public function testPriorCallTerminalLedgerScansItsWholeInterval( $foreign = $second; $kind = $opening['event_type']; $foreign['payload'][self::identity($kind)] = 'unrelated-resource'; + if ($scenario === 'later-other-step') { + $foreign = $second; + $foreign['payload']['sequence'] = 5; + } if ($scenario !== 'foreign-only' && $scenario !== 'later-only') { if ($scenario === 'foreign-before') { $fixture = self::insert($fixture, self::deliveryIndex($fixture), $foreign); @@ -248,10 +252,15 @@ public function testPriorCallTerminalLedgerScansItsWholeInterval( $fixture = self::insert($fixture, self::deliveryIndex($fixture), $second); } elseif (in_array($scenario, ['later-duplicate', 'later-only'], true)) { $fixture = self::insert($fixture, self::deliveryIndex($fixture) + 1, $second); + } elseif (in_array($scenario, ['later-foreign', 'later-other-step', 'later-duplicate-after-foreign'], true)) { + $fixture = self::insert($fixture, self::deliveryIndex($fixture) + 1, $foreign); + if ($scenario === 'later-duplicate-after-foreign') { + $fixture = self::insert($fixture, self::deliveryIndex($fixture) + 2, $second); + } } $violation = RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE]); - if (in_array($scenario, ['duplicate', 'duplicate-after-foreign'], true)) { + if (in_array($scenario, ['duplicate', 'duplicate-after-foreign', 'later-duplicate', 'later-duplicate-after-foreign'], true)) { self::assertNotNull($violation); self::assertStringContainsString('more than one terminal', $violation); } elseif (in_array($scenario, ['foreign-only', 'later-only'], true)) { @@ -277,18 +286,70 @@ public static function terminalLedgerCases(): iterable ]; foreach ($pairs as [$name, $first, $second]) { foreach (['duplicate', 'foreign-before', 'foreign-after', 'duplicate-after-foreign', - 'foreign-only', 'later-duplicate', 'later-only'] as $scenario) { + 'foreign-only', 'later-duplicate', 'later-only', 'later-foreign', 'later-other-step', + 'later-duplicate-after-foreign'] as $scenario) { yield $name.' / '.$first.' / '.$second.' / '.$scenario => [$families[$name][0], $first, $second, $scenario]; } } } + /** @param array $opening */ + #[DataProvider('wholeHistoryTerminalCases')] + public function testWholeSuccessorRejectsASecondPrimaryTerminal( + array $opening, + string $firstType, + string $secondType, + string $boundary, + ): void { + $fixture = self::priorCallFixture($opening); + if ($boundary === 'recorded delivery') { + $fixture['history'][6]['payload']['sequence'] = 3; + $fixture['history'][6]['payload']['call_kind'] = match ($opening['event_type']) { + 'TimerScheduled' => 'timer', + 'SignalWaitOpened' => 'signal', + default => ($opening['payload']['local_activity'] ?? false) ? 'local_activity' : 'activity', + }; + } elseif ($boundary === 'no cancellation') { + $fixture['history'] = array_values(array_filter($fixture['history'], static fn (array $event): bool => !in_array( + $event['event_type'], ['CooperativeCancellationRequested', 'CooperativeCancellationDelivered'], true, + ))); + } + $first = self::terminal($fixture, $firstType); + $position = $boundary === 'no cancellation' ? count($fixture['history']) : self::deliveryIndex($fixture); + $fixture = self::insert($fixture, $position, $first); + self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE]), + 'One terminal remains valid at each claimed history boundary.'); + $fixture = self::insert($fixture, count($fixture['history']), self::terminal($fixture, $secondType)); + $violation = RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE]); + + self::assertNotNull($violation); + self::assertStringContainsString('more than one terminal', $violation); + } + + /** @return iterable, string, string, string}> */ + public static function wholeHistoryTerminalCases(): iterable + { + foreach (self::priorCallFamilies() as $family => [$opening, , $terminals]) { + if (!in_array($family, ['activity', 'local activity', 'timer', 'signal'], true)) { + continue; + } + foreach ($terminals as $first) { + foreach ($terminals as $second) { + foreach (['next-step delivery', 'recorded delivery', 'no cancellation'] as $boundary) { + yield $family.' / '.$first.' / '.$second.' / '.$boundary => [$opening, $first, $second, $boundary]; + } + } + } + } + } + #[DataProvider('pairedWaitTerminals')] public function testCanonicalWaitAndBackingTimerRowsAreNotTwoLogicalTerminals( string $kind, string $layout, string $waitTerminal, string $timerTerminal, + bool $timerAfterDelivery, ): void { $fixture = self::waitTimeoutFixture($kind, $layout); $fixture = self::insert($fixture, self::deliveryIndex($fixture), [ @@ -296,18 +357,21 @@ public function testCanonicalWaitAndBackingTimerRowsAreNotTwoLogicalTerminals( 'payload' => ['sequence' => 3, $kind.'_wait_id' => $kind, 'workflow_command_id' => 'signal-command', 'signal_id' => 'signal-event'], ]); - $fixture = self::insert($fixture, self::deliveryIndex($fixture), self::waitTimeoutTerminal($kind, $timerTerminal)); + $fixture = self::insert($fixture, self::deliveryIndex($fixture) + (int) $timerAfterDelivery, + self::waitTimeoutTerminal($kind, $timerTerminal)); self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE])); } - /** @return iterable */ + /** @return iterable */ public static function pairedWaitTerminals(): iterable { foreach (['wait', 'timer', 'both'] as $layout) { - yield 'condition satisfied / '.$layout => ['condition', $layout, 'ConditionWaitSatisfied', 'TimerCancelled']; - yield 'condition timed out / '.$layout => ['condition', $layout, 'ConditionWaitTimedOut', 'TimerFired']; - yield 'signal applied / '.$layout => ['signal', $layout, 'SignalApplied', 'TimerCancelled']; + foreach ([false, true] as $later) { + yield 'condition satisfied / '.$layout.' / '.(int) $later => ['condition', $layout, 'ConditionWaitSatisfied', 'TimerCancelled', $later]; + yield 'condition timed out / '.$layout.' / '.(int) $later => ['condition', $layout, 'ConditionWaitTimedOut', 'TimerFired', $later]; + yield 'signal applied / '.$layout.' / '.(int) $later => ['signal', $layout, 'SignalApplied', 'TimerCancelled', $later]; + } } } @@ -328,13 +392,14 @@ public function testForeignTerminalModeAndTimerKindCannotChangeTheClosedState( $foreign['payload'] = [...$foreign['payload'], ...$foreignFields]; $fixture = self::insert($fixture, self::deliveryIndex($fixture), $valid); self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE])); - $fixture = self::insert($fixture, $scenario === 'before' ? 6 : self::deliveryIndex($fixture), $foreign); - if ($scenario === 'duplicate-after') { - $fixture = self::insert($fixture, self::deliveryIndex($fixture), $valid); + $later = str_starts_with($scenario, 'later-'); + $fixture = self::insert($fixture, $scenario === 'before' ? 6 : self::deliveryIndex($fixture) + (int) $later, $foreign); + if (str_ends_with($scenario, 'duplicate-after')) { + $fixture = self::insert($fixture, self::deliveryIndex($fixture) + ($later ? 2 : 0), $valid); } $violation = RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE]); - if ($scenario === 'duplicate-after') { + if (str_ends_with($scenario, 'duplicate-after')) { self::assertNotNull($violation); self::assertStringContainsString('more than one terminal', $violation); } else { @@ -353,7 +418,7 @@ public static function terminalModeAndKindCases(): iterable ['timer', 'TimerFired', ['timer_kind' => 'signal_timeout', 'signal_wait_id' => 'signal']], ]; foreach ($cases as $index => [$family, $type, $fields]) { - foreach (['before', 'after', 'duplicate-after'] as $scenario) { + foreach (['before', 'after', 'duplicate-after', 'later-after', 'later-duplicate-after'] as $scenario) { yield $family.' / '.$index.' / '.$scenario => [$families[$family][0], $type, $fields, $scenario]; } } diff --git a/tests/Support/RedriveSuccessorHistory.php b/tests/Support/RedriveSuccessorHistory.php index 6bf91c50..a627886a 100644 --- a/tests/Support/RedriveSuccessorHistory.php +++ b/tests/Support/RedriveSuccessorHistory.php @@ -10,8 +10,9 @@ * Checks the supported replay-fixture shapes against Workflow 2.19 redrive. * This is a test evidence model. Scoped, grouped, selection and service-operation * deliveries remain outside its qualified single-call boundary. - * Terminal multiplicity is checked for remote/local activities, plain timers - * and SignalApplied markers. Backing-timer/wait outcome combinations and child + * Terminal multiplicity is checked across the whole successor for remote/local + * activities, plain timers and SignalApplied markers. Prior-call reachability + * uses only rows before delivery. Backing-timer/wait outcome combinations and child * closure semantics retain their existing presence/identity bounds; this is * not a generalized logical-outcome interpreter for those shapes. * @@ -284,10 +285,13 @@ public static function violation(array $fixture, array $reusedEventTypes): ?stri return 'resume_step_sequence must be the last reused workflow step + 1 ('.($lastReusedStep + 1).')'; } if ($requests !== [] || $deliveries !== []) { - return self::cancellationViolation($history, $requests, $deliveries, $startedAt, $instance, $run); + $violation = self::cancellationViolation($history, $requests, $deliveries, $startedAt, $instance, $run); + if ($violation !== null) { + return $violation; + } } - return null; + return self::primaryTerminalViolation($history); } /** @@ -538,26 +542,60 @@ private static function callResolutionViolation(string $kind, array $payload, ar return $unresolved; } $resolved = false; - $closed = false; foreach ($afterOpening as $terminal) { - $terminalPayload = is_array($terminal['payload'] ?? null) ? $terminal['payload'] : []; - $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]) { + if (self::terminalMatchesCall($kind, $payload, [$identity, $terminals], $terminal)) { $resolved = true; - if (!self::closesModeledCall($kind, $type)) { + } + } + + return $resolved ? null : $unresolved; + } + + /** @param list> $history */ + private static function primaryTerminalViolation(array $history): ?string + { + foreach ($history as $index => $event) { + $payload = is_array($event['payload'] ?? null) ? $event['payload'] : []; + $kind = self::callKind((string) ($event['event_type'] ?? ''), $payload); + if (!in_array($kind, ['activity', 'local_activity', 'timer', 'signal'], true)) { + continue; + } + $shape = self::CALL_RESOLUTIONS[$kind]; + if (!self::nonBlank($payload[$shape[0]] ?? null) + || !is_int($payload['sequence'] ?? null) || $payload['sequence'] < 1) { + return 'a modeled primary call needs a resource identity and positive workflow step'; + } + $closed = false; + foreach (array_slice($history, $index + 1) as $terminal) { + if (!self::closesModeledCall($kind, $terminal['event_type'] ?? null) + || !self::terminalMatchesCall($kind, $payload, $shape, $terminal)) { continue; } if ($closed) { - return 'the cancellation delivery targets an unreachable next step: an earlier call has more than one terminal'; + return 'a modeled primary call has more than one terminal'; } $closed = true; } } - return $resolved ? null : $unresolved; + return null; + } + + /** + * @param array $opening + * @param array{string, list} $shape + * @param array $terminal + */ + private static function terminalMatchesCall(string $kind, array $opening, array $shape, array $terminal): bool + { + [$identity, $terminals] = $shape; + $payload = is_array($terminal['payload'] ?? null) ? $terminal['payload'] : []; + $type = $terminal['event_type'] ?? null; + + return in_array($type, $terminals, true) + && self::terminalFamilyMatches($kind, $type, $payload) + && ($payload['sequence'] ?? null) === $opening['sequence'] + && ($payload[$identity] ?? null) === $opening[$identity]; } private static function closesModeledCall(string $kind, mixed $type): bool