diff --git a/CHANGELOG.md b/CHANGELOG.md index b25f73b2..001f9a55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ project follows [Semantic Versioning](https://semver.org/). ### Added +- `WorkflowContext::upsertSearchAttributes()` accepts an optional complete map + of canonical type declarations (#244). Explicitly typed calls bind replay to + both values and recorded type identity. Existing one-argument calls retain + value-only replay, and histories without type metadata retain legacy + compatibility. Malformed recorded type identities are refused. + - `WorkflowContext::startChildWorkflow()` starts a child and returns a `ChildWorkflowHandle` once the runtime has committed the start (#185). The handle's `identity()` gives the child call ID, the child workflow ID and the diff --git a/docs/portal/build/workflows-activities.md b/docs/portal/build/workflows-activities.md index 5fd73ffb..d4498bc5 100644 --- a/docs/portal/build/workflows-activities.md +++ b/docs/portal/build/workflows-activities.md @@ -605,6 +605,31 @@ The context provides these replay-aware commands: Do not use PHP's process sleep, random-number functions, current wall-clock time, or mutable global state directly to make workflow decisions. `sideEffect()` is for small values whose result belongs in history, not for network or database work. +## Declare search-attribute types + +Pass a type map when the workflow must preserve search-attribute type identity +across replay and redrive: + +```php +$context->upsertSearchAttributes( + ['Stage' => 'approved', 'Attempts' => 2], + ['Stage' => 'keyword', 'Attempts' => 'int'], +); +``` + +Declare every non-deleted attribute. Canonical types are `string`, `keyword`, +`keyword_list`, `int`, `float`, `bool`, and `datetime`. Use `null` to delete a +key. The runtime also checks declarations against the namespace's registered +types. Search metadata uses the existing `typed_search_attributes` worker +capability and protocol contract. + +An explicitly typed call refuses a changed value or a changed recorded type. +Malformed recorded type maps also refuse replay. Histories written before type +metadata existed still compare values, since their type identity is unknown. +Existing calls that omit the type map keep their value-only behavior. That +legacy path does not establish typed replay equivalence; signal-wait redrive +qualification is also separate. + ## Bound long histories Use `continueAsNew()` for recurring or event-heavy workflows. Pass only the state needed by the next run: diff --git a/regression-corpus-policy.json b/regression-corpus-policy.json index 9bbabfba..2f415569 100644 --- a/regression-corpus-policy.json +++ b/regression-corpus-policy.json @@ -44,6 +44,9 @@ { "glob": "src/Worker/ConditionPredicateSource.php" }, + { + "glob": "src/Worker/SearchAttributeTypes.php" + }, { "glob": "src/Worker/WorkflowEntryArguments.php" }, diff --git a/scripts/ci/run-replay-regression-fixture.php b/scripts/ci/run-replay-regression-fixture.php index 242f2e72..c402d55d 100644 --- a/scripts/ci/run-replay-regression-fixture.php +++ b/scripts/ci/run-replay-regression-fixture.php @@ -943,6 +943,12 @@ private static function workflow(string $workflowType): callable return $context->activity('Tests\\Fixtures\\V2\\TestRedriveFirstActivity', [$name]); }, + 'golden.redrive-reused-search-attribute-types' => static function (WorkflowContext $context, mixed $name): mixed { + $context->upsertMemo(['stage' => 'greeting', 'customer' => $name]); + $context->upsertSearchAttributes(['Stage' => 'greeting'], ['Stage' => 'keyword']); + + return $context->activity('Tests\\Fixtures\\V2\\TestRedriveFirstActivity', [$name]); + }, 'golden.redrive-reused-search-attributes-missing' => static function (WorkflowContext $context, mixed $name): mixed { $context->upsertMemo(['stage' => 'greeting', 'customer' => $name]); $context->upsertSearchAttributes(['Stage' => 'greeting']); diff --git a/src/Worker/Replayer.php b/src/Worker/Replayer.php index 63d6bc12..8d6db0b3 100644 --- a/src/Worker/Replayer.php +++ b/src/Worker/Replayer.php @@ -1857,6 +1857,9 @@ private function recordedSteps(array $history, bool $preserveConditionReopens, C resolutionOrder: $resolutionOrder, ), 'search_attributes' => $this->canonicalSearchAttributes($attributes, $sequence, 'recorded'), + 'search_attribute_types' => array_key_exists('attribute_types', $payload) + ? $this->recordedSearchAttributeTypes($attributes, $payload['attribute_types'], $sequence) + : null, ]; } elseif ($type === 'ConditionWaitOpened') { $conditionKey = $this->stringValue($payload['condition_key'] ?? null); @@ -2699,6 +2702,14 @@ private function assertCommandMatchesStep(WorkflowCommand $command, array $step) 'search_attribute_value_mismatch', ); } + $recordedTypes = $step['search_attribute_types'] ?? null; + if ($recordedTypes !== null && array_key_exists('attribute_types', $command->attributes) + && $recordedTypes !== $command->attributes['attribute_types']) { + throw new NonDeterministicWorkflow( + 'Declared search-attribute types changed during replay.', + $step['sequence'], reason: 'search_attribute_type_mismatch', + ); + } } $actualDetail = $this->commandDetail($command); @@ -2987,6 +2998,21 @@ private function commandDetail(WorkflowCommand $command): ?string private const SEARCH_ATTRIBUTE_MAX_KEYWORD_LENGTH = 255; + /** + * @param array $attributes + * @return array + */ + private function recordedSearchAttributeTypes(array $attributes, mixed $types, int $sequence): array + { + try { + return SearchAttributeTypes::canonical($attributes, $types); + } catch (\InvalidArgumentException $error) { + throw new NonDeterministicWorkflow( + $error->getMessage(), $sequence, reason: 'search_attribute_types_malformed', + ); + } + } + /** * The canonical replay identity of a search-attribute upsert's values. * @@ -2998,10 +3024,10 @@ private function commandDetail(WorkflowCommand $command): ?string * sorted. The authored call is normalized the same way. Anything outside * that set, recorded or authored, refuses replay instead of being coerced. * - * Recorded attribute_types are not part of this identity: this SDK's - * upsert command carries no declared types, and the effective type the - * Server records may come from a namespace registration the worker cannot - * see. This is value-only binding. + * Explicitly authored types bind separately against known recorded types. + * Legacy one-argument calls remain value-only because effective types may + * come from a namespace registration the worker cannot see. Histories + * without type metadata also retain value-only compatibility. * * @return array> */ diff --git a/src/Worker/SearchAttributeTypes.php b/src/Worker/SearchAttributeTypes.php new file mode 100644 index 00000000..27a51ac1 --- /dev/null +++ b/src/Worker/SearchAttributeTypes.php @@ -0,0 +1,120 @@ + $attributes + * @return array + */ + public static function canonical(array $attributes, mixed $types): array + { + if ($attributes === [] || count($attributes) > 100) { + throw new InvalidArgumentException('A typed search-attribute update requires between one and 100 attributes.'); + } + foreach ($attributes as $key => $_) { + if (!is_string($key) || preg_match('/\A[A-Za-z0-9_.:-]{1,64}\z/', $key) !== 1) { + throw new InvalidArgumentException('Search attributes require portable string keys.'); + } + } + $canonical = self::typeMap($attributes, $types); + foreach ($attributes as $key => $value) { + if ($value !== null && !array_key_exists($key, $canonical)) { + throw new InvalidArgumentException("Search attribute [{$key}] needs an explicit type; use null to delete it."); + } + } + ksort($canonical, SORT_STRING); + + return $canonical; + } + + /** + * @param array $attributes + * @return array + */ + private static function typeMap(array $attributes, mixed $types): array + { + if (!is_array($types)) { + throw new InvalidArgumentException('Search-attribute types must be a canonical type map.'); + } + $canonical = []; + foreach ($types as $key => $type) { + if (!is_string($key) || !array_key_exists($key, $attributes)) { + throw new InvalidArgumentException('A declared search-attribute type has no corresponding attribute.'); + } + if (!is_string($type) || !in_array($type, self::TYPES, true)) { + throw new InvalidArgumentException("Search attribute [{$key}] must declare a canonical type name."); + } + if (!self::accepts($type, $attributes[$key])) { + throw new InvalidArgumentException("Search attribute [{$key}] has a value incompatible with its declared type."); + } + $canonical[$key] = $type; + } + return $canonical; + } + + private static function accepts(string $type, mixed $value): bool + { + if ($value === null) { + return true; + } + + return match ($type) { + 'string' => self::stringValue($value, 2048), + 'keyword' => self::stringValue($value, 255), + 'keyword_list' => self::keywordList($value), + 'int' => is_int($value), + 'float' => is_int($value) || (is_float($value) && is_finite($value)), + 'bool' => is_bool($value), + 'datetime' => self::dateTime($value), + default => false, + }; + } + + private static function stringValue(mixed $value, int $limit, bool $allowBlank = false): bool + { + if (!is_string($value) || (!$allowBlank && trim($value) === '')) { + return false; + } + $length = preg_match_all('/./su', trim($value)); + + return $length !== false && $length <= $limit; + } + + private static function keywordList(mixed $value): bool + { + if (!is_array($value) || !array_is_list($value)) { + return false; + } + foreach ($value as $item) { + // Core allows blank list items, unlike standalone string attributes. + if (!self::stringValue($item, 255, allowBlank: true)) { + return false; + } + } + + return true; + } + + private static function dateTime(mixed $value): bool + { + if (!is_string($value) || !self::stringValue($value, 2048) + || preg_match('/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)\z/i', trim($value)) !== 1) { + return false; + } + $parsed = date_parse(trim($value)); + + return $parsed['warning_count'] === 0 && $parsed['error_count'] === 0; + } +} diff --git a/src/Worker/WorkflowCommand.php b/src/Worker/WorkflowCommand.php index aadc0f0b..be640dc8 100644 --- a/src/Worker/WorkflowCommand.php +++ b/src/Worker/WorkflowCommand.php @@ -786,10 +786,18 @@ public static function continueAsNew(array $arguments, ?string $workflowType = n ]); } - /** @param array $attributes */ - public static function upsertSearchAttributes(array $attributes): self + /** + * @param array $attributes + * @param array|null $attributeTypes + */ + public static function upsertSearchAttributes(array $attributes, ?array $attributeTypes = null): self { - return new self('upsert_search_attributes', 'search_attributes', ['attributes' => $attributes]); + $identity = ['attributes' => $attributes]; + if ($attributeTypes !== null) { + $identity['attribute_types'] = SearchAttributeTypes::canonical($attributes, $attributeTypes); + } + + return new self('upsert_search_attributes', 'search_attributes', $identity); } /** @param array $entries */ @@ -890,6 +898,11 @@ public function toWire(PayloadCodec $codec, string $defaultTaskQueue): array { $wire = ['type' => $this->type]; foreach ($this->attributes as $key => $value) { + if ($key === 'attribute_types' && $value === []) { + // An all-deletion update has no wire type declarations, but its + // empty authored identity still constrains recorded type maps. + continue; + } if ($key === 'request_payload_value') { $wire['request_payload'] = $codec->envelope($value); continue; diff --git a/src/Worker/WorkflowContext.php b/src/Worker/WorkflowContext.php index c709a70d..0fca43c4 100644 --- a/src/Worker/WorkflowContext.php +++ b/src/Worker/WorkflowContext.php @@ -671,10 +671,16 @@ public function continueAsNew( throw new LogicException('A continue-as-new command cannot resume the current workflow execution.'); } - /** @param array $attributes */ - public function upsertSearchAttributes(array $attributes): void + /** + * Explicit types bind replay to the complete type identity of non-deleted + * attributes. Omit the map to retain the existing value-only API. + * + * @param array $attributes + * @param array|null $attributeTypes + */ + public function upsertSearchAttributes(array $attributes, ?array $attributeTypes = null): void { - $this->suspend(WorkflowCommand::upsertSearchAttributes($attributes)); + $this->suspend(WorkflowCommand::upsertSearchAttributes($attributes, $attributeTypes)); } /** diff --git a/tests/SearchAttributeReplayTest.php b/tests/SearchAttributeReplayTest.php index 1dd7900a..8e1aef7f 100644 --- a/tests/SearchAttributeReplayTest.php +++ b/tests/SearchAttributeReplayTest.php @@ -22,8 +22,8 @@ * matches its history. This matters most for redrive, where the successor * reuses the failed run's upsert instead of running it again. * - * This is value-only binding. Recorded attribute_types are not compared, - * because this SDK's upsert command cannot declare types. + * These legacy one-argument calls bind values. Explicit type declarations + * and their replay identity are covered in TypedSearchAttributeReplayTest. */ final class SearchAttributeReplayTest extends TestCase { diff --git a/tests/Support/RedriveFixtureRegistry.php b/tests/Support/RedriveFixtureRegistry.php index ab150ba3..49825759 100644 --- a/tests/Support/RedriveFixtureRegistry.php +++ b/tests/Support/RedriveFixtureRegistry.php @@ -28,6 +28,7 @@ final class RedriveFixtureRegistry 'redrive-reused-signal-wait-clock' => ['SignalWaitOpened', 'SignalReceived', 'SignalApplied'], 'redrive-reused-search-attributes-changed' => ['MemoUpserted', 'SearchAttributesUpserted'], 'redrive-reused-search-attributes-missing' => ['MemoUpserted', 'SearchAttributesUpserted'], + 'redrive-reused-search-attribute-type-changed' => ['MemoUpserted', 'SearchAttributesUpserted'], 'redrive-successor-reused-markers' => ['SideEffectRecorded', 'VersionMarkerRecorded', 'ActivityCompleted'], 'redrive-successor-reused-timer' => ['ActivityCompleted', 'TimerScheduled', 'TimerFired'], 'redrive-successor-reused-cancellation-clock' => ['ActivityCompleted', 'TimerScheduled', 'TimerFired'], diff --git a/tests/Support/ReplayRegressionFixture.php b/tests/Support/ReplayRegressionFixture.php index 38785c02..801e680c 100644 --- a/tests/Support/ReplayRegressionFixture.php +++ b/tests/Support/ReplayRegressionFixture.php @@ -643,6 +643,12 @@ private static function workflow(string $workflowType): callable return $context->activity('Tests\\Fixtures\\V2\\TestRedriveFirstActivity', [$name]); }, + 'golden.redrive-reused-search-attribute-types' => static function (WorkflowContext $context, mixed $name): mixed { + $context->upsertMemo(['stage' => 'greeting', 'customer' => $name]); + $context->upsertSearchAttributes(['Stage' => 'greeting'], ['Stage' => 'keyword']); + + return $context->activity('Tests\\Fixtures\\V2\\TestRedriveFirstActivity', [$name]); + }, 'golden.redrive-reused-search-attributes-missing' => static function (WorkflowContext $context, mixed $name): mixed { $context->upsertMemo(['stage' => 'greeting', 'customer' => $name]); $context->upsertSearchAttributes(['Stage' => 'greeting']); diff --git a/tests/TypedSearchAttributeReplayTest.php b/tests/TypedSearchAttributeReplayTest.php new file mode 100644 index 00000000..a0a87172 --- /dev/null +++ b/tests/TypedSearchAttributeReplayTest.php @@ -0,0 +1,190 @@ +replay(['Stage' => 'keyword'], ['Stage' => 'string']); + } catch (NonDeterministicWorkflow $error) { + self::assertSame('search_attribute_type_mismatch', $error->reason); + self::assertSame(1, $error->sequence); + self::assertStringNotContainsString('private-stage-value', $error->getMessage()); + + return; + } + self::fail('An unchanged value must not hide a changed declared type.'); + } + + public function testAnExplicitTypeDeclarationIsSentWithTheCommand(): void + { + $command = WorkflowCommand::upsertSearchAttributes(['Stage' => 'greeting'], ['Stage' => 'keyword']); + + self::assertSame(['Stage' => 'keyword'], $command->toWire(new AvroPayloadCodec(), 'test')['attribute_types'] ?? null); + } + + public function testIdenticalTypeIdentityReplays(): void + { + self::assertSame(['complete_workflow'], $this->replay(['Stage' => 'keyword'], ['Stage' => 'keyword'])); + } + + public function testLegacyHistoryWithoutTypeMetadataKeepsValueOnlyCompatibility(): void + { + self::assertSame(['complete_workflow'], $this->replay(['Stage' => 'keyword'], null, hasTypeField: false)); + } + + public function testExistingOneArgumentCallsKeepTheirWireShape(): void + { + self::assertSame( + ['type' => 'upsert_search_attributes', 'attributes' => ['Stage' => 'greeting']], + WorkflowCommand::upsertSearchAttributes(['Stage' => 'greeting'])->toWire(new AvroPayloadCodec(), 'test'), + ); + } + + public function testAllCanonicalTypesAndMapOrderingRoundTrip(): void + { + $attributes = ['Text' => 'hello', 'Stage' => 'ready', 'Tags' => [' first ', 'second'], + 'Count' => 42, 'Ratio' => 1.25, 'Ready' => true, 'At' => '2026-11-04T12:00:00.123456+02:00']; + $types = ['Text' => 'string', 'Stage' => 'keyword', 'Tags' => 'keyword_list', + 'Count' => 'int', 'Ratio' => 'float', 'Ready' => 'bool', 'At' => 'datetime']; + self::assertSame(['complete_workflow'], $this->replay($types, array_reverse($types, true), $attributes)); + $wire = WorkflowCommand::upsertSearchAttributes($attributes, $types)->toWire(new AvroPayloadCodec(), 'test'); + ksort($types, SORT_STRING); + self::assertSame($types, $wire['attribute_types']); + self::assertSame($attributes, $wire['attributes']); + } + + public function testDeletionKeepsItsExplicitOrEmptyTypeIdentity(): void + { + self::assertSame(['complete_workflow'], $this->replay([], [], ['Stage' => null])); + self::assertSame(['complete_workflow'], $this->replay(['Stage' => 'keyword'], ['Stage' => 'keyword'], ['Stage' => null])); + $wire = WorkflowCommand::upsertSearchAttributes(['Stage' => null], [])->toWire(new AvroPayloadCodec(), 'test'); + self::assertArrayNotHasKey('attribute_types', $wire); + self::assertSame(['Stage' => null], $wire['attributes']); + } + + /** @return iterable */ + public static function malformedRecordedTypes(): iterable + { + yield 'explicit null' => [null]; + yield 'scalar' => ['keyword']; + yield 'list' => [['keyword']]; + yield 'non-string type' => [['Stage' => 7]]; + yield 'unsupported type' => [['Stage' => 'unsupported-private-type']]; + yield 'noncanonical alias' => [['Stage' => 'Keyword']]; + yield 'orphan declaration' => [['Stage' => 'keyword', 'Other' => 'keyword']]; + yield 'missing declaration' => [[]]; + yield 'incompatible value' => [['Stage' => 'bool']]; + } + + #[DataProvider('malformedRecordedTypes')] + public function testMalformedRecordedTypesFailClosed(mixed $types): void + { + try { + $this->replay(['Stage' => 'keyword'], $types); + } catch (NonDeterministicWorkflow $error) { + self::assertSame('search_attribute_types_malformed', $error->reason); + self::assertSame(1, $error->sequence); + self::assertStringNotContainsString('private', $error->getMessage()); + + return; + } + self::fail('Malformed recorded type identity must be refused.'); + } + + /** @return iterable, array}> */ + public static function invalidDeclarations(): iterable + { + yield 'empty attributes' => [[], []]; + yield 'missing explicit types' => [['Stage' => 'ready'], []]; + yield 'extra type' => [['Stage' => 'ready'], ['Stage' => 'keyword', 'Other' => 'keyword']]; + yield 'unknown type' => [['Stage' => 'ready'], ['Stage' => 'unsupported-private-type']]; + yield 'numeric key' => [[1 => 'ready'], [1 => 'keyword']]; + yield 'invalid key' => [['bad key' => 'ready'], ['bad key' => 'keyword']]; + yield 'int is not bool' => [['Ready' => 1], ['Ready' => 'bool']]; + yield 'bool is not int' => [['Count' => true], ['Count' => 'int']]; + yield 'float is not int' => [['Count' => 1.25], ['Count' => 'int']]; + yield 'nonfinite float' => [['Ratio' => INF], ['Ratio' => 'float']]; + yield 'numeric string is not float' => [['Ratio' => '1.25'], ['Ratio' => 'float']]; + yield 'empty string needs null' => [['Stage' => ' '], ['Stage' => 'keyword']]; + yield 'map is not list' => [['Tags' => ['tag' => 'x']], ['Tags' => 'keyword_list']]; + yield 'list elements must be strings' => [['Tags' => [1]], ['Tags' => 'keyword_list']]; + yield 'datetime timezone required' => [['At' => '2026-11-04T12:00:00'], ['At' => 'datetime']]; + yield 'invalid calendar date' => [['At' => '2026-02-31T12:00:00Z'], ['At' => 'datetime']]; + yield 'invalid timezone' => [['At' => '2026-11-04T12:00:00+25:00'], ['At' => 'datetime']]; + yield 'keyword limit' => [['Stage' => str_repeat('x', 256)], ['Stage' => 'keyword']]; + yield 'string limit' => [['Text' => str_repeat('x', 2049)], ['Text' => 'string']]; + yield 'keyword list limit' => [['Tags' => [str_repeat('x', 256)]], ['Tags' => 'keyword_list']]; + } + + #[DataProvider('invalidDeclarations')] + public function testInvalidAuthoredDeclarationsAreRejected(array $attributes, array $types): void + { + $this->expectException(InvalidArgumentException::class); + WorkflowCommand::upsertSearchAttributes($attributes, $types); + } + + public function testInvalidKeysAreRejectedBeforeTheirTypeIsDiagnosed(): void + { + $key = "private-key\n"; + try { + WorkflowCommand::upsertSearchAttributes([$key => 'private-value'], [$key => 'private-type']); + } catch (InvalidArgumentException $error) { + self::assertStringNotContainsString('private', $error->getMessage()); + + return; + } + self::fail('An invalid key must be refused without reflecting its contents.'); + } + + public function testRedriveFixturePinsTypeChangesAtTheReusedStep(): void + { + $path = RedriveFixtureRegistry::path('redrive-reused-search-attribute-type-changed'); + $fixture = json_decode((string) file_get_contents($path), true, flags: JSON_THROW_ON_ERROR); + self::assertNull(RedriveSuccessorHistory::violation($fixture, ['MemoUpserted', 'SearchAttributesUpserted'])); + $tasks = ReplayRegressionFixture::executeFile($path); + self::assertSame([['type' => 'replay_error', 'reason' => 'search_attribute_type_mismatch', 'sequence' => 2]], $tasks[0]['command_sequence']); + } + + /** + * @param array $authoredTypes + * @param array $attributes + * @return list + */ + private function replay(array $authoredTypes, mixed $recordedTypes, + array $attributes = ['Stage' => 'private-stage-value'], bool $hasTypeField = true): array + { + $payload = ['sequence' => 1, 'attributes' => $attributes, 'merged' => $attributes]; + if ($hasTypeField) { + $payload['attribute_types'] = $recordedTypes; + } + $result = (new Replayer(new AvroPayloadCodec()))->replay( + static function (WorkflowContext $context) use ($authoredTypes, $attributes): string { + $context->upsertSearchAttributes($attributes, $authoredTypes); + + return 'done'; + }, + [['event_type' => 'SearchAttributesUpserted', 'payload' => $payload]], + [], + 'typed-search-replay', + ); + + return array_column($result->commands, 'type'); + } +} diff --git a/tests/fixtures/replay-regressions/redrive-reused-search-attribute-type-changed.json b/tests/fixtures/replay-regressions/redrive-reused-search-attribute-type-changed.json new file mode 100644 index 00000000..78c7e05e --- /dev/null +++ b/tests/fixtures/replay-regressions/redrive-reused-search-attribute-type-changed.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://raw.githubusercontent.com/durable-workflow/.github/main/regression-corpus/evidence-schema.json", + "fixture_schema": "durable-workflow.replay-regression/v1", + "id": "php-redrive-reused-search-attribute-type-changed", + "protocol_version": "1.20", + "bindings": [ + "php" + ], + "workflow": { + "type": "golden.redrive-reused-search-attribute-types", + "input": [ + "Taylor" + ] + }, + "history": [ + { + "sequence": 1, + "event_type": "WorkflowStarted", + "recorded_at": "2026-11-03T00:05:00+00:00", + "payload": { + "workflow_class": "Tests\\Fixtures\\V2\\TestRedriveVisibilityWorkflow", + "workflow_type": "golden.redrive-reused-search-attribute-types", + "workflow_instance_id": "regression-corpus-redrive-upserts", + "workflow_run_id": "regression-corpus-run", + "workflow_command_id": "01JBREDRIVECOMMAND00000000", + "continued_from_run_id": "01JBREDRIVESOURCERUN000000", + "resume_step_sequence": 3, + "recovery_kind": "redrive", + "execution_deadline_at": null, + "run_deadline_at": null, + "replayed_started_at": "2026-11-03T00:00:00+00:00", + "workflow_link_id": "01JBREDRIVELINK00000000000" + } + }, + { + "sequence": 2, + "event_type": "MemoUpserted", + "recorded_at": "2026-11-03T00:05:00+00:00", + "payload": { + "sequence": 1, + "entries": { + "codec": "avro", + "blob": "wwHioz3/VYAiNw4EEGN1c3RvbWVyCgxUYXlsb3IKc3RhZ2UKEGdyZWV0aW5nAA==" + }, + "merged": { + "codec": "avro", + "blob": "wwHioz3/VYAiNw4EEGN1c3RvbWVyCgxUYXlsb3IKc3RhZ2UKEGdyZWV0aW5nAA==" + }, + "reused_from_run_id": "01JBREDRIVESOURCERUN000000", + "reused_recorded_at": "2026-11-03T00:00:00+00:00" + } + }, + { + "sequence": 3, + "event_type": "SearchAttributesUpserted", + "recorded_at": "2026-11-03T00:05:00+00:00", + "payload": { + "sequence": 2, + "attributes": { + "Stage": "greeting" + }, + "attribute_types": { + "Stage": "string" + }, + "merged": { + "Stage": "greeting" + }, + "reused_from_run_id": "01JBREDRIVESOURCERUN000000", + "reused_recorded_at": "2026-11-03T00:00:00+00:00" + } + } + ], + "workflow_tasks": [ + { + "workflow_id": "regression-corpus-redrive-upserts", + "run_id": "regression-corpus-run" + } + ], + "expected": { + "workflow_tasks": [ + { + "command_sequence": [ + { + "type": "replay_error", + "reason": "search_attribute_type_mismatch", + "sequence": 2 + } + ] + } + ] + } +}