Skip to content

fix(service-automation)!: the subflow guards see a subflow or map call inside a region body - #28959

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-9kerqb
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-9kerqb

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

domain:server#3 · os-project-manager · session_01FEkQ5omF1nCbQTGgW1nbRA

Fixes #28846

Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — skills/** does not teach the ADR-0126 §7.3 subflow guards, and this changes no authorable shape

维护者速读

  • BREAKING(收窄):子流程守卫(ADR-0126 §7.3)以前只看流程顶层的 subflow / map 节点,放在 loop、parallel 分支或 try_catch 里的调用看不到。现在所有区域层级都会读取,读法复用 spec 已有的 collectFlowGraphs,没有新写遍历器。
  • 实测(改动前):6 种区域位置(loop、parallel、try_catch 各配 subflow 和 map),指向一个已停用的打包子流程时,调用方都照常启用,而顶层的同样写法会被拒绝启用。修复后行为和顶层一致:调用方不启用,/_status 显示 disabled_subflow;有已启用的调用方时,停用或删除子流程会被拒绝,返回 DELETE_RESTRICTED 409。
  • 按席位裁定(6568462471),automation: a save_notice after a subflow or map whose child suspends passes publish and strands every decision of the parent run #28840 的 save_notice 判定仍然只看顶层:区域里的调用即使子流程会挂起,运行也会在区域边界直接失败,根本到不了 save_notice。这个失败另立了 automation: a subflow or map in a region body whose child suspends arms, fails every run at the region boundary, and leaves the child run suspended #28897。
  • 分诊建议项 1 已处理:save-notice-placement.ts 文件头和 flow.zod.ts 的注释改为说明,子流程这种情况由启用闸门(notice_after_suspending_subflow)处理,运行时按名字拒绝只是剩余情况。
  • 已发布的流程里,唯一的区域内调用是 HotCRM 的 campaign_enrollment(loop 里调用两个已启用的子流程),仍然启用。以后它启用期间,停用或删除这两个子流程会被拒绝,席位已认可。
  • 没有需要你拍板的事项。合并前由席位做契约复核。

What changes

subflowTargets reads every region depth (packages/services/service-automation/src/subflow-guards.ts). It used to read flow.nodes only. It now reads every graph collectFlowGraphs (@objectstack/spec/automation) returns: the top level and each loop body, parallel branch and try_catch slot, at every depth the spec walks. That is the spec's one region walk, so no new walker exists. Every guard that reads subflowTargets now sees the same calls:

  • The arming decline (declineOntoDisabledSubflows, via disabledPackagedSubflows). A packaged caller whose region body calls a disabled packaged child registers but is not armed. It warns once, and /_status reports unboundCause: 'disabled_subflow' naming the child.
  • The re-judge (rejudgeSubflowCallers, via packagedSubflowCallers). A child republished obsolete under an armed caller disarms it, and republished active arms it again.
  • The switch-off and removal refusals (refuseUnderReachingCallers). Switching off or removing a packaged child that an enabled packaged caller calls from a region body is refused with DELETE_RESTRICTED / 409, with subflowCallers naming the caller.
  • The enable refusal and the ledger-cycle exemption (refuseEnableOntoDisabledSubflow, ledgerDisabledChainReaches) read the same set.
  • The engine's subflowTargets docblock says so.

The save-notice half of the arming gate stays top-level (subflow-notice-guard.ts; only its header prose changes). A region body cannot carry a durable pause. Measured before this change: a region-nested subflow / map whose child suspends fails the run at the region boundary ("durable pause inside a structured region"), before anything after the container runs. So the call cannot strand a save_notice after it, and a child whose only suspending call sits in a region cannot suspend its caller. The seat ruled this in 6568462471, withdrew the order's container rule, and filed the region-boundary failure (with the child run it leaves suspended) as #28897. Two pins hold the gate there.

Spec prose, triage should-fix 1 (6568019187). The save-notice-placement.ts header and the comment at its flow.zod.ts wiring now say where a save_notice after a suspending subflow / map is met. It is met by the engine's arming gate, which declines the flow with notice_after_suspending_subflow, before any run. A region-nested call fails at the region first. The run-time refusal by name is the residue, for a plugin-registered pausing type. Comments only: nothing in packages/spec/dist changes, so the spec package carries no changeset.

Why

#28846, found on #28840: every §7.3 guard read only the top-level nodes. So a packaged caller with its call in a loop body armed onto a disabled child and failed mid-run at that node, the late, inexplicable failure §7.3 exists to prevent. The child could also be switched off or removed under an enabled caller.

Verification

  • Measured first (a scratch test, not committed), each case against its top-level twin:
    • Disabled packaged child, with subflow and with map, inside loop, parallel and try_catch: all 6 cases armed with no unboundCause, while both top-level twins declined with disabled_subflow.
    • A suspending child plus a save_notice after the container: all 6 armed, but execute() answered failed with the region-boundary refusal. No notice was set, and the child run was left suspended. The top-level twins declined with notice_after_suspending_subflow.
  • Red first. service-automation/src/subflow-guard-region-calls.test.ts has 14 cases. On base, 10 failed and 4 passed (2 controls and the 2 notice-gate pins). On head, 14 of 14 pass.
    • The refusal cases assert code: 'DELETE_RESTRICTED', status: 409, subflowCallers and the sentence.
  • Ablations, through scripts/ablation-replace.mjs, each red and then restored (blob == HEAD):
    • Top-level only (collectFlowGraphs(flow).slice(0, 1)): all 10 region pins went red. The controls and the subflow-guard-every-door / subflow-notice-arming suites stayed green (34 passed).
    • The notice gate follows a region call in the caller: the first notice pin went red.
    • A child counts a region call as suspending: the second notice pin went red.
  • Shipped flows.
    • examples/** and packages/qa/**: every subflow / map node is top-level.
    • HotCRM origin/main (d4ffe99): campaign_enrollment calls campaign_lead_member_enroll and campaign_contact_member_enroll from two loop bodies. Both children are status: 'active' and hold no suspending node, so it still arms. No other region-nested call.
  • Gates. dispatch-gates.mjs --commands derived 83. --ran reconciles 83 run, 0 NOT-MEASURED and 0 UNRUN, all exit 0.
  • Suites. Full vitest run exit 0:
    • @objectstack/service-automation: 256 files, 3295 tests.
    • @objectstack/plugin-approvals: 107 files.
    • @objectstack/trigger-record-change: 12 files.
    • @objectstack/example-showcase: 42 files.
    • @objectstack/spec src/automation: 45 files, 1745 tests.
  • Typecheck. The workspace pnpm typecheck (6 GB heap) passed every task except @objectstack/spec, whose test-layer tsc was killed without diagnostics (exit null) while the run shared the box. Run alone with an 8 GB heap, @objectstack/spec pnpm typecheck exits 0 and holds its pinned debt.
  • eslint --no-inline-config over packages/services/service-automation and packages/spec/src/automation exits 0.
  • Dogfood battery (owed by the (narrowing) arm), on head 87411da8 under os-verify-lock.sh:
    • Closure build: VERDICT command-exit 0.
    • pnpm --filter @objectstack/dogfood test: VERDICT command-exit 0. 382 files passed and 3 skipped (385); 3156 tests passed and 28 skipped (3184).

Acceptance notes

  • Metadata-protocol: none. Registration, the save door and objectstack validate accept the same flows. The runtime guards now read calls they should already have read. Only @objectstack/service-automation ships a change (patch, with the BREAKING banner and the ADR-0087 no-migration-prescription marker).
  • Clause-②: no (narrowing). Nothing public widens: no export, type or served value is added.
  • Unchanged: FLOW_PAUSE_CAPABLE_NODE_TYPES (ruling D), the save-notice gate's walk, the region-boundary refusal and ui/**. No new gate. No hotcrm change.
  • Only a subflow / map node that collectFlowGraphs reaches is read. That covers regions up to MAX_REGION_DEPTH, where the spec's own walk stops.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 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 (comments only): no generated artifact moves (check-generated and the spec artifact gates pass on this head).
  • 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 by Claude Code

…l inside a region body

subflowTargets now reads every region depth through collectFlowGraphs, so
the ADR-0126 7.3 arming decline and the switch-off and removal refusals see a
call inside a loop body, a parallel branch or a try_catch slot. The
save-notice half of the arming gate keeps reading top-level calls only: a
region cannot carry a durable pause. The save_notice placement header and
its flow.zod.ts wiring comment now name the arming gate as where a notice
after a suspending subflow is met.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/automation/flows.mdx (via FlowSchema (symbol, a top-level const))

⛔ 6 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), FlowSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-4.mdx (via FlowSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-6.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-7.mdx (via FlowSchema (symbol, a top-level const))
  • content/docs/releases/v18/18-1.mdx (via FlowSchema (symbol, a top-level const))

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/src/subflow-notice-guard.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 — 168 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 ff88105061012c8b14f7ec937d7b3113e404e922 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1c0ec675ac1c5971c582625cbec010e03bfb32ca — the merge of head 87411da83042879ee3c0d8a72a22a73ac3f99fe0 into base ff88105061012c8b14f7ec937d7b3113e404e922, 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 1c0ec675ac1c5971c582625cbec010e03bfb32ca && git checkout 1c0ec675ac1c5971c582625cbec010e03bfb32ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ff88105061012c8b14f7ec937d7b3113e404e922 87411da83042879ee3c0d8a72a22a73ac3f99fe0 && git checkout -B drift-repro ff88105061012c8b14f7ec937d7b3113e404e922 && git merge --no-ff 87411da83042879ee3c0d8a72a22a73ac3f99fe0

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

⚠️ 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 ff88105061012c8b14f7ec937d7b3113e404e922 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet objectstack-fleet Bot added the needs:contract-review Marker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides label Nov 2, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 87411da83042879ee3c0d8a72a22a73ac3f99fe0
Local-runs: none
Scope: packages/spec/src/automation/flow.zod.ts, packages/spec/src/automation/save-notice-placement.ts, packages/services/service-automation/src/subflow-guards.ts, packages/services/service-automation/src/subflow-notice-guard.ts, packages/services/service-automation/src/engine.ts, packages/services/service-automation/src/subflow-guard-region-calls.test.ts, .changeset/28846-region-nested-subflow-calls.md + packages/spec/src/automation/control-flow.zod.ts (collectFlowGraphs, MAX_REGION_DEPTH), packages/spec/src/automation/region-slots.ts (FLOW_REGION_SLOTS), packages/services/service-automation/src/region-suspension-refusal.ts, packages/services/service-automation/src/builtin/save-notice-node.ts, packages/services/service-automation/src/subflow-guard-every-door.test.ts, packages/services/service-automation/src/subflow-notice-arming.test.ts, packages/spec/src/conversions/registry.ts (flow-node-*-flow-alias), packages/lint/src/flow-cel-root-scope.ts, docs/adr/0126-packaged-metadata-customization-model.md §7.3, hotcrm src/marketing/flows/campaign-enrollment.flow.ts and campaign-member-enroll.flow.ts (hotcrm checkout a5982564)

Review face: the brief asks the narrowing face; record-recognisers --template --paths over the PR's file list reads the schema face off the two packages/spec/src/automation/* paths (its hits). Both map to CONTRACT_REVIEW_TIER, and both hits are inside Scope: above. The three prose paths (flow.zod.ts, save-notice-placement.ts, subflow-notice-guard.ts) are confirmed comment-only in the net diff against main (merge-base b15b634c, origin/main ff88105061; main has not moved on any touched path since the merge-base): every hunk sits inside a /** … */ header or a // line comment, no token outside a comment changes, and none is a .describe() or the TSDoc of an export.

Inputs read: card #28846 body and all four comments (triage 6566019553, claim 6568384018, seat ruling 6568462471, os-dev-report 6569441106); PR #28959 body, file list (7 files, +296/−15) and the net diff origin/main...87411da8; the check-runs on 87411da8. The cited code was read in the tree at 87411da8; nothing was built, run or re-run.

Check-runs on the head, read 2026-11-02T05:05Z: 20 success, 11 skipped, 30 in progress. Success: Governed Surface Queue Guard, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, Lint · CI, release & engine gates, Lint · docs & code guards, Lint · ESLint, Lint · repo & tooling gates, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus, Check Changeset, Type Check · source gates, Type Check · debt ledger, Spec property liveness, Dogfood Regression Gate, PR-body guards, Plan Test Core shards, Flag docs affected by code changes, filter, Auto Label-adjacent jobs. Still in progress at that reading: Build Core, Test Core (1–8/8), Type Check · workspace, Type Check · consumer gates, and the ui/ legs (Type Check UI, Test UI (1–16/16), Build & E2E UI, Live E2E UI). None failed. Re-read 2026-11-02T05:21Z after every run completed: 53 success, 11 skipped, 0 failure, 0 in progress — Build Core, Test Core (1–8/8), Type Check · workspace, Type Check · consumer gates, Type Check UI, Test UI (1–16/16), Build & E2E UI and Live E2E UI all success. Those conclusions are the gate verdicts for every derived gate family; nothing was re-run here.

① Derived judgments

Accept-set and surface changes the diff implies, each judged against the code as it stands on the head:

  1. subflowTargets reads every region depth (subflow-guards.ts:62-72): the top-level graph plus every loop.body, every parallel.branches[i] and both try_catch.try / try_catch.catch slots, at every depth up to MAX_REGION_DEPTH (32), through collectFlowGraphs. Those four slots are exactly FLOW_REGION_SLOTS (region-slots.ts), the one declaration the parse, the lint walk and the conversion walk all read — so the guards cannot drift from what the spec calls a region. Right. The engine's subflowTargets docblock already named it "the ONE reading of 'flow A calls flow B' that both directions of the subflow guard share"; a region-nested subflow / map reaches its child through the same engine.execute on every pass, so the top-level-only reading was an implementation gap under that contract, not a reading the contract declared.
  2. Arming decline (declineOntoDisabledSubflows → disabledPackagedSubflows → subflowTargets): a packaged caller whose region body calls a ledger- or status-disabled packaged child registers, is not armed, warns once, and /_status reports bound: false, unboundCause: 'disabled_subflow' naming the child. Packaged callers only, as before (engine.ts:5493-5497). Right — pinned by 6 it.each cases plus the two-deep loop { parallel { subflow } } case.
  3. Re-judge (rejudgeSubflowCallers → packagedSubflowCallers and transitiveSubflowCallers, both over subflowTargets): a child republished obsolete disarms a region caller; republished active re-arms it. The second loop now re-asks a superset of callers; its settled test (subflowNoticeTrap(...) === undefined for a bound caller) keeps a caller the notice reading never moved from being flipped. Right — pinned (arming: a child republished obsolete under an armed caller that calls it from a loop body…).
  4. Switch-off and removal refusal (refuseUnderReachingCallers, via packagedSubflowCallers): toggleFlow(child, false) and unregisterFlow(child) under an enabled packaged caller with a region-nested call are refused with code: 'DELETE_RESTRICTED', status: 409, subflowCallers: [caller], the §7.3 sentence; nothing moves. Right — pinned (parallel/map switch-off, try_catch/subflow removal), both halves of the pin present (envelope + getFlow still defined).
  5. Enable refusal and ledger-cycle exemption (refuseEnableOntoDisabledSubflow → disabledPackagedSubflows; ledgerDisabledChainReaches → subflowTargets): read the same set by construction (one function). Right mechanically; not pinned with a region-nested call in this PR (should-fix 1 below).
  6. The save-notice half of the arming gate stays top-level (subflow-notice-guard.ts: topLevel() and suspendingNode() unchanged; only the header prose moves). Right. region-suspension-refusal.ts (service-automation: a durable suspension inside a structured region (loop / parallel branch / try_catch) must fail the run with a named error, not leave progress state and report success — runtime half of #15646 ruling D #18881, ruling D on service-automation: a PAUSING map inside a contained region leaves its progress state behind — later loop iterations skip items and the exhausted map returns success having run nothing #15646) is the standing decision that a region body "runs SYNCHRONOUSLY inside the enclosing run. It cannot park that run on a durable pause", and the refusal is branded un-routable so no container can swallow it. So a region-nested call whose child suspends fails the caller's run at the region boundary; it never suspends the caller and never reaches a save_notice after the container. Counting it would decline with notice_after_suspending_subflow, a cause that is false for this shape. Pinned by two tests (a notice after a region call; a child whose only suspending call sits in a region). The seat's ruling 6568462471 and the dev's measurement (6/6 cases failed at the region boundary, child run left suspended) agree with the code; the stranded child run is automation: a subflow or map in a region body whose child suspends arms, fails every run at the region boundary, and leaves the child run suspended #28897, not this PR.
  7. Published surface: no export added, removed or renamed; subflow-guards.ts and subflow-notice-guard.ts are not re-exported from the package index; AutomationEngine.subflowTargets is private. The only consumer-visible change is behavioural (items 2–5). Right — Clause-②: no (narrowing) is the correct arm.
  8. packages/spec: no schema, no authorable key, no .describe(), no runtime logic (Prime Directive ✨ Set up Copilot instructions #2 holds); the parse, the save door and objectstack validate accept exactly the flows they accepted before. Right — Metadata-protocol: none is correct, and no spec generated artifact can move (the Type Check job's check:docs / check:api-surface lanes would red it if one did; Type Check · source gates is green on the head).
  9. Robustness of the rewrite: the old body returned [] on a non-array nodes; the new body hands the flow to collectFlowGraphs, which takes flow.nodes ?? [] and skips non-record members, but would throw on a non-array nodes. Every caller reads the flow from this.flows, which "only ever holds FlowSchema.parse output" (engine.ts:9589, set at :4526), so nodes is always an array there. Right; no tolerant fallback was added at the consumer (Prime Directive Add comprehensive test suite for Zod schema validation #12).
  10. Depth ceiling: a call nested deeper than MAX_REGION_DEPTH is unread — the same boundary every spec walk and the parse itself stop at, acknowledged in the PR's acceptance notes. Right; not a hole this PR could close on its own.

② Semver level

  • Changeset .changeset/28846-region-nested-subflow-calls.md: '@objectstack/service-automation': patch, Clause-②: no (narrowing), Metadata-protocol: none, a **BREAKING** banner with Before / Now / Not changed / The fix / Who moves, and exactly one adr-0087: marker: not-required (no-migration-prescription) …. The PR body carries the same two declaration lines, line-initial, and the title carries !.
  • Level: the kind is none, so the level is patch per the Post-Task Checklist ("a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition"). Matches what the diff publishes: only @objectstack/service-automation ships a behavioural change.
  • Disposition: no metadata key is added, removed, renamed or re-shaped; the "fix" in the body is an administrator's enable / republish, not a consumer code rewrite, so no-migration-prescription is the honest category and nothing to register in the ADR-0087 registries. Check Changeset is success on the head.
  • No @objectstack/spec changeset: the spec diff is comment-only and publishes nothing (no dist byte can move from a comment inside a function body or a file-level header that is not an export's TSDoc). This is not a skip-changeset label on a publishing diff; it is a non-publishing part of a mixed diff whose publishing part carries its changeset. Accepted.
  • Clause-②: line: no (narrowing) — the correct arm; a flow that arms today may decline, and a child switch-off / removal that is accepted today may be refused. Nothing widens.

③ Boundary flags

  • Dev deviation 1 — half (b) of the order (the container rule for the automation: a save_notice after a subflow or map whose child suspends passes publish and strands every decision of the parent run #28840 gate) not implemented: the measured premise was false and the seat withdrew it in ruling 6568462471, filing the region-boundary failure as automation: a subflow or map in a region body whose child suspends arms, fails every run at the region boundary, and leaves the child run suspended #28897. Read against region-suspension-refusal.ts, the withdrawal is right (① item 6). Answered.
  • Dev deviation 2 — subflow-notice-guard.ts header prose edited: read; the new sentences ("a call inside a region body cannot suspend the run … the run fails at its boundary instead"; "the re-judge … re-asks a superset") are true of the code on the head. Answered.
  • Dev deviation 3 — no @objectstack/spec changeset: answered in ②.
  • Triage should-fix 1 (spec prose): save-notice-placement.ts header and the flow.zod.ts wiring comment now say the subflow/map case is met by the arming gate (notice_after_suspending_subflow) and that the run-time refusal by name is the residue for a plugin-registered pausing type. Checked against the code: the gate is engine.ts activateFlowTrigger → subflowNoticeTrap; the residue is builtin/save-notice-node.ts:66-73 (this run holds no record write to answer …). Accurate.
  • open_questions: [] — none to answer.
  • Out-of-scope finding automation: a subflow or map in a region body whose child suspends arms, fails every run at the region boundary, and leaves the child run suspended #28897 (seat-filed): a region-nested call whose child suspends fails every run and strands the child run. Not this PR's; filed. Escalated already.
  • Dev typecheck note: the workspace pnpm typecheck killed the @objectstack/spec test-layer tsc on the shared box (exit null); run alone it exits 0. The head's Type Check · workspace check-run is the verdict on that, not the dev's local run: success at 2026-11-02T05:21Z. Answered.
  • Shipped flows: examples/** and packages/qa/** have no region-nested subflow / map on the head (the dev's reading; consistent with the grep of every type === 'subflow' / 'map' reader in the tree, which found no shipped-flow walker to contradict it). HotCRM campaign_enrollment (read at hotcrm a5982564): a screen flow whose two loop bodies call campaign_lead_member_enroll and campaign_contact_member_enroll, both status: 'active' with no pausing node — it still arms; switching those children off or removing them while it is enabled is now refused. That is §7.3 working, as the seat accepted.
  • Check-runs on the head: every derived gate family answered by its check-run — 53 success, 11 skipped, none failed or in progress at 2026-11-02T05:21Z.

④ Design judgment

The narrowing, read against the published spec on main. ADR-0126 §7.3: "Disabling flow B is refused while any packaged flow references B as a subflow, and the refusal names the callers — otherwise a vendor flow breaks mid-run at its subflow node with an inexplicable failure." The ADR speaks of a reference, not of a top-level node; the engine's own docblock for subflowTargets calls it the single reading of "A calls B" that both directions share. A subflow / map inside a loop body, a parallel branch or a try_catch slot is a reference the run follows on every pass. So on main the spec declared a refusal the runtime delivered only for one node position: that is Prime Directive #10's "declared ≠ enforced", and the right repair is the one taken — implement the declared refusal at every call site, bulk paths included — not to narrow the claim. The diff takes no declared capability away at the consumer: registration, the save door and objectstack validate accept the same flows; what moves is arming and the child's switch-off / removal, which §7.3 already reserved. The narrowing makes the spec's own refusal real.

The card's three wants, in the tree: (1) measured first — the dev's 6/6 region cases armed and the top-level twins declined, consistent with the old body's for (const node of nodes); (2) subflowTargets reuses collectFlowGraphs — the import is from @objectstack/spec/automation and the body is a two-level loop over the graphs it returns, no second walker; (3) no new gate — the diff adds no guard, no FLOW_PAUSE_CAPABLE_NODE_TYPES change, no ui/**, no hotcrm change.

Attempts to break it.

  • A malformed region. collectFlowGraphs drops non-record members and skips a slot whose nodes is not an array; a region the schema refused never reaches this.flows. Cannot throw through the guards.
  • A region-nested call to a suspending child. Would the asymmetry (subflowTargets at every depth, the notice gate top-level) let a trap through? No: the only way a region-nested call could strand a notice is by suspending the caller, and region-suspension-refusal.ts fails the run at the boundary instead, un-routably. The two new pins hold exactly this. The asymmetry is correct, not a gap, and the header prose on both sides says why.
  • Re-judge churn. transitiveSubflowCallers now returns more callers; each is re-offered only when its own reading moved (settled), so a region caller that is bound and trap-free is left alone.
  • The ledger-disabled cycle exemption now sees a cycle closed through a region call; it is read with the same subflowTargets on both legs, so it stays symmetric.
  • A map whose child is disabled, in a region with an empty collection. It declines although no pass would call the child. §7.3 refuses on the reference, and so did the top-level reading for a decision branch never taken; consistent.
  • The alias key. A region-nested subflow carrying the retired config.flow alias is refused by the strict region schema at parse (and the conversion walk reads FLOW_REGION_SLOTS_BY_TYPE), so the guard's canonical-key-only reading is not a hole.
  • Depth. Past MAX_REGION_DEPTH the call is unread — by the parse too; not a reachable shape for a flow the parse accepted.

Findings.

Implemented-by: claude/project-thread-9kerqb
Reviewed-by: session_01FEkQ5kE5BrPUPNMMyv6oCH

VERDICT: PASS

Record rendered 2026-11-02T05:24Z.

@objectstack-fleet objectstack-fleet Bot removed the needs:contract-review Marker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides label Nov 2, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH

席位核对 · ACCEPT · PR #28959 · 2026-11-02T05:26Z

Head-sha: 87411da83042879ee3c0d8a72a22a73ac3f99fe0

维护者速读(席位终稿)

核对六项

  1. 形态:draft → main;Fixes #28846,与认领 6568384018、裁定 6568462471 一致。文件 7 个,均在申报面内(三处 spec / notice-guard 文件只改注释,复核逐块核实)。
  2. 前提实测:dev 在 main 实测 6 种区域形态(subflow/map × loop/parallel/try_catch)全部照常上膛,顶层孪生全部拒绝;base 上红 10 绿 4,head 14/14;三处消融各自变红后还原。
  3. 门禁:本 head check-runs 56 success、15 skipped(路径未命中)、0 失败;Check Changeset 因摘标签重跑,此前同 head 已 success。dev 本地 83 条派生门禁 exit 0,dogfood VERDICT 0。dev 本地 workspace typecheck 中 spec 测试层 tsc 被共享机杀掉,由本 head Type Check · workspace success 作答。
  4. 接受的偏差:无 spec changeset(spec 只改注释,不发布任何字节),复核接受。复核两条 should-fix(启用门补一条区域调用钉子;subflow-guards.ts 文件头 "moved unedited" 一句更新)席位接受,并入同线程下一单 automation: a subflow or map in a region body whose child suspends arms, fails every run at the region boundary, and leaves the child run suspended #28897。
  5. 范围外去向:automation: a subflow or map in a region body whose child suspends arms, fails every run at the region boundary, and leaves the child run suspended #28897(区域内调用会挂起的子流程:每次运行在区域边界失败并遗留挂起的子运行)已立卡,本 PR 合并后派给同一线程。Metadata-protocol: none。
  6. 落地条款:收窄 ⇒ CONTRACT_REVIEW_TIER 复核 PASS 6569776675(Head-sha 与现 head 一致),Clause-②: no (narrowing),needs:contract-review 已摘。semver:@objectstack/service-automation patch(ruling A),BREAKING 横幅与 ADR-0087 处置齐全,与 changeset 一致。check-governed-merges --pr 28959:NOT governed,311 行。落地授权,维护者原话:「按照 pm-dispatch 规则复核过的都授权」(2026-10-25T11:35Z)。

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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

automation: subflow guards read top-level nodes only, so a subflow or map inside a region body escapes every subflow guard

1 participant