From 75d4e2397bd48b29917ab1553259d3274997a9d6 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 23 Oct 2026 11:18:50 +0000 Subject: [PATCH 1/3] test: reproduce lossy numeric kinds in CLI history exports --- .../V2HistoryExportNumericKindsTest.php | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 tests/Unit/Commands/V2HistoryExportNumericKindsTest.php diff --git a/tests/Unit/Commands/V2HistoryExportNumericKindsTest.php b/tests/Unit/Commands/V2HistoryExportNumericKindsTest.php new file mode 100644 index 000000000..fb2193352 --- /dev/null +++ b/tests/Unit/Commands/V2HistoryExportNumericKindsTest.php @@ -0,0 +1,155 @@ +set([ + 'database.default' => 'sqlite', + 'database.connections.sqlite' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => true, + ], + 'cache.default' => 'array', + 'queue.default' => 'redis', + 'workflows.v1.enabled' => false, + 'workflows.serializer' => 'avro', + 'workflows.v2.compatibility.current' => null, + 'workflows.v2.execution_continuation.enabled' => false, + ]); + } + + /** + * @return iterable + */ + public static function exportModes(): iterable + { + yield 'waiting compact' => [false, false]; + yield 'waiting pretty' => [false, true]; + yield 'completed compact' => [true, false]; + yield 'completed pretty' => [true, true]; + } + + #[DataProvider('exportModes')] + public function testSerializedExportsPreserveNumericKinds(bool $complete, bool $pretty): void + { + $this->artisan('migrate', ['--force' => true])->assertSuccessful(); + Queue::fake(); + $memo = [ + 'whole' => 1.0, + 'signed_zero' => -0.0, + 'nested' => [ + 'whole' => 2.0, + 'signed_zero' => -0.0, + 'list' => [3.0, -0.0, 1.5, 4], + ], + ]; + $workflow = WorkflowStub::make(NumericExportWorkflow::class); + $workflow->start(new StartOptions( + executionTimeoutSeconds: 30, + runTimeoutSeconds: 30, + memo: $memo, + )); + $this->runReadyTask(TaskType::Workflow); + + if ($complete) { + $this->runReadyTask(TaskType::Activity); + $this->runReadyTask(TaskType::Workflow); + $this->assertTrue($workflow->refresh()->completed()); + } else { + $this->assertFalse($workflow->refresh()->run()->status->isTerminal()); + } + + $path = sys_get_temp_dir() . '/workflow-numeric-export-' . Str::ulid() . '.json'; + $this->beforeApplicationDestroyed(static function () use ($path): void { + if (is_file($path)) { + unlink($path); + } + }); + $this->artisan('workflow:v2:history-export', [ + 'target' => $workflow->runId(), + '--run' => true, + '--output' => $path, + '--pretty' => $pretty, + ])->assertSuccessful(); + + $raw = (string) file_get_contents($path); + $hash = hash('sha256', $raw); + $bundle = json_decode($raw, true, flags: JSON_THROW_ON_ERROR); + $projection = $bundle['workflow']['memo']; + $this->assertSame($memo, $projection); + $this->assertSame(-INF, fdiv(1.0, $projection['signed_zero'])); + $this->assertSame(-INF, fdiv(1.0, $projection['nested']['signed_zero'])); + $this->assertSame(-INF, fdiv(1.0, $projection['nested']['list'][1])); + $this->assertSame($memo, MemoPayload::decodeEntries($bundle['workflow']['memo_payload'])); + $report = BundleIntegrityVerifier::verifyJson($raw); + $this->assertSame(BundleIntegrityVerifier::STATUS_OK, $report['status'], json_encode($report)); + $this->artisan('workflow:v2:replay-verify', [ + 'bundle' => $path, + '--skip-replay' => true, + '--json' => true, + ])->assertSuccessful(); + $this->assertSame($hash, hash_file('sha256', $path)); + + // Historical lossy exports remain invalid. A reader must not coerce + // their projections or replace their recorded canonical checksum. + $lossy = json_encode($bundle, JSON_THROW_ON_ERROR); + $refusal = BundleIntegrityVerifier::verifyJson($lossy); + $this->assertSame(BundleIntegrityVerifier::STATUS_FAILED, $refusal['status']); + $rules = array_column($refusal['findings'], 'rule'); + $this->assertContains('workflow.memo_projection_mismatch', $rules); + $this->assertContains('integrity.checksum_mismatch', $rules); + } + + private function runReadyTask(TaskType $type): void + { + $task = WorkflowTask::query()->where('status', TaskStatus::Ready->value) + ->where('task_type', $type->value)->sole(); + $job = $type === TaskType::Workflow ? new RunWorkflowTask($task->id) : new RunActivityTask($task->id); + $this->app->call([$job, 'handle']); + } +} + +final class NumericExportWorkflow extends Workflow +{ + public function handle(): array + { + return \Workflow\V2\activity(NumericExportActivity::class); + } +} + +final class NumericExportActivity extends Activity +{ + public function handle(): array + { + return [1.0, -0.0, ['whole' => 2.0]]; + } +} From b090f0c847b065b2acb75bec35fb13d433f09653 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 23 Oct 2026 11:20:36 +0000 Subject: [PATCH 2/3] test: match canonical memo ordering in export regression --- .../Commands/V2HistoryExportNumericKindsTest.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/Unit/Commands/V2HistoryExportNumericKindsTest.php b/tests/Unit/Commands/V2HistoryExportNumericKindsTest.php index fb2193352..b35f15a49 100644 --- a/tests/Unit/Commands/V2HistoryExportNumericKindsTest.php +++ b/tests/Unit/Commands/V2HistoryExportNumericKindsTest.php @@ -64,13 +64,13 @@ public function testSerializedExportsPreserveNumericKinds(bool $complete, bool $ $this->artisan('migrate', ['--force' => true])->assertSuccessful(); Queue::fake(); $memo = [ - 'whole' => 1.0, - 'signed_zero' => -0.0, 'nested' => [ - 'whole' => 2.0, - 'signed_zero' => -0.0, 'list' => [3.0, -0.0, 1.5, 4], + 'signed_zero' => -0.0, + 'whole' => 2.0, ], + 'signed_zero' => -0.0, + 'whole' => 1.0, ]; $workflow = WorkflowStub::make(NumericExportWorkflow::class); $workflow->start(new StartOptions( @@ -111,7 +111,11 @@ public function testSerializedExportsPreserveNumericKinds(bool $complete, bool $ $this->assertSame(-INF, fdiv(1.0, $projection['nested']['list'][1])); $this->assertSame($memo, MemoPayload::decodeEntries($bundle['workflow']['memo_payload'])); $report = BundleIntegrityVerifier::verifyJson($raw); - $this->assertSame(BundleIntegrityVerifier::STATUS_OK, $report['status'], json_encode($report)); + $this->assertSame( + BundleIntegrityVerifier::STATUS_OK, + $report['status'], + json_encode($report, JSON_THROW_ON_ERROR), + ); $this->artisan('workflow:v2:replay-verify', [ 'bundle' => $path, '--skip-replay' => true, From e74fb302002c45eb81524e33b8aef06e8f1d84f4 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 23 Oct 2026 11:21:40 +0000 Subject: [PATCH 3/3] fix: preserve whole floats in CLI history exports --- CHANGELOG.md | 6 ++ composer.json | 2 +- src/Commands/V2HistoryExportCommand.php | 2 +- .../V2HistoryExportNumericKindsTest.php | 66 ++++++++++--------- 4 files changed, 42 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed5542825..5cc3ad333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2.11.4 - 2026-10-23 + +- Preserve whole-valued floats and signed zero in CLI history-export JSON, + including nested memo projections. Untouched compact and pretty exports + retain their original canonical checksum and pass integrity verification. + ## 2.11.3 - 2026-10-23 - Refuse dependency injection that would silently discard recorded activity diff --git a/composer.json b/composer.json index 8c8420ea1..9b73cbf5f 100644 --- a/composer.json +++ b/composer.json @@ -87,7 +87,7 @@ "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.11.3", + "product-train": "2.11.4", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" }, diff --git a/src/Commands/V2HistoryExportCommand.php b/src/Commands/V2HistoryExportCommand.php index 30e1f5064..79a84faa3 100644 --- a/src/Commands/V2HistoryExportCommand.php +++ b/src/Commands/V2HistoryExportCommand.php @@ -85,7 +85,7 @@ public function handle(): int */ private function encode(array $bundle): string { - $flags = JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR; + $flags = JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR; if ((bool) $this->option('pretty')) { $flags |= JSON_PRETTY_PRINT; diff --git a/tests/Unit/Commands/V2HistoryExportNumericKindsTest.php b/tests/Unit/Commands/V2HistoryExportNumericKindsTest.php index b35f15a49..fb84fb4ac 100644 --- a/tests/Unit/Commands/V2HistoryExportNumericKindsTest.php +++ b/tests/Unit/Commands/V2HistoryExportNumericKindsTest.php @@ -23,30 +23,6 @@ final class V2HistoryExportNumericKindsTest extends TestCase { - protected function getPackageProviders($app): array - { - return [WorkflowServiceProvider::class]; - } - - protected function defineEnvironment($app): void - { - $app['config']->set([ - 'database.default' => 'sqlite', - 'database.connections.sqlite' => [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - 'foreign_key_constraints' => true, - ], - 'cache.default' => 'array', - 'queue.default' => 'redis', - 'workflows.v1.enabled' => false, - 'workflows.serializer' => 'avro', - 'workflows.v2.compatibility.current' => null, - 'workflows.v2.execution_continuation.enabled' => false, - ]); - } - /** * @return iterable */ @@ -61,7 +37,9 @@ public static function exportModes(): iterable #[DataProvider('exportModes')] public function testSerializedExportsPreserveNumericKinds(bool $complete, bool $pretty): void { - $this->artisan('migrate', ['--force' => true])->assertSuccessful(); + $this->artisan('migrate', [ + '--force' => true, + ])->assertSuccessful(); Queue::fake(); $memo = [ 'nested' => [ @@ -73,11 +51,7 @@ public function testSerializedExportsPreserveNumericKinds(bool $complete, bool $ 'whole' => 1.0, ]; $workflow = WorkflowStub::make(NumericExportWorkflow::class); - $workflow->start(new StartOptions( - executionTimeoutSeconds: 30, - runTimeoutSeconds: 30, - memo: $memo, - )); + $workflow->start(new StartOptions(executionTimeoutSeconds: 30, runTimeoutSeconds: 30, memo: $memo)); $this->runReadyTask(TaskType::Workflow); if ($complete) { @@ -133,10 +107,35 @@ public function testSerializedExportsPreserveNumericKinds(bool $complete, bool $ $this->assertContains('integrity.checksum_mismatch', $rules); } + protected function getPackageProviders($app): array + { + return [WorkflowServiceProvider::class]; + } + + protected function defineEnvironment($app): void + { + $app['config']->set([ + 'database.default' => 'sqlite', + 'database.connections.sqlite' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => true, + ], + 'cache.default' => 'array', + 'queue.default' => 'redis', + 'workflows.v1.enabled' => false, + 'workflows.serializer' => 'avro', + 'workflows.v2.compatibility.current' => null, + 'workflows.v2.execution_continuation.enabled' => false, + ]); + } + private function runReadyTask(TaskType $type): void { $task = WorkflowTask::query()->where('status', TaskStatus::Ready->value) - ->where('task_type', $type->value)->sole(); + ->where('task_type', $type->value) + ->sole(); $job = $type === TaskType::Workflow ? new RunWorkflowTask($task->id) : new RunActivityTask($task->id); $this->app->call([$job, 'handle']); } @@ -154,6 +153,9 @@ final class NumericExportActivity extends Activity { public function handle(): array { - return [1.0, -0.0, ['whole' => 2.0]]; + return [ + 1.0, -0.0, [ + 'whole' => 2.0, + ]]; } }