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
2 changes: 2 additions & 0 deletions docs/business-state-machines.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions src/V2/StateMachine/Binding/ChildReservationStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 15 additions & 2 deletions tests/Feature/V2/V2ChildReservationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,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');
Expand Down Expand Up @@ -226,6 +229,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
Expand All @@ -242,11 +253,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');
}
Expand Down
219 changes: 219 additions & 0 deletions tests/Unit/V2/StateMachine/ChildReservationRecoveryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
<?php

declare(strict_types=1);

namespace Tests\Unit\V2\StateMachine;

use Illuminate\Support\Facades\DB;
use LogicException;
use Tests\Fixtures\V2\TestGreetingWorkflow;
use Tests\TestCase;
use Workflow\V2\Enums\RunStatus;
use Workflow\V2\Models\WorkflowInstance;
use Workflow\V2\Models\WorkflowRun;
use Workflow\V2\StateMachine\Binding\BindingAuthorization;
use Workflow\V2\StateMachine\Binding\BindingIdentity;
use Workflow\V2\StateMachine\Binding\ChildReservation;
use Workflow\V2\StateMachine\Binding\ChildReservationStore;
use Workflow\V2\StateMachine\Binding\NeverAdmittedChild;
use Workflow\V2\StateMachine\Execution\ExecutionReference;
use Workflow\V2\Support\ChildWorkflowOptions;
use Workflow\V2\Support\TypeRegistry;

final class ChildReservationRecoveryTest extends TestCase
{
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 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');
}
}
Loading