Skip to content
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## Unreleased

- Experimental embedded child-start milestone: `Workflow::startChild()` returns
a versioned `StartedChildWorkflow` receipt after native child admission, and
`$child->await()` observes that same child later. Cold replay reuses the
original admission; a queued request alone cannot supply a receipt. The
existing `child()` still waits for completion. This adds a
`ChildWorkflowAwaited` history event and an optional
`child_completion_mode` on `ChildWorkflowScheduled`, with no database
migration. Worker-protocol execution refuses the new calls. State-bound
business bindings and retained compensation remain separate #1069 work.

- Require patched Laravel 12.72 or 13.41 minimums for the current Composer
security advisories. Keep existing application audits enabled and update
application lockfiles before upgrading Workflow (#1133).
Expand Down
21 changes: 20 additions & 1 deletion docs/business-state-machines.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,26 @@ The next nested slice must preserve the same four methods and add:
- Verified child compensation before earlier parent compensation. Technical child completion, accepted signals and timeouts are insufficient evidence.
- Acyclic bounded binding graphs, resource limits and explicit adoption ownership.

Existing `child()` can be called from a step and durably await a child result. That does not yet implement the separate state-bound start milestone. This slice deliberately adds no alternate child scheduler or compensation engine.
Existing `child()` can be called from a step and durably await a child result. The separate native admission primitive below supplies a confirmed start milestone; it does not itself implement the state-bound binding protocol. Both paths use the existing child scheduler and history.

## Confirmed native child admission

Embedded V2 workflows can separate native child admission from waiting for its result:

```php
$child = self::startChild(ChildMachineWorkflow::class, $input);
self::activity(RecordChildAdmission::class, $child->toArray());
// Continue the parent, then observe this exact child when its result is needed.
$result = $child->await();
```

The child class and activity above are application implementations. `startChild()` accepts the same optional `ChildWorkflowOptions` as `child()`. The child may still be pending execution when the receipt returns: its native instance, run, lineage and task have been admitted, rather than merely queued as a request to create them. A cold duplicate resumes with the same admission. The later await creates neither another child nor another compensation owner, and works whether the child completes before or after the await.

`StartedChildWorkflow::toArray()` is the version-1 serialized receipt. It binds namespace, original parent instance/run, owning start sequence, child instance, initial child run, child-call ID and workflow type. `fromArray()` validates its shape; the runtime separately checks it against that parent's paired schedule/start history before awaiting. Modified, foreign-parent and missing-admission receipts refuse. Awaiting is scoped to the original parent execution. Query replay uses committed parent resolution history, not a live terminal row as an unrecorded result.

Native retries and continue-as-new remain attached only through the recorded descendant chain rooted at that receipt. A later current run in the same instance does not authorize substitution. Await cancellation records both its own workflow step and the original child operation, so cold replay preserves the selected child cancellation policy.

This primitive is experimental and embedded-only. Worker-protocol execution refuses these calls. It preserves ordinary `child()` terminal-wait behavior and parent-close/cancellation policies. `RecordChildAdmission` still owns application authorization, expected state/revision checks and a protected idempotent business commit; a receipt does not implement those boundaries. The scope/slot/generation binding, policy pinning, adoption ownership, business-outcome verification and retained recovery in the next section remain required before claiming nested business-machine support.

## Follow-up implementation sequence

Expand Down
1 change: 1 addition & 0 deletions src/V2/Enums/HistoryEventType.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ enum HistoryEventType: string
case WorkflowStarted = 'WorkflowStarted';
case WorkflowContinuedAsNew = 'WorkflowContinuedAsNew';
case ChildWorkflowScheduled = 'ChildWorkflowScheduled';
case ChildWorkflowAwaited = 'ChildWorkflowAwaited';
case ChildCancellationRequested = 'ChildCancellationRequested';
case ChildCancellationResolved = 'ChildCancellationResolved';
case ChildRunStarted = 'ChildRunStarted';
Expand Down
77 changes: 77 additions & 0 deletions src/V2/StartedChildWorkflow.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

namespace Workflow\V2;

use InvalidArgumentException;
use Workflow\V2\Support\AwaitStartedChildWorkflowCall;
use Workflow\V2\Support\WorkflowFiberContext;

/** A confirmed native child admission, independent of its eventual result. */
final readonly class StartedChildWorkflow
{
public function __construct(
public string $namespace,
public string $parentInstanceId,
public string $parentRunId,
public int $startSequence,
public string $childInstanceId,
public string $childRunId,
public string $childCallId,
public string $workflowType,
) {
foreach (get_object_vars($this) as $value) {
if (is_string($value) && trim($value) === '') {
throw new InvalidArgumentException('Child admission identifiers must not be empty.');
}
}
if ($startSequence < 1) {
throw new InvalidArgumentException('A child admission requires its positive owning step.');
}
}

public function await(): mixed
{
return WorkflowFiberContext::suspend(new AwaitStartedChildWorkflowCall($this));
}

/**
* @return array<string, int|string>
*/
public function toArray(): array
{
return [
'version' => 1,
...get_object_vars($this),
];
}

/**
* @param array<string, mixed> $value
*/
public static function fromArray(array $value): self
{
$strings = ['namespace', 'parentInstanceId', 'parentRunId', 'childInstanceId',
'childRunId', 'childCallId', 'workflowType'];
if (($value['version'] ?? null) !== 1 || ! is_int($value['startSequence'] ?? null)
|| count($value) !== count($strings) + 2) {
throw new InvalidArgumentException('Unsupported or malformed child admission.');
}
foreach ($strings as $key) {
if (! is_string($value[$key] ?? null)) {
throw new InvalidArgumentException('Child admission identifiers must be strings.');
}
}
return new self(
$value['namespace'],
$value['parentInstanceId'],
$value['parentRunId'],
$value['startSequence'],
$value['childInstanceId'],
$value['childRunId'],
$value['childCallId'],
$value['workflowType']
);
}
}
16 changes: 16 additions & 0 deletions src/V2/Support/AwaitStartedChildWorkflowCall.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Workflow\V2\Support;

use Workflow\V2\Contracts\YieldedCommand;
use Workflow\V2\StartedChildWorkflow;

final readonly class AwaitStartedChildWorkflowCall implements YieldedCommand
{
public function __construct(
public StartedChildWorkflow $child
) {
}
}
Loading
Loading