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
14 changes: 13 additions & 1 deletion docs/business-state-machines.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This is the first implementation slice of the composable business state-machine proposal. It provides an optional embedded V2 transition protocol with a four-method step contract, recorded assessments, versioned profiles and active-run native Saga integration. Existing workflows and `Workflow\States\StateMachine` are unchanged. Symfony Workflow is not a dependency.

**Experimental.** The `Workflow\V2\StateMachine` API is under review and may change in a minor release until the nested slices land; it carries no backward-compatibility promise yet. This slice does not implement state-bound child start/adoption, retained compensation scopes, a business-state database schema or a persistence adapter for a specific application. It does not complete that proposal.
**Experimental.** The `Workflow\V2\StateMachine` API is under review and may change in a minor release until the nested slices land; it carries no backward-compatibility promise yet. The complete state-bound binding runtime, retained compensation adapter, business-state database schema and application persistence integration remain unfinished. This does not complete the proposal.

## Responsibilities and flow

Expand Down Expand Up @@ -231,3 +231,15 @@ Tests exercise refusal/unknown outcomes, source and scope substitution, changed
| 21–22 | Runnable V2 regression example, existing APIs unchanged | Full integration example, persistence crash tests and upstream qualification |

This table reports scope, not completion of the issue's unchecked acceptance criteria.

## Native child reservation boundary

The experimental `Binding\ChildReservationStore` provides the native reservation needed before state-bound child admission. Call `reserve()` and `close()` inside recorded application activities, outside caller-owned SQL transactions. Each method owns its transaction so a returned closure is committed and host authorization runs before native locks. The supplied `BindingAuthorization` must validate the authenticated principal, original application/business owner, generation and requested operation before protected storage is read. Identity values and a serialized reservation are not credentials.

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.

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.

This primitive does not resolve an existing `dw.child-scope` participant whose child scope is missing. The retained-scope adapter must independently reread authoritative closure evidence or wait for the exact admitted child's verified compensation. A deserialized `NeverAdmittedChild`, technical cancellation or lookup absence is insufficient. That adapter, protected business cancellation/commit integration, real database race qualification and the two-integration example remain required before the full binding contract is delivered. Forward ownership transfer through continue-as-new or redrive is not supported for these reserved children.
14 changes: 14 additions & 0 deletions src/V2/StateMachine/Binding/BindingAuthorization.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Workflow\V2\StateMachine\Binding;

/** @experimental The host checks the authenticated principal and original business owner. */
interface BindingAuthorization
{
/**
* Refuse unauthorized access, or return the applicable authorization policy version.
*/
public function assertAllowed(BindingIdentity $identity, string $principal, string $operation): string;
}
172 changes: 172 additions & 0 deletions src/V2/StateMachine/Binding/BindingIdentity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
<?php

declare(strict_types=1);

namespace Workflow\V2\StateMachine\Binding;

use InvalidArgumentException;
use Workflow\V2\Enums\CancellationPolicy;
use Workflow\V2\Enums\ParentClosePolicy;
use Workflow\V2\StateMachine\Execution\ExecutionReference;
use Workflow\V2\Support\ChildWorkflowOptions;

/**
* Original business/state-entry identity, independent of any later current-state pointer.
*
* @experimental This value is not authentication or a persistence/concurrency fence.
*/
final readonly class BindingIdentity
{
public function __construct(
public ExecutionReference $parentExecution,
public string $namespace,
public string $parentInstanceId,
public string $parentRunId,
public string $slot,
public string $generation,
public string $hostingState,
public string $childWorkflowType,
public string $childPolicyFingerprint,
public string $requiredOutcome,
public string $inputFingerprint,
public ChildWorkflowOptions $childOptions = new ChildWorkflowOptions(),
) {
foreach ([$namespace, $parentInstanceId, $parentRunId, $slot, $generation,
$hostingState, $childWorkflowType, $requiredOutcome] as $value) {
if (trim($value) === '' || strlen($value) > 512) {
throw new InvalidArgumentException('Binding identity fields must contain between 1 and 512 bytes.');
}
}
foreach ([$parentExecution->policyFingerprint, $childPolicyFingerprint, $inputFingerprint] as $fingerprint) {
if (preg_match('/\A[0-9a-f]{64}\z/D', $fingerprint) !== 1) {
throw new InvalidArgumentException('Binding policy and input fingerprints must be SHA-256 values.');
}
}
}

/**
* Exact original generation lookup; never substitute a latest/current binding.
*/
public function key(): string
{
return hash('sha256', json_encode([
'version' => 1,
'scope' => $this->parentExecution->scope,
'subject' => $this->parentExecution->subject,
'executionId' => $this->parentExecution->executionId,
'namespace' => $this->namespace,
'parentInstanceId' => $this->parentInstanceId,
'parentRunId' => $this->parentRunId,
'slot' => $this->slot,
'generation' => $this->generation,
], JSON_THROW_ON_ERROR));
}

/**
* The immutable descriptor must also match when an existing key is reused.
*/
public function fingerprint(): string
{
return hash('sha256', json_encode($this->toArray(), JSON_THROW_ON_ERROR));
}

/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'version' => 1,
'parentExecution' => [
'scope' => $this->parentExecution->scope,
'subject' => $this->parentExecution->subject,
'executionId' => $this->parentExecution->executionId,
'policyFingerprint' => $this->parentExecution->policyFingerprint,
'sourceState' => $this->parentExecution->sourceState,
'sourceRevision' => $this->parentExecution->sourceRevision,
],
'namespace' => $this->namespace,
'parentInstanceId' => $this->parentInstanceId,
'parentRunId' => $this->parentRunId,
'slot' => $this->slot,
'generation' => $this->generation,
'hostingState' => $this->hostingState,
'childWorkflowType' => $this->childWorkflowType,
'childPolicyFingerprint' => $this->childPolicyFingerprint,
'requiredOutcome' => $this->requiredOutcome,
'inputFingerprint' => $this->inputFingerprint,
'childOptions' => $this->childOptions->toSnapshot(),
];
}

/**
* @param array<string, mixed> $payload
*/
public static function fromArray(array $payload): self
{
$strings = ['namespace', 'parentInstanceId', 'parentRunId', 'slot', 'generation',
'hostingState', 'childWorkflowType', 'childPolicyFingerprint', 'requiredOutcome', 'inputFingerprint'];
self::strings($payload, $strings, ['version', 'parentExecution', 'childOptions']);
if ($payload['version'] !== 1 || ! is_array($payload['parentExecution']) || ! is_array(
$payload['childOptions']
)) {
throw new InvalidArgumentException('Unsupported or malformed binding identity.');
}
$parent = $payload['parentExecution'];
self::strings(
$parent,
['scope', 'subject', 'executionId', 'policyFingerprint', 'sourceState', 'sourceRevision']
);
$options = $payload['childOptions'];
self::strings($options, ['parent_close_policy', 'cancellation_policy'], ['connection', 'queue']);
foreach (['connection', 'queue'] as $key) {
if ($options[$key] !== null && ! is_string($options[$key])) {
throw new InvalidArgumentException('Binding child routing must be a string or null.');
}
}
return new self(
new ExecutionReference(
$parent['scope'],
$parent['subject'],
$parent['executionId'],
$parent['policyFingerprint'],
$parent['sourceState'],
$parent['sourceRevision']
),
$payload['namespace'],
$payload['parentInstanceId'],
$payload['parentRunId'],
$payload['slot'],
$payload['generation'],
$payload['hostingState'],
$payload['childWorkflowType'],
$payload['childPolicyFingerprint'],
$payload['requiredOutcome'],
$payload['inputFingerprint'],
new ChildWorkflowOptions(
ParentClosePolicy::from($options['parent_close_policy']),
$options['connection'],
$options['queue'],
CancellationPolicy::from($options['cancellation_policy']),
),
);
}

/**
* @param array<string, mixed> $payload
* @param list<string> $strings
* @param list<string> $other
*/
private static function strings(array $payload, array $strings, array $other = []): void
{
$expected = [...$strings, ...$other];
if (count($payload) !== count($expected) || array_diff($expected, array_keys($payload)) !== []) {
throw new InvalidArgumentException('Unexpected or missing binding identity field.');
}
foreach ($strings as $key) {
if (! is_string($payload[$key])) {
throw new InvalidArgumentException('Binding identity fields must be strings.');
}
}
}
}
59 changes: 59 additions & 0 deletions src/V2/StateMachine/Binding/ChildBinding.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

namespace Workflow\V2\StateMachine\Binding;

use InvalidArgumentException;
use Workflow\V2\StartedChildWorkflow;

/**
* A confirmed native receipt tied to one immutable business/state-entry generation.
*
* @experimental Persistence and live authorization are supplied by the binding runtime.
*/
final readonly class ChildBinding
{
public function __construct(
public BindingIdentity $identity,
public StartedChildWorkflow $admission,
) {
if ($identity->namespace !== $admission->namespace
|| $identity->parentInstanceId !== $admission->parentInstanceId
|| $identity->parentRunId !== $admission->parentRunId
|| $identity->childWorkflowType !== $admission->workflowType) {
throw new InvalidArgumentException('The child admission belongs to a different binding owner or type.');
}
}

public function matches(BindingIdentity $expected): bool
{
return $this->identity->toArray() === $expected->toArray();
}

/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'version' => 1,
'identity' => $this->identity->toArray(),
'admission' => $this->admission->toArray(),
];
}

/**
* @param array<string, mixed> $payload
*/
public static function fromArray(array $payload): self
{
if (count($payload) !== 3 || ($payload['version'] ?? null) !== 1
|| ! is_array($payload['identity'] ?? null) || ! is_array($payload['admission'] ?? null)) {
throw new InvalidArgumentException('Unsupported or malformed child binding.');
}
return new self(BindingIdentity::fromArray($payload['identity']), StartedChildWorkflow::fromArray(
$payload['admission']
));
}
}
90 changes: 90 additions & 0 deletions src/V2/StateMachine/Binding/ChildReservation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

namespace Workflow\V2\StateMachine\Binding;

use InvalidArgumentException;
use Workflow\V2\Compensation\Definition\PortableDescriptor;
use Workflow\V2\StartedChildWorkflow;
use Workflow\V2\Support\ChildWorkflowCall;
use Workflow\V2\Support\StartChildWorkflowCall;
use Workflow\V2\Support\WorkflowFiberContext;

/** @experimental An immutable descriptor, not proof of authorization or admission. */
final readonly class ChildReservation
{
public function __construct(
public BindingIdentity $identity,
public string $workflowClass,
public string $definitionFingerprint,
public string $authorizationVersion,
) {
if ($workflowClass === '' || strlen($workflowClass) > 512
|| trim($authorizationVersion) === '' || strlen($authorizationVersion) > 128
|| preg_match('/\Asha256:[0-9a-f]{64}\z/D', $definitionFingerprint) !== 1) {
throw new InvalidArgumentException('Invalid child reservation definition or authorization version.');
}
PortableDescriptor::encode($this->toArray());
}

public function childInstanceId(): string
{
return 'dw-bound-child-' . $this->identity->key();
}

/**
* Start through the original parent's native command/history boundary.
*/
public function start(mixed ...$arguments): StartedChildWorkflow
{
return WorkflowFiberContext::suspend(new StartChildWorkflowCall(new ChildWorkflowCall(
$this->workflowClass,
$arguments,
$this->identity->childOptions,
$this,
)));
}

/**
* @param array<int, mixed> $arguments
*/
public static function inputFingerprint(array $arguments): string
{
return hash('sha256', PortableDescriptor::encode($arguments));
}

/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'version' => 1,
'identity' => $this->identity->toArray(),
'workflowClass' => $this->workflowClass,
'definitionFingerprint' => $this->definitionFingerprint,
'authorizationVersion' => $this->authorizationVersion,
];
}

/**
* @param array<string, mixed> $value
*/
public static function fromArray(array $value): self
{
if (count($value) !== 5 || ($value['version'] ?? null) !== 1
|| ! is_array($value['identity'] ?? null)
|| ! is_string($value['workflowClass'] ?? null)
|| ! is_string($value['definitionFingerprint'] ?? null)
|| ! is_string($value['authorizationVersion'] ?? null)) {
throw new InvalidArgumentException('Unsupported or malformed child reservation.');
}
return new self(
BindingIdentity::fromArray($value['identity']),
$value['workflowClass'],
$value['definitionFingerprint'],
$value['authorizationVersion'],
);
}
}
Loading
Loading