Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## 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.

## 2.12.2 - 2026-10-24

- Read recent failure links and rebuild history-budget counters on PostgreSQL
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
"dev-main": "2.0.x-dev"
},
"durable-workflow": {
"product-train": "2.12.2",
"product-train": "2.13.0",
"laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json",
"laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json"
},
Expand Down
42 changes: 42 additions & 0 deletions docs/rejected-outcome-reader-rollout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 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.

The prospective Core 2.13.0 reader 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 after the independent
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. 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
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.
1 change: 1 addition & 0 deletions src/V2/Enums/HistoryEventType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 4 additions & 0 deletions src/V2/Support/HistoryEventPayloadContract.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ final class HistoryEventPayloadContract
* @var array<string, list<string>>
*/
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',
],
Expand Down
45 changes: 45 additions & 0 deletions src/V2/Support/HistoryTimeline.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,48 @@ public static function fromWorkerTerminalEvent(WorkflowHistoryEvent $event): arr
return self::mapEvent($event, collect(), collect(), collect(), collect(), $failures);
}

/**
* @return array<string, mixed>
*/
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 : [];
$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,
'sequence' => $event->sequence,
'type' => $event->event_type->value,
'kind' => 'activity',
'entry_kind' => 'point',
'source_kind' => 'activity_execution',
'source_id' => $activityId,
'summary' => self::boundedSummary(sprintf(
'Rejected %s outcome from activity attempt %s: %s.',
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' => $activityId,
'timer_id' => null,
'failure_id' => null,
'activity' => [
'id' => $activityId,
'attempt_id' => $attemptId,
'attempt_count' => $attemptNumber,
],
];
}

private static function mapEvent(
WorkflowHistoryEvent $event,
Collection $commands,
Expand All @@ -150,6 +192,9 @@ private static function mapEvent(
Collection $timers,
Collection $failures,
): array {
if ($event->event_type === HistoryEventType::ActivityOutcomeRejected) {
return self::fromActivityOutcomeRejection($event);
}
/** @var array<string, mixed> $payload */
$payload = is_array($event->payload) ? $event->payload : [];
$commandId = self::stringValue($event->workflow_command_id)
Expand Down
Original file line number Diff line number Diff line change
@@ -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"}]
}
}
40 changes: 40 additions & 0 deletions tests/Unit/V2/HistoryEventPayloadContractTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,52 @@
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());
Expand Down
43 changes: 43 additions & 0 deletions tests/Unit/V2/HistoryTimelineSummaryLimitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading