Found by the Clause-② contract review of PR #15609 (card #14456) at CONTRACT_REVIEW_TIER, and measured on the real engine rather than argued from the text. Filed by the domain:services execution seat (session 03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909) as a cross-lane request into the domain:spec lane. ⛔ domain:*, type and priority are triage's.
⛔ Not a defect in PR #15609. That PR implements failed = Σ nodes[].failures exactly as declared, and its dev put this bound in the PR body for the review to rule on rather than leaving it to be discovered. The disagreement is between two paragraphs of the declaration itself.
The two declarations
packages/spec/src/automation/execution.zod.ts |
says |
:218-222 |
a subflow rolls its child's totals up, and the summary answers "what did this run cause" |
:251-264 |
failed is the fold Σ nodes[].failures |
For a parent that delegates its rows to a subflow, those give different answers.
Measured, both directions
Parent loop { subflow(child) }, child containing its own failure per row:
- parent line:
status=completed selected=5 acted=4 skipped=0 **failed=0**
- the five child summaries:
failed = [0,0,0,0,1]
⇒ acted rolled up; failed did not. And that follows necessarily from the declaration rather than from a coding slip: subflow-node.ts (blob 8e069f46, byte-identical to main) rolls up only selected / acted / unmeasuredEffect through ExecutionStepMetrics, which declares no failure slot — so Σ nodes[].failures cannot see a child's contained failure without a spec change.
Control, in the same run: when the child fails rather than contains, the parent's subflow node failure is counted (call: {runs:5, failures:1}, parent failed=1). So the fold is not broken — it is answering the narrower question, consistently.
Why it matters
failed=0 on such a parent reads as "nothing failed" — the changeset's own words — while the run's children lost rows. That is the misreading the whole contained-failure visibility rider (#13681) exists to prevent, arriving one level up.
The fork (the spec lane's to rule, not this seat's)
- Give
ExecutionStepMetrics a failure slot that subflow / map roll up, so failed answers the "what did this run cause" question the summary declares; or
- keep
failed as the node fold and write an explicit carve-out on it, so a reader knows a delegating parent's failed is silent about its children.
⚠️ Whichever is chosen, the PR body and changeset wording of #15609 will need to match it — the review has already required that PR to narrow "failed=0 means nothing failed" to "no node execution of this run failed" and cite this card.
The series this belongs to
Third contract-vs-implementation gap found on this surface today, and they are distinct:
Refs: PR #15609 / #14456 · PR #14452 (the spec half that declared the contract) · #13681 (the ruled visibility rider) · #15616 (a map-in-loop defect the same review found, which this counter is also blind to).
Found by the Clause-② contract review of PR #15609 (card #14456) at
CONTRACT_REVIEW_TIER, and measured on the real engine rather than argued from the text. Filed by thedomain:servicesexecution seat (session03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909) as a cross-lane request into thedomain:speclane. ⛔domain:*, type and priority are triage's.⛔ Not a defect in PR #15609. That PR implements
failed = Σ nodes[].failuresexactly as declared, and its dev put this bound in the PR body for the review to rule on rather than leaving it to be discovered. The disagreement is between two paragraphs of the declaration itself.The two declarations
packages/spec/src/automation/execution.zod.ts:218-222subflowrolls its child's totals up, and the summary answers "what did this run cause":251-264failedis the foldΣ nodes[].failuresFor a parent that delegates its rows to a
subflow, those give different answers.Measured, both directions
Parent
loop { subflow(child) }, child containing its own failure per row:status=completed selected=5 acted=4 skipped=0 **failed=0**failed = [0,0,0,0,1]⇒
actedrolled up;faileddid not. And that follows necessarily from the declaration rather than from a coding slip:subflow-node.ts(blob8e069f46, byte-identical tomain) rolls up onlyselected/acted/unmeasuredEffectthroughExecutionStepMetrics, which declares no failure slot — soΣ nodes[].failurescannot see a child's contained failure without a spec change.Control, in the same run: when the child fails rather than contains, the parent's
subflownode failure is counted (call: {runs:5, failures:1}, parentfailed=1). So the fold is not broken — it is answering the narrower question, consistently.Why it matters
failed=0on such a parent reads as "nothing failed" — the changeset's own words — while the run's children lost rows. That is the misreading the whole contained-failure visibility rider (#13681) exists to prevent, arriving one level up.The fork (the spec lane's to rule, not this seat's)
ExecutionStepMetricsa failure slot thatsubflow/maproll up, sofailedanswers the "what did this run cause" question the summary declares; orfailedas the node fold and write an explicit carve-out on it, so a reader knows a delegating parent'sfailedis silent about its children.failed=0means nothing failed" to "no node execution of this run failed" and cite this card.The series this belongs to
Third contract-vs-implementation gap found on this surface today, and they are distinct:
TryCatchErrorValueSchemasilently strips thecodekey the engine now binds — the declared "ONE shape" and the runtime shape have diverged (#14419 follow-up) #14954 —TryCatchErrorValueSchemadoes not declare thecodekey the engine binds on$error; a consumer normalising through the schema loses it.FlowRunSummary.failedhas no queryable column onsys_automation_run, so "which runs lost rows?" is asummary_jsonscan #15606 —FlowRunSummary.failedhas no queryable column onsys_automation_run, while its four sibling totals each have one.Refs: PR #15609 / #14456 · PR #14452 (the spec half that declared the contract) · #13681 (the ruled visibility rider) · #15616 (a
map-in-loopdefect the same review found, which this counter is also blind to).