Skip to content

fix(service-automation)!: a map whose item run was cancelled records the item as cancelled on a parent-id resume - #28176

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-2p9rtc
Oct 30, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-2p9rtc

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread
Fixes #27971

Clause-②: yes (widening)
Metadata-protocol: added
Ruling-ref: 6523833308
Skills-docs: none — skills/** names neither RESUME_CHILD_PENDING nor a map's cancelled item, so no skill text describes the shape that changes; the hand-written guide content/docs/automation/flows.mdx (batch-approval paragraph) is updated here, and the generated content/docs/references/automation/builtin-node-config.mdx is regenerated from the spec describe.

维护者速读

  • 改了什么:批量节点(map)里某一项的子流程被取消后,再用 map 自己的 run id 恢复,map 会继续往下走,并在该项的位置记下 { $status: 'cancelled', $itemRunId } 标记;count 只算真正跑过的项。子项挂起记录没了、运行历史又不是 cancelled 的,直接 409 拒绝,什么都不动。
  • 为什么改:以前取消一项后恢复批次,map 会悄悄跳过这一项,results 少一项、下标错位,批次还显示 completed。按你的裁决 A 修。
  • 风险与回滚:只影响停在内置 map 节点、且子项挂起记录已不在的那次恢复;正常完成的项、仍挂起的项(409)不变。回滚即 revert 本 PR。
  • 先看:packages/services/service-automation/src/run-resume.ts(continuePastCancelledMapItem 的判定与拒绝)、src/builtin/map-node.ts(标记写入与 count)、src/map-cancelled-item-resume.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 src/map-cancelled-item-resume.test.ts,预期 7 个用例全绿。
  • 需要你做的:无,席位在 fable 契约复审通过后落地。

What changes

  • The resume gate reads the item's run history (run-resume.ts, continuePastCancelledMapItem). When a run's linked-run chain ends on a map: link whose item suspension row is gone, and the run is parked at the built-in map node, the gate calls getRun(itemRunId):
  • The map records the marker (builtin/map-node.ts). On re-entry with that handoff it pushes { $status: 'cancelled', $itemRunId } into the item's slot, so results[i] is still item i. count is the items that ran (isMapItemCancelledMarker filters the markers); the cancelled item adds nothing to the step totals. The marker is closed and $-keyed: a child flow's output keys are its declared variable names, which flowBoundVariableNameSchema keeps off $, so no real output can look like it.
  • Through a subflow link too. The engine-built signal is delegated down to the map one level below, the same way a caller's signal is.
  • The caller still cannot write the handoff. A caller signal that writes the engine's $ namespace gets no continuation; resumeInternal refuses it 400 INVALID_SIGNAL with the run left parked, as before.
  • Only the built-in map node. Another executor that mints a map: correlation keeps its resume unchanged (the concurrent-replica test's map_items node is one).
  • Spec and docs. MapConfigSchema.outputVariable's describe and the map node's own config-schema description say an element can be the marker; flows.mdx's batch paragraph describes the marker, count, and both 409 arms; the reference page is regenerated.
  • Changeset: @objectstack/service-automation minor, @objectstack/spec minor (Metadata-protocol: added), with Ruling-ref: 6523833308 and the ADR-0087 answer not-required (no-migration-prescription).

Tests

New src/map-cancelled-item-resume.test.ts (7 cases): the cancelled item's slot holds the marker, the map parks on item 2, and the downstream node reads [marker, {done: 'ran t2'}] with count 1; the same one level down through a subflow parent; an item row gone without a cancelled history row is refused 409 with nothing moved; an unreadable history is refused 409; a caller signal writing $mapItemCancelled is refused INVALID_SIGNAL; controls: a still-suspended item is still refused naming it, and items that complete are recorded as before with count 2.

Ablations (fix committed first; the tests import ./engine.js and ./builtin/map-node.js from src, no dist on the path)

Each via scripts/ablation-replace.mjs (anchor x1 → x0 on disk, then the run, then restore to the HEAD blob with git diff HEAD empty):

  • status check item?.status !== 'cancelled' → item == null: refuses … without a cancelled history row red (1 failed, 6 passed).
  • the caller-signal guard (applyResumeSignal(new Map(), signal, link.nodeId) line) deleted: still refuses a caller signal that writes the engine namespace red (1 failed, 6 passed).
  • count filter → state.results.length: the two marker cases red (2 failed, 5 passed).
  • the marker push deleted: the two marker cases red (2 failed, 5 passed).
  • the nodeType !== 'map' guard deleted: concurrent-replica-resume-race.test.ts THE CONDITION (correlation) red (1 failed, 11 passed). That pin is how the guard was found: without it the gate refused that test's synthetic map:item_1 correlation.

Acceptance notes

  • Measured on main f6adf48 before the fix: cancelRun(item1) answered true and getRun(item1).status read cancelled; resume(parentId) answered success/paused, $mapState became {started: 2, results: []}, and after item 2 completed the parent was completed with a downstream node reading results = [item 2's output] and count 1.
  • The marker's keys ($status, $itemRunId) are the dev's proposal under ruling A; the ruling leaves their exact spelling to the contract review.
  • The approval revise-window recall (plugin-approvals calling cancelRun) is a producer of a cancelled item in a batch sign-off; read, not measured here.

Verification

Package suites
  • pnpm --filter @objectstack/service-automation test: Test Files 231 passed (231), Tests 2911 passed (2911). typecheck: check:test-typecheck OK, 0 errors.
  • pnpm --filter @objectstack/spec test: Test Files 756 passed (756), Tests 22513 passed, 1 todo.
  • pnpm --filter @objectstack/spec check:generated: all 14 generated artifacts up to date, after gen:schema + gen:docs and a spec build.
Full dogfood (the title carries a bang)

Closure built with pnpm --filter '@objectstack/dogfood^...' build, then pnpm --filter @objectstack/dogfood test under the verify lock: VERDICT command-exit 0; Test Files 358 passed, 3 skipped (361); Tests 2883 passed, 20 skipped (2903). Run on 0dfb617; the later commit only adds the changeset's ADR-0087 line.

Derived gate families (dispatch-gates --commands, reconciled with --ran)

107 derived, 107 run with an exit code, 0 NOT MEASURED. They ran at 0dfb617. check-adr-0087-registration --base origin/main first exited 1 (no disposition line), fixed in the changeset and re-run at 84b980b with exit 0; the five changeset families were also re-run at 84b980b, all exit 0. check-platform-checklist --changed-from origin/main: OK, 17 anchors judged, 0 lost. git merge-tree against the current origin/main: clean. pnpm lint is CI's.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (describe text only)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes. (CI)
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_016WV4d1oZadHHFdEZXANxiG


Generated by Claude Code

…med run no longer names that run as its own child

resolveSuspensionChain now seeds its seen-set with the caller's run id, so an
A -> B -> A correlation cycle ends the chain at B and the map refusal names
the item run, not the run the caller is already resuming.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4d1oZadHHFdEZXANxiG
…her resume refusals

The client resume docblock, the service README's code list and the automation
checklist's trigger-status item now name the map-item refusal the resume door
answers since the parent-id gate landed.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4d1oZadHHFdEZXANxiG
…the item as cancelled on a parent-id resume

The resume gate reads the item run's history when the map is parked on an
item whose suspension row is gone. A cancelled item lets the map go on with
the item's slot holding { $status: 'cancelled', $itemRunId }, outside count;
any other history (none, unreadable, not cancelled) is refused with
RESUME_CHILD_PENDING before anything moves.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4d1oZadHHFdEZXANxiG
…marker

MapConfigSchema.outputVariable's description and the flows guide's batch
paragraph say a cancelled item keeps its slot as the closed $-keyed marker,
outside count; the generated reference page is regenerated. Changeset for the
map fix.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4d1oZadHHFdEZXANxiG
…ed-item handoff

The cancelled-item continuation and its refusal apply when the run is parked
at a `map` node; another executor that mints a `map:` correlation keeps its
resume unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4d1oZadHHFdEZXANxiG
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 30, 2026
@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/client, @objectstack/service-automation, @objectstack/spec, touching 24 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-automation/README.md), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx (via /api/v1/automation/:name/runs (route, bridged from symbol getRun — its route source's handler names it), /automation/:name/runs/:runId (route, the route ledger binds it to client method automation.getRun))
  • content/docs/api/client-sdk.mdx (via AutomationResult (symbol, a top-level interface), ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-catalog.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-handling-server.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/automation/approvals.mdx (via /api/v1/automation/:name/runs (route, bridged from symbol getRun — its route source's handler names it), /automation/:name/runs/:runId (route, the route ledger binds it to client method automation.getRun))
  • content/docs/automation/flows.mdx (via $itemRunId (symbol, a field of interface MapItemCancelledMarker), AutomationResult (symbol, a top-level interface), getRun (symbol, a field of interface RunResumeHost), RESUME_CHILD_PENDING (literal, a string literal in ERROR_CODE_LEDGER), getRun (literal, a string literal in continuePastCancelledMapItem; a string literal in getRun; a string literal in refuseGatedResume), /api/v1/automation/:name/runs (route, bridged from symbol getRun — its route source's handler names it), /automation/:name/runs/:runId (route, the route ledger binds it to client method automation.getRun))
  • content/docs/kernel/contracts/data-engine.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))

⛔ 8 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class), ERROR_CODE_LEDGER (symbol, a top-level const object), getRun (symbol, a field of interface RunResumeHost), getRun (literal, a string literal in continuePastCancelledMapItem; a string literal in getRun; a string literal in refuseGatedResume))
  • content/docs/releases/v17/17-1.mdx (via AutomationResult (symbol, a top-level interface), ERROR_CODE_LEDGER (symbol, a top-level const object), /api/v1/automation/:name/runs (route, bridged from symbol getRun — its route source's handler names it), /automation/:name/runs/:runId (route, the route ledger binds it to client method automation.getRun))
  • content/docs/releases/v17/17-3.mdx (via AutomationResult (symbol, a top-level interface))
  • content/docs/releases/v17/17-4.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/releases/v17/17-6.mdx (via AutomationEngine (symbol, a top-level class), AutomationResult (symbol, a top-level interface), /api/v1/automation/:name/runs (route, bridged from symbol getRun — its route source's handler names it), /automation/:name/runs/:runId (route, the route ledger binds it to client method automation.getRun))
  • content/docs/releases/v17/17-7.mdx (via /api/v1/automation/:name/runs (route, bridged from symbol getRun — its route source's handler names it), /automation/:name/runs/:runId (route, the route ledger binds it to client method automation.getRun))
  • content/docs/releases/v18/18-2.mdx (via /api/v1/automation/:name/runs (route, bridged from symbol getRun — its route source's handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-automation/README.md) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 173 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c48cd2c103a9dfa99313a67a3ec622291d793900 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0f967d22d50ced459f455516495a246b94697a21 — the merge of head 8ec36611b9ba9005b191cb11e3378a636a57a53e into base c48cd2c103a9dfa99313a67a3ec622291d793900, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0f967d22d50ced459f455516495a246b94697a21 && git checkout 0f967d22d50ced459f455516495a246b94697a21
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c48cd2c103a9dfa99313a67a3ec622291d793900 8ec36611b9ba9005b191cb11e3378a636a57a53e && git checkout -B drift-repro c48cd2c103a9dfa99313a67a3ec622291d793900 && git merge --no-ff 8ec36611b9ba9005b191cb11e3378a636a57a53e

node scripts/docs-audit/affected-docs.mjs --json c48cd2c103a9dfa99313a67a3ec622291d793900

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c48cd2c103a9dfa99313a67a3ec622291d793900 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4czVnUdQQCVKSW5J2uk

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 84b980b361bab0e34fccba340894617e21ae07cb
Local-runs: none
Scope: packages/spec/src/automation/builtin-node-config.zod.ts + content/docs/references/automation/builtin-node-config.mdx, packages/services/service-automation/src/builtin/map-node.ts, packages/services/service-automation/src/run-resume.ts, packages/services/service-automation/src/engine.ts, packages/services/service-automation/src/map-cancelled-item-resume.test.ts, content/docs/automation/flows.mdx, .changeset/27971-map-cancelled-item.md; read for the collision and refusal claims: packages/spec/src/automation/flow-bound-variable-name.ts, packages/spec/src/automation/flow.zod.ts, packages/spec/src/contracts/automation-service.ts, packages/spec/src/api/error-code-ledger.zod.ts, packages/services/service-automation/src/run-history.ts

Reviewed 2026-10-30T15:38Z · PR #28176 · card #27971 · ruling 6523833308 (letter A) · base origin/main a3d24c9 (merge-base 2695de7) · the whole diff read as the net diff against main at the head; nothing checked out, built or run.

Check-runs on the head at this reading — completed: Auto Label, Dogfood Regression Gate (its three matrix shards skipped), Plan Test Core shards, Lint · scoped · tooling self-tests, Lint · PM dispatch-gates, Check PR Size, Check Changeset, Check Documentation Links, Governed Surface Queue Guard, Flag docs affected by code changes, PR-body guards, filter, Spec property liveness — all success; skipped by their own predicates: Temporal Conformance, Dogfood Verify CLI, UI Published Artifacts, Schedule gate, 18.x upgrade harness, Packed-tarball smoke. Still in_progress: Test Core 1–8, Build Core, Build Docs, Build UI Deps, Live E2E UI, Type Check (workspace, source gates, consumer gates, debt ledger), Lint (ESLint, repo & tooling gates, docs & code guards, CI/release/engine gates, scoped slot lookup & corpus, scoped query-options erasure). No conclusion was failure at this reading; the seat lands only once the in-progress families converge green, as its own rule already says.

① Derived judgments

  • Accept-set of MapConfigSchema: unchanged — right. The one hunk in the hits file replaces the .describe() string on outputVariable (L1490); the key, its flowBoundVariableNameSchema('outputVariable').optional() schema and every sibling key are as on main. Nothing an author writes is accepted or refused differently.
  • Published spec surface: the JSON-Schema description of MapConfigSchema.outputVariable changes — right, and complete. git grep of the old sentence on origin/main finds exactly two carriers, the Zod source and content/docs/references/automation/builtin-node-config.mdx; the head carries the new sentence in both. No other generated artifact holds this describe, so the regeneration is whole (Spec property liveness and Check Changeset are green).
  • Runtime value of outputVariable / the map node's results widens: an element may be { $status: 'cancelled', $itemRunId } — right under ruling A. map-node.ts pushes the marker into state.results on the handoff, variables.set(outVar, state.results) binds the same array, and the downstream reader sees it at the item's index (test 1 pins results[0] is the marker and results[1] item 2's output).
  • count = items that ran — right. count is the length of state.results with every isMapItemCancelledMarker element filtered out; null results of items that ran without output still count, markers do not. The step metrics (selected / acted / failures) never see the cancelled item: creditChildRun credits a paused item only when its child bubbles, and a cancelled child never does.
  • resume() gains a continuation and a second refusal arm — right, with one text gap (④ S1). refuseGatedResume now answers AutomationResult | GatedResumeContinuation | null; resume routes a continuation into resumeInternal(runId, continueWith, false). The arm order is unchanged for every other node (authority first, the still-suspended 409 second, the new gate third and only for nodeType === 'map' with a map: correlation and no successor in the chain). An item row gone whose history is not cancelled — including getRun degrading to null — is refused 409 RESUME_CHILD_PENDING with nothing touched; that is the ruling's own "a map that cannot record it refuses" and the triage's "no silent skip either way". The arm narrows one runtime behaviour that used to succeed (the consumed-strand / completed-without-bubble case advanced silently); that silent advance is the defect the card measured, so refusing it is the accepted shape.
  • Public API of @objectstack/service-automation: unchanged — right. src/index.ts re-exports a named list from ./engine.js that does not include engineBuilt; MapItemCancelledMarker, isMapItemCancelledMarker, mapItemCancelledVariable and GatedResumeContinuation are package-internal module exports. The HTTP door cannot carry the ENGINE_BUILT_SIGNAL symbol, so no transport can mint the continuation.
  • The Studio form text (configSchema.properties.outputVariable.description in map-node.ts) says the same thing — right, so the online form and the published spec agree.
  • docs-drift-check rows: flows.mdx is edited in this PR; actions-as-tools.mdx and approvals.mdx are listed only through the getRun route anchor, and the diff adds getRun as a host field on RunResumeHost, not a change to the route or the client method — no re-verification owed.

② Semver level

  • Changeset .changeset/27971-map-cancelled-item.md: @objectstack/service-automation: minor, @objectstack/spec: minor; both declaration lines present and identical to the PR body (Clause-②: yes (widening), Metadata-protocol: added), Ruling-ref: 6523833308, one ADR-0087 marker not-required (no-migration-prescription). Check Changeset and Lint · PM dispatch-gates (which runs check-adr-0087-registration) are green on the head.
  • Clause-②: yes (widening) — right. The published spec's description widens what an outputVariable element may be; nothing is taken away from any consumer and no author-writable key is removed or refused, so (narrowing) does not apply.
  • Metadata-protocol: added — accepted on the ruling's stated reading. The diff adds no author-writable key or accepted value (MapConfigSchema keeps every key; ① above); what it adds is a readable output shape an author's CEL may now branch on (results[i]['$status']). The ruling 6523833308 and the director's brief 6523502465 both fixed the arm as added with "a readable output shape, no new authored key — the seat confirms the arm from the diff", and the diff confirms exactly that premise: one shape added, none removed, narrowed or re-meant. Under AGENTS.md's wording alone ("what a metadata author can write") a strict reader could call it none/patch; the ruling settled the arm, and added takes at least minor on both packages the diff moves — so minor / minor matches what the diff publishes.
  • The title's ! banner is consistent with the runtime change (an element of results may now be a marker, and a formerly-succeeding resume may now answer 409); the changeset body states the FROM → TO in prose and the ADR-0087 disposition is the one the gate accepted. Nothing an author writes is renamed, so no tombstone is owed.

③ Boundary flags

  • Dev flag — "the marker's keys are the dev's proposal; the ruling leaves their exact spelling to the contract review." Fixed here: the marker is { $status: 'cancelled', $itemRunId } exactly as the head spells it — $status a closed string literal 'cancelled', $itemRunId the cancelled item's run id as getRun records it; two keys, $-led, no third key (isMapItemCancelledMarker requires exactly two). The collision claim holds in the cited code: a child flow's output is built only from its isOutput variables keyed by v.name (engine.ts L5999–6005), and FlowSchema.variables[].name is flowBoundVariableNameSchema('variableName'), a regex that refuses a $-led name and is emitted as pattern in the published JSON Schema. No real item output can carry a $status key.
  • Dev out-of-scope finding (carrier: this review) — the plugin-approvals revise-window recall calls cancelRun on the run parked at the approval; on a batch sign-off that run is a map item. Answered: under A the recall now leaves the batch parked on a slot that a later parent-id resume records as cancelled and continues past — no longer the skip the card measured, and no longer the stranding C would have caused. What remains is that nothing wakes the parent after the recall: the recall caller never reads cancelRun's boolean and never resumes the parent, so the batch waits for an operator's parent-id resume. That is a producer-side gap, read not measured, outside this PR's surface. Escalated to the seat to file as its own card (plugin-approvals: after a revise-window recall of a map item, who resumes the batch, and should the recall say so?). Not a finding against this head.
  • open_questions: [] in the final os-dev-report 6526540343 — none to answer.
  • PR body Skills-docs: none — confirmed: skills/objectstack-automation/SKILL.md's map row (L67) describes neither results, count nor the 409, so no skill text describes the shape that changes.
  • Docs half and rider: landed on main through PR fix(service-automation): a corrupt correlation cycle back to the resumed run no longer names that run as its own child #28101 (client docblock L5948, README L310, checklist row L1731, resolveSuspensionChain seeded with the root id — all present on origin/main); this PR is the main fix only. No single-writer breach: the PR-body guards are green.

④ Design judgment

packages/spec/src/automation/builtin-node-config.zod.ts (the one hits file) against ruling A and its first consumer — holds. The describe sentence makes four claims; each is read against the runtime on the same head:

  1. "An item whose run was cancelled keeps its slot as the closed marker { $status: 'cancelled', $itemRunId }." — continuePastCancelledMapItem reads getRun(itemRunId), continues only on status === 'cancelled', and the map pushes the marker on re-entry. The slot is right: at the suspend state.started = idx + 1 with results.length === idx; the marker is pushed before the loop, so results.length === started and the loop resumes at item idx + 1. The $mapItemDone branch cannot fire on the same entry (a cancelled child never bubbles), so no double push. Through a subflow link the same signal object is delegated down (resumeInternal(childRunId, signal, true)), so the symbol tag survives and the handoff lands in the inner run's scope; the outer leg then rebuilds its own signal (buildSubflowResumeSignal), so nothing leaks upward. Test 2 pins it.
  2. "closed" — isMapItemCancelledMarker is exact-two-keys; the Studio description says the same shape. A caller cannot forge it: applyResumeSignal rejects any $-led or .$ name unless the signal is engine-built, the gate declines to build a continuation when the caller's own bag already fails that check (a dry applyResumeSignal over a fresh map at link.nodeId returns a non-empty rejected list), and resumeInternal then answers 400 INVALID_SIGNAL with the run still parked (test 5). Folding a clean caller bag into the engine-built signal is safe because it already passed the $ check.
  3. "never an output" — a child output is { [v.name]: … } over isOutput variables whose names cannot start with $ (③ above). Holds.
  4. "not counted as a run" — count filters markers; no step metric credits a cancelled item. Holds.

Break attempts that did not break it: a null output from an item that ran is not a marker (counted, as it should be); a run whose correlation is map: but whose node type is not the built-in map (map_items in the concurrent-replica test) is left alone by the nodeType !== 'map' guard; a ring-buffer eviction of the cancelled history with no durable store turns the case into the no-record 409 — the ruling's own refusal arm, loud and nothing moved; a corrupt chain cannot reach the gate with a successor because the still-suspended 409 runs first.

Must-fix: none.

Should-fix S1 — the second arm of RESUME_CHILD_PENDING is not in the code's own contract text, and its message names no remedy. The code's definition in packages/spec/src/contracts/automation-service.ts (L525–535), the ledger comment in packages/spec/src/api/error-code-ledger.zod.ts (L1326), the client docblock packages/client/src/index.ts (L5948), the service README (L310) and the checklist row docs/qa/platform-checklist/areas/automation.json (L1731) all say the item "is still suspended; resume the child run the message names". The new arm (row gone, history not cancelled) names a run that cannot be resumed, and its error text stops at "Nothing was changed" — the operator's only remedy (cancel the parent run) is unstated. The card body itself foresaw this widening ("the code's spec docblock says the item 'is still suspended'"). Only flows.mdx describes both arms. One sentence in each of the five places, plus the remedy in the refusal message; the contract docblock in packages/spec is governed, so it rides with the seat's landing or a docs-only follow-up under this card. Not a FAIL: the code is an unchanged member of the closed union with its 409 mapping intact, the ruling itself chose this code for the refusal, and no consumer branches on the sentence.

Should-fix S2 — escalation, not a change to this head: the plugin-approvals recall gap in ③, filed as its own card by the seat.

Implemented-by: claude/project-thread-2p9rtc
Reviewed-by: session_016WV4czVnUdQQCVKSW5J2uk

VERDICT: PASS

…ecord arm and its remedy

The code's docblock, its ledger row, the client resume docblock, the service
README and the automation checklist now say the same 409 answers a map item
whose suspension is gone without a cancelled history entry, and that a
cancelled item is recorded instead. The refusal message names the remedy:
cancel the map's run (and the run that waits on it) to end the batch.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4d1oZadHHFdEZXANxiG
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4czVnUdQQCVKSW5J2uk

Contract review (delta) · PR #28176 · head 8ec3661 · 2026-10-30T16:35Z

Delta: one commit 8ec3661 on top of the PASSED head 84b980b (7 files, +28/-6), read with git show/git diff; nothing checked out, built or run. Check-runs on 8ec3661 via REST at this reading: 41 success, 9 skipped, 0 failed, 0 in progress.

S1 disposition

S1 (second RESUME_CHILD_PENDING arm absent from the five contract texts; refusal message named no remedy) — addressed. All five carriers now state both arms, and the message in continuePastCancelledMapItem (run-resume.ts L577–581) ends with the remedy. Still-suspended arm → "resume the named item run"; row-gone-without-cancelled arm → "cancel the parent run"; cancelled item → recorded in its slot, map continues. Each matches the runtime: the gate returns the refusal before any state is touched (the continuation is built only on status === 'cancelled'), and refuseMapItemSkip keeps the first arm unchanged.

Checks

  1. Five places, both arms. packages/spec/src/contracts/automation-service.ts L534–540 (names the three sub-cases "no entry, an unreadable store, or another status" = the code's item?.status !== 'cancelled'; "Nothing moves"; remedy = cancel parent or retry once readable; cancelled item recorded). error-code-ledger.zod.ts L1326 row comment (both arms, both remedies; still one 'RESUME_CHILD_PENDING' literal, position in the owner array unchanged). packages/client/src/index.ts L5948–5951 (both arms under the "refusals that leave the suspension intact" lead; does not restate the cancelled-item record — acceptable, it is a refusal list). README L310–313 and checklist variant L1731 (both arms, "nothing moves", cancelled item recorded). flows.mdx unchanged and already consistent. Terminology "parent run" = the map's own run throughout, the same run the message names.
  2. Remedy is true. cancelRun(link.runId) (run-resume.ts L823–) does loadSuspendedRunStrict → forgetSuspendedRun(run, 'cancelled') → recordLog(status: 'cancelled'); the map's run is the suspension parked on map:<item>, so cancelling it is exactly what ends the batch. Cancellation never cascades (engine docblock: "never touches the parent"), so in the nested case the caller's run runId stays parked on its subflow: link and must be cancelled too — "and run 'OUTER', which waits on it" is right, and runId = chain[0] is the caller's run. "Retry once readable": getRun (run-history.ts L317–) degrades a thrown store.loadTerminal to null under a warn, and the in-memory journal is consulted first, so a later read of the same id after store recovery answers cancelled and the resume then mints the continuation; conditional wording ("if the store was unreachable") keeps it true for the no-row case, which is not transient. Gap: for a chain deeper than two (OUTER → MID → MAP, MAX_SUSPENSION_CHAIN_DEPTH 32 allows it) the message names only link.runId and runId; intermediate runs stay parked unnamed — see Should-fix.
  3. No contract change beyond text. Code literal, owner array, ErrorCode union and the 409 mapping untouched (delta touches only the row's // comment). No generator consumes that comment: check-generated.ts's only ledger entry is check:error-code-provenance ("no artifact"), export-origins/api.json maps symbols not comments, and git grep of the old sentence on the head finds only the three edited source carriers (plus an unrelated line in scripts/pm/os-verify-lock.sh). Spec CI families (Spec property liveness, Check Changeset, Lint) green. Changeset: the existing .changeset/27971-map-cancelled-item.md already states the second arm ("Nothing to record is refused … 409 RESUME_CHILD_PENDING before anything moves") and bumps @objectstack/spec minor; a message-wording change on an existing refusal needs no further line.
  4. Pin bites. map-cancelled-item-resume.test.ts L183 and L200 assert toContain("To end the batch, cancel run '${paused.runId}'") where paused.runId is the batch_flow (map) run, distinct from item1.runId; removing or re-targeting the remedy fails both. The nested wording ("and run '…', which waits on it") has no pin.
  5. Checklist JSON parses; item revision 9 → 10 and the new history row { revision: 10, date, change, ref: "#27971" } matches its neighbours (rows 7–9) and check-platform-checklist.mjs L3395's rule that revision equals the last history entry's revision. variants line edited in place; no acceptance clause changed.

Must-fix

None.

Should-fix

  • S3 — chains deeper than two. continuePastCancelledMapItem names link.runId and runId only; with an intermediate subflow: run between them it stays parked after both are cancelled. Name every run in chain (or say "and the runs waiting on it"). Message text only; can ride a follow-up under this card.
  • S4 — pin the nested remedy wording (test 2's outer_flow case asserts success only; a refusal through the subflow is unpinned for the "and run … which waits on it" suffix).

Reviewed-by: session_016WV4czVnUdQQCVKSW5J2uk
Analysis-tier: fable

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4czVnUdQQCVKSW5J2uk

Seat ACCEPT · PR #28176 · head 8ec36611b9ba9005b191cb11e3378a636a57a53e · 2026-10-30T16:35Z

维护者速读

  • 改了什么:批量(map)节点里某个子项的运行被取消后,按父运行恢复时,该子项在 results 里保留位置,记为 { $status: 'cancelled', $itemRunId },不计入 count,批量继续往下跑;子项既不在挂起表里、历史里也不是 cancelled 时,仍回 409 RESUME_CHILD_PENDING,什么都不动,提示句写明处理办法(取消批量所在的运行)。
  • 为什么改:service-automation: a parent-id resume of a map whose parked item run was cancelled still advances past that item #27971,按裁决 A(Ruling 6523833308)。之前恢复会悄悄跳过被取消的子项,结果数组错位。
  • 风险与回滚:results 里可能出现这个标记元素,读结果的 CEL 需要能识别;以前能成功的某种恢复现在会回 409。spec 和 service-automation 各升 minor,changeset 已写明。回滚即 revert。
  • 先看:run-resume.ts 的 continuePastCancelledMapItem。
  • 两分钟验证:批量审批中取消其中一项的运行,再按父运行恢复:该项位置是 cancelled 标记,其余项照常完成。
  • 需要你做的:无(按本席落地授权落地)。

席位核对

  • Shape: draft → main, Fixes #27971. 13 files, +449/−19. check-governed-merges --pr 28176 on the final file list: NOT governed, 468 changed lines.
  • Declarations: Clause-②: yes (widening), Metadata-protocol: added (the ruling's reading: a readable output shape, no new authored key), Skills-docs: none. Changeset: @objectstack/service-automation minor, @objectstack/spec minor, Ruling-ref: 6523833308, ADR-0087 not-required (no-migration-prescription).
  • Contract review: full review PASS on 84b980b3, and delta review PASS on this head (both posted above). Should-fix S1 is carried on this head: RESUME_CHILD_PENDING's contract text (spec docblock + ledger row, client, README, checklist) now names the no-record arm, and its message names the remedy: cancel the map's run. The PR body was written before S1, and this line records the change.
  • Local (card thread, on this head): service-automation 2911/2911, spec 22513/22513, full dogfood 2883 passed / 20 skipped (lock VERDICT 0), 110/110 derived families exit 0, spec check:generated 14/14 up to date.
  • Gate check-runs on this head: 50, 41 success, 9 skipped, 0 failed. mergeable_state: clean.
  • Carried forward (non-blocking): delta S3 (the refusal message should name every run in a chain deeper than two) and S4 (pin the nested-remedy suffix) go on a follow-up card. Producer-side recall gap: plugin-approvals: a revise-window recall on a batch sign-off cancels the map item's run and never resumes the parent, so the batch stays parked #28183.
  • Landing: non-governed, under the maintainer's landing grant for this seat term (2026-10-30 10:20Z, seat-1 thread). Ready + auto-merge through the relay.

ACCEPT

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

service-automation: a parent-id resume of a map whose parked item run was cancelled still advances past that item

1 participant