From d44bf00f311f3b86647148df36c34619c5c92446 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sat, 24 Oct 2026 02:00:28 +0000 Subject: [PATCH 1/4] test: require reader compatibility before rejected outcome emission --- .../V2/HistoryEventPayloadContractTest.php | 38 +++++++++ .../V2/QueryStateReplayerBoundaryTest.php | 77 +++++++++++++++++++ tests/Unit/V2/RunHistoryPageTest.php | 41 ++++++++++ tests/Unit/V2/WorkflowStepHistoryTest.php | 19 +++++ 4 files changed, 175 insertions(+) diff --git a/tests/Unit/V2/HistoryEventPayloadContractTest.php b/tests/Unit/V2/HistoryEventPayloadContractTest.php index 51c2ff052..21f313f04 100644 --- a/tests/Unit/V2/HistoryEventPayloadContractTest.php +++ b/tests/Unit/V2/HistoryEventPayloadContractTest.php @@ -5,12 +5,50 @@ namespace Tests\Unit\V2; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Workflow\V2\Enums\HistoryEventType; use Workflow\V2\Support\HistoryEventPayloadContract; final class HistoryEventPayloadContractTest extends TestCase { + public function testRejectedOutcomeReaderRecognizesOnlyBoundDiagnosticFields(): void + { + $type = HistoryEventType::from('ActivityOutcomeRejected'); + HistoryEventPayloadContract::assertKnownPayloadKeys($type, [ + 'activity_execution_id' => 'activity-one', + 'activity_attempt_id' => 'attempt-one', + 'attempt_number' => 1, + 'lease_owner' => 'original-worker', + 'outcome' => 'completed', + 'reason' => 'lease_expired', + 'observed_at' => '2026-10-24T01:00:00.000000Z', + ]); + + $this->assertSame('ActivityOutcomeRejected', $type->value); + $this->assertNull(HistoryEventType::tryFrom('UnrecognizedOutcome')); + } + + #[DataProvider('rejectedOutcomePrivateFields')] + public function testRejectedOutcomeContractDoesNotAllowBusinessPayloadsOrTaskSnapshots(string $field): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('undocumented key(s): ' . $field); + + HistoryEventPayloadContract::assertKnownPayloadKeys( + HistoryEventType::from('ActivityOutcomeRejected'), + [$field => 'must-not-be-retained'], + ); + } + + public static function rejectedOutcomePrivateFields(): array + { + return [ + ['result'], ['message'], ['stack_trace'], ['external_reference'], + ['task'], ['command'], ['sequence'], + ]; + } + public function testEveryHistoryEventTypeHasAPayloadContractEntry(): void { $registered = array_keys(HistoryEventPayloadContract::payloadKeys()); diff --git a/tests/Unit/V2/QueryStateReplayerBoundaryTest.php b/tests/Unit/V2/QueryStateReplayerBoundaryTest.php index 7aef34198..ba73ae2fc 100644 --- a/tests/Unit/V2/QueryStateReplayerBoundaryTest.php +++ b/tests/Unit/V2/QueryStateReplayerBoundaryTest.php @@ -19,11 +19,88 @@ use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowSignal; use Workflow\V2\Support\QueryStateReplayer; +use Workflow\V2\Support\HistoryExport; +use Workflow\V2\Support\HistoryTimeline; +use Workflow\V2\Support\WorkflowReplayer; +use Workflow\V2\Support\WorkflowStepHistory; final class QueryStateReplayerBoundaryTest extends TestCase { private const GOLDEN_HISTORY = __DIR__ . '/../../Fixtures/V2/GoldenHistory/php-workflow-2.0.0-alpha.7.json'; + public function testRejectedOutcomePreservesColdAndExportedReplayWithoutBecomingABusinessStep(): void + { + foreach ($this->goldenCases() as $caseIndex => $case) { + $original = $this->createRun( + $case['scenario'], $case['history'], "original-{$caseIndex}", RunStatus::Completed, + ); + $originalState = (new QueryStateReplayer())->replayState($original->fresh()); + $originalNext = WorkflowStepHistory::nextDurableCommandSequence($original->fresh()); + $history = $case['history']; + array_splice($history, 1, 0, [[ + 'event_type' => 'ActivityOutcomeRejected', + 'payload' => $this->rejectedOutcomePayload(), + ]]); + $run = $this->createRun($case['scenario'], $history, "diagnostic-{$caseIndex}", RunStatus::Completed); + $before = $run->getRawOriginal(); + $rowCounts = $this->runtimeRowCounts($run); + + $cold = (new QueryStateReplayer())->replayState($run->fresh()); + $bundle = HistoryExport::forRun($run->fresh()); + $exported = (new WorkflowReplayer())->replayExport($bundle); + $timeline = HistoryTimeline::fromHistory($run->fresh()); + $point = collect($timeline)->firstWhere('type', 'ActivityOutcomeRejected'); + + $this->assertNull($cold->current); + $this->assertNull($exported->current); + $this->assertSame($originalState->sequence, $cold->sequence); + $this->assertSame($originalState->sequence, $exported->sequence); + $this->assertSame($case['expected_state'], $cold->workflow->currentState()); + $this->assertSame($case['expected_state'], $exported->workflow->currentState()); + $this->assertSame($originalNext, WorkflowStepHistory::nextDurableCommandSequence($run->fresh())); + $this->assertSame('activity', $point['kind']); + $this->assertSame('original-activity', $point['source_id']); + $this->assertStringContainsString('lease_expired', $point['summary']); + $this->assertSame($before, $run->fresh()->getRawOriginal()); + $this->assertSame($rowCounts, $this->runtimeRowCounts($run)); + } + } + + public function testRejectedCompletionCannotResolveAnUnfinishedActivity(): void + { + $case = $this->goldenCases()[0]; + $history = [ + $case['history'][0], + ['event_type' => 'ActivityOutcomeRejected', 'payload' => $this->rejectedOutcomePayload()], + ]; + $run = $this->createRun('single-activity', $history, 'rejection-is-not-completion', RunStatus::Waiting); + $before = $this->runtimeRowCounts($run); + + $cold = (new QueryStateReplayer())->replayState($run->fresh()); + $exported = (new WorkflowReplayer())->replayExport(HistoryExport::forRun($run->fresh())); + + $this->assertNotNull($cold->current); + $this->assertNotNull($exported->current); + $this->assertSame($cold->current::class, $exported->current::class); + $this->assertSame($cold->sequence, $exported->sequence); + $this->assertSame($before, $this->runtimeRowCounts($run)); + $this->assertSame(RunStatus::Waiting, $run->fresh()->status); + $this->assertNull($run->fresh()->output); + } + + private function rejectedOutcomePayload(): array + { + return [ + 'activity_execution_id' => 'original-activity', + 'activity_attempt_id' => 'original-attempt', + 'attempt_number' => 1, + 'lease_owner' => 'original-worker', + 'outcome' => 'completed', + 'reason' => 'lease_expired', + 'observed_at' => '2026-10-24T01:00:00.000000Z', + ]; + } + public function testEveryGoldenHistoryPrefixReplaysDeterministicallyWithoutWritingRuntimeState(): void { Carbon::setTestNow('2026-08-29 12:00:00'); diff --git a/tests/Unit/V2/RunHistoryPageTest.php b/tests/Unit/V2/RunHistoryPageTest.php index 9bf69034f..5884665c8 100644 --- a/tests/Unit/V2/RunHistoryPageTest.php +++ b/tests/Unit/V2/RunHistoryPageTest.php @@ -19,6 +19,47 @@ final class RunHistoryPageTest extends TestCase { + public function testRejectedOutcomeIsReadableWithoutChangingBusinessStateOrRebuildingProjections(): void + { + $run = $this->createRun('rejected-outcome', 'namespace-a'); + $this->events($run, 1, 2); + $payload = [ + 'activity_execution_id' => 'original-activity', + 'activity_attempt_id' => 'original-attempt', + 'attempt_number' => 1, + 'lease_owner' => 'original-worker', + 'outcome' => 'failed', + 'reason' => 'lease_expired', + 'observed_at' => '2026-10-24T01:00:00.000000Z', + ]; + DB::table('workflow_history_events')->where('id', 'rejected-outcome-2')->update([ + 'event_type' => 'ActivityOutcomeRejected', + 'payload' => json_encode($payload, JSON_THROW_ON_ERROR), + ]); + $before = $run->getRawOriginal(); + DB::flushQueryLog(); + DB::enableQueryLog(); + + try { + $page = RunHistoryPage::forRun($run, afterSequence: 1); + $queries = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertCount(1, $page['events']); + $this->assertSame('ActivityOutcomeRejected', $page['events'][0]['event_type']); + $this->assertSame($payload, $page['events'][0]['payload']); + $this->assertSame(2, $page['through_sequence']); + $this->assertSame($before, $run->fresh()->getRawOriginal()); + $this->assertSame(2, WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)->count()); + $this->assertSame(0, DB::table('workflow_run_timeline_entries')->count()); + $this->assertSame(0, WorkflowRunSummary::query()->where('id', $run->id)->count()); + foreach ($queries as $query) { + $this->assertStringStartsWith('select ', strtolower(ltrim($query['query']))); + } + } + public function testLongHistoryReadsOnlyOnePageAndNoChildOrProjectionModels(): void { $run = $this->createRun('selected', 'namespace-a'); diff --git a/tests/Unit/V2/WorkflowStepHistoryTest.php b/tests/Unit/V2/WorkflowStepHistoryTest.php index ac815054b..977dd4485 100644 --- a/tests/Unit/V2/WorkflowStepHistoryTest.php +++ b/tests/Unit/V2/WorkflowStepHistoryTest.php @@ -16,6 +16,25 @@ final class WorkflowStepHistoryTest extends TestCase { + public function testRejectedOutcomeDoesNotResolveAnActivityOrConsumeACommandPosition(): void + { + $run = $this->runWithHistoryEvents([ + $this->historyEvent(HistoryEventType::ActivityScheduled, [ + 'sequence' => 1, + ]), + $this->historyEvent(HistoryEventType::from('ActivityOutcomeRejected'), [ + 'activity_execution_id' => 'activity-one', + 'outcome' => 'completed', + 'reason' => 'lease_expired', + 'sequence' => 999, + ]), + ]); + + $this->assertSame(2, WorkflowStepHistory::nextDurableCommandSequence($run)); + WorkflowStepHistory::assertCompatible($run, 1, WorkflowStepHistory::ACTIVITY); + WorkflowStepHistory::assertCompatible($run, 2, WorkflowStepHistory::NO_TYPED_HISTORY); + } + public function testScopeOpeningReservesItsOwnDurableCommandPosition(): void { $run = $this->runWithHistoryEvents([ From e814c2217cae9f1e57e1115bef95c179e71a64f8 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sat, 24 Oct 2026 02:06:19 +0000 Subject: [PATCH 2/4] fix: prepare history readers before rejected outcome emission --- docs/rejected-outcome-reader-rollout.md | 39 +++++++ src/V2/Enums/HistoryEventType.php | 1 + .../Support/HistoryEventPayloadContract.php | 4 + src/V2/Support/HistoryTimeline.php | 42 ++++++++ ...activity-rejected-outcome-cold-replay.json | 100 ++++++++++++++++++ .../V2/HistoryEventPayloadContractTest.php | 4 +- .../V2/QueryStateReplayerBoundaryTest.php | 42 +++++--- tests/Unit/V2/RunHistoryPageTest.php | 1 + 8 files changed, 215 insertions(+), 18 deletions(-) create mode 100644 docs/rejected-outcome-reader-rollout.md create mode 100644 tests/Fixtures/V2/ReplayRegression/activity-rejected-outcome-cold-replay.json diff --git a/docs/rejected-outcome-reader-rollout.md b/docs/rejected-outcome-reader-rollout.md new file mode 100644 index 000000000..08264e70e --- /dev/null +++ b/docs/rejected-outcome-reader-rollout.md @@ -0,0 +1,39 @@ +# Reader-first rollout for rejected activity outcomes + +The rejected-outcome audit in [issue 878](https://github.com/durable-workflow/workflow/issues/878) +is not yet published. Its emitter remains separate from this reader change. + +Core 2.12.0 cannot read `ActivityOutcomeRejected`: the history model's enum cast +throws `ValueError`. Adding the event to an application's database before every +reader understands it would break history pages, exports and replay inspection. + +This bridge recognizes the proposed event and its bounded identity, outcome, +reason and observation fields. It maps a diagnostic timeline point and preserves +the event during history export. It does not emit events, accept late activity +results, renew leases, change retries or turn a refused result into a replay step. +Unknown event names remain unknown; this is not a catch-all enum fallback. + +## Qualification and deployment order + +1. Qualify and publish the non-emitting reader release. Exercise history pages, + cold replay and exported replay with constructed rejected-outcome facts. + A rejected completion must leave an unfinished activity unresolved. +2. Update all participating embedded applications, PHP Server roles, inspection + tools and rollback artifacts to a reader-equipped release. A package tag does + not update a pinned Server image. Keep the emitter disabled while any reader + still uses Core 2.12.0 or earlier without this bridge. +3. Qualify the normal PHP, Python and Rust SDK readers and real Waterline against + the exact published tuple. Frozen parity actors are not moved to establish + this acceptance, and no native adoption follows from the PHP release. +4. Qualify the separate emitter's identity, authentication, retention races, + early refusal before external resolution and bounded first-record contract. + Only then publish and adopt that change. The audit remains diagnostic and + never grants business-outcome authority. + +After the first new fact is written, rolling back to an older reader that rejects +the event is unsafe. Use the qualified reader-equipped rollback artifact. Do not +delete audit facts, drop unknown rows or rewrite business history to make an old +binary appear compatible. + +These are maintainer delivery gates. Source checks alone do not establish a +published reader, a completed rollout or the full activity lease requirement. diff --git a/src/V2/Enums/HistoryEventType.php b/src/V2/Enums/HistoryEventType.php index 8d6cca8d1..aba689e00 100644 --- a/src/V2/Enums/HistoryEventType.php +++ b/src/V2/Enums/HistoryEventType.php @@ -55,6 +55,7 @@ enum HistoryEventType: string case ActivityRetryScheduled = 'ActivityRetryScheduled'; case ActivityCompleted = 'ActivityCompleted'; case ActivityFailed = 'ActivityFailed'; + case ActivityOutcomeRejected = 'ActivityOutcomeRejected'; case ActivityCancelled = 'ActivityCancelled'; case ActivityCancellationAcknowledged = 'ActivityCancellationAcknowledged'; case ActivityTimedOut = 'ActivityTimedOut'; diff --git a/src/V2/Support/HistoryEventPayloadContract.php b/src/V2/Support/HistoryEventPayloadContract.php index ccf660a4e..f4e54b8f8 100644 --- a/src/V2/Support/HistoryEventPayloadContract.php +++ b/src/V2/Support/HistoryEventPayloadContract.php @@ -25,6 +25,10 @@ final class HistoryEventPayloadContract * @var array> */ private const PAYLOAD_KEYS = [ + 'ActivityOutcomeRejected' => [ + 'activity_execution_id', 'activity_attempt_id', 'attempt_number', + 'lease_owner', 'outcome', 'reason', 'observed_at', + ], 'CancellationScopeRequested' => [ 'schema', 'workflow_run_id', 'scope_id', 'parent_scope_id', 'request_id', 'cancellation', ], diff --git a/src/V2/Support/HistoryTimeline.php b/src/V2/Support/HistoryTimeline.php index 58626fcd3..a6b5d3304 100644 --- a/src/V2/Support/HistoryTimeline.php +++ b/src/V2/Support/HistoryTimeline.php @@ -142,6 +142,45 @@ public static function fromWorkerTerminalEvent(WorkflowHistoryEvent $event): arr return self::mapEvent($event, collect(), collect(), collect(), collect(), $failures); } + /** + * @return array + */ + public static function fromActivityOutcomeRejection(WorkflowHistoryEvent $event): array + { + if ($event->event_type !== HistoryEventType::ActivityOutcomeRejected) { + throw new \LogicException('Rejection timeline requires an ActivityOutcomeRejected event.'); + } + $payload = is_array($event->payload) ? $event->payload : []; + + return [ + 'id' => $event->id, + 'sequence' => $event->sequence, + 'type' => $event->event_type->value, + 'kind' => 'activity', + 'entry_kind' => 'point', + 'source_kind' => 'activity_execution', + 'source_id' => $payload['activity_execution_id'] ?? null, + 'summary' => sprintf( + 'Rejected %s outcome from activity attempt %s: %s.', + $payload['outcome'] ?? 'unknown', + $payload['attempt_number'] ?? 'unknown', + $payload['reason'] ?? 'unknown', + ), + 'recorded_at' => self::timestamp($event->recorded_at), + 'command_id' => null, + 'command_sequence' => null, + 'task_id' => $event->workflow_task_id, + 'activity_execution_id' => $payload['activity_execution_id'] ?? null, + 'timer_id' => null, + 'failure_id' => null, + 'activity' => [ + 'id' => $payload['activity_execution_id'] ?? null, + 'attempt_id' => $payload['activity_attempt_id'] ?? null, + 'attempt_count' => $payload['attempt_number'] ?? null, + ], + ]; + } + private static function mapEvent( WorkflowHistoryEvent $event, Collection $commands, @@ -150,6 +189,9 @@ private static function mapEvent( Collection $timers, Collection $failures, ): array { + if ($event->event_type === HistoryEventType::ActivityOutcomeRejected) { + return self::fromActivityOutcomeRejection($event); + } /** @var array $payload */ $payload = is_array($event->payload) ? $event->payload : []; $commandId = self::stringValue($event->workflow_command_id) diff --git a/tests/Fixtures/V2/ReplayRegression/activity-rejected-outcome-cold-replay.json b/tests/Fixtures/V2/ReplayRegression/activity-rejected-outcome-cold-replay.json new file mode 100644 index 000000000..8eda29080 --- /dev/null +++ b/tests/Fixtures/V2/ReplayRegression/activity-rejected-outcome-cold-replay.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://raw.githubusercontent.com/durable-workflow/.github/main/regression-corpus/evidence-schema.json", + "fixture_schema": "durable-workflow.replay-regression/v1", + "id": "activity-rejected-outcome-cold-replay", + "protocol_version": "1.0", + "bindings": ["php"], + "consumers": ["workflow-fiber-runner"], + "workflow": { + "type": "Tests\\Fixtures\\V2\\TestHandledFailureWorkflow", + "arguments": [], + "payload_codec": "avro" + }, + "history": [ + { + "sequence": 1, + "event_type": "WorkflowStarted", + "payload": {}, + "recorded_at": "2026-10-22T01:00:00.000000Z" + }, + { + "sequence": 2, + "event_type": "ActivityScheduled", + "recorded_at": "2026-10-22T01:00:00.000001Z", + "payload": { + "sequence": 1, + "activity_execution_id": "01j00000000000000000000001", + "activity_class": "Tests\\Fixtures\\V2\\TestFailingActivity", + "activity_type": "Tests\\Fixtures\\V2\\TestFailingActivity" + } + }, + { + "sequence": 3, + "event_type": "ActivityStarted", + "recorded_at": "2026-10-22T01:00:00.000002Z", + "payload": { + "sequence": 1, + "activity_execution_id": "01j00000000000000000000001", + "activity_attempt_id": "01j0000000000000000000000a", + "activity_class": "Tests\\Fixtures\\V2\\TestFailingActivity", + "activity_type": "Tests\\Fixtures\\V2\\TestFailingActivity", + "attempt_number": 1 + } + }, + { + "sequence": 4, + "event_type": "ActivityFailed", + "recorded_at": "2026-10-22T01:00:30.000002Z", + "payload": { + "sequence": 1, + "activity_execution_id": "01j00000000000000000000001", + "activity_attempt_id": "01j0000000000000000000000a", + "activity_class": "Tests\\Fixtures\\V2\\TestFailingActivity", + "activity_type": "Tests\\Fixtures\\V2\\TestFailingActivity", + "attempt_number": 1, + "failure_id": "01j0000000000000000000000f", + "failure_category": "internal", + "non_retryable": false, + "failure_reason": "lease_expired", + "lease_expires_at": "2026-10-22T01:00:30.000002Z", + "lease_owner": "original-worker", + "exception_class": "Workflow\\V2\\Exceptions\\ActivityLeaseExpiredException", + "message": "Activity Tests\\Fixtures\\V2\\TestFailingActivity lost its original task lease before the attempt settled." + } + }, + { + "sequence": 5, + "event_type": "ActivityOutcomeRejected", + "recorded_at": "2026-10-22T01:00:30.500000Z", + "payload": { + "activity_execution_id": "01j00000000000000000000001", + "activity_attempt_id": "01j0000000000000000000000a", + "attempt_number": 1, + "lease_owner": "original-worker", + "outcome": "completed", + "reason": "stale_attempt", + "observed_at": "2026-10-22T01:00:30.500000Z" + } + }, + { + "sequence": 6, + "event_type": "ActivityCompleted", + "recorded_at": "2026-10-22T01:00:31.000000Z", + "payload": { + "sequence": 2, + "activity_execution_id": "01j00000000000000000000002", + "activity_attempt_id": "01j0000000000000000000000b", + "activity_class": "Tests\\Fixtures\\V2\\TestGreetingActivity", + "activity_type": "Tests\\Fixtures\\V2\\TestGreetingActivity", + "attempt_number": 1, + "result": "wwHioz3/VYAiNwoiSGVsbG8sIFJlY292ZXJlZCE=", + "payload_codec": "avro" + } + } + ], + "expected": { + "completed": true, + "result": "Hello, Recovered!", + "commands": [{"type": "complete_workflow"}] + } +} diff --git a/tests/Unit/V2/HistoryEventPayloadContractTest.php b/tests/Unit/V2/HistoryEventPayloadContractTest.php index 21f313f04..309a86683 100644 --- a/tests/Unit/V2/HistoryEventPayloadContractTest.php +++ b/tests/Unit/V2/HistoryEventPayloadContractTest.php @@ -37,7 +37,9 @@ public function testRejectedOutcomeContractDoesNotAllowBusinessPayloadsOrTaskSna HistoryEventPayloadContract::assertKnownPayloadKeys( HistoryEventType::from('ActivityOutcomeRejected'), - [$field => 'must-not-be-retained'], + [ + $field => 'must-not-be-retained', + ], ); } diff --git a/tests/Unit/V2/QueryStateReplayerBoundaryTest.php b/tests/Unit/V2/QueryStateReplayerBoundaryTest.php index ba73ae2fc..9cbc18ed9 100644 --- a/tests/Unit/V2/QueryStateReplayerBoundaryTest.php +++ b/tests/Unit/V2/QueryStateReplayerBoundaryTest.php @@ -18,9 +18,9 @@ use Workflow\V2\Models\WorkflowInstance; use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowSignal; -use Workflow\V2\Support\QueryStateReplayer; use Workflow\V2\Support\HistoryExport; use Workflow\V2\Support\HistoryTimeline; +use Workflow\V2\Support\QueryStateReplayer; use Workflow\V2\Support\WorkflowReplayer; use Workflow\V2\Support\WorkflowStepHistory; @@ -32,7 +32,10 @@ public function testRejectedOutcomePreservesColdAndExportedReplayWithoutBecoming { foreach ($this->goldenCases() as $caseIndex => $case) { $original = $this->createRun( - $case['scenario'], $case['history'], "original-{$caseIndex}", RunStatus::Completed, + $case['scenario'], + $case['history'], + "original-{$caseIndex}", + RunStatus::Completed, ); $originalState = (new QueryStateReplayer())->replayState($original->fresh()); $originalNext = WorkflowStepHistory::nextDurableCommandSequence($original->fresh()); @@ -42,6 +45,7 @@ public function testRejectedOutcomePreservesColdAndExportedReplayWithoutBecoming 'payload' => $this->rejectedOutcomePayload(), ]]); $run = $this->createRun($case['scenario'], $history, "diagnostic-{$caseIndex}", RunStatus::Completed); + $run = $run->fresh(); $before = $run->getRawOriginal(); $rowCounts = $this->runtimeRowCounts($run); @@ -49,7 +53,8 @@ public function testRejectedOutcomePreservesColdAndExportedReplayWithoutBecoming $bundle = HistoryExport::forRun($run->fresh()); $exported = (new WorkflowReplayer())->replayExport($bundle); $timeline = HistoryTimeline::fromHistory($run->fresh()); - $point = collect($timeline)->firstWhere('type', 'ActivityOutcomeRejected'); + $point = collect($timeline) + ->firstWhere('type', 'ActivityOutcomeRejected'); $this->assertNull($cold->current); $this->assertNull($exported->current); @@ -71,7 +76,10 @@ public function testRejectedCompletionCannotResolveAnUnfinishedActivity(): void $case = $this->goldenCases()[0]; $history = [ $case['history'][0], - ['event_type' => 'ActivityOutcomeRejected', 'payload' => $this->rejectedOutcomePayload()], + [ + 'event_type' => 'ActivityOutcomeRejected', + 'payload' => $this->rejectedOutcomePayload(), + ], ]; $run = $this->createRun('single-activity', $history, 'rejection-is-not-completion', RunStatus::Waiting); $before = $this->runtimeRowCounts($run); @@ -88,19 +96,6 @@ public function testRejectedCompletionCannotResolveAnUnfinishedActivity(): void $this->assertNull($run->fresh()->output); } - private function rejectedOutcomePayload(): array - { - return [ - 'activity_execution_id' => 'original-activity', - 'activity_attempt_id' => 'original-attempt', - 'attempt_number' => 1, - 'lease_owner' => 'original-worker', - 'outcome' => 'completed', - 'reason' => 'lease_expired', - 'observed_at' => '2026-10-24T01:00:00.000000Z', - ]; - } - public function testEveryGoldenHistoryPrefixReplaysDeterministicallyWithoutWritingRuntimeState(): void { Carbon::setTestNow('2026-08-29 12:00:00'); @@ -165,6 +160,19 @@ public function testReplayRejectsAnActivityCompletionForTheWrongAuthoredActivity (new QueryStateReplayer())->replayState($run->fresh()); } + private function rejectedOutcomePayload(): array + { + return [ + 'activity_execution_id' => 'original-activity', + 'activity_attempt_id' => 'original-attempt', + 'attempt_number' => 1, + 'lease_owner' => 'original-worker', + 'outcome' => 'completed', + 'reason' => 'lease_expired', + 'observed_at' => '2026-10-24T01:00:00.000000Z', + ]; + } + /** * @return list 'ActivityOutcomeRejected', 'payload' => json_encode($payload, JSON_THROW_ON_ERROR), ]); + $run = $run->fresh(); $before = $run->getRawOriginal(); DB::flushQueryLog(); DB::enableQueryLog(); From b80f5740cb645b1c3815c8e9191dd01543568a65 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:01:34 +0000 Subject: [PATCH 3/4] Keep reader rollout aligned with combined patch qualification --- docs/rejected-outcome-reader-rollout.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/rejected-outcome-reader-rollout.md b/docs/rejected-outcome-reader-rollout.md index cf64451d0..be58f7c77 100644 --- a/docs/rejected-outcome-reader-rollout.md +++ b/docs/rejected-outcome-reader-rollout.md @@ -16,13 +16,13 @@ Unknown event names remain unknown; this is not a catch-all enum fallback. ## Qualification and deployment order 1. Qualify and publish the non-emitting reader release after the independent - Core 2.12.1 failure-storage patch. Exercise history pages, + failure-storage and retention repairs. Exercise history pages, cold replay and exported replay with constructed rejected-outcome facts. A rejected completion must leave an unfinished activity unresolved. 2. Update all participating embedded applications, PHP Server roles, inspection tools and rollback artifacts to a reader-equipped release. A package tag does not update a pinned Server image. Keep the emitter disabled while any reader - still uses a release without this bridge, including Core 2.12.1. Qualify the + still uses a release without this bridge. Qualify the exact reader-equipped package and image rather than assuming that a version range or an unrelated patch includes this event. 3. Qualify the normal PHP, Python and Rust SDK readers and real Waterline against From a1b4c516a9b44c93a444bed6bc6f8ebac821ec06 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:31:02 +0000 Subject: [PATCH 4/4] Bound rejected-outcome timeline diagnostics --- CHANGELOG.md | 4 +- src/V2/Support/HistoryTimeline.php | 23 +++++----- .../V2/HistoryTimelineSummaryLimitTest.php | 43 +++++++++++++++++++ 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1582a6f02..41a4b9d63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,12 @@ # Changelog -## 2.13.0 - Unreleased +## 2.13.0 - 2026-10-24 - Recognize bounded `ActivityOutcomeRejected` diagnostics in history pages, timeline inspection, exports and cold replay. The reader preserves business replay positions and does not produce the event or accept a refused outcome. +- Bound diagnostic timeline summaries for storage and tolerate malformed fields + without changing the original history payload. - Require reader-equipped applications, Server roles, inspection tools and rollback artifacts before enabling the separate rejected-outcome producer. diff --git a/src/V2/Support/HistoryTimeline.php b/src/V2/Support/HistoryTimeline.php index a6b5d3304..6519357ba 100644 --- a/src/V2/Support/HistoryTimeline.php +++ b/src/V2/Support/HistoryTimeline.php @@ -151,6 +151,9 @@ public static function fromActivityOutcomeRejection(WorkflowHistoryEvent $event) throw new \LogicException('Rejection timeline requires an ActivityOutcomeRejected event.'); } $payload = is_array($event->payload) ? $event->payload : []; + $activityId = self::stringValue($payload['activity_execution_id'] ?? null); + $attemptId = self::stringValue($payload['activity_attempt_id'] ?? null); + $attemptNumber = self::intValue($payload['attempt_number'] ?? null); return [ 'id' => $event->id, @@ -159,24 +162,24 @@ public static function fromActivityOutcomeRejection(WorkflowHistoryEvent $event) 'kind' => 'activity', 'entry_kind' => 'point', 'source_kind' => 'activity_execution', - 'source_id' => $payload['activity_execution_id'] ?? null, - 'summary' => sprintf( + 'source_id' => $activityId, + 'summary' => self::boundedSummary(sprintf( 'Rejected %s outcome from activity attempt %s: %s.', - $payload['outcome'] ?? 'unknown', - $payload['attempt_number'] ?? 'unknown', - $payload['reason'] ?? 'unknown', - ), + self::stringValue($payload['outcome'] ?? null) ?? 'unknown', + $attemptNumber ?? 'unknown', + self::stringValue($payload['reason'] ?? null) ?? 'unknown', + ), ''), 'recorded_at' => self::timestamp($event->recorded_at), 'command_id' => null, 'command_sequence' => null, 'task_id' => $event->workflow_task_id, - 'activity_execution_id' => $payload['activity_execution_id'] ?? null, + 'activity_execution_id' => $activityId, 'timer_id' => null, 'failure_id' => null, 'activity' => [ - 'id' => $payload['activity_execution_id'] ?? null, - 'attempt_id' => $payload['activity_attempt_id'] ?? null, - 'attempt_count' => $payload['attempt_number'] ?? null, + 'id' => $activityId, + 'attempt_id' => $attemptId, + 'attempt_count' => $attemptNumber, ], ]; } diff --git a/tests/Unit/V2/HistoryTimelineSummaryLimitTest.php b/tests/Unit/V2/HistoryTimelineSummaryLimitTest.php index b4b2bc267..d1de63eef 100644 --- a/tests/Unit/V2/HistoryTimelineSummaryLimitTest.php +++ b/tests/Unit/V2/HistoryTimelineSummaryLimitTest.php @@ -14,6 +14,49 @@ final class HistoryTimelineSummaryLimitTest extends TestCase { + public function testRejectedOutcomeSummaryIsBoundedWithoutChangingDiagnosticEvidence(): void + { + $payload = [ + 'activity_execution_id' => 'original-activity', + 'activity_attempt_id' => 'original-attempt', + 'attempt_number' => 1, + 'outcome' => 'completed', + 'reason' => str_repeat('界🦀', 10_000), + ]; + $event = $this->event(HistoryEventType::ActivityOutcomeRejected, $payload); + $entry = HistoryTimeline::fromHistory($this->timelineRun($event))[0]; + + $this->assertLessThanOrEqual(65_535, strlen($entry['summary'])); + $this->assertTrue(mb_check_encoding($entry['summary'], 'UTF-8')); + $this->assertStringContainsString('[truncated]', $entry['summary']); + $this->assertSame('original-activity', $entry['activity_execution_id']); + $this->assertSame('original-attempt', $entry['activity']['attempt_id']); + $this->assertSame(1, $entry['activity']['attempt_count']); + $this->assertSame($payload, $event->payload); + $this->assertSame($entry, HistoryTimeline::fromActivityOutcomeRejection($event)); + } + + public function testMalformedRejectedOutcomeFieldsDoNotBreakTimelineInspection(): void + { + $payload = [ + 'activity_execution_id' => [], + 'activity_attempt_id' => [], + 'attempt_number' => [], + 'outcome' => [], + 'reason' => [], + ]; + $event = $this->event(HistoryEventType::ActivityOutcomeRejected, $payload); + $entry = HistoryTimeline::fromHistory($this->timelineRun($event))[0]; + + $this->assertSame('Rejected unknown outcome from activity attempt unknown: unknown.', $entry['summary']); + $this->assertNull($entry['source_id']); + $this->assertNull($entry['activity_execution_id']); + $this->assertNull($entry['activity']['id']); + $this->assertNull($entry['activity']['attempt_id']); + $this->assertNull($entry['activity']['attempt_count']); + $this->assertSame($payload, $event->payload); + } + #[DataProvider('failureEvents')] public function testLargeSummariesFitStorageWithoutChangingFailureEvidence( HistoryEventType $type,