Skip to content

Commit 78f9239

Browse files
GitHub #82: Contract: Child parent-close policy (#525)
1 parent cab3a6f commit 78f9239

4 files changed

Lines changed: 297 additions & 0 deletions

File tree

docs/workflow/plan.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,9 @@ release:
227227
- Indexed search attributes and non-indexed memo are separate
228228
first-release contracts.
229229
- Workflows default to no retry unless an explicit retry policy is set.
230+
- Child workflows default to `ParentClosePolicy::Abandon` so children
231+
outlive a parent close unless the parent explicitly opts in to
232+
request-cancel or terminate per child.
230233
- Supported backend combinations are validated early by doctor/readiness
231234
checks.
232235
- Operator actions are typed engine commands from first release.
@@ -238,6 +241,101 @@ release:
238241
- Local activities and worker sessions are deferred or opt-in only after
239242
they receive their own explicit runtime contracts.
240243

244+
## Child Parent-Close Policy Contract
245+
246+
This section pins the parent-close policy contract for child workflows.
247+
It expands the `Child workflows` row of the Feature Compatibility Matrix
248+
and is the normative reference for what every implementation must
249+
preserve when a parent run reaches a terminal disposition while open
250+
children still exist.
251+
252+
### Snapshot and default
253+
254+
Every child call snapshots a parent-close policy at scheduling time. The
255+
snapshot lives in two durable rows so the enforcer and projections never
256+
have to recompute it:
257+
258+
- `workflow_child_calls.parent_close_policy` — the per-call snapshot
259+
recorded when `ChildCallService::scheduleChild` writes the row.
260+
- `workflow_links.parent_close_policy` — the per-link snapshot copied
261+
to every `child_workflow` link, including the link rewritten across
262+
child continue-as-new so the active link still names the policy.
263+
264+
The snapshot value is one of `abandon`, `request_cancel`, or
265+
`terminate`. When `ChildWorkflowOptions` is omitted the snapshot is
266+
`abandon`; this default is named in `ChildWorkflowOptions`,
267+
`Workflow::child(...)`, `Workflow::executeChildWorkflow(...)`, and the
268+
public authoring docs so source-compatible callers cannot accidentally
269+
inherit a different default.
270+
271+
### Per-child override observability
272+
273+
Per-child overrides are observable on three first-class surfaces:
274+
275+
- **Typed history**. `ChildWorkflowScheduled` and `ChildRunStarted`
276+
payloads carry the snapshotted `parent_close_policy`. When a child
277+
continues-as-new, the re-emitted `ChildRunStarted` for the new child
278+
run keeps the same policy field so replay never has to inspect the
279+
link table to know the policy. `ParentClosePolicyApplied` and
280+
`ParentClosePolicyFailed` events name the targeted `child_run_id`,
281+
the applied policy, and the operator-readable reason or error.
282+
- **Projections**. `RunLineageView::continuedWorkflowsForRun` exposes
283+
`parent_close_policy`, `parent_close_policy_outcome` (`applied`,
284+
`failed`, or `null` while the parent still runs),
285+
`parent_close_policy_reason`, and `parent_close_policy_error` per
286+
child entry. `RunLineageProjector` persists those fields onto each
287+
`workflow_run_lineage_entries` row's payload so the lineage surface
288+
stays inspectable after history pruning.
289+
- **Operator commands**. The `ChildCallService` enforcement metadata
290+
(`parent_close_cancel_requested`, `parent_close_terminate_requested`)
291+
is preserved on the child-call row for operator reasoning when a
292+
history event has not yet projected.
293+
294+
### Parent disposition matrix
295+
296+
Every terminal disposition of the parent must define how policy applies.
297+
The runtime path that performs each transition is the source of truth;
298+
projections and Waterline both read from the events those paths record.
299+
300+
| Parent disposition | Runtime path | Policy applies | Notes |
301+
| --- | --- | --- | --- |
302+
| Completion | `WorkflowExecutor::completeRun` records `WorkflowCompleted` and calls `ParentClosePolicyEnforcer::enforce` | yes | Open children with `request_cancel` or `terminate` get the corresponding command; `abandon` children are left running. |
303+
| Failure | `WorkflowExecutor::failRun` records `WorkflowFailed` and calls `ParentClosePolicyEnforcer::enforce` | yes | Includes deterministic terminal failures and structural-limit failures. |
304+
| Timeout | `WorkflowExecutor::timeoutRun` records `WorkflowTimedOut` and calls `ParentClosePolicyEnforcer::enforce` | yes | Both run-deadline and execution-deadline timeouts share the same enforcement path. |
305+
| Cancellation | `WorkflowStub::attemptTerminalCommand` (cancel branch) records `WorkflowCancelled` and calls `ParentClosePolicyEnforcer::enforce` | yes | Cooperative cancel of the parent still applies the snapshotted child policy; children with `abandon` keep running. |
306+
| Termination | `WorkflowStub::attemptTerminalCommand` (terminate branch) records `WorkflowTerminated` and calls `ParentClosePolicyEnforcer::enforce` | yes | Terminate is immediate; children with `abandon` continue independently. |
307+
| Continue-as-new | `WorkflowExecutor::continueAsNew` rewrites every open `child_workflow` link onto the continued run with the same `parent_close_policy` and re-emits `ChildRunStarted` on the parent line, but does **not** call the enforcer | no | The new run owns the open children and keeps the policy snapshot; only a terminal disposition of the new run triggers enforcement. |
308+
| Reset | Reserved as a later-phase command (see V2.0 Defaults and Gap Analysis) | reserved | Reset is intentionally not part of the v2.0 correctness core. When reset lands it must either transfer child-link ownership to the reset destination run or call `ParentClosePolicyEnforcer::enforce` against the discarded run before the old run stops owning children, mirroring the continue-as-new and terminal paths respectively. |
309+
310+
The enforcer records `ParentClosePolicyApplied` for each successfully
311+
delivered command and `ParentClosePolicyFailed` (with the throwable
312+
message) when the child rejects the command — for example because the
313+
child reached a terminal state between the parent's terminal commit and
314+
the enforcer's read. Both events name the `child_instance_id`,
315+
`child_run_id`, `policy`, and `reason`, and the failed event also names
316+
`error`. Operator surfaces must distinguish "policy applied" from
317+
"policy failed silently" using these typed events; the link row's
318+
metadata flags are diagnostic only.
319+
320+
### Waterline observability
321+
322+
Waterline reads the `RunLineageView` fields above and renders a child
323+
lineage badge that reflects the disposition:
324+
325+
- "Closed by parent (cancelled)" or "Closed by parent (terminated)"
326+
when `parent_close_policy_outcome` is `applied` and the snapshot is
327+
`request_cancel` or `terminate`.
328+
- "Parent-close policy failed" when `parent_close_policy_outcome` is
329+
`failed`, surfacing the recorded error reason for operator triage.
330+
- "Abandoned by parent on close" when the parent has closed and the
331+
snapshot is `abandon`.
332+
- The standing snapshot ("Parent-close policy: abandon|request_cancel
333+
|terminate") while the parent is still running.
334+
335+
Continue-as-new lineage entries are deliberately unaffected; the
336+
continued run owns the open children and re-emits the relevant child
337+
history events on its own line.
338+
241339
## Gap Analysis
242340

243341
| Item | Classification | Decision |

src/V2/Workflow.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,12 @@ public static function executeLocalActivity(string $activity, mixed ...$argument
311311
/**
312312
* Invoke a child workflow and wait for its result.
313313
*
314+
* Pass a {@see \Workflow\V2\Support\ChildWorkflowOptions} as the first
315+
* argument to override the parent-close policy or routing for this
316+
* call. The default parent-close policy is
317+
* {@see \Workflow\V2\Enums\ParentClosePolicy::Abandon}, which leaves
318+
* the child running independently when the parent closes.
319+
*
314320
* @see child()
315321
*/
316322
public static function child(string $workflow, mixed ...$arguments): mixed
@@ -320,6 +326,10 @@ public static function child(string $workflow, mixed ...$arguments): mixed
320326

321327
/**
322328
* Alias for {@see child()} matching Temporal's `executeChildWorkflow` name.
329+
*
330+
* Inherits the same {@see \Workflow\V2\Support\ChildWorkflowOptions}
331+
* contract: the default parent-close policy is
332+
* {@see \Workflow\V2\Enums\ParentClosePolicy::Abandon}.
323333
*/
324334
public static function executeChildWorkflow(string $workflow, mixed ...$arguments): mixed
325335
{

tests/Unit/V2/FeatureMappingDocumentationTest.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,35 @@ final class FeatureMappingDocumentationTest extends TestCase
2424
'## Feature Compatibility Matrix',
2525
'## New-In-V2 Capabilities',
2626
'## V2.0 Defaults',
27+
'## Child Parent-Close Policy Contract',
2728
'## Gap Analysis',
2829
'## Relationship To Other Contracts',
2930
'## Changing This Contract',
3031
];
3132

33+
private const REQUIRED_PARENT_CLOSE_POLICY_PINS = [
34+
'### Snapshot and default',
35+
'### Per-child override observability',
36+
'### Parent disposition matrix',
37+
'### Waterline observability',
38+
'workflow_child_calls.parent_close_policy',
39+
'workflow_links.parent_close_policy',
40+
'`abandon`, `request_cancel`, or',
41+
'ChildWorkflowOptions',
42+
'Workflow::child(...)',
43+
'Workflow::executeChildWorkflow(...)',
44+
'| Completion |',
45+
'| Failure |',
46+
'| Timeout |',
47+
'| Cancellation |',
48+
'| Termination |',
49+
'| Continue-as-new |',
50+
'| Reset |',
51+
'ParentClosePolicyApplied',
52+
'ParentClosePolicyFailed',
53+
'ParentClosePolicyEnforcer::enforce',
54+
];
55+
3256
private const REQUIRED_FEATURE_ROWS = [
3357
'Workflow start and identity',
3458
'Workflow status and output',
@@ -139,6 +163,7 @@ final class FeatureMappingDocumentationTest extends TestCase
139163
'Workflow-mode guardrails ship early',
140164
'Indexed search attributes and non-indexed memo are separate',
141165
'Workflows default to no retry unless an explicit retry policy is set',
166+
'Child workflows default to `ParentClosePolicy::Abandon`',
142167
'Supported backend combinations are validated early',
143168
'Operator actions are typed engine commands',
144169
'Cross-namespace service calls always write a durable service-call row',
@@ -247,6 +272,19 @@ public function testContractDocumentPinsV20Defaults(): void
247272
}
248273
}
249274

275+
public function testContractDocumentPinsChildParentClosePolicyContract(): void
276+
{
277+
$contents = $this->documentContents();
278+
279+
foreach (self::REQUIRED_PARENT_CLOSE_POLICY_PINS as $pin) {
280+
$this->assertStringContainsString(
281+
$pin,
282+
$contents,
283+
sprintf('Child parent-close policy contract must include %s.', $pin),
284+
);
285+
}
286+
}
287+
250288
public function testContractDocumentDeclaresGapAnalysis(): void
251289
{
252290
$contents = $this->documentContents();
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Unit\V2;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use ReflectionClass;
9+
use Workflow\V2\Enums\ParentClosePolicy;
10+
use Workflow\V2\Support\ChildWorkflowOptions;
11+
use Workflow\V2\Support\ParentClosePolicyEnforcer;
12+
use Workflow\V2\Support\WorkflowExecutor;
13+
use Workflow\V2\Workflow;
14+
use Workflow\V2\WorkflowStub;
15+
16+
/**
17+
* Pins the parent-disposition matrix documented in the
18+
* "Child Parent-Close Policy Contract" section of
19+
* docs/workflow/plan.md. Every documented terminal disposition must
20+
* route through ParentClosePolicyEnforcer::enforce, except the
21+
* continue-as-new disposition which transfers child links instead.
22+
*/
23+
final class ParentClosePolicyContractTest extends TestCase
24+
{
25+
public function testDefaultPolicyIsAbandonOnSourceCompatibleOptions(): void
26+
{
27+
$options = new ChildWorkflowOptions();
28+
29+
$this->assertSame(
30+
ParentClosePolicy::Abandon,
31+
$options->parentClosePolicy,
32+
'ChildWorkflowOptions must default to ParentClosePolicy::Abandon.',
33+
);
34+
}
35+
36+
public function testWorkflowFacadeDocblockNamesAbandonDefault(): void
37+
{
38+
$reflection = new ReflectionClass(Workflow::class);
39+
40+
foreach (['child', 'executeChildWorkflow'] as $method) {
41+
$docComment = $reflection->getMethod($method)->getDocComment();
42+
43+
$this->assertIsString(
44+
$docComment,
45+
sprintf('Workflow::%s must carry a docblock that names the default policy.', $method),
46+
);
47+
$this->assertStringContainsString(
48+
'ParentClosePolicy::Abandon',
49+
$docComment,
50+
sprintf(
51+
'Workflow::%s docblock must name ParentClosePolicy::Abandon as the default for source-compatible callers.',
52+
$method,
53+
),
54+
);
55+
}
56+
}
57+
58+
public function testCompletionFailureAndTimeoutPathsCallEnforcer(): void
59+
{
60+
$source = $this->classSource(WorkflowExecutor::class);
61+
62+
foreach (['completeRun', 'failRun', 'timeoutRun'] as $method) {
63+
$body = $this->methodBody($source, $method);
64+
65+
$this->assertStringContainsString(
66+
'ParentClosePolicyEnforcer::enforce',
67+
$body,
68+
sprintf(
69+
'Disposition matrix: WorkflowExecutor::%s must call ParentClosePolicyEnforcer::enforce.',
70+
$method,
71+
),
72+
);
73+
}
74+
}
75+
76+
public function testContinueAsNewDoesNotCallEnforcer(): void
77+
{
78+
$source = $this->classSource(WorkflowExecutor::class);
79+
$body = $this->methodBody($source, 'continueAsNew');
80+
81+
$this->assertStringNotContainsString(
82+
'ParentClosePolicyEnforcer::enforce',
83+
$body,
84+
'Disposition matrix: continueAsNew must transfer child links instead of enforcing parent-close policy.',
85+
);
86+
$this->assertStringContainsString(
87+
"'parent_close_policy' => \$parentChildLink->parent_close_policy",
88+
$body,
89+
'continueAsNew must copy the per-child parent_close_policy snapshot onto the rewritten child link.',
90+
);
91+
}
92+
93+
public function testCancellationAndTerminationPathCallsEnforcer(): void
94+
{
95+
$source = $this->classSource(WorkflowStub::class);
96+
$body = $this->methodBody($source, 'attemptTerminalCommand');
97+
98+
$this->assertStringContainsString(
99+
'ParentClosePolicyEnforcer::enforce',
100+
$body,
101+
'Disposition matrix: WorkflowStub::attemptTerminalCommand must enforce policy for cancel and terminate.',
102+
);
103+
}
104+
105+
public function testEnforcerEmitsAppliedAndFailedHistoryEvents(): void
106+
{
107+
$source = $this->classSource(ParentClosePolicyEnforcer::class);
108+
109+
$this->assertStringContainsString(
110+
'HistoryEventType::ParentClosePolicyApplied',
111+
$source,
112+
'Enforcer must record ParentClosePolicyApplied history events on success.',
113+
);
114+
$this->assertStringContainsString(
115+
'HistoryEventType::ParentClosePolicyFailed',
116+
$source,
117+
'Enforcer must record ParentClosePolicyFailed history events on rejection.',
118+
);
119+
}
120+
121+
/**
122+
* @param class-string $class
123+
*/
124+
private function classSource(string $class): string
125+
{
126+
$reflection = new ReflectionClass($class);
127+
$path = $reflection->getFileName();
128+
129+
$this->assertNotFalse($path, sprintf('Could not resolve source path for %s.', $class));
130+
131+
$contents = file_get_contents($path);
132+
133+
$this->assertIsString($contents, sprintf('Could not read source for %s.', $class));
134+
135+
return $contents;
136+
}
137+
138+
private function methodBody(string $source, string $method): string
139+
{
140+
$pattern = sprintf(
141+
'/function\s+%s\b[^{]*(\{(?:[^{}]++|(?1))*+\})/s',
142+
preg_quote($method, '/'),
143+
);
144+
145+
if (preg_match($pattern, $source, $matches) !== 1) {
146+
$this->fail(sprintf('Could not locate method body for %s.', $method));
147+
}
148+
149+
return $matches[1];
150+
}
151+
}

0 commit comments

Comments
 (0)