Owning public component
Workflow engine
Exact version or source identity
2.0.7
Minimal reproduction and evidence
Reproduction
A child workflow whose body handles a failed activity and then finishes normally:
class ChildWorkflow extends Workflow
{
public function handle(): string
{
try {
activity(FailingActivity::class); // exhausts its attempts, recording a WorkflowFailure
} catch (Throwable) {
// handled in the workflow body; the run carries on
}
return 'done'; // the child completes normally, with a failure recorded against it
}
}
Call it from a parent with child(ChildWorkflow::class). The child reaches WorkflowCompleted while a WorkflowFailure row exists for it, and the parent then records the resolution.
This is a normal shape for any workflow with a failure-handling branch: a step fails, the workflow routes around it, the run ends successfully with a real result.
Verified on 2.0.7. The child's activity exhausted its single attempt, the body handled it, the walk continued and finished, and the child's own history then read:
ActivityScheduled → ActivityStarted → ActivityFailed → FailureHandled → ActivityCompleted
WorkflowCompleted seq=13 06:38:48.513838 output = the child's real outcome
WorkflowFailed seq=14 06:38:48.520169 ← 6.3ms later
The parent recorded ChildRunFailed and failed with it, having never seen the output.
Analysis
ActivityOutcomeRecorder writes a WorkflowFailure against the run when an activity stops retrying — not per attempt, and regardless of whether the workflow body then handles it (src/V2/Support/ActivityOutcomeRecorder.php:303-314, 'handled' => false). So a completed run can, and routinely does, own a failure row.
DefaultWorkflowTaskBridge::recordChildResolution() then builds one payload for every resolution type (src/V2/Support/DefaultWorkflowTaskBridge.php:4686-4724):
'failure_id' => $failure?->id, // ← never gated
'failure_category' => match ($eventType) {
HistoryEventType::ChildRunFailed => …,
HistoryEventType::ChildRunCancelled => …,
HistoryEventType::ChildRunTerminated => …,
default => null, // ← gated, correctly
},
…
'exception_class' => $childTerminalEvent?->event_type === HistoryEventType::WorkflowFailed
? $childTerminalEvent->payload['exception_class'] ?? $failure?->exception_class
: $failure?->exception_class, // ← non-failure branch, still set
'message' => $childTerminalEvent?->event_type === HistoryEventType::WorkflowFailed
? $childTerminalEvent->payload['message'] ?? $failure?->message
: $failure?->message, // ← same
$failure is $childRun->failures->first() (line 4675) — any failure on the run, not one scoped to how it ended, and not filtered on handled.
That last part is the crux, because the engine already knows the failure was handled. When the workflow body handles a failure the engine records a FailureHandled history event and sets handled = true on the failure row (src/V2/Support/WorkflowExecutor.php:3983-3992). In the run reproduced above, the child's own rows read:
source_kind: activity_execution exception_class: RuntimeException handled: 1
with one FailureHandled event in its history. recordChildResolution() then picks that same row up and describes the child by it. The signal that distinguishes "a failure this run recovered from" from "the failure that ended this run" is recorded, and not consulted.
failure_category is gated on the event type; failure_id is not gated at all, and exception_class / message fall back to the failure row in the non-failure branch. The surrounding array_filter only drops nulls, so all three survive into a ChildRunCompleted.
The contract for that event type does not allow them (src/V2/Support/HistoryEventPayloadContract.php:544-565) — it lists only sequence, workflow_link_id, child_call_id, child_workflow_*, child_status, closed_reason, closed_at, output, result, payload_codec and the parallel_group_* keys.
WorkflowHistoryEvent::record() asserts the contract on every write (src/V2/Models/WorkflowHistoryEvent.php:82 → HistoryEventPayloadContract:901), so the write throws — and the child that already completed is then closed as failed.
Observed behavior
A child run that completed successfully is converted into a failed child, and its parent is told it failed.
Observed sequence, from the child's own history:
ActivityScheduled → ActivityStarted → ActivityFailed → FailureHandled
… (the workflow body continues and finishes normally) …
WorkflowCompleted ← the child completed, with its real output recorded
WorkflowFailed ← appended milliseconds later
The child ends status = failed, closed_reason = failed, carrying a second failure row:
source_kind: workflow_run
exception_class: InvalidArgumentException
message: ChildRunCompleted history payload contains undocumented key(s):
failure_id, exception_class, message.
and the parent records ChildRunFailed for it, taking its failure path.
Three consequences:
- The child's real result is discarded. The run completed and produced a valid outcome; the parent never sees it.
- The parent branches on a failure that did not happen — a successful child is indistinguishable, to the caller, from one that genuinely failed.
- The child's history holds two terminal events,
WorkflowCompleted followed by WorkflowFailed, which no consumer of that history can reconcile.
Nothing in the calling application can influence which keys recordChildResolution() includes, and assertKnownPayloadKeys() is called unconditionally from WorkflowHistoryEvent::record() (src/V2/Models/WorkflowHistoryEvent.php:82) — there is no setting that relaxes it.
Reproduced on 2.0.7 from a two-document case, and seen before that across six unrelated runs — always the same three keys.
Expected behavior and acceptance criteria
Expected: a child that completes successfully resolves as completed on its parent, and its output reaches the caller — whether or not failures were recorded during its run. Failure-describing keys belong only on failure-shaped resolutions.
Acceptance criteria:
recordChildResolution() gates failure_id, exception_class and message on the resolution event type, as failure_category already is — and does not describe a child by a failure the engine has already marked handled.
- A child that records a
WorkflowFailure and then completes produces a valid ChildRunCompleted payload; the parent records ChildRunCompleted and receives the child's output.
- A run that has recorded
WorkflowCompleted is never subsequently closed as failed. A failure raised while recording a resolution is a fault in the parent's recording, not a retroactive verdict on a child that already finished — a run's history should not be able to hold two terminal events.
- Regression test: child with a failed activity handled inside the workflow body → completes → parent resolves it as completed, without
assertKnownPayloadKeys() throwing.
- Either the contract or the writer is authoritative for every event type; today they disagree for
ChildRunCompleted.
Dependencies and related public issues
None.
Public intake checks
Owning public component
Workflow engine
Exact version or source identity
2.0.7
Minimal reproduction and evidence
Reproduction
A child workflow whose body handles a failed activity and then finishes normally:
Call it from a parent with
child(ChildWorkflow::class). The child reachesWorkflowCompletedwhile aWorkflowFailurerow exists for it, and the parent then records the resolution.This is a normal shape for any workflow with a failure-handling branch: a step fails, the workflow routes around it, the run ends successfully with a real result.
Verified on 2.0.7. The child's activity exhausted its single attempt, the body handled it, the walk continued and finished, and the child's own history then read:
The parent recorded
ChildRunFailedand failed with it, having never seen the output.Analysis
ActivityOutcomeRecorderwrites aWorkflowFailureagainst the run when an activity stops retrying — not per attempt, and regardless of whether the workflow body then handles it (src/V2/Support/ActivityOutcomeRecorder.php:303-314,'handled' => false). So a completed run can, and routinely does, own a failure row.DefaultWorkflowTaskBridge::recordChildResolution()then builds one payload for every resolution type (src/V2/Support/DefaultWorkflowTaskBridge.php:4686-4724):$failureis$childRun->failures->first()(line 4675) — any failure on the run, not one scoped to how it ended, and not filtered onhandled.That last part is the crux, because the engine already knows the failure was handled. When the workflow body handles a failure the engine records a
FailureHandledhistory event and setshandled = trueon the failure row (src/V2/Support/WorkflowExecutor.php:3983-3992). In the run reproduced above, the child's own rows read:with one
FailureHandledevent in its history.recordChildResolution()then picks that same row up and describes the child by it. The signal that distinguishes "a failure this run recovered from" from "the failure that ended this run" is recorded, and not consulted.failure_categoryis gated on the event type;failure_idis not gated at all, andexception_class/messagefall back to the failure row in the non-failure branch. The surroundingarray_filteronly drops nulls, so all three survive into aChildRunCompleted.The contract for that event type does not allow them (
src/V2/Support/HistoryEventPayloadContract.php:544-565) — it lists onlysequence,workflow_link_id,child_call_id,child_workflow_*,child_status,closed_reason,closed_at,output,result,payload_codecand theparallel_group_*keys.WorkflowHistoryEvent::record()asserts the contract on every write (src/V2/Models/WorkflowHistoryEvent.php:82→HistoryEventPayloadContract:901), so the write throws — and the child that already completed is then closed as failed.Observed behavior
A child run that completed successfully is converted into a failed child, and its parent is told it failed.
Observed sequence, from the child's own history:
The child ends
status = failed,closed_reason = failed, carrying a second failure row:and the parent records
ChildRunFailedfor it, taking its failure path.Three consequences:
WorkflowCompletedfollowed byWorkflowFailed, which no consumer of that history can reconcile.Nothing in the calling application can influence which keys
recordChildResolution()includes, andassertKnownPayloadKeys()is called unconditionally fromWorkflowHistoryEvent::record()(src/V2/Models/WorkflowHistoryEvent.php:82) — there is no setting that relaxes it.Reproduced on 2.0.7 from a two-document case, and seen before that across six unrelated runs — always the same three keys.
Expected behavior and acceptance criteria
Expected: a child that completes successfully resolves as completed on its parent, and its output reaches the caller — whether or not failures were recorded during its run. Failure-describing keys belong only on failure-shaped resolutions.
Acceptance criteria:
recordChildResolution()gatesfailure_id,exception_classandmessageon the resolution event type, asfailure_categoryalready is — and does not describe a child by a failure the engine has already markedhandled.WorkflowFailureand then completes produces a validChildRunCompletedpayload; the parent recordsChildRunCompletedand receives the child's output.WorkflowCompletedis never subsequently closed asfailed. A failure raised while recording a resolution is a fault in the parent's recording, not a retroactive verdict on a child that already finished — a run's history should not be able to hold two terminal events.assertKnownPayloadKeys()throwing.ChildRunCompleted.Dependencies and related public issues
None.
Public intake checks