From 27fa9f8909adba3b08b0d950de718181177cfcb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 13:44:50 +0000 Subject: [PATCH 1/4] wip(spec): ExecutionStepMetrics failure slot + FlowRunSummary.failed fold reconciliation (#15617 spec half) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf --- .../execution-step-metrics-failure-slot.md | 59 +++++++++ .../spec/src/automation/execution.test.ts | 115 ++++++++++++++++++ packages/spec/src/automation/execution.zod.ts | 56 +++++++-- 3 files changed, 222 insertions(+), 8 deletions(-) create mode 100644 .changeset/execution-step-metrics-failure-slot.md diff --git a/.changeset/execution-step-metrics-failure-slot.md b/.changeset/execution-step-metrics-failure-slot.md new file mode 100644 index 0000000000..d09f0da7de --- /dev/null +++ b/.changeset/execution-step-metrics-failure-slot.md @@ -0,0 +1,59 @@ +--- +'@objectstack/spec': minor +--- + +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) + +Additive. Nothing an author writes is renamed, retired or narrowed; no accept +set shrinks. One optional key is declared on a runtime-produced schema and the +prose of a published contract is reconciled with itself. + +**What was wrong.** `FlowRunSummary` said two things about `failed`. Its +header paragraph declared that a `subflow` node rolls its child run's totals +up into the parent — "this summary answers *what did this run cause*" — while +the field itself declared `failed = Σ nodes[].failures`, a fold over the +parent's own node executions. For a parent that delegates its rows to a +`subflow` (or a `map` item) those give different answers, and the engine could +only satisfy the second one: `ExecutionStepMetrics` carried `selected` / +`acted` / `unmeasuredEffect` and no failure slot, so a child's contained +failures had no path into the parent's fold. Measured on the real engine by +the services seat (#15617): parent `loop { subflow(child) }` → parent +`failed=0` while the five child summaries carried `failed=[0,0,0,0,1]` — +`acted` rolled up, `failed` did not. + +**What this declares.** + +- `ExecutionStepMetrics.failures` (optional, integer ≥ 0): node executions + that failed inside a child run this execution delegated to and went on from + — a `subflow` child or a `map` item whose run COMPLETED while containing + failures, i.e. the child's `summary.failed`, rolled up. It folds into the + delegating node's `nodes[].failures` and so into the run-level `failed`, by + exactly the path the child's writes take into `acted`. Absent means the + step delegated nothing or its child tracked no count — not zero. +- It is NOT the step's own outcome. A step that failed is `status: 'failure'` + and counts once through `nodes[].failures`, as before; a child that FAILED + rather than contained is precisely that step failure — its own `failed` + stays on the child's run row and nothing rides up, so one failure is never + counted twice. The control the card measured (a failing child → parent + `failed=1`) keeps counting exactly as today. +- `FlowRunSummary.failed` is declared, at the field, as the fold of + `nodes[].failures` INCLUDING what a delegating node rolled up; the + `FlowRunNodeSummary.failures` describe names the roll-up path, and its + `status` describe states that a delegating node whose child contained + failures reads `success` beside `failures > 0` — status is judged on the + node's own executions. + +**What this does not do yet.** This is the contract half of a two-lane +landing (contract first). No producer populates `failures` in this release: +`subflow-node.ts` and the `map` node roll the child's contained failures into +the slot in the services half, #16314, and only then does a parent's +`failed` start counting them. Until that lands, every `ExecutionStepMetrics` +the engine emits is byte-identical to today's, `failed` is numerically what it +was, and the flow-run reference page keeps the narrowed wording PR #15609 +shipped ("node executions **of this run**") on purpose — it is widened when +both halves are in. + +**Consumers.** A reader of `ExecutionStepMetrics` sees one more optional +number and nothing else changes shape; a consumer that already sums +`nodes[].failures` to cross-check `failed` keeps agreeing with it, because the +fold is unchanged — the roll-up enters the per-node array, not beside it. diff --git a/packages/spec/src/automation/execution.test.ts b/packages/spec/src/automation/execution.test.ts index 0f3289abe8..0096b8a7dd 100644 --- a/packages/spec/src/automation/execution.test.ts +++ b/packages/spec/src/automation/execution.test.ts @@ -9,6 +9,8 @@ import { ConcurrencyPolicySchema, ScheduleStateSchema, FlowRunSummarySchema, + FlowRunNodeSummarySchema, + ExecutionStepMetricsSchema, } from './execution.zod'; // ========================================== @@ -47,6 +49,39 @@ describe('ExecutionStatus', () => { // Execution Step Log // ========================================== +describe('ExecutionStepMetricsSchema', () => { + // #15617 — the failure slot a delegating node rolls its child's contained + // failures through. `z.object` STRIPS an undeclared key on parse, so keeping + // the value through the parse is what proves the declaration exists. + it('declares `failures` — a delegating step keeps its child\'s contained-failure count through the parse', () => { + const metrics = ExecutionStepMetricsSchema.parse({ selected: 5, acted: 4, failures: 1 }); + expect(metrics.failures).toBe(1); + expect(metrics).toEqual({ selected: 5, acted: 4, failures: 1 }); + }); + + it('leaves `failures` absent on a step that delegated nothing — absent is not zero, and is not defaulted', () => { + const metrics = ExecutionStepMetricsSchema.parse({ selected: 1, acted: 1 }); + expect(metrics.failures).toBeUndefined(); + expect(Object.keys(metrics)).not.toContain('failures'); + }); + + it('rejects a negative or fractional `failures`', () => { + expect(ExecutionStepMetricsSchema.safeParse({ failures: -1 }).success).toBe(false); + expect(ExecutionStepMetricsSchema.safeParse({ failures: 1.5 }).success).toBe(false); + }); + + it('says at the point of use which shape it is for, and that it is not the step\'s own outcome', () => { + // The carve-out has to be readable where a consumer reads the field, not + // only in a paragraph above the schema: the describe names both delegating + // node kinds and separates the slot from the step's own `status`. + const doc = ExecutionStepMetricsSchema.shape.failures.description ?? ''; + expect(doc).toContain('`subflow`'); + expect(doc).toContain('`map`'); + expect(doc).toContain('`summary.failed`'); + expect(doc).toContain('NOT this execution'); + }); +}); + describe('ExecutionStepLogSchema', () => { it('should accept a valid step log', () => { const step = ExecutionStepLogSchema.parse({ @@ -157,6 +192,24 @@ describe('ExecutionStepLogSchema', () => { expect(step.branch).toBe(1); }); + it('a `success` step that delegated to a child carries the child\'s contained failures on `metrics.failures` (#15617)', () => { + // The card's shape: `loop { subflow(child) }`, one iteration whose child + // COMPLETED while losing a row. The subflow step itself succeeded — the + // failure is the child's, contained — so `status` stays `success` and the + // count rides on the metrics, not on the step's own outcome. + const step = ExecutionStepLogSchema.parse({ + nodeId: 'call', + nodeType: 'subflow', + status: 'success', + startedAt: '2026-09-05T00:00:00Z', + iteration: 4, + regionKind: 'loop-body', + metrics: { selected: 1, acted: 0, failures: 1 }, + }); + expect(step.status).toBe('success'); + expect(step.metrics?.failures).toBe(1); + }); + it('`branch: 0` — the first branch — survives the parse as 0', () => { // Zero is a real index; a falsy-check anywhere on the way would drop it. const step = ExecutionStepLogSchema.parse({ @@ -350,6 +403,68 @@ describe('FlowRunSummarySchema', () => { expect(summary.failed).toBe(summary.nodes.reduce((sum, node) => sum + node.failures, 0)); }); + it('`failed` is the fold INCLUDING what a delegating node rolled up from its child — the card\'s measured shape, as ruled (#15617)', () => { + // Parent `loop { subflow(child) }` over five rows; the child COMPLETED on + // every iteration and contained one failure on the last. The subflow node + // succeeded five times — `status: success`, its own executions never + // failed — and carries the rolled-up count on `failures`, exactly as it + // carries the child's writes on `acted`. The fold then sees it, so the + // parent no longer reads `failed: 0` while a child lost a row. + const summary = FlowRunSummarySchema.parse({ + selected: 5, acted: 4, skipped: 0, failed: 1, + nodes: [ + { nodeId: 'each', nodeType: 'loop', status: 'success' as const, runs: 1, failures: 0, skipped: 0 }, + { nodeId: 'call', nodeType: 'subflow', status: 'success' as const, runs: 5, failures: 1, skipped: 0, selected: 5, acted: 4 }, + ], + gates: [], + }); + expect(summary.failed).toBe(1); + expect(summary.failed).toBe(summary.nodes.reduce((sum, node) => sum + node.failures, 0)); + // Declared shape: a delegating node's status is judged on its OWN + // executions, so `success` beside `failures: 1` is the contract, not a + // contradiction. + const call = summary.nodes.find((node) => node.nodeId === 'call'); + expect(call?.status).toBe('success'); + expect(call?.failures).toBe(1); + }); + + it('the control keeps counting as before: a child that FAILED is the delegating step\'s own failure, counted once (#15617)', () => { + // Same parent, but the child FAILED on the last row rather than containing + // the failure. That is the subflow step's own `status: failure` — one + // execution failed — and nothing rides up on top of it: the child's own + // `failed` stays on the child's run row, so the parent reads 1, not 2. + const summary = FlowRunSummarySchema.parse({ + selected: 5, acted: 4, skipped: 0, failed: 1, + nodes: [ + { nodeId: 'each', nodeType: 'loop', status: 'success' as const, runs: 1, failures: 0, skipped: 0 }, + { nodeId: 'call', nodeType: 'subflow', status: 'failure' as const, runs: 5, failures: 1, skipped: 0, selected: 5, acted: 4 }, + ], + gates: [], + }); + expect(summary.failed).toBe(1); + expect(summary.failed).toBe(summary.nodes.reduce((sum, node) => sum + node.failures, 0)); + }); + + it('declares the roll-up at the point of use — the field describes say so, not only the paragraph above the schema (#15617)', () => { + // Triage's explicit failure mode for this card: a reconciliation that + // leaves `failed`'s own `.describe()` saying the narrow thing. A consumer + // reads the field's description, so the widened rule has to be there. + const failedDoc = FlowRunSummarySchema.shape.failed.description ?? ''; + expect(failedDoc).toContain('a fold of `nodes[].failures`'); + expect(failedDoc).toContain('INCLUDING'); + expect(failedDoc).toContain('`subflow`'); + expect(failedDoc).toContain('`map`'); + expect(failedDoc).toContain('what did this run cause'); + + const nodeFailuresDoc = FlowRunNodeSummarySchema.shape.failures.description ?? ''; + expect(nodeFailuresDoc).toContain('`metrics.failures`'); + expect(nodeFailuresDoc).toContain('the run-level `failed` is the sum of this across `nodes`'); + + const nodeStatusDoc = FlowRunNodeSummarySchema.shape.status.description ?? ''; + expect(nodeStatusDoc).toContain('OWN executions'); + expect(nodeStatusDoc).toContain('`failures > 0`'); + }); + it('leaves `failed` absent on a run that never tracked it — absent is not zero, and is not defaulted', () => { // Same convention as `unmeasured`: a run recorded before the field existed // did not count contained failures, and a `0` here would tell an operator diff --git a/packages/spec/src/automation/execution.zod.ts b/packages/spec/src/automation/execution.zod.ts index f0351cdb46..be78cd7720 100644 --- a/packages/spec/src/automation/execution.zod.ts +++ b/packages/spec/src/automation/execution.zod.ts @@ -83,6 +83,28 @@ export type ExecutionStatus = z.input; * reports, alongside a declared write whose dispatch failed (the upstream may * have been reached) and a `script` step calling a function declared * `'writes'` (#4396). + * + * `failures` is the fourth answer, and it exists for one shape: a node that + * DELEGATES to a child run — a `subflow`, or each item of a `map` — whose + * child completed while containing failures of its own (#15617). The child's + * steps live in the child's log, so the parent's per-node fold cannot see + * them; `selected` / `acted` already ride up through these metrics so that a + * parent answers "what did this run cause", and until this slot existed the + * failure count did not: a parent whose child lost a row read `failed: 0`, + * which is the misreading the run-level `failed` was added to prevent + * (#13681), one level up. The slot carries the child's `summary.failed` and + * folds into the delegating node's `failures` by exactly the rule `acted` + * follows, so `failed = Σ nodes[].failures` keeps holding — with the child + * counted in. + * + * It is NOT this execution's own outcome. A step that failed is + * `status: 'failure'` and counts once, in `nodes[].failures`, as it always + * has — and that is also the whole answer for a child that FAILED rather than + * contained: the delegating step is the failure, the child's own `failed` + * (which carries the fatal one) stays on the child's run row, and nothing + * rides up here, so one failure is never counted twice. Absent ⇒ this + * execution delegated nothing, or its child tracked no count (an older run); + * either way it is not `0`. */ export const ExecutionStepMetricsSchema = lazySchema(() => z.object({ selected: z.number().int().min(0).optional() @@ -91,6 +113,8 @@ export const ExecutionStepMetricsSchema = lazySchema(() => z.object({ .describe('Records this node WROTE (created / updated / deleted) or effects it dispatched (notifications delivered)'), unmeasuredEffect: z.boolean().optional() .describe('This execution may have caused an effect the platform cannot count (an external write through a connector). NOT interchangeable with `acted: 0` — it says the count is unknown, not that it is zero.'), + failures: z.number().int().min(0).optional() + .describe('Node executions that failed inside a child run this execution delegated to and went on from — a `subflow` child or a `map` item whose run COMPLETED while containing failures: its `summary.failed`, rolled up so the parent answers "what did this run cause" the way `acted` already does. Folds into this node\'s `failures` and so into the run-level `failed`. NOT this execution\'s own outcome: a step that failed is `status: \'failure\'` and counts once through `nodes[].failures`, and a child that FAILED rather than contained is exactly that step failure — its own `failed` stays on the child\'s run row and nothing rides up here. Absent = delegated nothing, or the child tracked no count; not zero.'), })); export type ExecutionStepMetrics = z.input; @@ -159,7 +183,7 @@ export const ExecutionStepLogSchema = lazySchema(() => z.object({ // #4354: what the step did to the data, and — for a `skipped` step — which // gate stopped it. Both feed the run summary aggregated on ExecutionLog. metrics: ExecutionStepMetricsSchema.optional() - .describe('Records this step selected / acted on, as reported by the node executor'), + .describe('Records this step selected / acted on — and, for a step that delegated to a child run (`subflow`, a `map` item), the failures that child contained — as reported by the node executor'), skippedBy: ExecutionStepSkipReasonSchema.optional() .describe('The gate that closed, when `status` is `skipped`'), })); @@ -178,9 +202,9 @@ export const FlowRunNodeSummarySchema = lazySchema(() => z.object({ nodeType: z.string().describe('Node action type (e.g., "get_record", "decision")'), nodeLabel: z.string().optional().describe('Human-readable node label'), status: z.enum(['success', 'failure', 'skipped']) - .describe('Terminal status of the node across the run — `failure` if any execution failed, else `success` if any succeeded, else `skipped`'), + .describe('Terminal status of the node across the run — `failure` if any execution failed, else `success` if any succeeded, else `skipped`. Judged on this node\'s OWN executions: a delegating node (`subflow` / `map`) whose child completed while containing failures reads `success` here with `failures > 0`'), runs: z.number().int().min(0).describe('Times the node executed (loop iterations and parallel branches each count)'), - failures: z.number().int().min(0).describe('Executions that failed — a failure a `try_catch` caught or a `fault` edge routed counts here too; the run-level `failed` is the sum of this across `nodes`'), + failures: z.number().int().min(0).describe('Executions that failed — a failure a `try_catch` caught or a `fault` edge routed counts here too — plus what a delegating execution rolled up from its child run (`metrics.failures`: the contained failures of a `subflow` child or a `map` item that completed), the way `acted` carries the child\'s writes; the run-level `failed` is the sum of this across `nodes`'), skipped: z.number().int().min(0).describe('Times a closed gate kept this node from running at all'), selected: z.number().int().min(0).optional().describe('Records read across every execution — omitted for a node that reads none'), acted: z.number().int().min(0).optional().describe('Records written / effects dispatched across every execution — omitted for a node that writes none'), @@ -222,9 +246,15 @@ export type FlowRunGateSummary = z.input; * * Totals are sums over `nodes`, which is itself a fold of the run's step log, * so a loop that ran a write 30 times contributes 30 to `acted`. A `subflow` - * node rolls its child run's totals up into this one — the child keeps its own - * run row, so the child's work is counted there too, deliberately: this summary - * answers "what did this run cause", not "what did this run's own nodes do". + * node — and each item of a `map` — rolls its child run's totals up into this + * one, `failed` included: the child's contained failures ride on the + * delegating step's `metrics.failures`, fold into that node's `failures`, and + * so into `failed`, by exactly the rule `acted` follows (#15617). The child + * keeps its own run row, so the child's work is counted there too, + * deliberately: this summary answers "what did this run cause", not "what did + * this run's own nodes do" — and every total here answers it, not only the + * ones that count writes. A child that FAILED rather than contained is the + * delegating step's own failure, counted once, as it always was. */ export const FlowRunSummarySchema = lazySchema(() => z.object({ selected: z.number().int().min(0).describe('Total records read by the run'), @@ -259,7 +289,17 @@ export const FlowRunSummarySchema = lazySchema(() => z.object({ * * Every node execution that failed counts — on a run that completed all of * them were contained (caught by a `try_catch` or routed down a `fault` - * edge); on a run that failed, the fatal one is in the count too. + * edge); on a run that failed, the fatal one is in the count too. And the + * fold INCLUDES what a delegating node rolled up from its child (#15617): a + * `subflow` or `map` child that completed while containing failures reports + * them on the delegating step's `metrics.failures`, which folds into that + * node's `failures` and so arrives here — the same path the child's writes + * take into `acted`. Before that slot existed the fold could not see them, + * so a parent whose child lost rows read `failed: 0` while the paragraph + * above promised "what did this run cause"; the two now agree. A child that + * FAILED rather than contained is the delegating step's own failure, + * counted once here as it always was, and its own `failed` stays on its + * own run row. * * Same convention as `unmeasured`, for the same reason: optional, and absent * is NOT zero. A run recorded before this field existed did not carry the @@ -267,7 +307,7 @@ export const FlowRunSummarySchema = lazySchema(() => z.object({ * about a run nobody measured. */ failed: z.number().int().min(0).optional() - .describe('Total node executions that failed — a fold of `nodes[].failures`. On a run that completed every one of them was contained (caught by a `try_catch` or routed down a `fault` edge) and the run went on. Absent = not tracked (an older run), which is not the same as zero.'), + .describe('Total node executions that failed — a fold of `nodes[].failures`, INCLUDING what a delegating node (`subflow` / `map`) rolled up from a child run that completed while containing failures, the way `acted` includes the child\'s writes: this total answers "what did this run cause", subflows included, so a parent whose child lost rows does not read `failed: 0`. On a run that completed every one of them was contained (caught by a `try_catch` or routed down a `fault` edge) and the run went on. Absent = not tracked (an older run), which is not the same as zero.'), nodes: z.array(FlowRunNodeSummarySchema).describe('Per-node breakdown, in first-execution order'), gates: z.array(FlowRunGateSummarySchema).describe('Gates that closed during the run, most-skipped first'), detailOmitted: z.boolean().optional() From dfe92a7b1c2ae8d500aad7ca335ae9b1bd8880dc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 13:50:44 +0000 Subject: [PATCH 2/4] chore(spec): regenerate authorable-surface + docs references for the ExecutionStepMetrics failure slot (#15617) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf --- .../docs/references/automation/execution.mdx | 18 ++++++++++-------- .../spec/authorable-surface/automation.json | 1 + 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/content/docs/references/automation/execution.mdx b/content/docs/references/automation/execution.mdx index d94afc9023..990464118c 100644 --- a/content/docs/references/automation/execution.mdx +++ b/content/docs/references/automation/execution.mdx @@ -144,7 +144,7 @@ const result = CheckpointSchema.parse(data); | **iteration** | `integer` | optional | Zero-based iteration of the enclosing `loop`, carried through any nesting — a step inside a `parallel` branch that is itself inside a loop body carries the loop's iteration here and its branch index on `branch`. A step inside a `try` / `catch` region that is itself inside a loop body carries the enclosing loop's iteration — a try/catch region has no index of its own — while `regionKind` stays `try` / `catch`. | | **branch** | `integer` | optional | Zero-based index of the enclosing `parallel` branch. Present only on a step inside a parallel branch; absent everywhere else. When the parallel node is itself inside a loop body, the loop iteration is reported through `iteration`, never here. | | **regionKind** | `string` | optional | Region kind the step ran in: loop-body \| parallel-branch \| try \| catch. Stays `try` / `catch` for a step inside a try/catch region nested in a loop body; the loop is reported through `iteration`. For `parallel-branch` the branch index is reported through `branch`, and the enclosing loop iteration — when the parallel node sits inside a loop body — through `iteration`. | -| **metrics** | `{ selected?: integer; acted?: integer; unmeasuredEffect?: boolean }` | optional | Records this step selected / acted on, as reported by the node executor | +| **metrics** | `{ selected?: integer; acted?: integer; unmeasuredEffect?: boolean; failures?: integer }` | optional | Records this step selected / acted on — and, for a step that delegated to a child run (`subflow`, a `map` item), the failures that child contained — as reported by the node executor | | **skippedBy** | `{ nodeId: string; edgeId?: string; label?: string }` | optional | The gate that closed, when `status` is `skipped` | ### Nested Shape: `ExecutionLog.summary` @@ -155,7 +155,7 @@ const result = CheckpointSchema.parse(data); | **acted** | `integer` | ✅ | Total records written / effects dispatched by the run | | **skipped** | `integer` | ✅ | Total node executions a closed gate prevented | | **unmeasured** | `integer` | optional | Total executions that may have caused an effect the platform cannot count. Absent = not tracked (an older run), which is not the same as zero. | -| **failed** | `integer` | optional | Total node executions that failed — a fold of `nodes[].failures`. On a run that completed every one of them was contained (caught by a `try_catch` or routed down a `fault` edge) and the run went on. Absent = not tracked (an older run), which is not the same as zero. | +| **failed** | `integer` | optional | Total node executions that failed — a fold of `nodes[].failures`, INCLUDING what a delegating node (`subflow` / `map`) rolled up from a child run that completed while containing failures, the way `acted` includes the child's writes: this total answers "what did this run cause", subflows included, so a parent whose child lost rows does not read `failed: 0`. On a run that completed every one of them was contained (caught by a `try_catch` or routed down a `fault` edge) and the run went on. Absent = not tracked (an older run), which is not the same as zero. | | **nodes** | `{ nodeId: string; nodeType: string; nodeLabel?: string; status: Enum<'success' \| 'failure' \| 'skipped'>; … }[]` | ✅ | Per-node breakdown, in first-execution order | | **gates** | `{ nodeId: string; targetNodeId: string; edgeId?: string; label?: string; … }[]` | ✅ | Gates that closed during the run, most-skipped first | | **detailOmitted** | `boolean` | optional | Set when persistence dropped `nodes`/`gates` to keep the stored row bounded — the totals are still exact. Declared so empty arrays are never mistaken for "nothing ran". | @@ -201,7 +201,7 @@ const result = CheckpointSchema.parse(data); | **iteration** | `integer` | optional | Zero-based iteration of the enclosing `loop`, carried through any nesting — a step inside a `parallel` branch that is itself inside a loop body carries the loop's iteration here and its branch index on `branch`. A step inside a `try` / `catch` region that is itself inside a loop body carries the enclosing loop's iteration — a try/catch region has no index of its own — while `regionKind` stays `try` / `catch`. | | **branch** | `integer` | optional | Zero-based index of the enclosing `parallel` branch. Present only on a step inside a parallel branch; absent everywhere else. When the parallel node is itself inside a loop body, the loop iteration is reported through `iteration`, never here. | | **regionKind** | `string` | optional | Region kind the step ran in: loop-body \| parallel-branch \| try \| catch. Stays `try` / `catch` for a step inside a try/catch region nested in a loop body; the loop is reported through `iteration`. For `parallel-branch` the branch index is reported through `branch`, and the enclosing loop iteration — when the parallel node sits inside a loop body — through `iteration`. | -| **metrics** | `{ selected?: integer; acted?: integer; unmeasuredEffect?: boolean }` | optional | Records this step selected / acted on, as reported by the node executor | +| **metrics** | `{ selected?: integer; acted?: integer; unmeasuredEffect?: boolean; failures?: integer }` | optional | Records this step selected / acted on — and, for a step that delegated to a child run (`subflow`, a `map` item), the failures that child contained — as reported by the node executor | | **skippedBy** | `{ nodeId: string; edgeId?: string; label?: string }` | optional | The gate that closed, when `status` is `skipped` | ### Nested Shape: `ExecutionStepLog.error` @@ -219,6 +219,7 @@ const result = CheckpointSchema.parse(data); | **selected** | `integer` | optional | Records this node READ or matched (a `get_record` query, a lookup) | | **acted** | `integer` | optional | Records this node WROTE (created / updated / deleted) or effects it dispatched (notifications delivered) | | **unmeasuredEffect** | `boolean` | optional | This execution may have caused an effect the platform cannot count (an external write through a connector). NOT interchangeable with `acted: 0` — it says the count is unknown, not that it is zero. | +| **failures** | `integer` | optional | Node executions that failed inside a child run this execution delegated to and went on from — a `subflow` child or a `map` item whose run COMPLETED while containing failures: its `summary.failed`, rolled up so the parent answers "what did this run cause" the way `acted` already does. Folds into this node's `failures` and so into the run-level `failed`. NOT this execution's own outcome: a step that failed is `status: 'failure'` and counts once through `nodes[].failures`, and a child that FAILED rather than contained is exactly that step failure — its own `failed` stays on the child's run row and nothing rides up here. Absent = delegated nothing, or the child tracked no count; not zero. | ### Nested Shape: `ExecutionStepLog.skippedBy` @@ -240,6 +241,7 @@ const result = CheckpointSchema.parse(data); | **selected** | `integer` | optional | Records this node READ or matched (a `get_record` query, a lookup) | | **acted** | `integer` | optional | Records this node WROTE (created / updated / deleted) or effects it dispatched (notifications delivered) | | **unmeasuredEffect** | `boolean` | optional | This execution may have caused an effect the platform cannot count (an external write through a connector). NOT interchangeable with `acted: 0` — it says the count is unknown, not that it is zero. | +| **failures** | `integer` | optional | Node executions that failed inside a child run this execution delegated to and went on from — a `subflow` child or a `map` item whose run COMPLETED while containing failures: its `summary.failed`, rolled up so the parent answers "what did this run cause" the way `acted` already does. Folds into this node's `failures` and so into the run-level `failed`. NOT this execution's own outcome: a step that failed is `status: 'failure'` and counts once through `nodes[].failures`, and a child that FAILED rather than contained is exactly that step failure — its own `failed` stays on the child's run row and nothing rides up here. Absent = delegated nothing, or the child tracked no count; not zero. | --- @@ -281,9 +283,9 @@ const result = CheckpointSchema.parse(data); | **nodeId** | `string` | ✅ | Node ID | | **nodeType** | `string` | ✅ | Node action type (e.g., "get_record", "decision") | | **nodeLabel** | `string` | optional | Human-readable node label | -| **status** | `Enum<'success' \| 'failure' \| 'skipped'>` | ✅ | Terminal status of the node across the run — `failure` if any execution failed, else `success` if any succeeded, else `skipped` | +| **status** | `Enum<'success' \| 'failure' \| 'skipped'>` | ✅ | Terminal status of the node across the run — `failure` if any execution failed, else `success` if any succeeded, else `skipped`. Judged on this node's OWN executions: a delegating node (`subflow` / `map`) whose child completed while containing failures reads `success` here with `failures > 0` | | **runs** | `integer` | ✅ | Times the node executed (loop iterations and parallel branches each count) | -| **failures** | `integer` | ✅ | Executions that failed — a failure a `try_catch` caught or a `fault` edge routed counts here too; the run-level `failed` is the sum of this across `nodes` | +| **failures** | `integer` | ✅ | Executions that failed — a failure a `try_catch` caught or a `fault` edge routed counts here too — plus what a delegating execution rolled up from its child run (`metrics.failures`: the contained failures of a `subflow` child or a `map` item that completed), the way `acted` carries the child's writes; the run-level `failed` is the sum of this across `nodes` | | **skipped** | `integer` | ✅ | Times a closed gate kept this node from running at all | | **selected** | `integer` | optional | Records read across every execution — omitted for a node that reads none | | **acted** | `integer` | optional | Records written / effects dispatched across every execution — omitted for a node that writes none | @@ -302,7 +304,7 @@ const result = CheckpointSchema.parse(data); | **acted** | `integer` | ✅ | Total records written / effects dispatched by the run | | **skipped** | `integer` | ✅ | Total node executions a closed gate prevented | | **unmeasured** | `integer` | optional | Total executions that may have caused an effect the platform cannot count. Absent = not tracked (an older run), which is not the same as zero. | -| **failed** | `integer` | optional | Total node executions that failed — a fold of `nodes[].failures`. On a run that completed every one of them was contained (caught by a `try_catch` or routed down a `fault` edge) and the run went on. Absent = not tracked (an older run), which is not the same as zero. | +| **failed** | `integer` | optional | Total node executions that failed — a fold of `nodes[].failures`, INCLUDING what a delegating node (`subflow` / `map`) rolled up from a child run that completed while containing failures, the way `acted` includes the child's writes: this total answers "what did this run cause", subflows included, so a parent whose child lost rows does not read `failed: 0`. On a run that completed every one of them was contained (caught by a `try_catch` or routed down a `fault` edge) and the run went on. Absent = not tracked (an older run), which is not the same as zero. | | **nodes** | `{ nodeId: string; nodeType: string; nodeLabel?: string; status: Enum<'success' \| 'failure' \| 'skipped'>; … }[]` | ✅ | Per-node breakdown, in first-execution order | | **gates** | `{ nodeId: string; targetNodeId: string; edgeId?: string; label?: string; … }[]` | ✅ | Gates that closed during the run, most-skipped first | | **detailOmitted** | `boolean` | optional | Set when persistence dropped `nodes`/`gates` to keep the stored row bounded — the totals are still exact. Declared so empty arrays are never mistaken for "nothing ran". | @@ -314,9 +316,9 @@ const result = CheckpointSchema.parse(data); | **nodeId** | `string` | ✅ | Node ID | | **nodeType** | `string` | ✅ | Node action type (e.g., "get_record", "decision") | | **nodeLabel** | `string` | optional | Human-readable node label | -| **status** | `Enum<'success' \| 'failure' \| 'skipped'>` | ✅ | Terminal status of the node across the run — `failure` if any execution failed, else `success` if any succeeded, else `skipped` | +| **status** | `Enum<'success' \| 'failure' \| 'skipped'>` | ✅ | Terminal status of the node across the run — `failure` if any execution failed, else `success` if any succeeded, else `skipped`. Judged on this node's OWN executions: a delegating node (`subflow` / `map`) whose child completed while containing failures reads `success` here with `failures > 0` | | **runs** | `integer` | ✅ | Times the node executed (loop iterations and parallel branches each count) | -| **failures** | `integer` | ✅ | Executions that failed — a failure a `try_catch` caught or a `fault` edge routed counts here too; the run-level `failed` is the sum of this across `nodes` | +| **failures** | `integer` | ✅ | Executions that failed — a failure a `try_catch` caught or a `fault` edge routed counts here too — plus what a delegating execution rolled up from its child run (`metrics.failures`: the contained failures of a `subflow` child or a `map` item that completed), the way `acted` carries the child's writes; the run-level `failed` is the sum of this across `nodes` | | **skipped** | `integer` | ✅ | Times a closed gate kept this node from running at all | | **selected** | `integer` | optional | Records read across every execution — omitted for a node that reads none | | **acted** | `integer` | optional | Records written / effects dispatched across every execution — omitted for a node that writes none | diff --git a/packages/spec/authorable-surface/automation.json b/packages/spec/authorable-surface/automation.json index 23619610b4..69da28d70f 100644 --- a/packages/spec/authorable-surface/automation.json +++ b/packages/spec/authorable-surface/automation.json @@ -141,6 +141,7 @@ "automation/ExecutionStepLog:startedAt", "automation/ExecutionStepLog:status", "automation/ExecutionStepMetrics:acted", + "automation/ExecutionStepMetrics:failures", "automation/ExecutionStepMetrics:selected", "automation/ExecutionStepMetrics:unmeasuredEffect", "automation/ExecutionStepSkipReason:edgeId", From aebacb8c40b1abb2043455511161e0189fb7a45c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 14:57:53 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix(spec):=20contract=20review=20R1.1=20on?= =?UTF-8?q?=20#15617=20=E2=80=94=20narrow=20the=20header=20roll-up=20claus?= =?UTF-8?q?e=20to=20the=20totals=20that=20roll,=20drop=20the=20acted=20ana?= =?UTF-8?q?logy=20at=20the=20failed-child=20boundary,=20state=20the=20mixe?= =?UTF-8?q?d=20case=20and=20the=20third=20absence=20arm=20in=20the=20contr?= =?UTF-8?q?act=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf --- .../execution-step-metrics-failure-slot.md | 28 +++++--- .../spec/src/automation/execution.test.ts | 32 +++++++++ packages/spec/src/automation/execution.zod.ts | 65 +++++++++++-------- 3 files changed, 90 insertions(+), 35 deletions(-) diff --git a/.changeset/execution-step-metrics-failure-slot.md b/.changeset/execution-step-metrics-failure-slot.md index d09f0da7de..a3e81d1ccf 100644 --- a/.changeset/execution-step-metrics-failure-slot.md +++ b/.changeset/execution-step-metrics-failure-slot.md @@ -2,7 +2,7 @@ '@objectstack/spec': minor --- -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) +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 (maintainer ruling 2026-09-06 on #15617, spec half) Additive. Nothing an author writes is renamed, retired or narrowed; no accept set shrinks. One optional key is declared on a runtime-produced schema and the @@ -27,15 +27,19 @@ the services seat (#15617): parent `loop { subflow(child) }` → parent that failed inside a child run this execution delegated to and went on from — a `subflow` child or a `map` item whose run COMPLETED while containing failures, i.e. the child's `summary.failed`, rolled up. It folds into the - delegating node's `nodes[].failures` and so into the run-level `failed`, by - exactly the path the child's writes take into `acted`. Absent means the - step delegated nothing or its child tracked no count — not zero. + delegating node's `nodes[].failures` and so into the run-level `failed` — + the same fold shape `acted` has, but not the same rule at the failed-child + boundary (next bullet). Absent means the step delegated nothing, or its + child tracked no count, or the producer did not track it (every step the + engine emits between this release and the engine half) — never zero. - It is NOT the step's own outcome. A step that failed is `status: 'failure'` and counts once through `nodes[].failures`, as before; a child that FAILED - rather than contained is precisely that step failure — its own `failed` + — whether or not it also contained failures before it failed — is + precisely that step failure: its own `failed`, contained and fatal alike, stays on the child's run row and nothing rides up, so one failure is never - counted twice. The control the card measured (a failing child → parent - `failed=1`) keeps counting exactly as today. + counted twice. This is where the rule parts from `acted`, which does carry + a failed child's writes up to the parent. The control the card measured (a + failing child → parent `failed=1`) keeps counting exactly as today. - `FlowRunSummary.failed` is declared, at the field, as the fold of `nodes[].failures` INCLUDING what a delegating node rolled up; the `FlowRunNodeSummary.failures` describe names the roll-up path, and its @@ -56,4 +60,12 @@ both halves are in. **Consumers.** A reader of `ExecutionStepMetrics` sees one more optional number and nothing else changes shape; a consumer that already sums `nodes[].failures` to cross-check `failed` keeps agreeing with it, because the -fold is unchanged — the roll-up enters the per-node array, not beside it. +fold is unchanged — the roll-up enters the per-node array, not beside it. Two +consequences of that placement are part of the contract from this release, +even though no producer populates the slot yet: on a delegating node +`nodes[].failures` may exceed `runs` (`runs: 5, failures: 15` is a legal +shape — five subflow executions whose children each contained three), and it +is no longer only that node's own failed executions, so a reader that derived +"this node's executions that failed" or a failure RATE from `failures / runs` +must read a delegating node's number as "failures this node caused, its +child's contained ones included". diff --git a/packages/spec/src/automation/execution.test.ts b/packages/spec/src/automation/execution.test.ts index 0096b8a7dd..add592b962 100644 --- a/packages/spec/src/automation/execution.test.ts +++ b/packages/spec/src/automation/execution.test.ts @@ -80,6 +80,19 @@ describe('ExecutionStepMetricsSchema', () => { expect(doc).toContain('`summary.failed`'); expect(doc).toContain('NOT this execution'); }); + + it('states the failed-child rule for the MIXED case, the departure from `acted`, and the third absence arm — at the point of use', () => { + // A child that contained failures and THEN failed is not a third case: the + // failed-child rule holds whether or not the child also contained, and + // that has to be readable on the field, because `acted` does the opposite + // (it carries a failed child's writes) and an implementer who mirrors + // `acted` here double-counts on the failed arm. The third absence arm is + // the window between this landing and the producer populating the slot. + const doc = ExecutionStepMetricsSchema.shape.failures.description ?? ''; + expect(doc).toContain('whether or not it also contained failures before it failed'); + expect(doc).toContain('unlike `acted`'); + expect(doc).toContain('or the producer did not track it'); + }); }); describe('ExecutionStepLogSchema', () => { @@ -463,6 +476,25 @@ describe('FlowRunSummarySchema', () => { const nodeStatusDoc = FlowRunNodeSummarySchema.shape.status.description ?? ''; expect(nodeStatusDoc).toContain('OWN executions'); expect(nodeStatusDoc).toContain('`failures > 0`'); + + // The mixed case and the departure from `acted` are stated on the total + // itself, not only on the slot that feeds it. + expect(failedDoc).toContain('whether or not it also contained failures before it failed'); + expect(failedDoc).toContain('unlike `acted`'); + }); + + it('a delegating node\'s `failures` may exceed its `runs` — `runs: 5, failures: 15` is a legal shape, and the describe says so', () => { + // Five subflow executions whose children each contained three failures: + // the node ran five times, succeeded five times, and rolled fifteen up. + // `failures` is no longer only this node's own failed executions. + const node = FlowRunNodeSummarySchema.parse({ + nodeId: 'call', nodeType: 'subflow', status: 'success' as const, runs: 5, failures: 15, skipped: 0, selected: 5, acted: 5, + }); + expect(node.failures).toBe(15); + expect(node.runs).toBe(5); + const nodeFailuresDoc = FlowRunNodeSummarySchema.shape.failures.description ?? ''; + expect(nodeFailuresDoc).toContain('may therefore exceed `runs`'); + expect(nodeFailuresDoc).toContain('no longer only this node'); }); it('leaves `failed` absent on a run that never tracked it — absent is not zero, and is not defaulted', () => { diff --git a/packages/spec/src/automation/execution.zod.ts b/packages/spec/src/automation/execution.zod.ts index be78cd7720..1e4824f148 100644 --- a/packages/spec/src/automation/execution.zod.ts +++ b/packages/spec/src/automation/execution.zod.ts @@ -92,19 +92,23 @@ export type ExecutionStatus = z.input; * parent answers "what did this run cause", and until this slot existed the * failure count did not: a parent whose child lost a row read `failed: 0`, * which is the misreading the run-level `failed` was added to prevent - * (#13681), one level up. The slot carries the child's `summary.failed` and - * folds into the delegating node's `failures` by exactly the rule `acted` - * follows, so `failed = Σ nodes[].failures` keeps holding — with the child - * counted in. + * (#13681), one level up. The slot carries a COMPLETED child's + * `summary.failed` and folds into the delegating node's `failures` — the same + * fold shape `acted` has, so `failed = Σ nodes[].failures` keeps holding with + * the child counted in. It is NOT the same rule as `acted` at the failed-child + * boundary, below. * * It is NOT this execution's own outcome. A step that failed is * `status: 'failure'` and counts once, in `nodes[].failures`, as it always - * has — and that is also the whole answer for a child that FAILED rather than - * contained: the delegating step is the failure, the child's own `failed` - * (which carries the fatal one) stays on the child's run row, and nothing - * rides up here, so one failure is never counted twice. Absent ⇒ this - * execution delegated nothing, or its child tracked no count (an older run); - * either way it is not `0`. + * has — and that is also the whole answer for a child that FAILED, whether or + * not it also contained failures before it failed: the delegating step is the + * failure, the child's own `failed` (contained and fatal alike) stays on the + * child's run row, and nothing rides up here, so one failure is never counted + * twice. Unlike `acted`, which does carry a failed child's writes up (rows it + * wrote before it died), this slot carries nothing from a failed child. + * Absent ⇒ this execution delegated nothing, or its child tracked no count + * (an older run), or the producer did not track it (a step recorded before + * its executor populated the slot); in every case it is not `0`. */ export const ExecutionStepMetricsSchema = lazySchema(() => z.object({ selected: z.number().int().min(0).optional() @@ -114,7 +118,7 @@ export const ExecutionStepMetricsSchema = lazySchema(() => z.object({ unmeasuredEffect: z.boolean().optional() .describe('This execution may have caused an effect the platform cannot count (an external write through a connector). NOT interchangeable with `acted: 0` — it says the count is unknown, not that it is zero.'), failures: z.number().int().min(0).optional() - .describe('Node executions that failed inside a child run this execution delegated to and went on from — a `subflow` child or a `map` item whose run COMPLETED while containing failures: its `summary.failed`, rolled up so the parent answers "what did this run cause" the way `acted` already does. Folds into this node\'s `failures` and so into the run-level `failed`. NOT this execution\'s own outcome: a step that failed is `status: \'failure\'` and counts once through `nodes[].failures`, and a child that FAILED rather than contained is exactly that step failure — its own `failed` stays on the child\'s run row and nothing rides up here. Absent = delegated nothing, or the child tracked no count; not zero.'), + .describe('Node executions that failed inside a child run this execution delegated to and went on from — a `subflow` child or a `map` item whose run COMPLETED while containing failures: its `summary.failed`, rolled up so the parent answers "what did this run cause". Folds into this node\'s `failures` and so into the run-level `failed`. NOT this execution\'s own outcome: a step that failed is `status: \'failure\'` and counts once through `nodes[].failures`, and a child that FAILED — whether or not it also contained failures before it failed — is exactly that step failure: its own `failed`, contained and fatal alike, stays on the child\'s run row and nothing rides up here (unlike `acted`, which does carry a failed child\'s writes). Absent = delegated nothing, or the child tracked no count, or the producer did not track it; never zero.'), })); export type ExecutionStepMetrics = z.input; @@ -204,7 +208,7 @@ export const FlowRunNodeSummarySchema = lazySchema(() => z.object({ status: z.enum(['success', 'failure', 'skipped']) .describe('Terminal status of the node across the run — `failure` if any execution failed, else `success` if any succeeded, else `skipped`. Judged on this node\'s OWN executions: a delegating node (`subflow` / `map`) whose child completed while containing failures reads `success` here with `failures > 0`'), runs: z.number().int().min(0).describe('Times the node executed (loop iterations and parallel branches each count)'), - failures: z.number().int().min(0).describe('Executions that failed — a failure a `try_catch` caught or a `fault` edge routed counts here too — plus what a delegating execution rolled up from its child run (`metrics.failures`: the contained failures of a `subflow` child or a `map` item that completed), the way `acted` carries the child\'s writes; the run-level `failed` is the sum of this across `nodes`'), + failures: z.number().int().min(0).describe('Executions that failed — a failure a `try_catch` caught or a `fault` edge routed counts here too — plus what a delegating execution rolled up from a child run that COMPLETED (`metrics.failures`: the contained failures of a `subflow` child or a `map` item). On a delegating node this may therefore exceed `runs` and is no longer only this node\'s own failed executions; a child that FAILED adds only the step\'s own failure (unlike `acted`, which carries a failed child\'s writes too); the run-level `failed` is the sum of this across `nodes`'), skipped: z.number().int().min(0).describe('Times a closed gate kept this node from running at all'), selected: z.number().int().min(0).optional().describe('Records read across every execution — omitted for a node that reads none'), acted: z.number().int().min(0).optional().describe('Records written / effects dispatched across every execution — omitted for a node that writes none'), @@ -246,15 +250,21 @@ export type FlowRunGateSummary = z.input; * * Totals are sums over `nodes`, which is itself a fold of the run's step log, * so a loop that ran a write 30 times contributes 30 to `acted`. A `subflow` - * node — and each item of a `map` — rolls its child run's totals up into this - * one, `failed` included: the child's contained failures ride on the - * delegating step's `metrics.failures`, fold into that node's `failures`, and - * so into `failed`, by exactly the rule `acted` follows (#15617). The child + * node — and each item of a `map` — rolls its child run up into this one + * through the delegating step's metrics, total by total, each on its own + * rule: `selected` and `acted` as totals, from a completed and a failed child + * alike; `unmeasured` as ONE per-execution flag (N uncountable effects in the + * child collapse to one on the parent's step, so this total counts parent + * executions, not the child's effects); `failed` as the child's contained + * failures, from a child that COMPLETED only, on `metrics.failures`, folding + * into that node's `failures` and so into `failed` (#15617); and `skipped` + * not at all — a child's closed gates stay on the child's row. The child * keeps its own run row, so the child's work is counted there too, * deliberately: this summary answers "what did this run cause", not "what did - * this run's own nodes do" — and every total here answers it, not only the - * ones that count writes. A child that FAILED rather than contained is the - * delegating step's own failure, counted once, as it always was. + * this run's own nodes do". A child that FAILED — whether or not it also + * contained failures before it failed — is the delegating step's own failure, + * counted once, as it always was; nothing of that child's `failed` rides up, + * which is the one place this rule parts from `acted`'s. */ export const FlowRunSummarySchema = lazySchema(() => z.object({ selected: z.number().int().min(0).describe('Total records read by the run'), @@ -293,13 +303,14 @@ export const FlowRunSummarySchema = lazySchema(() => z.object({ * fold INCLUDES what a delegating node rolled up from its child (#15617): a * `subflow` or `map` child that completed while containing failures reports * them on the delegating step's `metrics.failures`, which folds into that - * node's `failures` and so arrives here — the same path the child's writes - * take into `acted`. Before that slot existed the fold could not see them, - * so a parent whose child lost rows read `failed: 0` while the paragraph - * above promised "what did this run cause"; the two now agree. A child that - * FAILED rather than contained is the delegating step's own failure, - * counted once here as it always was, and its own `failed` stays on its - * own run row. + * node's `failures` and so arrives here. Before that slot existed the fold + * could not see them, so a parent whose child lost rows read `failed: 0` + * while the paragraph above promised "what did this run cause"; the two now + * agree. A child that FAILED — whether or not it also contained failures + * before it failed — is the delegating step's own failure, counted once + * here as it always was; its own `failed`, contained and fatal alike, stays + * on its own run row and nothing of it rides up (unlike `acted`, which + * carries a failed child's writes). * * Same convention as `unmeasured`, for the same reason: optional, and absent * is NOT zero. A run recorded before this field existed did not carry the @@ -307,7 +318,7 @@ export const FlowRunSummarySchema = lazySchema(() => z.object({ * about a run nobody measured. */ failed: z.number().int().min(0).optional() - .describe('Total node executions that failed — a fold of `nodes[].failures`, INCLUDING what a delegating node (`subflow` / `map`) rolled up from a child run that completed while containing failures, the way `acted` includes the child\'s writes: this total answers "what did this run cause", subflows included, so a parent whose child lost rows does not read `failed: 0`. On a run that completed every one of them was contained (caught by a `try_catch` or routed down a `fault` edge) and the run went on. Absent = not tracked (an older run), which is not the same as zero.'), + .describe('Total node executions that failed — a fold of `nodes[].failures`, INCLUDING what a delegating node (`subflow` / `map`) rolled up from a child run that COMPLETED while containing failures: this total answers "what did this run cause", subflows included, so a parent whose child lost rows does not read `failed: 0`. A child that FAILED — whether or not it also contained failures before it failed — counts once, as the delegating step\'s own failure, and its own `failed` stays on its row (unlike `acted`, which carries a failed child\'s writes). On a run that completed every one of them was contained (caught by a `try_catch` or routed down a `fault` edge) and the run went on. Absent = not tracked (an older run), which is not the same as zero.'), nodes: z.array(FlowRunNodeSummarySchema).describe('Per-node breakdown, in first-execution order'), gates: z.array(FlowRunGateSummarySchema).describe('Gates that closed during the run, most-skipped first'), detailOmitted: z.boolean().optional() From 7653f814e2bc1e4e548fff9a161a9b7378564c6a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 15:03:16 +0000 Subject: [PATCH 4/4] chore(spec): regenerate docs references for the R1.1 describe wording (#15617) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf --- content/docs/references/automation/execution.mdx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/content/docs/references/automation/execution.mdx b/content/docs/references/automation/execution.mdx index 990464118c..37f00dc3eb 100644 --- a/content/docs/references/automation/execution.mdx +++ b/content/docs/references/automation/execution.mdx @@ -155,7 +155,7 @@ const result = CheckpointSchema.parse(data); | **acted** | `integer` | ✅ | Total records written / effects dispatched by the run | | **skipped** | `integer` | ✅ | Total node executions a closed gate prevented | | **unmeasured** | `integer` | optional | Total executions that may have caused an effect the platform cannot count. Absent = not tracked (an older run), which is not the same as zero. | -| **failed** | `integer` | optional | Total node executions that failed — a fold of `nodes[].failures`, INCLUDING what a delegating node (`subflow` / `map`) rolled up from a child run that completed while containing failures, the way `acted` includes the child's writes: this total answers "what did this run cause", subflows included, so a parent whose child lost rows does not read `failed: 0`. On a run that completed every one of them was contained (caught by a `try_catch` or routed down a `fault` edge) and the run went on. Absent = not tracked (an older run), which is not the same as zero. | +| **failed** | `integer` | optional | Total node executions that failed — a fold of `nodes[].failures`, INCLUDING what a delegating node (`subflow` / `map`) rolled up from a child run that COMPLETED while containing failures: this total answers "what did this run cause", subflows included, so a parent whose child lost rows does not read `failed: 0`. A child that FAILED — whether or not it also contained failures before it failed — counts once, as the delegating step's own failure, and its own `failed` stays on its row (unlike `acted`, which carries a failed child's writes). On a run that completed every one of them was contained (caught by a `try_catch` or routed down a `fault` edge) and the run went on. Absent = not tracked (an older run), which is not the same as zero. | | **nodes** | `{ nodeId: string; nodeType: string; nodeLabel?: string; status: Enum<'success' \| 'failure' \| 'skipped'>; … }[]` | ✅ | Per-node breakdown, in first-execution order | | **gates** | `{ nodeId: string; targetNodeId: string; edgeId?: string; label?: string; … }[]` | ✅ | Gates that closed during the run, most-skipped first | | **detailOmitted** | `boolean` | optional | Set when persistence dropped `nodes`/`gates` to keep the stored row bounded — the totals are still exact. Declared so empty arrays are never mistaken for "nothing ran". | @@ -219,7 +219,7 @@ const result = CheckpointSchema.parse(data); | **selected** | `integer` | optional | Records this node READ or matched (a `get_record` query, a lookup) | | **acted** | `integer` | optional | Records this node WROTE (created / updated / deleted) or effects it dispatched (notifications delivered) | | **unmeasuredEffect** | `boolean` | optional | This execution may have caused an effect the platform cannot count (an external write through a connector). NOT interchangeable with `acted: 0` — it says the count is unknown, not that it is zero. | -| **failures** | `integer` | optional | Node executions that failed inside a child run this execution delegated to and went on from — a `subflow` child or a `map` item whose run COMPLETED while containing failures: its `summary.failed`, rolled up so the parent answers "what did this run cause" the way `acted` already does. Folds into this node's `failures` and so into the run-level `failed`. NOT this execution's own outcome: a step that failed is `status: 'failure'` and counts once through `nodes[].failures`, and a child that FAILED rather than contained is exactly that step failure — its own `failed` stays on the child's run row and nothing rides up here. Absent = delegated nothing, or the child tracked no count; not zero. | +| **failures** | `integer` | optional | Node executions that failed inside a child run this execution delegated to and went on from — a `subflow` child or a `map` item whose run COMPLETED while containing failures: its `summary.failed`, rolled up so the parent answers "what did this run cause". Folds into this node's `failures` and so into the run-level `failed`. NOT this execution's own outcome: a step that failed is `status: 'failure'` and counts once through `nodes[].failures`, and a child that FAILED — whether or not it also contained failures before it failed — is exactly that step failure: its own `failed`, contained and fatal alike, stays on the child's run row and nothing rides up here (unlike `acted`, which does carry a failed child's writes). Absent = delegated nothing, or the child tracked no count, or the producer did not track it; never zero. | ### Nested Shape: `ExecutionStepLog.skippedBy` @@ -241,7 +241,7 @@ const result = CheckpointSchema.parse(data); | **selected** | `integer` | optional | Records this node READ or matched (a `get_record` query, a lookup) | | **acted** | `integer` | optional | Records this node WROTE (created / updated / deleted) or effects it dispatched (notifications delivered) | | **unmeasuredEffect** | `boolean` | optional | This execution may have caused an effect the platform cannot count (an external write through a connector). NOT interchangeable with `acted: 0` — it says the count is unknown, not that it is zero. | -| **failures** | `integer` | optional | Node executions that failed inside a child run this execution delegated to and went on from — a `subflow` child or a `map` item whose run COMPLETED while containing failures: its `summary.failed`, rolled up so the parent answers "what did this run cause" the way `acted` already does. Folds into this node's `failures` and so into the run-level `failed`. NOT this execution's own outcome: a step that failed is `status: 'failure'` and counts once through `nodes[].failures`, and a child that FAILED rather than contained is exactly that step failure — its own `failed` stays on the child's run row and nothing rides up here. Absent = delegated nothing, or the child tracked no count; not zero. | +| **failures** | `integer` | optional | Node executions that failed inside a child run this execution delegated to and went on from — a `subflow` child or a `map` item whose run COMPLETED while containing failures: its `summary.failed`, rolled up so the parent answers "what did this run cause". Folds into this node's `failures` and so into the run-level `failed`. NOT this execution's own outcome: a step that failed is `status: 'failure'` and counts once through `nodes[].failures`, and a child that FAILED — whether or not it also contained failures before it failed — is exactly that step failure: its own `failed`, contained and fatal alike, stays on the child's run row and nothing rides up here (unlike `acted`, which does carry a failed child's writes). Absent = delegated nothing, or the child tracked no count, or the producer did not track it; never zero. | --- @@ -285,7 +285,7 @@ const result = CheckpointSchema.parse(data); | **nodeLabel** | `string` | optional | Human-readable node label | | **status** | `Enum<'success' \| 'failure' \| 'skipped'>` | ✅ | Terminal status of the node across the run — `failure` if any execution failed, else `success` if any succeeded, else `skipped`. Judged on this node's OWN executions: a delegating node (`subflow` / `map`) whose child completed while containing failures reads `success` here with `failures > 0` | | **runs** | `integer` | ✅ | Times the node executed (loop iterations and parallel branches each count) | -| **failures** | `integer` | ✅ | Executions that failed — a failure a `try_catch` caught or a `fault` edge routed counts here too — plus what a delegating execution rolled up from its child run (`metrics.failures`: the contained failures of a `subflow` child or a `map` item that completed), the way `acted` carries the child's writes; the run-level `failed` is the sum of this across `nodes` | +| **failures** | `integer` | ✅ | Executions that failed — a failure a `try_catch` caught or a `fault` edge routed counts here too — plus what a delegating execution rolled up from a child run that COMPLETED (`metrics.failures`: the contained failures of a `subflow` child or a `map` item). On a delegating node this may therefore exceed `runs` and is no longer only this node's own failed executions; a child that FAILED adds only the step's own failure (unlike `acted`, which carries a failed child's writes too); the run-level `failed` is the sum of this across `nodes` | | **skipped** | `integer` | ✅ | Times a closed gate kept this node from running at all | | **selected** | `integer` | optional | Records read across every execution — omitted for a node that reads none | | **acted** | `integer` | optional | Records written / effects dispatched across every execution — omitted for a node that writes none | @@ -304,7 +304,7 @@ const result = CheckpointSchema.parse(data); | **acted** | `integer` | ✅ | Total records written / effects dispatched by the run | | **skipped** | `integer` | ✅ | Total node executions a closed gate prevented | | **unmeasured** | `integer` | optional | Total executions that may have caused an effect the platform cannot count. Absent = not tracked (an older run), which is not the same as zero. | -| **failed** | `integer` | optional | Total node executions that failed — a fold of `nodes[].failures`, INCLUDING what a delegating node (`subflow` / `map`) rolled up from a child run that completed while containing failures, the way `acted` includes the child's writes: this total answers "what did this run cause", subflows included, so a parent whose child lost rows does not read `failed: 0`. On a run that completed every one of them was contained (caught by a `try_catch` or routed down a `fault` edge) and the run went on. Absent = not tracked (an older run), which is not the same as zero. | +| **failed** | `integer` | optional | Total node executions that failed — a fold of `nodes[].failures`, INCLUDING what a delegating node (`subflow` / `map`) rolled up from a child run that COMPLETED while containing failures: this total answers "what did this run cause", subflows included, so a parent whose child lost rows does not read `failed: 0`. A child that FAILED — whether or not it also contained failures before it failed — counts once, as the delegating step's own failure, and its own `failed` stays on its row (unlike `acted`, which carries a failed child's writes). On a run that completed every one of them was contained (caught by a `try_catch` or routed down a `fault` edge) and the run went on. Absent = not tracked (an older run), which is not the same as zero. | | **nodes** | `{ nodeId: string; nodeType: string; nodeLabel?: string; status: Enum<'success' \| 'failure' \| 'skipped'>; … }[]` | ✅ | Per-node breakdown, in first-execution order | | **gates** | `{ nodeId: string; targetNodeId: string; edgeId?: string; label?: string; … }[]` | ✅ | Gates that closed during the run, most-skipped first | | **detailOmitted** | `boolean` | optional | Set when persistence dropped `nodes`/`gates` to keep the stored row bounded — the totals are still exact. Declared so empty arrays are never mistaken for "nothing ran". | @@ -318,7 +318,7 @@ const result = CheckpointSchema.parse(data); | **nodeLabel** | `string` | optional | Human-readable node label | | **status** | `Enum<'success' \| 'failure' \| 'skipped'>` | ✅ | Terminal status of the node across the run — `failure` if any execution failed, else `success` if any succeeded, else `skipped`. Judged on this node's OWN executions: a delegating node (`subflow` / `map`) whose child completed while containing failures reads `success` here with `failures > 0` | | **runs** | `integer` | ✅ | Times the node executed (loop iterations and parallel branches each count) | -| **failures** | `integer` | ✅ | Executions that failed — a failure a `try_catch` caught or a `fault` edge routed counts here too — plus what a delegating execution rolled up from its child run (`metrics.failures`: the contained failures of a `subflow` child or a `map` item that completed), the way `acted` carries the child's writes; the run-level `failed` is the sum of this across `nodes` | +| **failures** | `integer` | ✅ | Executions that failed — a failure a `try_catch` caught or a `fault` edge routed counts here too — plus what a delegating execution rolled up from a child run that COMPLETED (`metrics.failures`: the contained failures of a `subflow` child or a `map` item). On a delegating node this may therefore exceed `runs` and is no longer only this node's own failed executions; a child that FAILED adds only the step's own failure (unlike `acted`, which carries a failed child's writes too); the run-level `failed` is the sum of this across `nodes` | | **skipped** | `integer` | ✅ | Times a closed gate kept this node from running at all | | **selected** | `integer` | optional | Records read across every execution — omitted for a node that reads none | | **acted** | `integer` | optional | Records written / effects dispatched across every execution — omitted for a node that writes none |