Skip to content

Commit 27fa9f8

Browse files
committed
wip(spec): ExecutionStepMetrics failure slot + FlowRunSummary.failed fold reconciliation (#15617 spec half)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf
1 parent 554a160 commit 27fa9f8

3 files changed

Lines changed: 222 additions & 8 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): `ExecutionStepMetrics` gains an optional `failures` slot, and `FlowRunSummary.failed` is declared as the fold INCLUDING what a delegating node rolled up from its child — the rule `acted` already follows (maintainer ruling 2026-09-06 on #15617, spec half)
6+
7+
Additive. Nothing an author writes is renamed, retired or narrowed; no accept
8+
set shrinks. One optional key is declared on a runtime-produced schema and the
9+
prose of a published contract is reconciled with itself.
10+
11+
**What was wrong.** `FlowRunSummary` said two things about `failed`. Its
12+
header paragraph declared that a `subflow` node rolls its child run's totals
13+
up into the parent — "this summary answers *what did this run cause*" — while
14+
the field itself declared `failed = Σ nodes[].failures`, a fold over the
15+
parent's own node executions. For a parent that delegates its rows to a
16+
`subflow` (or a `map` item) those give different answers, and the engine could
17+
only satisfy the second one: `ExecutionStepMetrics` carried `selected` /
18+
`acted` / `unmeasuredEffect` and no failure slot, so a child's contained
19+
failures had no path into the parent's fold. Measured on the real engine by
20+
the services seat (#15617): parent `loop { subflow(child) }` → parent
21+
`failed=0` while the five child summaries carried `failed=[0,0,0,0,1]`
22+
`acted` rolled up, `failed` did not.
23+
24+
**What this declares.**
25+
26+
- `ExecutionStepMetrics.failures` (optional, integer ≥ 0): node executions
27+
that failed inside a child run this execution delegated to and went on from
28+
— a `subflow` child or a `map` item whose run COMPLETED while containing
29+
failures, i.e. the child's `summary.failed`, rolled up. It folds into the
30+
delegating node's `nodes[].failures` and so into the run-level `failed`, by
31+
exactly the path the child's writes take into `acted`. Absent means the
32+
step delegated nothing or its child tracked no count — not zero.
33+
- It is NOT the step's own outcome. A step that failed is `status: 'failure'`
34+
and counts once through `nodes[].failures`, as before; a child that FAILED
35+
rather than contained is precisely that step failure — its own `failed`
36+
stays on the child's run row and nothing rides up, so one failure is never
37+
counted twice. The control the card measured (a failing child → parent
38+
`failed=1`) keeps counting exactly as today.
39+
- `FlowRunSummary.failed` is declared, at the field, as the fold of
40+
`nodes[].failures` INCLUDING what a delegating node rolled up; the
41+
`FlowRunNodeSummary.failures` describe names the roll-up path, and its
42+
`status` describe states that a delegating node whose child contained
43+
failures reads `success` beside `failures > 0` — status is judged on the
44+
node's own executions.
45+
46+
**What this does not do yet.** This is the contract half of a two-lane
47+
landing (contract first). No producer populates `failures` in this release:
48+
`subflow-node.ts` and the `map` node roll the child's contained failures into
49+
the slot in the services half, #16314, and only then does a parent's
50+
`failed` start counting them. Until that lands, every `ExecutionStepMetrics`
51+
the engine emits is byte-identical to today's, `failed` is numerically what it
52+
was, and the flow-run reference page keeps the narrowed wording PR #15609
53+
shipped ("node executions **of this run**") on purpose — it is widened when
54+
both halves are in.
55+
56+
**Consumers.** A reader of `ExecutionStepMetrics` sees one more optional
57+
number and nothing else changes shape; a consumer that already sums
58+
`nodes[].failures` to cross-check `failed` keeps agreeing with it, because the
59+
fold is unchanged — the roll-up enters the per-node array, not beside it.

packages/spec/src/automation/execution.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
ConcurrencyPolicySchema,
1010
ScheduleStateSchema,
1111
FlowRunSummarySchema,
12+
FlowRunNodeSummarySchema,
13+
ExecutionStepMetricsSchema,
1214
} from './execution.zod';
1315

1416
// ==========================================
@@ -47,6 +49,39 @@ describe('ExecutionStatus', () => {
4749
// Execution Step Log
4850
// ==========================================
4951

52+
describe('ExecutionStepMetricsSchema', () => {
53+
// #15617 — the failure slot a delegating node rolls its child's contained
54+
// failures through. `z.object` STRIPS an undeclared key on parse, so keeping
55+
// the value through the parse is what proves the declaration exists.
56+
it('declares `failures` — a delegating step keeps its child\'s contained-failure count through the parse', () => {
57+
const metrics = ExecutionStepMetricsSchema.parse({ selected: 5, acted: 4, failures: 1 });
58+
expect(metrics.failures).toBe(1);
59+
expect(metrics).toEqual({ selected: 5, acted: 4, failures: 1 });
60+
});
61+
62+
it('leaves `failures` absent on a step that delegated nothing — absent is not zero, and is not defaulted', () => {
63+
const metrics = ExecutionStepMetricsSchema.parse({ selected: 1, acted: 1 });
64+
expect(metrics.failures).toBeUndefined();
65+
expect(Object.keys(metrics)).not.toContain('failures');
66+
});
67+
68+
it('rejects a negative or fractional `failures`', () => {
69+
expect(ExecutionStepMetricsSchema.safeParse({ failures: -1 }).success).toBe(false);
70+
expect(ExecutionStepMetricsSchema.safeParse({ failures: 1.5 }).success).toBe(false);
71+
});
72+
73+
it('says at the point of use which shape it is for, and that it is not the step\'s own outcome', () => {
74+
// The carve-out has to be readable where a consumer reads the field, not
75+
// only in a paragraph above the schema: the describe names both delegating
76+
// node kinds and separates the slot from the step's own `status`.
77+
const doc = ExecutionStepMetricsSchema.shape.failures.description ?? '';
78+
expect(doc).toContain('`subflow`');
79+
expect(doc).toContain('`map`');
80+
expect(doc).toContain('`summary.failed`');
81+
expect(doc).toContain('NOT this execution');
82+
});
83+
});
84+
5085
describe('ExecutionStepLogSchema', () => {
5186
it('should accept a valid step log', () => {
5287
const step = ExecutionStepLogSchema.parse({
@@ -157,6 +192,24 @@ describe('ExecutionStepLogSchema', () => {
157192
expect(step.branch).toBe(1);
158193
});
159194

195+
it('a `success` step that delegated to a child carries the child\'s contained failures on `metrics.failures` (#15617)', () => {
196+
// The card's shape: `loop { subflow(child) }`, one iteration whose child
197+
// COMPLETED while losing a row. The subflow step itself succeeded — the
198+
// failure is the child's, contained — so `status` stays `success` and the
199+
// count rides on the metrics, not on the step's own outcome.
200+
const step = ExecutionStepLogSchema.parse({
201+
nodeId: 'call',
202+
nodeType: 'subflow',
203+
status: 'success',
204+
startedAt: '2026-09-05T00:00:00Z',
205+
iteration: 4,
206+
regionKind: 'loop-body',
207+
metrics: { selected: 1, acted: 0, failures: 1 },
208+
});
209+
expect(step.status).toBe('success');
210+
expect(step.metrics?.failures).toBe(1);
211+
});
212+
160213
it('`branch: 0` — the first branch — survives the parse as 0', () => {
161214
// Zero is a real index; a falsy-check anywhere on the way would drop it.
162215
const step = ExecutionStepLogSchema.parse({
@@ -350,6 +403,68 @@ describe('FlowRunSummarySchema', () => {
350403
expect(summary.failed).toBe(summary.nodes.reduce((sum, node) => sum + node.failures, 0));
351404
});
352405

406+
it('`failed` is the fold INCLUDING what a delegating node rolled up from its child — the card\'s measured shape, as ruled (#15617)', () => {
407+
// Parent `loop { subflow(child) }` over five rows; the child COMPLETED on
408+
// every iteration and contained one failure on the last. The subflow node
409+
// succeeded five times — `status: success`, its own executions never
410+
// failed — and carries the rolled-up count on `failures`, exactly as it
411+
// carries the child's writes on `acted`. The fold then sees it, so the
412+
// parent no longer reads `failed: 0` while a child lost a row.
413+
const summary = FlowRunSummarySchema.parse({
414+
selected: 5, acted: 4, skipped: 0, failed: 1,
415+
nodes: [
416+
{ nodeId: 'each', nodeType: 'loop', status: 'success' as const, runs: 1, failures: 0, skipped: 0 },
417+
{ nodeId: 'call', nodeType: 'subflow', status: 'success' as const, runs: 5, failures: 1, skipped: 0, selected: 5, acted: 4 },
418+
],
419+
gates: [],
420+
});
421+
expect(summary.failed).toBe(1);
422+
expect(summary.failed).toBe(summary.nodes.reduce((sum, node) => sum + node.failures, 0));
423+
// Declared shape: a delegating node's status is judged on its OWN
424+
// executions, so `success` beside `failures: 1` is the contract, not a
425+
// contradiction.
426+
const call = summary.nodes.find((node) => node.nodeId === 'call');
427+
expect(call?.status).toBe('success');
428+
expect(call?.failures).toBe(1);
429+
});
430+
431+
it('the control keeps counting as before: a child that FAILED is the delegating step\'s own failure, counted once (#15617)', () => {
432+
// Same parent, but the child FAILED on the last row rather than containing
433+
// the failure. That is the subflow step's own `status: failure` — one
434+
// execution failed — and nothing rides up on top of it: the child's own
435+
// `failed` stays on the child's run row, so the parent reads 1, not 2.
436+
const summary = FlowRunSummarySchema.parse({
437+
selected: 5, acted: 4, skipped: 0, failed: 1,
438+
nodes: [
439+
{ nodeId: 'each', nodeType: 'loop', status: 'success' as const, runs: 1, failures: 0, skipped: 0 },
440+
{ nodeId: 'call', nodeType: 'subflow', status: 'failure' as const, runs: 5, failures: 1, skipped: 0, selected: 5, acted: 4 },
441+
],
442+
gates: [],
443+
});
444+
expect(summary.failed).toBe(1);
445+
expect(summary.failed).toBe(summary.nodes.reduce((sum, node) => sum + node.failures, 0));
446+
});
447+
448+
it('declares the roll-up at the point of use — the field describes say so, not only the paragraph above the schema (#15617)', () => {
449+
// Triage's explicit failure mode for this card: a reconciliation that
450+
// leaves `failed`'s own `.describe()` saying the narrow thing. A consumer
451+
// reads the field's description, so the widened rule has to be there.
452+
const failedDoc = FlowRunSummarySchema.shape.failed.description ?? '';
453+
expect(failedDoc).toContain('a fold of `nodes[].failures`');
454+
expect(failedDoc).toContain('INCLUDING');
455+
expect(failedDoc).toContain('`subflow`');
456+
expect(failedDoc).toContain('`map`');
457+
expect(failedDoc).toContain('what did this run cause');
458+
459+
const nodeFailuresDoc = FlowRunNodeSummarySchema.shape.failures.description ?? '';
460+
expect(nodeFailuresDoc).toContain('`metrics.failures`');
461+
expect(nodeFailuresDoc).toContain('the run-level `failed` is the sum of this across `nodes`');
462+
463+
const nodeStatusDoc = FlowRunNodeSummarySchema.shape.status.description ?? '';
464+
expect(nodeStatusDoc).toContain('OWN executions');
465+
expect(nodeStatusDoc).toContain('`failures > 0`');
466+
});
467+
353468
it('leaves `failed` absent on a run that never tracked it — absent is not zero, and is not defaulted', () => {
354469
// Same convention as `unmeasured`: a run recorded before the field existed
355470
// did not count contained failures, and a `0` here would tell an operator

0 commit comments

Comments
 (0)