feat(spec): ExecutionStepMetrics gains an optional failures slot; FlowRunSummary.failed is declared as the fold INCLUDING what a delegating node rolled up from its child (#15617, spec half) - #16635
Conversation
…fold reconciliation (#15617 spec half) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf
…ExecutionStepMetrics failure slot (#15617) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 130 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c8876d5549f8ce37195eb80cf19f29c3a14d65c5 && git checkout c8876d5549f8ce37195eb80cf19f29c3a14d65c5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bc0ac1d1f5775659e72ad35452c463443a71f1be 7653f814e2bc1e4e548fff9a161a9b7378564c6a && git checkout -B drift-repro bc0ac1d1f5775659e72ad35452c463443a71f1be && git merge --no-ff 7653f814e2bc1e4e548fff9a161a9b7378564c6a
node scripts/docs-audit/affected-docs.mjs --json bc0ac1d1f5775659e72ad35452c463443a71f1be |
… clause to the totals that roll, drop the acted analogy at the failed-child boundary, state the mixed case and the third absence arm in the contract text Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf
…#15617) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf
Fixes #15617
Clause-②: yes
Spec half (lane 1) of the maintainer's ruling on #15617 — decision batch #55, director seat, 2026-09-06 13:51Z, the maintainer's reply verbatim 「同意」 (comment 5559667158). The services half is card #16314,
pm:blockedon this card by design; #16314 is not addressed here and nothing underpackages/services/**is touched — contract first, the engine follows.What this declares
ExecutionStepMetricsSchema(packages/spec/src/automation/execution.zod.ts) gains an optionalfailuresslot — integer ≥ 0 — declared as: node executions that failed inside a child run this execution delegated to and went on from (asubflowchild, or amapitem, whose run COMPLETED while containing failures) — the child'ssummary.failed, rolled up so the parent answers "what did this run cause". It folds into the delegating node'snodes[].failuresand so into the run-levelfailed— the same fold SHAPEactedhas, but not the same rule at the failed-child boundary (next bullet). Absent = delegated nothing, or the child tracked no count, or the producer did not track it (every subflow/map step the engine emits between this landing and service-automation:subflowandmaproll a child's contained failures into the parent'sfailedthrough theExecutionStepMetricsfailure slot ruled on #15617 #16314) — never zero.status: 'failure'and counts once throughnodes[].failuresas before; a child that FAILED — whether or not it also contained failures before it failed — is exactly that step failure: its ownfailed, contained and fatal alike, stays on the child's run row and nothing rides up here, so one failure is never counted twice. This is where the rule parts fromacted, which does carry a failed child's writes up (subflow-node.ts:144-147,map-node.ts:197-203). The control the card measured (a failing child ⇒ parentfailed=1) keeps counting exactly as today.FlowRunSummaryparagraphs are reconciled: the roll-up paragraph now says which totals roll up and on what rule each —selected/actedas totals from a completed and a failed child alike,unmeasuredas one per-execution flag (creditChildRuncollapses N child effects to one),failedas a COMPLETED child's contained failures,skippednot at all — and namesmapbesidesubflow; thefailedTSDoc says the fold INCLUDES what a delegating node rolled up from its child; and — the failure mode triage called out — the field's own.describe()says so at the point of use, not only the paragraph above the schema.FlowRunNodeSummary.failures's describe names the roll-up path (metrics.failures), and itsstatusdescribe states that status is judged on the node's OWN executions, so a delegating node whose child contained failures readssuccessbesidefailures > 0— the shape service-automation:subflowandmaproll a child's contained failures into the parent'sfailedthrough theExecutionStepMetricsfailure slot ruled on #15617 #16314's engine has to produce, declared here so it cannot be misread.ExecutionStepLog.metrics's describe mentions the delegating case.Patch round R1.1 — contract review VERDICT: FAIL, four text fixes, no design change (commits
aebacb8c,7653f814)The at-tier review upheld the conservative mixed-case rule and
minorwith no BREAKING banner; what failed was the text. All four items are in the contract text now:selected/actedas totals from a completed and a failed child alike;unmeasuredas ONE per-execution flag (N child effects collapse to one, measured atcreditChildRun);failedas a COMPLETED child's contained failures;skippednot at all (no metrics slot, nochild.summary.skippedreader).actedanalogy — all six sites changed, plus the changeset: the metrics TSDoc (was :96-97) now says "the same fold shapeactedhas … NOT the same rule at the failed-child boundary"; themetrics.failuresdescribe (:117), thenodes[].failuresdescribe (:207), the header paragraph (:252), thefailedTSDoc (:296-297) and thefaileddescribe (:310) each drop "the wayacted…" / "the ruleactedfollows" / "the same path the child's writes take intoacted" and say instead "unlikeacted, which carries a failed child's writes"; the changeset's title and both bullets likewise. Residual grep for the five old spellings: 0 hits.failed, contained and fatal alike, stays on its own run row" — in the metrics TSDoc, themetrics.failuresdescribe, the header, thefailedTSDoc, thefaileddescribe, and the changeset.metrics.failuresdescribe and TSDoc, and the changeset, now read "Absent = delegated nothing, or the child tracked no count, or the producer did not track it; never zero".Changeset "Consumers" paragraph now discloses that on a delegating node
nodes[].failuresmay exceedruns(runs: 5, failures: 15is a legal shape) and no longer equals the node's own failed executions, so a failure RATE derived fromfailures / runsmust read a delegating node's number as failures it caused, child's contained ones included. Thenodes[].failuresdescribe says the same at the point of use.Two pins added (+2 ⇒ 53 in the file): the mixed case, the departure from
actedand the third arm are asserted on themetrics.failuresdescribe and thefaileddescribe;runs: 5, failures: 15parses onFlowRunNodeSummarySchemaand its describe says it may exceedruns. Ablation on committed7653f814: deleting the mixed-case phrase from themetrics.failuresdescribe (on-disk count 1 → 0, blob2ca9708e≠ HEAD1e4824f1) reds exactly the new pin (1 failed | 52 passed);git checkout HEAD --under a trap restores blob1e4824f1,git diff HEADempty, 53 passed.R1.1 verification on
7653f814: spec build green (dts 34/34);check:generated—check:docsstale, regenerated (execution.mdx+6 −6, describe text only);pnpm --filter @objectstack/spec test461 passed / 12813 passed;typecheckgreen incl. test layer; gates re-derived from the patched diff — the same 97 families — all run on a fresh ledger,--ran97/97; 91 green first pass, the six non-zero are the same prerequisite misses as R1 (lintclosure rebuilt →doc-formula-expressions,doc-security-posture,docs-transcript-driftre-run exit 0;skill-examples,dual-build-cjs-loads,type-check-debtstill need the whole-repo build → CI); eslint narrowed run 2 files / 0 / 0;check:nul-bytes0.content/docs/automation/flows.mdx:1030untouched, per the coordinator.Pins (
execution.test.ts, +8 in R1, +2 in R1.1)ExecutionStepMetricsSchema: keepsfailuresthrough the parse (az.objectSTRIPS an undeclared key, so keeping the value is what proves the declaration); absent stays absent, not defaulted; negative / fractional refused; the describe namessubflow,map,summary.failedand separates the slot from the step's own outcome.ExecutionStepLogSchema: asuccesssubflow step inside a loop iteration carriesmetrics.failures: 1.FlowRunSummarySchema: the card's measured shape as ruled — parentloop { subflow(child) }, subflow nodestatus: success, runs: 5, failures: 1, acted: 4,failed: 1equal to the fold overnodes[].failures; the control — subflow nodestatus: failure, failures: 1,failed: 1, nothing on top; and the point-of-use describes (failed,nodes[].failures,nodes[].status) carry the widened rule.Premise check (rule 6) and the PM's mechanism assumptions
554a1603:ExecutionStepMetricsSchemaat :87 (card said :81); node-levelfailuresdescribe at :183 (card said :177); thesubflowroll-up sentence at :224–227 (the ruling's ":218" is the "reads BESIDE that filter" line two paragraphs earlier, so that anchor was stale too);FlowRunSummarySchemaat :229; thefailedfield describe at :270 (card said :264). Both paragraphs were there and did disagree — premise valid.mapflow throughExecutionStepMetrics? Measured, yes:packages/services/service-automation/src/builtin/map-node.tsemitsmetrics: { selected, acted, unmeasuredEffect }accumulated from each item'schild.summaryon all three exits (lines 190, 199–202, 243 at554a1603);subflow-node.tsdoes the same at 132–140. Both are real producers of this schema, so the declaration names both. The other gap in that comment — whether objectui readssummary.failed— could not be measured here: no../objectuicheckout exists in this container andpackages/console/distis absent. Left open below.failed=0beside childfailed=[0,0,0,0,1]; control parentfailed=1) are the card's / the services seat's, not re-run by me.Verification (R1, on
dfe92a7b; base554a1603— R1.1 numbers are in the patch-round section above)pnpm --filter @objectstack/spec build— green under the verify lock (2m36s shared-box);check-dts-emitted34/34..d.ts: everyunmeasuredEffect: ZodOptional of ZodBooleanshape inpackages/spec/distis followed byfailures: ZodOptional of ZodNumber— 7 of 7.automationentry (scratch tsconfig mapping@objectstack/spec/automationtodist/automation/index.d.ts):{ selected: 5, acted: 4, failures: 1 }typechecks (exit 0); the misspellingfailuersis refused with TS2561 (exit 2) — the consumer reads the rebuilt declaration, not a cache.pnpm --filter @objectstack/spec check:generated: one artifact stale (check:docs), regenerated with--fix;authorable-surface/automation.jsongained the one lineautomation/ExecutionStepMetrics:failuresfrom the build'sgen:schema;api-surfaceunchanged (it records existence, not shape).authorable-surface.base.jsonuntouched.pnpm --filter @objectstack/spec test:Test Files 461 passed (461) · Tests 12811 passed (12811). The pin file alone: 51 passed (51).pnpm --filter @objectstack/spec typecheck: green, includingcheck:scripts-typecheckandcheck:test-typecheck(54 files / 261 pinned);tsc -p tsconfig.test.json --listFileslistssrc/automation/execution.test.ts— the test layer does compile the new pins.dfe92a7b; the pin file resolves./execution.zodSOURCE, so no dist leg is involved): deleted thefailureskey + describe fromExecutionStepMetricsSchema— anchor count 1 → 0 on disk, blobbd23acc6≠ HEAD blobbe78cd77; pin file:Tests 4 failed | 47 passed (51)— exactly the four pins that assert the declaration (keeps-through-parse, negative refused, point-of-use describe, step-log carrier) — direction: turned red, as predicted. Restore viagit checkout HEAD -- PATHunder an EXIT/INT/TERM trap on the absolute path:git diff HEADempty,git status --porcelainempty, blob equals HEAD (be78cd77), anchor count 1; pin file 51 passed (51).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths; derived from the merge base) printed 97 commands; every one was run with its exit code recorded to disk as it went;--ranreconciles97 derived, 97 run, 0 UNRUN. 90 green on the first pass. The seven non-zero first runs, each read from the gate's own verdict line:check-plugin-teardown-shape --self-testexit 1 — "cannot read the positive control" because the shared.gitwas shallow when it ran; re-run after the clone was deepened: 47 cases pass, exit 0.lint check:doc-formula-expressions,lint check:doc-security-posture,check:docs-transcript-drift— exit 3PREREQUISITE NOT MET(@objectstack/lint/@objectstack/formulaunbuilt); the 4-packagelintclosure was built under the lock and all three re-run exit 0.spec check:skill-examples(exit 1:packages/client-react/distunbuilt),check:dual-build-cjs-loads(exit 3: 85 packages withoutdist/),check:type-check-debt(exit 3: closure not built) — NOT MEASURED locally; they need the whole-repo build and are declared to CI.pnpm check:nul-bytesexit 0; control-byte self-scan of the three hand-edited files: no hits.pnpm lintis CI's run; delivered as a proven narrowing: ① population read fromeslint.config.mjs—**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}minusNEVER_LINTED; of this diff's five files exactly two are in it (the.md,.mdxand.jsonare outside); ②eslint --no-inline-config --format jsonon those two: 2 files, 0 errors, 0 warnings, exit 0; ③ invariance: the config enables noparserOptions.projectand no typed@typescript-eslintrules (its own comment at line 328), so every file's verdict is a function of that file alone and this diff cannot move an untouched file's verdict.check:changesetfamily:check-empty-changeset,check-changeset-no-major,check-adr-0087-registrationall exit 0 (additiveminor, no BREAKING banner, so no ADR-0087 marker is owed).验收备注
content/docs/automation/flows.mdx:1030keeps PR service-automation: populate the contained-failure visibility contract —FlowRunSummary.failedfold, loop iteration throughtry_catch,$error.iteration/$error.item,failed=on the summary line #15609's narrowed wording ("node executions of this run" … "not folded up here the wayactedis (that inconsistency in the declaration is spec:FlowRunSummary's two paragraphs disagree for a subflow parent —failedis declared a node fold, while the summary is declared to answer "what did this run cause" and roll a child's totals up #15617)"). By the ruling that row is widened "once both halves land", i.e. in service-automation:subflowandmaproll a child's contained failures into the parent'sfailedthrough theExecutionStepMetricsfailure slot ruled on #15617 #16314's landing; between the two landings its parenthetical reads stale (the declaration is reconciled; the engine lags). Noted, not filed — 承接者:service-automation:subflowandmaproll a child's contained failures into the parent'sfailedthrough theExecutionStepMetricsfailure slot ruled on #15617 #16314. The coordinator is deciding this row separately; not touched in R1.1.node.status = node.failures > 0 ? 'failure' : …(run-summary.ts:143) will have to be decoupled from the count once the slot folds in, because the declaration says status is judged on the node's own executions. That is the content of service-automation:subflowandmaproll a child's contained failures into the parent'sfailedthrough theExecutionStepMetricsfailure slot ruled on #15617 #16314, not a new finding — noted, not filed — 承接者:service-automation:subflowandmaproll a child's contained failures into the parent'sfailedthrough theExecutionStepMetricsfailure slot ruled on #15617 #16314.subflowandmaproll a child's contained failures into the parent'sfailedthrough theExecutionStepMetricsfailure slot ruled on #15617 #16314 — not taken because no producer can compute it from the summary alone.summary.failedremains unmeasured (no sibling checkout in this container) — the decision comment's second confidence gap, still open; nothing in this PR changes the wire shape offailed, only its declared meaning.维护者速读(草稿)
改了什么:给流程运行的"每一步做了什么"记录(
ExecutionStepMetrics)加了一个可选的failures槽,用来承载子流程(subflow/map的每一项)跑完但内部包含了失败时的失败数;并把运行汇总里failed的两段互相矛盾的说明统一为:failed是逐节点失败数的合计,包含委托节点从跑完的子流程卷上来的部分。合计的形状与acted相同,但在"子流程整体失败"这一处规则不同:acted会把失败子流程已写的行卷上来,failures什么都不卷——子流程整体失败(无论它之前有没有包含过失败)只按父节点自己那一次失败计数。字段自己的描述、节点级failures与status的描述都写明了这条规则;10 条 pin 钉住声明;@objectstack/specminor、纯新增。为什么改:services 席在真实引擎上量到:父流程
loop { subflow(child) }五次里子流程有一次丢了行,父运行却显示failed=0——因为acted会卷上来而failed没有通道。维护者 2026-09-06 裁定方案 1(契约先行、两车道)。本 PR 是契约半边:先把规则声明清楚并钉住;引擎在 #16314 落实卷积。风险与代价(含回滚):本 PR 不改任何引擎行为、不改任何已发布数字——引擎在 #16314 落地前不会填这个槽,
failed数值与今天完全一致;文档页flows.mdx上 #15609 收窄过的措辞按裁决保留到两半都落地。新增的是一个可选键与更宽的声明语义;回滚 = revert 本 PR(纯新增,无迁移、无 tombstone)。"先包含失败、后整体失败"的子流程只算一次、不卷子流程内部的那几次——这条规则契约复审判定正确,现已写进契约文本本身(R1.1)。席位意见:(留空)
你要做的:确认"
failed包含委托节点从子流程卷上来的失败;整体失败的子流程只按父节点自己的那一次失败计数"这条规则与你的裁决意图一致,然后人工合并(或授权批准入队);合并后 #16314 自动解除阻塞。🤖 Generated with Claude Code
https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf