From 98fdc5798fbef0f8a73553407fc24c149fbb0e36 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:41:38 +0000 Subject: [PATCH 1/2] Recover original child reservations without loading current definitions --- docs/business-state-machines.md | 2 + .../Binding/ChildReservationStore.php | 21 +++ tests/Feature/V2/V2ChildReservationTest.php | 132 +++++++++++++++++- 3 files changed, 153 insertions(+), 2 deletions(-) diff --git a/docs/business-state-machines.md b/docs/business-state-machines.md index 731a864dd..4c26628db 100644 --- a/docs/business-state-machines.md +++ b/docs/business-state-machines.md @@ -238,6 +238,8 @@ The experimental `Binding\ChildReservationStore` provides the native reservation A reservation pins the original parent instance/run, business execution, slot/generation, policies, required outcome, portable input fingerprint, child definition fingerprint and child options. Repeating the exact reservation returns the stored descriptor. Reusing that lookup key with changed content refuses. New reservations require the active original parent; retrieving an existing reservation does not follow a newer current run or state entry. +After a deployment or lost acknowledgement, `load($originalIdentity, $principal)` recovers the exact stored descriptor without resolving or loading its historical PHP definition. The host must authorize the `load` operation against the original business owner. Missing records and changed binding content refuse. Loading neither starts a child nor proves absence. Use the returned descriptor with `close()` to obtain the authoritative admission or committed closure; a newer definition cannot replace the pinned one. + Within the original parent workflow, `ChildReservation::start(...$arguments)` uses the normal confirmed native child-start command. Admission locks the reserved child instance before payload storage or child creation, checks the actual executing parent and pinned definition/input/options, and commits the original child receipt with the run and paired parent history. Replay compares the original reservation and reuses its original admission. It does not start another child. `close()` locks that same instance. If admission committed first, it returns the exact `ChildBinding`. Otherwise it permanently closes the unused reservation and returns `NeverAdmittedChild` with the stored closure identity. Repeating close after a lost acknowledgement returns the same result. Late native work and ordinary start/signal-with-start cannot reopen that reservation. A missing record, malformed state or uncertain run pointer refuses rather than proving absence. Closed unused instances must remain retained; the reservation migration refuses rollback while any reservation exists. diff --git a/src/V2/StateMachine/Binding/ChildReservationStore.php b/src/V2/StateMachine/Binding/ChildReservationStore.php index d0ce15a43..cf3bab5b2 100644 --- a/src/V2/StateMachine/Binding/ChildReservationStore.php +++ b/src/V2/StateMachine/Binding/ChildReservationStore.php @@ -95,6 +95,27 @@ public function reserve(BindingIdentity $identity, string $workflowClass, string }, connection: $connection); } + /** + * Recover the original descriptor without selecting a deployed definition. + * A missing reservation is not proof that its child was never admitted. + */ + public function load(BindingIdentity $identity, string $principal): ChildReservation + { + $this->authorize($identity, $principal, 'load'); + $child = self::instances()->findOrFail('dw-bound-child-' . $identity->key()); + // Check the caller's original owner before decoding protected metadata. + if ($child->namespace !== $identity->namespace || $child->workflow_type !== $identity->childWorkflowType) { + throw new LogicException('The stored native child reservation does not match the original owner.'); + } + $state = self::read($child); + $reservation = ChildReservation::fromArray($state['reservation']); + if ($reservation->identity->toArray() !== $identity->toArray()) { + throw new LogicException('The stored native child reservation differs from the original binding.'); + } + self::matching($child, $reservation); + return $reservation; + } + /** * Linearize against native admission. A lost acknowledgement returns the * same closed receipt or the exact admitted child, never lookup absence. diff --git a/tests/Feature/V2/V2ChildReservationTest.php b/tests/Feature/V2/V2ChildReservationTest.php index 19512c32a..36cad2505 100644 --- a/tests/Feature/V2/V2ChildReservationTest.php +++ b/tests/Feature/V2/V2ChildReservationTest.php @@ -75,6 +75,121 @@ public function testCloseBeforeAdmissionPersistsTheSameFenceAndRefusesLateNative $this->assertSame(0, WorkflowHistoryEvent::query()->count()); } + public function testOriginalDescriptorCanBeLoadedAndClosedAfterItsDefinitionChanges(): void + { + $parent = $this->parent(); + $reservation = $this->reserve($parent); + $historical = new ChildReservation( + $reservation->identity, + $reservation->workflowClass, + 'sha256:' . str_repeat('f', 64), + $reservation->authorizationVersion, + ); + $instance = WorkflowInstance::query()->findOrFail($reservation->childInstanceId()); + $state = json_decode($instance->getRawOriginal('native_child_reservation'), true, flags: JSON_THROW_ON_ERROR); + $state['reservation'] = $historical->toArray(); + WorkflowInstance::query()->whereKey($instance->id)->toBase()->update([ + 'native_child_reservation' => json_encode($state, JSON_THROW_ON_ERROR), + ]); + $parent->forceFill([ + 'status' => RunStatus::Completed, + ])->save(); + + try { + $this->reserve($parent); + $this->fail('A new deployed definition silently replaced the historical reservation.'); + } catch (LogicException $error) { + $this->assertStringContainsString('different immutable descriptor', $error->getMessage()); + } + $loaded = $this->store() + ->load($reservation->identity, 'trusted-owner'); + $this->assertSame($historical->toArray(), $loaded->toArray()); + $closed = $this->store() + ->close($loaded, 'trusted-owner'); + $this->assertInstanceOf(NeverAdmittedChild::class, $closed); + $again = $this->store() + ->load($reservation->identity, 'trusted-owner'); + $this->assertSame($loaded->toArray(), $again->toArray()); + $this->assertSame($closed->closureId, $this->store()->close($again, 'trusted-owner')->closureId); + } + + public function testLoadingAStoredDescriptorDoesNotAutoloadItsHistoricalClass(): void + { + $reservation = $this->reserve($this->parent()); + $historicalClass = 'Tests\\Fixtures\\V2\\UnavailableHistoricalReservedChild'; + $historical = new ChildReservation( + $reservation->identity, + $historicalClass, + $reservation->definitionFingerprint, + $reservation->authorizationVersion, + ); + $instance = WorkflowInstance::query()->findOrFail($reservation->childInstanceId()); + $state = json_decode($instance->getRawOriginal('native_child_reservation'), true, flags: JSON_THROW_ON_ERROR); + $state['reservation'] = $historical->toArray(); + WorkflowInstance::query()->whereKey($instance->id)->toBase()->update([ + 'workflow_class' => $historicalClass, + 'native_child_reservation' => json_encode($state, JSON_THROW_ON_ERROR), + ]); + $autoloaded = []; + $loader = static function (string $class) use (&$autoloaded): void { + $autoloaded[] = $class; + }; + spl_autoload_register($loader); + try { + $loaded = $this->store() + ->load($reservation->identity, 'trusted-owner'); + $this->assertSame($historical->toArray(), $loaded->toArray()); + $this->assertInstanceOf(NeverAdmittedChild::class, $this->store()->close($loaded, 'trusted-owner')); + } finally { + spl_autoload_unregister($loader); + } + $this->assertNotContains($historicalClass, $autoloaded); + } + + public function testLoadingRejectsAChangedDescriptorWithTheSameLookupKey(): void + { + $reservation = $this->reserve($this->parent()); + $changed = $reservation->identity->toArray(); + $changed['requiredOutcome'] = 'substituted-outcome'; + $this->expectException(LogicException::class); + $this->expectExceptionMessage('differs from the original binding'); + $this->store() + ->load(BindingIdentity::fromArray($changed), 'trusted-owner'); + } + + public function testLoadingRequiresAnExistingReservationAndCannotCreateOne(): void + { + $identity = $this->identity($this->parent()); + try { + $this->store() + ->load($identity, 'trusted-owner'); + $this->fail('Lookup absence became a child reservation.'); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException) { + $this->assertSame(1, WorkflowInstance::query()->count()); + $this->assertSame(1, WorkflowRun::query()->count()); + } + } + + public function testLoadingReauthorizesItsOwnOperationWithoutReplacingThePinnedPolicy(): void + { + $reservation = $this->reserve($this->parent()); + $authorization = new class() implements BindingAuthorization { + public array $calls = []; + + public function assertAllowed(BindingIdentity $identity, string $principal, string $operation): string + { + $this->calls[] = [$identity->toArray(), $principal, $operation, DB::transactionLevel()]; + return 'current-host-policy-v2'; + } + }; + $loaded = (new ChildReservationStore($authorization))->load($reservation->identity, 'current-principal'); + $this->assertSame([ + [$reservation->identity->toArray(), 'current-principal', 'load', 0], + ], $authorization->calls); + $this->assertSame($reservation->toArray(), $loaded->toArray()); + $this->assertSame('host-policy-v1', $loaded->authorizationVersion); + } + public function testAdmissionBeforeCloseReturnsExactNativeReceiptAndHonorsPinnedRouting(): void { $parent = $this->parent(); @@ -180,10 +295,13 @@ public function testForeignInstanceOwnershipIsRefusedBeforeDecodingItsReservatio 'protected' => 'not-a-reservation', ], JSON_THROW_ON_ERROR), ]); - foreach (['reserve', 'close'] as $operation) { + foreach (['reserve', 'load', 'close'] as $operation) { try { if ($operation === 'reserve') { $this->reserve($parent); + } elseif ($operation === 'load') { + $this->store() + ->load($reservation->identity, 'trusted-owner'); } else { $this->store() ->close($reservation, 'trusted-owner'); @@ -226,6 +344,14 @@ public function testAuthorizationRefusalOccursBeforeAnyProtectedDatabaseRead(): $this->assertSame('binding_access_refused', $error->getMessage()); } $this->assertSame([], $queries); + try { + $this->store(false) + ->load($reservation->identity, 'untrusted'); + $this->fail('An unauthorized lookup reached binding storage.'); + } catch (LogicException $error) { + $this->assertSame('binding_access_refused', $error->getMessage()); + } + $this->assertSame([], $queries); } public function testCallerTransactionsCannotExposeUncommittedClosureOrRunHostAuthorizationUnderLocks(): void @@ -242,11 +368,13 @@ public function assertAllowed(BindingIdentity $identity, string $principal, stri } }; $store = new ChildReservationStore($authorization); - foreach (['reserve', 'close'] as $operation) { + foreach (['reserve', 'load', 'close'] as $operation) { try { DB::transaction(static function () use ($operation, $store, $reservation): void { if ($operation === 'reserve') { $store->reserve($reservation->identity, $reservation->workflowClass, 'owner'); + } elseif ($operation === 'load') { + $store->load($reservation->identity, 'owner'); } else { $store->close($reservation, 'owner'); } From f158a29ae4e7f0174f95dd990df81059bbabdefd Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:51:48 +0000 Subject: [PATCH 2/2] Exercise historical reservation recovery in the unit suite --- tests/Feature/V2/V2ChildReservationTest.php | 115 --------- .../ChildReservationRecoveryTest.php | 219 ++++++++++++++++++ 2 files changed, 219 insertions(+), 115 deletions(-) create mode 100644 tests/Unit/V2/StateMachine/ChildReservationRecoveryTest.php diff --git a/tests/Feature/V2/V2ChildReservationTest.php b/tests/Feature/V2/V2ChildReservationTest.php index 36cad2505..fe710047e 100644 --- a/tests/Feature/V2/V2ChildReservationTest.php +++ b/tests/Feature/V2/V2ChildReservationTest.php @@ -75,121 +75,6 @@ public function testCloseBeforeAdmissionPersistsTheSameFenceAndRefusesLateNative $this->assertSame(0, WorkflowHistoryEvent::query()->count()); } - public function testOriginalDescriptorCanBeLoadedAndClosedAfterItsDefinitionChanges(): void - { - $parent = $this->parent(); - $reservation = $this->reserve($parent); - $historical = new ChildReservation( - $reservation->identity, - $reservation->workflowClass, - 'sha256:' . str_repeat('f', 64), - $reservation->authorizationVersion, - ); - $instance = WorkflowInstance::query()->findOrFail($reservation->childInstanceId()); - $state = json_decode($instance->getRawOriginal('native_child_reservation'), true, flags: JSON_THROW_ON_ERROR); - $state['reservation'] = $historical->toArray(); - WorkflowInstance::query()->whereKey($instance->id)->toBase()->update([ - 'native_child_reservation' => json_encode($state, JSON_THROW_ON_ERROR), - ]); - $parent->forceFill([ - 'status' => RunStatus::Completed, - ])->save(); - - try { - $this->reserve($parent); - $this->fail('A new deployed definition silently replaced the historical reservation.'); - } catch (LogicException $error) { - $this->assertStringContainsString('different immutable descriptor', $error->getMessage()); - } - $loaded = $this->store() - ->load($reservation->identity, 'trusted-owner'); - $this->assertSame($historical->toArray(), $loaded->toArray()); - $closed = $this->store() - ->close($loaded, 'trusted-owner'); - $this->assertInstanceOf(NeverAdmittedChild::class, $closed); - $again = $this->store() - ->load($reservation->identity, 'trusted-owner'); - $this->assertSame($loaded->toArray(), $again->toArray()); - $this->assertSame($closed->closureId, $this->store()->close($again, 'trusted-owner')->closureId); - } - - public function testLoadingAStoredDescriptorDoesNotAutoloadItsHistoricalClass(): void - { - $reservation = $this->reserve($this->parent()); - $historicalClass = 'Tests\\Fixtures\\V2\\UnavailableHistoricalReservedChild'; - $historical = new ChildReservation( - $reservation->identity, - $historicalClass, - $reservation->definitionFingerprint, - $reservation->authorizationVersion, - ); - $instance = WorkflowInstance::query()->findOrFail($reservation->childInstanceId()); - $state = json_decode($instance->getRawOriginal('native_child_reservation'), true, flags: JSON_THROW_ON_ERROR); - $state['reservation'] = $historical->toArray(); - WorkflowInstance::query()->whereKey($instance->id)->toBase()->update([ - 'workflow_class' => $historicalClass, - 'native_child_reservation' => json_encode($state, JSON_THROW_ON_ERROR), - ]); - $autoloaded = []; - $loader = static function (string $class) use (&$autoloaded): void { - $autoloaded[] = $class; - }; - spl_autoload_register($loader); - try { - $loaded = $this->store() - ->load($reservation->identity, 'trusted-owner'); - $this->assertSame($historical->toArray(), $loaded->toArray()); - $this->assertInstanceOf(NeverAdmittedChild::class, $this->store()->close($loaded, 'trusted-owner')); - } finally { - spl_autoload_unregister($loader); - } - $this->assertNotContains($historicalClass, $autoloaded); - } - - public function testLoadingRejectsAChangedDescriptorWithTheSameLookupKey(): void - { - $reservation = $this->reserve($this->parent()); - $changed = $reservation->identity->toArray(); - $changed['requiredOutcome'] = 'substituted-outcome'; - $this->expectException(LogicException::class); - $this->expectExceptionMessage('differs from the original binding'); - $this->store() - ->load(BindingIdentity::fromArray($changed), 'trusted-owner'); - } - - public function testLoadingRequiresAnExistingReservationAndCannotCreateOne(): void - { - $identity = $this->identity($this->parent()); - try { - $this->store() - ->load($identity, 'trusted-owner'); - $this->fail('Lookup absence became a child reservation.'); - } catch (\Illuminate\Database\Eloquent\ModelNotFoundException) { - $this->assertSame(1, WorkflowInstance::query()->count()); - $this->assertSame(1, WorkflowRun::query()->count()); - } - } - - public function testLoadingReauthorizesItsOwnOperationWithoutReplacingThePinnedPolicy(): void - { - $reservation = $this->reserve($this->parent()); - $authorization = new class() implements BindingAuthorization { - public array $calls = []; - - public function assertAllowed(BindingIdentity $identity, string $principal, string $operation): string - { - $this->calls[] = [$identity->toArray(), $principal, $operation, DB::transactionLevel()]; - return 'current-host-policy-v2'; - } - }; - $loaded = (new ChildReservationStore($authorization))->load($reservation->identity, 'current-principal'); - $this->assertSame([ - [$reservation->identity->toArray(), 'current-principal', 'load', 0], - ], $authorization->calls); - $this->assertSame($reservation->toArray(), $loaded->toArray()); - $this->assertSame('host-policy-v1', $loaded->authorizationVersion); - } - public function testAdmissionBeforeCloseReturnsExactNativeReceiptAndHonorsPinnedRouting(): void { $parent = $this->parent(); diff --git a/tests/Unit/V2/StateMachine/ChildReservationRecoveryTest.php b/tests/Unit/V2/StateMachine/ChildReservationRecoveryTest.php new file mode 100644 index 000000000..17f0f0726 --- /dev/null +++ b/tests/Unit/V2/StateMachine/ChildReservationRecoveryTest.php @@ -0,0 +1,219 @@ +parent(); + $reservation = $this->reserve($parent); + $historical = new ChildReservation( + $reservation->identity, + $reservation->workflowClass, + 'sha256:' . str_repeat('f', 64), + $reservation->authorizationVersion, + ); + $instance = WorkflowInstance::query()->findOrFail($reservation->childInstanceId()); + $state = json_decode($instance->getRawOriginal('native_child_reservation'), true, flags: JSON_THROW_ON_ERROR); + $state['reservation'] = $historical->toArray(); + WorkflowInstance::query()->whereKey($instance->id)->toBase()->update([ + 'native_child_reservation' => json_encode($state, JSON_THROW_ON_ERROR), + ]); + $parent->forceFill([ + 'status' => RunStatus::Completed, + ])->save(); + + try { + $this->reserve($parent); + $this->fail('A new deployed definition silently replaced the historical reservation.'); + } catch (LogicException $error) { + $this->assertStringContainsString('different immutable descriptor', $error->getMessage()); + } + $loaded = $this->store() + ->load($reservation->identity, 'trusted-owner'); + $this->assertSame($historical->toArray(), $loaded->toArray()); + $closed = $this->store() + ->close($loaded, 'trusted-owner'); + $this->assertInstanceOf(NeverAdmittedChild::class, $closed); + $again = $this->store() + ->load($reservation->identity, 'trusted-owner'); + $this->assertSame($loaded->toArray(), $again->toArray()); + $this->assertSame($closed->closureId, $this->store()->close($again, 'trusted-owner')->closureId); + } + + public function testLoadingAStoredDescriptorDoesNotAutoloadItsHistoricalClass(): void + { + $reservation = $this->reserve($this->parent()); + $historicalClass = 'Tests\\Fixtures\\V2\\UnavailableHistoricalReservedChild'; + $historical = new ChildReservation( + $reservation->identity, + $historicalClass, + $reservation->definitionFingerprint, + $reservation->authorizationVersion, + ); + $instance = WorkflowInstance::query()->findOrFail($reservation->childInstanceId()); + $state = json_decode($instance->getRawOriginal('native_child_reservation'), true, flags: JSON_THROW_ON_ERROR); + $state['reservation'] = $historical->toArray(); + WorkflowInstance::query()->whereKey($instance->id)->toBase()->update([ + 'workflow_class' => $historicalClass, + 'native_child_reservation' => json_encode($state, JSON_THROW_ON_ERROR), + ]); + $autoloaded = []; + $loader = static function (string $class) use (&$autoloaded): void { + $autoloaded[] = $class; + }; + spl_autoload_register($loader); + try { + $loaded = $this->store() + ->load($reservation->identity, 'trusted-owner'); + $this->assertSame($historical->toArray(), $loaded->toArray()); + $this->assertInstanceOf(NeverAdmittedChild::class, $this->store()->close($loaded, 'trusted-owner')); + } finally { + spl_autoload_unregister($loader); + } + $this->assertNotContains($historicalClass, $autoloaded); + } + + public function testLoadingRejectsAChangedDescriptorWithTheSameLookupKey(): void + { + $reservation = $this->reserve($this->parent()); + $changed = $reservation->identity->toArray(); + $changed['requiredOutcome'] = 'substituted-outcome'; + $this->expectException(LogicException::class); + $this->expectExceptionMessage('differs from the original binding'); + $this->store() + ->load(BindingIdentity::fromArray($changed), 'trusted-owner'); + } + + public function testLoadingRequiresAnExistingReservationAndCannotCreateOne(): void + { + $identity = $this->identity($this->parent()); + try { + $this->store() + ->load($identity, 'trusted-owner'); + $this->fail('Lookup absence became a child reservation.'); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException) { + $this->assertSame(1, WorkflowInstance::query()->count()); + $this->assertSame(1, WorkflowRun::query()->count()); + } + } + + public function testLoadingReauthorizesItsOwnOperationWithoutReplacingThePinnedPolicy(): void + { + $reservation = $this->reserve($this->parent()); + $authorization = new class() implements BindingAuthorization { + public array $calls = []; + + public function assertAllowed(BindingIdentity $identity, string $principal, string $operation): string + { + $this->calls[] = [$identity->toArray(), $principal, $operation, DB::transactionLevel()]; + return 'current-host-policy-v2'; + } + }; + $loaded = (new ChildReservationStore($authorization))->load($reservation->identity, 'current-principal'); + $this->assertSame([ + [$reservation->identity->toArray(), 'current-principal', 'load', 0], + ], $authorization->calls); + $this->assertSame($reservation->toArray(), $loaded->toArray()); + $this->assertSame('host-policy-v1', $loaded->authorizationVersion); + } + + public function testForeignOwnerIsRejectedBeforeProtectedReservationDecoding(): void + { + $reservation = $this->reserve($this->parent()); + WorkflowInstance::query()->whereKey($reservation->childInstanceId())->toBase()->update([ + 'namespace' => 'foreign-tenant', + 'native_child_reservation' => '{invalid-protected-json', + ]); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('does not match the original owner'); + $this->store() + ->load($reservation->identity, 'trusted-owner'); + } + + private function store(bool $allowed = true): ChildReservationStore + { + return new ChildReservationStore(new class($allowed) implements BindingAuthorization { + public function __construct( + private readonly bool $allowed + ) { + } + + public function assertAllowed(BindingIdentity $identity, string $principal, string $operation): string + { + if (! $this->allowed || $principal !== 'trusted-owner' || $identity->namespace !== 'tenant-a') { + throw new LogicException('binding_access_refused'); + } + return 'host-policy-v1'; + } + }); + } + + private function parent(): WorkflowRun + { + $instance = WorkflowInstance::query()->create([ + 'workflow_class' => TestGreetingWorkflow::class, + 'workflow_type' => TypeRegistry::for(TestGreetingWorkflow::class), + 'namespace' => 'tenant-a', + 'run_count' => 1, + 'reserved_at' => now(), + ]); + $run = WorkflowRun::query()->create([ + 'workflow_instance_id' => $instance->id, + 'run_number' => 1, + 'workflow_class' => TestGreetingWorkflow::class, + 'workflow_type' => $instance->workflow_type, + 'namespace' => 'tenant-a', + 'status' => RunStatus::Running, + 'last_history_sequence' => 0, + 'started_at' => now(), + ]); + $instance->forceFill([ + 'current_run_id' => $run->id, + ])->save(); + return $run; + } + + private function identity(WorkflowRun $parent): BindingIdentity + { + return new BindingIdentity( + new ExecutionReference('app', 'business-1', 'execution-1', str_repeat('a', 64), 'ready', 'revision-1'), + 'tenant-a', + $parent->workflow_instance_id, + $parent->id, + 'shipment', + 'entry-1', + 'delivering', + TypeRegistry::for(TestGreetingWorkflow::class), + str_repeat('b', 64), + 'delivered', + ChildReservation::inputFingerprint(['Taylor']), + new ChildWorkflowOptions(connection: 'database', queue: 'bound-child'), + ); + } + + private function reserve(WorkflowRun $parent): ChildReservation + { + return $this->store() + ->reserve($this->identity($parent), TestGreetingWorkflow::class, 'trusted-owner'); + } +}