Skip to content

feat(service-automation)!: decline to arm a save_notice that runs after a subflow whose child can suspend - #28875

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

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-9kerqb

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

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

Fixes #28840

Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: follow-up #28787 — skills/** does not teach save_notice yet; that open card carries the teaching, which should state this rule beside the #28806 placement rule

维护者速读

  • BREAKING(收窄):记录触发的流程里,如果 save_notice 能沿连线从某个 subflow 或 map 节点之后到达,而这个节点调用的子流程会挂起(子流程从开始能走到 approval、wait、screen 或 approval_revise,或者它再调用的子流程会挂起),这个流程现在照常注册,但不启用触发器。以前它会启用,子流程一挂起,写入就已经返回,恢复时 save_notice 被按名字拒绝,每次审批决定都会卡住(RESUME_FAILED)。
  • 按席位裁定 A(评论 6565992870):是"不启用",不是"注册时拒绝"。引擎打一次 warn,/_status 显示 bound: false,原因点名 save_notice、subflow/map 节点和子流程里的挂起节点,unboundCause 是新值 notice_after_suspending_subflow。
  • 不依赖加载顺序:每注册一个流程,所有调用链能到达它的流程都会重新判定。子流程去掉挂起后重新发布,调用方自动重新启用;加回挂起,又会不启用。两个方向都有测试。
  • 子流程不会挂起的 subflow / map,以及放在调用之前的 save_notice,照常启用。
  • 已发布的流程没有这种形状(showcase、dogfood、HotCRM main 都测过)。
  • Metadata-protocol 实测为 none:注册、保存入口和 objectstack validate 接受的流程和以前完全一样,变的只是运行时是否启用。spec 只在运行时状态的原因列表里加了一个值,所以两个包都是 patch。
  • 没有需要你拍板的事项。合并前由席位做契约复核。

What changes

The arming gate declines a save_notice after a subflow / map whose child can suspend (packages/services/service-automation/src/subflow-notice-guard.ts, called from activateFlowTrigger in engine.ts). The gate already declines a packaged caller of a disabled packaged subflow (ADR-0126 §7.3). Right after that check, it now also asks whether a save_notice of the flow runs after a top-level subflow / map node whose child flow can suspend. The reading:

  • Can suspend. The child has a node of an always-suspending type (FLOW_PAUSE_CAPABLE_NODE_TYPES, unchanged) reachable from its start. Or it has a reachable subflow / map node whose own child can suspend, read through every call. A flow already on the walk is not read again, so a cycle of calls ends. A child that is not registered is not known to suspend.
  • After the call. The walk is the one from automation: a save_notice placed after an approval, wait or screen node passes publish and then strands every decision with RESUME_FAILED #28806: any path over the top-level edges, loops included, from the call node's successors. A save_notice inside a region body is judged by its top-level container.
  • The decline. The flow registers and is not armed. The engine warns once per reason, and GET /automation/_status reports bound: false with this reason and unboundCause: 'notice_after_suspending_subflow':

not armed — its save_notice node 'tell_after' is reached after subflow node 'review', whose child flow 'deal_manager_review' can suspend the run at approval node 'ask'. A suspended run resumes after the write has already answered, so on that path the node is refused by name at run time, and after an approval the decision cannot finish its run. Move the save_notice ahead of 'review', or tell the person after it with a notify node, and it is armed; it is also armed once 'deal_manager_review' is republished without a node that suspends

When the child suspends only through a flow it calls, the sentence names that flow too (… at \wait` node 'hold' of 'deal_ask'`).

Re-judged on every registration (rejudgeSubflowCallers in subflow-guards.ts). Its existing loop over packaged direct callers is unchanged. A second loop re-judges every flow whose calls reach the registered flow, directly or through other flows, packaged or not. A caller this half declined is offered to the gate again, and an armed caller it would now decline is unbound and offered again. So the decline does not depend on load order, and it moves in both directions when a child (or a child's child) is republished.

Spec contract. FLOW_UNBOUND_CAUSES (packages/spec/src/contracts/automation-service.ts) gains notice_after_suspending_subflow, documented with its owner and remedy, and the Eq pin in its test moves with it. The tuple's rule is one value per cause, never a catch-all, and this cause has its own owner and remedy, so it does not fold into disabled_subflow. The Console reads unboundCause as a string, and any value other than deployment_policy reads "Not running here", so ui/** does not change.

Round-trip test re-pointed (plugin-approvals/src/save-notice-approval-round-trip.integration.test.ts). Its #28806 graph (start → save_notice → subflow(approval) → save_notice) is the shape this PR declines.

Why

#28840, the should-fix from the contract review on #28838: the #28806 parse cannot see that a subflow / map child suspends, so "ask the approval in a subflow, then tell the saver" still stranded every decision. The registration refusal the order first named would depend on load order (the child may register after its caller), so the seat ruled the arming gate instead (comment 6565992870).

Verification

  • Red first. service-automation/src/subflow-notice-arming.test.ts, 12 cases. With the production sources at base, 9 failed and the 3 controls passed. On head, 12 of 12 pass.
  • Ablations in subflow-notice-guard.ts / subflow-guards.ts through scripts/ablation-replace.mjs. Each went red and was restored, proven by blob == HEAD:
    • Do not follow subflow targets (a call never suspends): all 9 decline pins went red.
    • No transitive call walk inside the child: the 3 two-calls-away pins went red.
    • No save-notice re-judge: the load-order, two-calls-away and re-arm pins went red (4).
    • Every call counts as suspending: the non-suspending control and the cycle control went red (8 red in all).
    • Count notices reachable from the start, not after the call: the notice-ahead control went red.
    • Ignore the region container: the region pin went red.
  • Ablations on service-automation/dist, by hand, restored and proven by git hash-object (a37ec0dd):
    • Gate call removed: the round-trip decline case went red. The resumed-leg case went red too, because the armed flow's create leaves a run row that case reads.
    • The executor's no-channel refusal replaced by a success: the resumed-leg case went red.
  • Shipped flows. examples/** and packages/qa/** (the dogfood fixtures) hold one save_notice (showcase_task_completed: start → script → save_notice → notify → end, with no subflow or map). HotCRM origin/main (a598256) has no save_notice.
  • Gates. dispatch-gates.mjs --commands derived 85, and --ran reconciles 85 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0. check-changeset-no-major with this body: exit 0.
  • Suites. Full vitest run exit 0 for each of these packages:
    • @objectstack/service-automation: 255 files, 3281 tests.
    • @objectstack/spec: 875 files.
    • @objectstack/plugin-approvals: 107 files.
    • @objectstack/trigger-record-change, @objectstack/trigger-schedule, @objectstack/example-showcase and @objectstack/client.
  • Typecheck. pnpm typecheck passes in service-automation, spec, plugin-approvals and client. The workspace pnpm typecheck passes too (190 of 190 tasks, run with NODE_OPTIONS=--max-old-space-size=6144, because the cli test typecheck runs out of memory at the default heap).
  • eslint --no-inline-config exits 0 over packages/services/service-automation, packages/spec and packages/plugins/plugin-approvals.
  • Dogfood battery (owed by the (narrowing) arm), on head 69983418 under os-verify-lock.sh. The closure build answered VERDICT command-exit 0. pnpm --filter @objectstack/dogfood test answered VERDICT command-exit 1: 376 files passed, 3 skipped and 4 failed (3115 tests passed, 28 skipped, 4 failed).
    • The 4 reds are 18.0-fixture upgrade pins: upgrade-18x-harness, audit-key-material, audit-mail-bodies and identity-mail-read-gate. Each one is missing the sys_activity copies (no such table: sys_activity__r20261019).
    • They are not this change. With this PR's service-automation and spec sources put back to base and both dists rebuilt, the same 4 files fail the same 4 tests (4 failed, 45 passed). A rerun on head gives the same result. No pin that reads flows, arming or save_notice failed.

Acceptance notes

  • Clause-②: yes (narrowing), per the contract review (6567682353). The diff narrows arming, and it also widens the published FLOW_UNBOUND_CAUSES tuple and the FlowUnboundCause union, which /_status serves and the client types. The level stays patch, and Metadata-protocol stays none.
  • Metadata-protocol: none. What an author can write has not changed: registration, the save door and objectstack validate accept the same flows. What changes is the runtime's arming of a flow. The one spec change adds a value to a runtime status response list, which no author writes. So both packages take patch under ruling A. The changeset keeps its BREAKING banner and the ADR-0087 no-migration-prescription marker.
  • No new spec export. The walk is repeated in the service (about 40 lines) instead of exporting automation: a save_notice placed after an approval, wait or screen node passes publish and then strands every decision with RESUME_FAILED #28806's internal suspendedReach. The order prefers an internal entry, and @objectstack/spec has none. It reuses the public collectFlowGraphs and FLOW_PAUSE_CAPABLE_NODE_TYPES.
  • Unchanged: FLOW_PAUSE_CAPABLE_NODE_TYPES (ruling D), the stranded-decision posture, RESUME_FAILED and the save-notice contract. No ui/** and no hotcrm changes.
  • Outside the gate, by design:

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: 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

…er a subflow whose child can suspend

The arming gate declines a flow whose save_notice is reachable from a
subflow or map node whose child flow can suspend (a pausing node reachable
from its start, or a call to a child that can suspend). The flow registers,
is not armed, warns once, and /_status reports it with the new unbound cause
notice_after_suspending_subflow. rejudgeSubflowCallers asks the gate again for
every flow whose calls reach a registered flow, so the decline does not
depend on load order and a child republished without its suspension arms the
caller again.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@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 34 documentable anchor(s).

⛔ 3 release-owned page(s) 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))
  • content/docs/releases/v17/17-6.mdx (via AutomationEngine (symbol, a top-level class))

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
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • 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 76bf6c50d86f2894526851fb0a1c751f4074bba9 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 76bf6c50d86f2894526851fb0a1c751f4074bba9

⚠️ 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 76bf6c50d86f2894526851fb0a1c751f4074bba9 → 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#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6998341844351372c560b56bc48cb065547a7551
Local-runs: none
Scope: packages/spec/src/contracts/automation-service.ts, packages/spec/src/contracts/automation-service.test.ts, packages/services/service-automation/src/engine.ts, packages/services/service-automation/src/flow-definitions.ts, packages/services/service-automation/src/subflow-guards.ts, packages/services/service-automation/src/subflow-notice-guard.ts, packages/services/service-automation/src/subflow-notice-arming.test.ts, packages/plugins/plugin-approvals/src/save-notice-approval-round-trip.integration.test.ts, .changeset/28840-subflow-notice-arming.md + packages/spec/src/automation/save-notice-placement.ts, packages/spec/src/automation/flow.zod.ts (FLOW_PAUSE_CAPABLE_NODE_TYPES, the saveNoticePlacementRefusals wiring), packages/spec/src/automation/control-flow.zod.ts (collectFlowGraphs), docs/adr/0126-packaged-metadata-customization-model.md §7.3, packages/services/service-automation/src/builtin/map-node.ts, packages/services/service-automation/src/loader-set.test-support.ts, packages/triggers/trigger-record-change/src/record-change-integration.test.ts, ui/packages/app-shell/src/views/setup/packagedFlows.ts, packages/client/src/return-type-precision.test.ts, packages/spec/api-surface/contracts.json, hotcrm a5982564

Face: --paths on the PR's file list derives the schema face (hit: packages/spec/src/contracts/automation-service.ts); the narrowing face floor (every non-prose path of the diff) is listed above it. Inputs: card #28840 (body, triage 6565522139, claim 6565920206, needs_decision report 6565979681, seat ruling 6565992870 option A, done report 6567425898), PR #28875 body and file list, the net diff against merge-base 00918c886c0d (9 files, +663 −13), and the check-runs on the head, read until every run completed at 2026-11-02T02:32:57Z: 69 runs, 56 success, 13 skipped, 0 failed, 0 in progress. Of the ten required contexts, nine concluded success (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard, Type Check UI, Test UI, Build & E2E UI, Live E2E UI) and Lint UI is skipped (no ui/** path in the diff); Check Changeset and Dogfood Regression Gate are success; every skipped run is an opt-in or paths-filtered job.

① Derived judgments

  1. Accept set at registration, save door and objectstack validate: unchanged — right. The diff touches no registrationChecks entry, not save-notice-placement.ts, not flow.zod.ts; registerFlow on the head still throws only out of the one checks list (engine.ts 4507). Every flow that registered at base registers on the head. Verified on the tree, not the body.
  2. Arming narrows — right, and it is the ruled posture. activateFlowTrigger gains one decline (declineOntoSuspendingSubflowNotice) placed right after declineOntoDisabledSubflows and before the trigger lookup (engine.ts 3893–3901), so it sits inside the one gate every arming path crosses (boot pull, publish, registerTrigger at kernel:ready, the enable toggle). The gate is reached only after resolveTriggerBinding resolves, and the parse already refuses a save_notice in any flow whose start is not a create/update record trigger, so the only flows this decline can touch are record-triggered create/update flows. Matches ruling A ("not armed", never "refused at registration").
  3. Public surface widens — right in substance, under-declared (see ②). FLOW_UNBOUND_CAUSES gains notice_after_suspending_subflow; the derived FlowUnboundCause union widens; /_status serves the value; @objectstack/client types the row as FlowRuntimeState. Consumers checked on the head: the Console reads unboundCause as an opaque string and renders "Not running here" for any value (packagedFlows.ts 97–99, its test loops the four known values but asserts nothing exhaustive); the client's type-precision test asserts FlowUnboundCause | undefined and does not enumerate; engine.test.ts, subflow-guard-every-door.test.ts and time-relative-bind-status-e2e.test.ts pin their own causes by literal. No switch over the union anywhere in the repo, so nothing breaks or needs the value. The spec Eq pin moved with it. api-surface/contracts.json records FLOW_UNBOUND_CAUSES (const) by existence only and no content/docs/references page lists the values, so no generated artifact moves — consistent with Human Minimum 6; the Type Check · source gates run is green on the head.
  4. Own cause rather than folding into disabled_subflow: right. The tuple's TSDoc gives each cause one owner and one remedy; this one's owner is the author (move the notice) or the child's publisher, not the administrator's activation switch.
  5. subflowTargets body: unchanged. subflow-guards.ts changes only the host interface, one import and rejudgeSubflowCallers; the function reads the same top-level subflow/map config.flowName as before (map's config key is flowName, map-node.ts 113/126). The existing guards (packagedSubflowCallers, disabledPackagedSubflows, the §7.3 refusals) see exactly what they saw. The new reading deliberately shares that same top-level call set, so the gate and the re-judge agree; region-nested calls stay automation: subflow guards read top-level nodes only, so a subflow or map inside a region body escapes every subflow guard #28846's.
  6. Load-order independence: holds. registerFlow ends with rejudgeSubflowCallers(name) for every registration (engine.ts 4584). The new loop walks transitiveSubflowCallers (BFS over callers, found set, so it terminates on call cycles) and re-offers a caller when either it is bound and now trapped, or unbound and in subflowNoticeDeclinedFlows. Traced on the code: child registered after parent → parent bound, trap found → unbound and declined; child republished without the pause → parent in the declined map → re-offered, subflowNoticeTrap undefined → map entry deleted, armed; child republished with the pause → declined again and the warn repeats (the "said once" key is the reason string, cleared on arm); grandchild changes → the BFS reaches the top caller through the middle flow, and suspendingNode reads through calls with a shared seen set (sound, since a flow's answer does not depend on the path it was reached by). The test file pins each of these and the cycle control. The early return keeps the boot pull cheap: before any trigger binds, both maps are empty and the loop never runs; the kernel:ready registerTrigger pass then runs the gate once per flow with every flow in hand.
  7. Child withdrawal: no re-judge, by declared design. withdrawFlow drops the caller's own entry only; a caller declined onto a child that is then removed keeps its decline (and its reason names the absent child) until the child or the caller is registered again. Conservative direction (the caller would fail at its call node if armed), louder than silence, and it matches the disabled-subflow half. Accepted, not a finding.
  8. Unregistered child = not known to suspend. suspendingNode answers undefined for a flow not in flows, so a caller registered ahead of its child arms, as today, and is disarmed when the child arrives suspending. Correct given the parse already refuses what it can see.

② Semver level

  • @objectstack/service-automation: patch, @objectstack/spec: patch, Metadata-protocol: none — right under ruling A. Nothing an author writes changes: no authorable key, value or default moves, and the one spec change is a value on a runtime status response. The BREAKING banner and the ADR-0087 not-required (no-migration-prescription) marker are in the changeset body with both declaration lines; Check Changeset is green on the head.
  • Clause-②: no (narrowing) (PR body and changeset) — under-declared; should-fix. AGENTS.md defines the line as "does this diff widen or narrow a published accept set or public surface", and this diff does both: it narrows arming and it widens the published FLOW_UNBOUND_CAUSES tuple and the FlowUnboundCause union exported from @objectstack/spec/contracts. The closed pair admits one arm, and scripts/pm/record-recognisers.mjs names the form for exactly this case: "yes (narrowing) included: a diff that widens and narrows owes the narrowing face". Precedent: the tuple's own introduction (6422da5) was declared yes (widening), and new published error codes are graded as a widening by check-changeset-no-major (fix(spec): every defineStack refusal carries an ADR-0112 envelope — six STACK_* codes beside STACK_CROSS_REFERENCE_INVALID #16342). The correct line is Clause-②: yes (narrowing) in both places. No mechanical reader's verdict moves with it (the face is narrowing either way, the level follows Metadata-protocol: none, the ADR-0087 arm is narrowing either way), which is why this is should-fix and not a FAIL.

③ Boundary flags

  • Dev deviation: Metadata-protocol: none with both packages patch — answered yes in ② (right).
  • Dev deviation: new FLOW_UNBOUND_CAUSES value, no new export — right in substance; the declaration line is the only correction (②).
  • Dev deviation: the automation: a save_notice placed after an approval, wait or screen node passes publish and then strands every decision with RESUME_FAILED #28806 walk repeated in the service (about 40 lines) instead of exporting spec's internal suspendedReach — accepted. The order prefers an internal entry and spec has none; a new public helper would itself be a surface widening. The copy mirrors the spec walk exactly (successors-not-self, loops included, region body judged by path[1], which collectFlowGraphs always sets to the top-level container index). Drift risk is real but bounded: both are pinned by their own tests.
  • Dev deviation: round-trip test re-pointed — answered in ④ item 5.
  • Dev deviation: rejudgeSubflowCallers' "moved unedited" docblock corrected — right, but incomplete: describeUnboundReason and withdrawFlow in flow-definitions.ts are also edited by this diff and their one-line docblocks still read "moved unedited". Should-fix (doc accuracy only).
  • Ruled boundaries (ruling D, the stranded-decision posture, RESUME_FAILED, the save-notice contract): every removed line of the net diff is in the test rewrite, three Pick types and the Eq pin; FLOW_PAUSE_CAPABLE_NODE_TYPES, SaveNoticeConfigSchema, the resume path and RESUME_FAILED are not touched. Held.
  • Load-order dependence (the order's escalation line): resolved by ruling A and verified in ① item 6.
  • Out-of-scope findings: automation: subflow guards read top-level nodes only, so a subflow or map inside a region body escapes every subflow guard #28846 (region-nested calls) correctly left out; the #28787 skills teaching is named on the PR body's Skills-docs line.
  • Dogfood battery: judged from the head's check-runs only. Dogfood Regression Gate is success; its sharded leg and Dogfood Verify CLI are skipped on this PR event. No check-run on the head reports the 4 local reds the dev attributes to 18.0-fixture pins; they are not a verdict of this head's gates and are out of this record's reach.
  • Governed surfaces: none in the file list; Governed Surface Queue Guard is success.

④ Design judgment

Judged against the published spec on main: save-notice-placement.ts (#28806) refuses what the parse can see and states in its own header that a subflow/map child's suspension is "left to the run" — this change makes that gap loud before any run, at the one gate the spec's own precedent (ADR-0126 §7.3, the disabled-subflow decline) already uses. It takes no declared capability away: the flow still registers, saves and validates; what it withholds is an arming that could only ever strand every decision.

  1. Does the trap walk hold? Yes. Call node must be reachable from start; the notice must be reachable from the call node's successors (so a notice ahead of the call, or on a branch beside it, is not declined; a loop back from after the call is). A region-body notice is judged by its container, as in automation: a save_notice placed after an approval, wait or screen node passes publish and then strands every decision with RESUME_FAILED #28806. A second call node is tried when the first reaches no notice. Controls for non-suspending child, notice-ahead and cycles are pinned.
  2. Does "can suspend" hold? It is the spec's own FLOW_PAUSE_CAPABLE_NODE_TYPES reachable from the child's start, else a reachable call whose child can suspend, transitively. Same deliberate limits as the parse: a plugin-registered pausing type and a region-nested call are not read (the run-time refusal by name still meets them). A child with no start node reads as not suspending — the same reading the spec's own suspendedReach takes without one; noted, not a finding.
  3. Is the verdict order-independent? Yes (① item 6). The one asymmetry — removal does not re-judge — is declared in the PR and errs on the unarmed side.
  4. Does the status surface lie? No. describeUnboundReason reports the cause only for an enabled, resolved, unbound flow, after policy and disabled-subflow (both of which outrank it, and a stale notice entry under them is recomputed the moment the gate runs past the disabled half). getFlowRuntimeStates carries reason and unboundCause together or not at all, as before. The warn and the reason share one sentence (describeSubflowNoticeTrap), name the notice, the call node, the child's suspending node (and the flow holding it when reached through further calls), and the two remedies; no tracker number in runtime text.
  5. Does the rewritten round-trip test still pin what it pinned? Every resumed-leg pin survives unchanged in case 2: the pause row at review with status: 'paused', the stored context without the channel's function, the child's request at ask, decide answering resumed: true with resumeFailure { RESUME_FAILED, runId, 'stranded' }, the refusal text on resumeError and on the terminal history row, and nothing reaching the write after the answer. What moved is how the run starts: automation.execute with the context the trigger hands a run, instead of the trigger itself. The one assertion dropped — the create's own answer carrying the first notice through the real trigger — is pinned in trigger-record-change/src/record-change-integration.test.ts (Save notice from a sandboxed hook or a flow: hook context and flow node set the write answer's notice #28251 block) and save-notice-channel.test.ts. Case 1 now pins the decline through the real trigger (registered, bound: false, the right cause, a create that starts no run and hears nothing) and is the first in-composition pin of the new gate. Case 3 (automation: a save_notice placed after an approval, wait or screen node passes publish and then strands every decision with RESUME_FAILED #28806 refusal at registerFlow) is unchanged.
  6. Shipped flows. The PR's measure (one save_notice in the showcase with no call node; none in the dogfood fixtures; none in hotcrm) is consistent with hotcrm a5982564 read here (zero save_notice matches). No shipped flow moves.
  7. Cost. transitiveSubflowCallers scans the flow map once per discovered caller; the early return on empty maps keeps the boot pull at base cost; after triggers bind it is one extra scan family per registration, same order as the existing packagedSubflowCallers scan.

Findings: two should-fix (the Clause-② arm in ②; the two stale "moved unedited" docblocks in ③). No must-fix.

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

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head 3ca80e701710aeb613830a201aa4d1ee8083c142 · 2026-11-02T02:52Z

Taken under references/contract-review.md rule (a): the review-request label is on and the lane's PASS (6567682353) names an earlier head. Face schema + narrowing. At CONTRACT_REVIEW_TIER.


Generated by Claude Code

@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: 3ca80e701710aeb613830a201aa4d1ee8083c142
Local-runs: none
Scope: packages/spec/src/contracts/automation-service.ts, packages/spec/src/contracts/automation-service.test.ts, packages/services/service-automation/src/engine.ts, packages/services/service-automation/src/flow-definitions.ts, packages/services/service-automation/src/subflow-guards.ts, packages/services/service-automation/src/subflow-notice-guard.ts, packages/services/service-automation/src/subflow-notice-arming.test.ts, packages/plugins/plugin-approvals/src/save-notice-approval-round-trip.integration.test.ts, .changeset/28840-subflow-notice-arming.md + docs/adr/0126-packaged-metadata-customization-model.md §7.3 (the one cited file main moved), and the delta 6998341844..3ca80e7017

This record re-confirms the review of record 6567682353 (PASS on 6998341844351372c560b56bc48cb065547a7551) on the PR's new head. The delta is two commits: the dev's e8d14593e4 and a merge of origin/main at 76bf6c50d8 (14 main commits). Read as the brief asks: the dev's commit, whether the merged-in main commits touch any path or cited file of the record, the current PR body, and the check-runs on the new head.

① Derived judgments

  1. The PR's own delta is the reviewed one plus the two should-fixes, and nothing else. git diff 76bf6c50d8..3ca80e7017 (9 files, +665 −15) against the reviewed git diff 00918c886c..6998341844 (9 files, +663 −13), index lines ignored, differs in exactly three places: the changeset's Clause-② line, and the one-line docblocks of describeUnboundReason and withdrawFlow in flow-definitions.ts. No source line of the gate, the walk, the re-judge, the spec tuple or the tests moved.
  2. Should-fix 1 is closed. .changeset/28840-subflow-notice-arming.md and the PR body (read back from the API) both read Clause-②: yes (narrowing); the body carries no no (narrowing) any more, keeps Metadata-protocol: none, the Skills-docs line and its session footer. Check Changeset on the new head is success.
  3. Should-fix 2 is closed. The two docblocks now read "The body of {@link …}; that method's docblock is its contract." with the false "moved unedited" removed.
  4. The merged-in main commits touch no path of the diff and no cited code. Of the 169 files main moved between 00918c886c and 76bf6c50d8, none is under packages/services/service-automation, packages/spec/src/automation, packages/spec/src/contracts, packages/plugins/plugin-approvals or packages/triggers, and none is a file of the record's Scope: except docs/adr/0126-packaged-metadata-customization-model.md, where main edited one line of §7.4 (a retirement note on the nav_permission_sets entry); §7.3, the section the record cites, is byte-identical between the two trees.

② Semver level

Unchanged from 6567682353 and now fully declared: patch on both packages under Metadata-protocol: none, with the BREAKING banner, the ADR-0087 not-required (no-migration-prescription) marker and Clause-②: yes (narrowing) on body and changeset. The narrowing face still owes this tier; the widened FLOW_UNBOUND_CAUSES is now declared.

③ Boundary flags

  • Both should-fixes of 6567682353 addressed (① items 2–3); no new deviation reported and none found in the delta.
  • Ruled boundaries (ruling D, the stranded posture, RESUME_FAILED, the save-notice contract): untouched by the delta; main's 14 commits do not reach them.
  • Governed surfaces: none in the file list; Governed Surface Queue Guard is success on the new head.
  • Check-runs on 3ca80e7017, read until every run completed at 2026-11-02T03:00:30Z: 68 runs, 57 success, 11 skipped, 0 failed, 0 in progress. Of the ten required contexts, nine concluded success (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard, Type Check UI, Test UI, Build & E2E UI, Live E2E UI) and Lint UI is skipped (no ui/** path in the diff); Check Changeset and Dogfood Regression Gate are success; every skipped run is an opt-in or paths-filtered job.

④ Design judgment

Nothing to re-judge: the delta carries no design change, and the published spec on main that the review judged against (save-notice-placement.ts, flow.zod.ts, control-flow.zod.ts, ADR-0126 §7.3) is unchanged by the merge. The findings and the verdict of 6567682353 stand on this head.

Findings: none open. The two should-fixes are closed.

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

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3ca80e701710aeb613830a201aa4d1ee8083c142
Local-runs: none
Scope: packages/spec/src/contracts/automation-service.ts + packages/spec/src/contracts/automation-service.test.ts (the Eq pin), packages/spec/src/automation/save-notice-placement.ts (#28838, the publish refusal and its header), packages/spec/src/automation/flow.zod.ts (FLOW_PAUSE_CAPABLE_NODE_TYPES, the saveNoticePlacementRefusals wiring), packages/spec/src/automation/control-flow.zod.ts (collectFlowGraphs), packages/spec/src/automation/builtin-node-config.zod.ts (MapConfigSchema, the subflow config keys), packages/services/service-automation/src/engine.ts (activateFlowTrigger, declineOntoSuspendingSubflowNotice, registerFlow, registerTrigger, deactivateFlowTrigger), packages/services/service-automation/src/subflow-notice-guard.ts, packages/services/service-automation/src/subflow-guards.ts (subflowTargets, rejudgeSubflowCallers), packages/services/service-automation/src/flow-definitions.ts (describeUnboundReason, getTriggerBindingAudit, withdrawFlow), packages/services/service-automation/src/builtin/subflow-node.ts and map-node.ts (the child-pause arms), packages/services/service-automation/src/subflow-notice-arming.test.ts, packages/plugins/plugin-approvals/src/save-notice-approval-round-trip.integration.test.ts, .changeset/28840-subflow-notice-arming.md, docs/adr/0087-metadata-protocol-upgrade-contract.md (the disposition vocabulary), docs/adr/0126-packaged-metadata-customization-model.md §7.3, scripts/check-changeset-no-major.mjs (the (narrowing) arm), scripts/pm/record-recognisers.mjs (reviewFace), skills/objectstack-data/rules/lifecycle.md (the save-notice teaching on main), ui/packages/app-shell/src/views/setup/packagedFlows.ts (the one unboundCause consumer)

Face: --paths on the PR's file list derives the schema face (hit: packages/spec/src/contracts/automation-service.ts); the committed changeset also declares Clause-②: yes (narrowing), so the narrowing face's floor (every non-prose path of the 9-file diff) is in the scope above. This head is the lane's reviewed head 6998341844 plus one commit, e8d14593e4 (the Clause-② arm moved to yes (narrowing) in the changeset; the two "moved unedited" docblocks in flow-definitions.ts corrected), and a merge of origin/main; the net diff against merge-base 76bf6c50d8 is the same 9 files. Inputs: card #28840 (body, triage 6565522139, claim 6565920206, needs_decision report 6565979681, seat ruling 6565992870 option A, done report 6567425898), PR #28875 body and file list, the net diff, and the check-runs on this head, re-fetched until every run completed: 68 runs, 57 success, 11 skipped, 0 failed, 0 in progress. Every required context concluded success (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard, Type Check UI, Test UI, Build & E2E UI, Live E2E UI, Check Changeset, Dogfood Regression Gate); Lint UI is skipped (no ui/** path in the diff); every other skipped run is an opt-in or paths-filtered job. The lane's PASS 6567682353 on the earlier head was read; this record judges the head independently.

① Derived judgments

  1. Accept set at registration, the save door and objectstack validate: unchanged — right. The diff touches no registrationChecks entry, not save-notice-placement.ts, not flow.zod.ts. registerFlow on the head throws only out of the one checks list; every flow that registered at base registers on the head. Read on the tree.
  2. Arming narrows — right, and it is the ruled posture (ruling A, 6565992870). activateFlowTrigger gains one decline, declineOntoSuspendingSubflowNotice, placed right after declineOntoDisabledSubflows and before the trigger lookup, inside the one gate every arming path crosses (boot pull and publish through registerFlow, the kernel:ready registerTrigger pass, the enable toggle). "Not armed", never "refused at registration".
  3. Public surface widens — right, now declared. FLOW_UNBOUND_CAUSES gains notice_after_suspending_subflow; the derived FlowUnboundCause union widens; /_status serves it and @objectstack/client types the row as FlowRuntimeState. Consumers on main: the Console (packagedFlows.ts) reads unboundCause as an opaque string and renders "Not running here" for any value but deployment_policy; its test loops the four known values without an exhaustive assertion; no switch over the union anywhere in the repo; no content/docs or skills/** page enumerates the values. The spec Eq pin moved with the tuple. api-surface/contracts.json records the const by existence only, so no generated artifact moves; Spec property liveness and Type Check · source gates are green.
  4. Own cause rather than folding into disabled_subflow: right. The tuple's TSDoc rule is one value per cause with its own owner and remedy; this one's owner is the author (move the notice) or the child's publisher, not the administrator's activation switch. The new TSDoc entry names both the owner and the remedy, in the shape of its neighbours.
  5. The two doors agree, and are disjoint by construction. The publish refusal (feat(spec)!: refuse at publish a save_notice that a suspending node reaches #28838) reads a same-graph node of FLOW_PAUSE_CAPABLE_NODE_TYPES that reaches a save_notice; the arming decline reads a top-level subflow / map node reachable from the start whose child can suspend and that reaches a save_notice. Both walks are the same reading: any path over the top-level edges from the node's successors (not itself), loops included, a region body's notice judged by its top-level container (graph.path[1]). The parse never reads a child and the gate never reads a same-graph pausing type, so no flow is judged twice and no flow the parse refuses reaches the gate. A flow that passes publish and is declined at arm is exactly the subflow shape, and the contract names it: the hits file's TSDoc, the unboundCause on the row, the reason sentence, the binding audit (getTriggerBindingAudit reads describeUnboundReason, one computation, two doors) and a warn once per reason. Not silent.
  6. "Can suspend" holds, within the same limits as the parse. A node of FLOW_PAUSE_CAPABLE_NODE_TYPES reachable from the child's start, else a reachable top-level subflow / map whose child can suspend, read depth-first with one seen set (reachability over a directed graph with a global visited set is exact; a cycle of calls ends). A region body cannot hold a pausing node (the spec refuses one, flow.zod.ts ruling D), so reading the child's top level is the whole reading. A region-nested call is automation: subflow guards read top-level nodes only, so a subflow or map inside a region body escapes every subflow guard #28846; a plugin-registered pausing type and an unregistered child are not known to suspend, and the run-time refusal by name still meets them, as the spec header says.
  7. The decline is not over-broad. subflow-node.ts and map-node.ts both await engine.execute(flowName, …) and suspend the parent on child.status === 'paused'; the subflow config declares flowName, input and outputVariable only, no no-wait key, so there is no call shape that leaves a notice after it reachable while the write still waits.
  8. Load-order independence holds. registerFlow ends with rejudgeSubflowCallers(name) on every registration; the new loop walks transitiveSubflowCallers (BFS over callers with a found set) and re-offers a caller that is bound and now trapped, or unbound and in subflowNoticeDeclinedFlows. Traced: child registered after an armed parent → parent unbound and declined; child republished without the pause → entry deleted, parent armed, reason and unboundCause absent; pause restored → declined again, the warn repeats (the "said once" key is the reason string, cleared on arm); a grandchild change reaches the top caller through the middle flow. Before any trigger binds all three maps are empty and the early return keeps the boot pull at base cost; the kernel:ready registerTrigger pass then runs the gate once per flow with every flow in hand. Each direction is pinned in subflow-notice-arming.test.ts (12 cases, cycle and two controls included).
  9. Child withdrawal does not re-judge: declared, conservative. The caller keeps its decline, and its reason names the absent child, until either flow is registered again. Armed, it would fail at its call node. Same posture as the disabled-subflow half. Accepted.
  10. Stale entries cannot surface. describeUnboundReason answers only for an enabled, resolved, unbound flow, after policy and disabled_subflow; every early return of the gate ahead of the notice half (disabled, no binding, bound) is a state the reader answers undefined for or outranks, and the entry is recomputed the moment the gate runs past the disabled half. withdrawFlow drops it with the flow.

② Semver level

  • @objectstack/service-automation: patch, @objectstack/spec: patch, Metadata-protocol: none — right under ruling A. Nothing an author writes changes: no authorable key, value or default moves; the one spec change is a value in a runtime status response list. check-changeset-no-major grades none as patch and keeps the BREAKING banner; Check Changeset is green on this head.
  • Clause-②: yes (narrowing) in the PR body and the changeset — right. The diff narrows arming and widens the published FLOW_UNBOUND_CAUSES tuple and FlowUnboundCause union; the closed pair admits one arm and record-recognisers.mjs names this spelling for exactly a diff that widens and narrows. The lane's should-fix on the earlier head is resolved by e8d14593e4, and the two lines agree with each other.
  • ADR-0087 disposition not-required (no-migration-prescription) — right; no migration entry is owed. The ledger holds rewrites objectstack migrate meta can project; nothing here is one: no key is added, removed, renamed or re-shaped, and moving a save_notice ahead of a call is an authoring decision no tool can prove. A stored flow with the shape was already non-functional at base (every decision stranded with RESUME_FAILED); on upgrade it registers, is left unarmed, and the warn, the /_status reason and the unboundCause name what to move. The changeset body names the fix in prose with no FROM/TO block, which is what the category requires, and check-adr-0087-registration runs inside the green Check Changeset job. The "Who moves" paragraph is consistent with the tree: the showcase's one save_notice has no call node, the dogfood fixtures carry none, hotcrm main carries none.

③ Boundary flags

  • Dev deviation: Metadata-protocol: none, both packages patch — answered yes in ②.
  • Dev deviation: new FLOW_UNBOUND_CAUSES value, no new export — right (① item 3 and 4).
  • Dev deviation: the automation: a save_notice placed after an approval, wait or screen node passes publish and then strands every decision with RESUME_FAILED #28806 walk repeated in the service instead of exporting spec's internal suspendedReach — accepted. The order prefers an internal entry, spec has none, and a new public helper would itself be a surface widening. The copy mirrors the spec walk (successors-not-self, loops included, region body judged by path[1]); drift is bounded by each side's own pins.
  • Dev deviation: round-trip test re-pointed — answered in ④ item 4.
  • Dev deviation: rejudgeSubflowCallers, describeUnboundReason and withdrawFlow docblocks no longer say "moved unedited" — right and now complete on this head (the lane's second should-fix is resolved).
  • Ruled boundaries (ruling D, the stranded-decision posture, RESUME_FAILED, the save-notice contract): FLOW_PAUSE_CAPABLE_NODE_TYPES, SaveNoticeConfigSchema, the resume path and RESUME_FAILED are not touched; every removed line of the diff is in the test rewrite, three Pick types, two docblocks and the Eq pin. Held.
  • The order's load-order escalation: resolved by ruling A, verified in ① item 8.
  • Out-of-scope findings: automation: subflow guards read top-level nodes only, so a subflow or map inside a region body escapes every subflow guard #28846 (region-nested calls) correctly left out. The Skills-docs line names skills(automation): teach the save notice — the save_notice flow node and ctx.notice in hooks (#28251) #28787 as the carrier of the teaching; on main, skills/objectstack-data/rules/lifecycle.md (merged after the lane's review) teaches the save notice as "refused outside a create/update record trigger and after an approval / wait" and says nothing about a subflow / map child, so it teaches nothing this PR makes false. Deferral accepted; see ④ should-fix 2.
  • Dogfood battery: judged from the head's check-runs only. Dogfood Regression Gate is success; its sharded leg and Dogfood Verify CLI are skipped on this PR event. The 4 local 18.0-fixture reds the dev reports are not a verdict of this head's gates and are out of this record's reach.
  • Governed surfaces: none in the file list; Governed Surface Queue Guard is success.
  • Security: nothing security-relevant. The reason sentence exposes flow and node names on /_status, as the existing causes already do.

④ Design judgment

Judged against the published spec on main: save-notice-placement.ts refuses what the parse can see and states in its header that a subflow / map child's suspension is "left to the run"; this change makes that gap loud before any run, at the gate the spec's own precedent (ADR-0126 §7.3) already uses for a caller a child would fail mid-run. It takes no declared capability away at the consumer: the flow still registers, saves and validates; what is withheld is an arming that could only strand every decision. It makes the spec's own refusal real one door earlier, and names it.

  1. The hits file holds against its first consumer. describeUnboundReason composes notice_after_suspending_subflow exactly where the gate recorded a decline, after deployment_policy and disabled_subflow (both of which outrank it for the gate's own reason) and before missing_trigger / binding_failure; getFlowRuntimeStates carries reason and unboundCause together or not at all, as the FlowRuntimeState TSDoc requires; the binding audit reads the same function. The Eq pin is the closed tuple. The Console's rendering is unchanged by construction.
  2. The decline names a remedy that works. One sentence (describeSubflowNoticeTrap) is shared by the warn and the row: the notice, the call node and its type, the child, the child's suspending node and type (and the flow holding it, with the flows between, when reached through further calls), the consequence (refused by name after the write answered, the decision cannot finish), and two author moves plus the child's republish. Each move was traced to arm the flow: the notice ahead of the call or on a notify node leaves nothing reachable after the call; a child republished without its pause clears suspendingNode and the re-judge arms the caller. No tracker number in runtime text.
  3. The walk breaks nowhere I could push it. A notice on a branch beside the call (not after it) arms; a loop back from after the call declines; a region-body notice is judged by its container; a second call node is tried when the first reaches no notice; a child with no start reads as not suspending, as the spec's own suspendedReach does; a self-call ends on seen. Edges are read regardless of type (a back edge counts), as in the spec.
  4. The rewritten round-trip test still pins what it pinned. Every resumed-leg assertion survives in case 2: the pause row at review, the stored context without the channel's function, the child's request at ask, decide answering resumed: true with resumeFailure { RESUME_FAILED, runId, 'stranded' }, the refusal text on the terminal history row, nothing reaching the write after the answer. What moved is the start (automation.execute with the context the trigger hands a run); the trigger's own hand-off of the channel is pinned in trigger-record-change (Save notice from a sandboxed hook or a flow: hook context and flow node set the write answer's notice #28251). Case 1 is the first in-composition pin of the new gate through the real trigger (registered, bound: false, the right cause, a create that starts no run and hears nothing). Case 3 (automation: a save_notice placed after an approval, wait or screen node passes publish and then strands every decision with RESUME_FAILED #28806 refusal at registerFlow) is unchanged.
  5. Cost. One extra scan family per registration after triggers bind, same order as packagedSubflowCallers; nothing before.

Should-fix 1 (spec prose, cited code): save-notice-placement.ts's header on main (and the flow.zod.ts comment at its wiring) still says the subflow / map case "is left to the run" and "refuses the node by name there". After this PR the arming gate meets that case first whenever the child is registered, and the run-time refusal is the residue (an unregistered child, a region-nested call, a plugin pausing type). The spec's statement of where its own gap is met should name the arming gate and notice_after_suspending_subflow, so the two doors read as one rule from either side. Not a FAIL: the contract consumers read (the hits file) is right and names the door, and the decline is loud.

Should-fix 2 (deferred, carried): skills/objectstack-data/rules/lifecycle.md teaches the two placement refusals and not this decline; #28787, named on the Skills-docs line, should state the rule beside the #28806 one so an AI author does not learn it from /_status.

No must-fix.

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

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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

席位核对 · ACCEPT · PR #28875 · 2026-11-02T03:02Z

Head-sha: 3ca80e701710aeb613830a201aa4d1ee8083c142

维护者速读(席位终稿)

核对六项

  1. 形态:draft → main;Fixes #28840,与派单 pm-orders/server/order-28840.md 及裁定 6565992870 一致。文件 9 个,在认领 6565920206 申报面内(另含 spec 契约文件一处,见第 4 项)。
  2. 前提实测:dev 实测 registerFlow 只拿 (name, flow)、开机按注册表顺序加载,裁定前上报 needs_decision;方案 A 下红先 9 红 3 绿对照,head 12/12;八组消融全红后还原。
  3. 门禁:本 head check-runs 57 success、13 skipped(路径未命中)、0 失败;Check Changeset 因摘标签重跑中,此前同 head 为 success。dev 本地 85 条派生门禁 exit 0。dev 本地 dogfood 的 4 条红为 18.0 迁移夹具,main 同红,已由 4-hourly full run: red on main (CI) #28849 / PR test(dogfood): the upgrade fixtures read as written the day before the run, so a boot's rotation never drops their activity shard #28867 跟进。
  4. 接受的偏差:
  5. 范围外去向:subflowTargets 只读顶层节点、区域内的 subflow/map 对所有子流程门不可见,已立卡 automation: subflow guards read top-level nodes only, so a subflow or map inside a region body escapes every subflow guard #28846(p3,pm:blocked 待本 PR)。Skills-docs:见 skills(automation): teach the save notice — the save_notice flow node and ctx.notice in hooks (#28251) #28787。
  6. 落地条款:packages/spec/src 路径 + 收窄 ⇒ CONTRACT_REVIEW_TIER 复核 PASS 6567682353,新 head 复核 PASS 6568005316(Head-sha 与现 head 一致),needs:contract-review 已摘。semver:spec 与 service-automation patch,与 changeset 一致。check-governed-merges --pr 28875:NOT governed,676 行。落地授权,维护者原话:「按照 pm-dispatch 规则复核过的都授权」(2026-10-25T11:35Z)。

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 03:03
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 03:03
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 1335c6b Nov 2, 2026
72 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-9kerqb branch November 2, 2026 03:29
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

automation: a save_notice after a subflow or map whose child suspends passes publish and strands every decision of the parent run

1 participant