Skip to content

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

Closed

Description

@objectstack-fleet

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

Blocked-by: PR #28838

Filing gate: ① should-fix of the CONTRACT_REVIEW_TIER review 6565504502 on PR #28838 (#28806). Measured by that PR's rewritten round-trip test (packages/plugins/plugin-approvals/src/save-notice-approval-round-trip.integration.test.ts). Filed by domain:server seat 3. ⛔ Not a claim.

Reach: after PR #28838, the placement door refuses a save_notice reachable from an approval, wait or screen node in the same graph. It cannot see a suspension inside a child flow. start → save_notice → subflow(child with an approval) → save_notice still parses. At run time:

  • the parent suspends at the subflow node;
  • the decision resumes the child, and bubbleToParent resumes the parent from the store;
  • the parent's second save_notice refuses by name;
  • the parent lands on the stranded exit, and decide answers 200 with resumed: true and resumeFailure: RESUME_FAILED.

So every decision strands the parent run. A map whose item flow suspends has the same shape. The parse cannot refuse by node type: FLOW_PAUSE_CAPABLE_NODE_TYPES leaves out subflow and map by ruling D, and start → subflow(synchronous child) → save_notice runs correctly today.

Wanted

The author learns before the first decision that a save_notice reachable after a subflow or map whose child can suspend never answers a write.

Direction: refuse it where the child flow is in hand. That is the existing registerFlow / activation door, where subflow-guards.ts already resolves subflowTargets. Reuse the #28806 sentence shape and name the subflow / map node and the child's suspending node. ⛔ No new gate: extend that door, or state why it cannot carry this. ⛔ Do not change FLOW_PAUSE_CAPABLE_NODE_TYPES (ruling D), the stranded-decision posture or RESUME_FAILED.

This narrows activation: a flow set that registers today is refused. So it is Clause-②: no (narrowing), with a CONTRACT_REVIEW_TIER review owed. The dev measures shipped flows (showcase, dogfood, hotcrm) for the shape. None is expected.

Dedupe

Read open issues for save_notice subflow, subflow approval strand and resumeFailure: #28806 (the same-graph half, PR #28838) and objectstack-ai/hotcrm#2398 (HotCRM places save_notice before its approval). New.

Reader who acts: domain:server (packages/services/service-automation registration guards, or packages/lint only if no registration door sees the child).

Links back: #28806, PR #28838, #28786, #28251

Activity

  1. objectstack-fleet commented on Nov 1, 2026

    @objectstack-fleet
    ContributorAuthor

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

    Path: 能用的自动化 | none | P③

    Triage: p3 · domain:server · area:automation · 2026-11-01T23:30Z

    ⛔ Not a claim.

  2. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

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

    Claim: PM loop round 1 · card thread · 2026-11-02T00:06Z
    Session: session_01FEkQ5kE5BrPUPNMMyv6oCH (the seat); the card thread is session_01FEkQ5omF1nCbQTGgW1nbRA
    Account: os-project-manager (the seat's linked user; the card's assignee)
    Branch: claude/project-thread-9kerqb (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
    Worktree: the card thread's own container checkout
    Domain: domain:server
    Seat: domain:server#3
    Ruling-ref: the maintainer, 2026-11-01T03:46Z: 「server 车道现在就是你负责,任务有一大堆啊,都可以派,包括 p3」, with 2026-10-26T10:29Z 「p1 p2 处理完了,就可以处理 p3」, and 2026-11-01T06:39Z 「server 队列还是很多任务,再开一个席位吧」. Seat 3 rules the door is the existing registration guard, with load-order dependence escalated rather than guessed, recorded in the order.
    File surface: packages/services/service-automation/src/ registration guards (subflow-guards.ts or the registerFlow door) and tests, packages/spec/src/automation/save-notice-placement.ts only to share the walk, packages/plugins/plugin-approvals/src/save-notice-approval-round-trip.integration.test.ts (re-pointed), changesets. ⛔ No new gate; ⛔ no FLOW_PAUSE_CAPABLE_NODE_TYPES change; ⛔ no ui/**.
    Container & model: S, mode:thread, model: opus (--tier names no path mandate: PM's call, default), effort: default (the coordinator sets effort only from the maintainer's words)
    Clause-②: no (narrowing) — a flow set that registers today (a save_notice after a subflow/map whose child suspends) is refused; CONTRACT_REVIEW_TIER review owed, run by the seat
    Metadata-protocol: none (expected; the dev measures and declares)
    Responsibility: card thread os-dev: implement, red-first, gates, os-dev-report on the card; the seat reviews, runs the contract review, and lands
    Thread-read: 6565522139
    Premise: measured by PR #28838's rewritten round-trip test (the parent strands with resumeFailure: RESUME_FAILED); the parse cannot see the child
    Serial constraints cleared: PR #28838 (same area) merged as 2cba2eb3
    Resume-from: none — the branch restarts from origin/main at 2cba2eb3 or later (its PR #28838 is merged)

    State: → pm:dispatched, assigned to os-project-manager, recorded by this claim. Order: pm-orders/server/order-28840.md (project folder).

  3. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · os-project-manager · session_01FEkQ5omF1nCbQTGgW1nbRA
    os-dev-report
    {
    "issue": 28840,
    "status": "needs_decision",
    "branch": "claude/project-thread-9kerqb",
    "pr": null,
    "head": "2cba2eb3 (branch restarted on origin/main; no commit, nothing pushed)",
    "session": "session_01FEkQ5omF1nCbQTGgW1nbRA",
    "premise_still_valid": true,
    "summary": "Stopped at the order's registration-order line: a refusal in the registerFlow guard would depend on load order, so I measured it and did not guess. The registration checks receive only (name, flow) and run before the flow is held (engine.ts:4488 loop, engine.ts:4507 this.flows.set), so a check can see only flows registered before it. A parent that names a child not yet registered registers today (probe: registerFlow('parent') with subflow flowName 'child_not_yet' succeeded, flows held: ['parent']). The boot pull registers in registry listItems order with no subflow ordering (plugin.ts:2156-2181, resolveFlowContenders, no sort), and the protocol re-sync and publish paths register one flow at a time. So: child first, the parent would be refused; parent first, it registers, and nothing refuses an already-held flow when the child arrives. The premise holds (the trap is real and the parse cannot see it); only the door is in question.",
    "tests": "Probe only (src/zz-order-probe.test.ts, deleted after the run): 1 passed, parent with an unregistered subflow target registered. No change made, so no red-first or ablation.",
    "gates": "not run: no diff",
    "dogfood_battery": "n/a: no diff",
    "line_budget": "0 lines",
    "deviations": [
    "No implementation: the order says to raise needs_decision rather than guess when the refusal would depend on load order, and it does."
    ],
    "files_changed": [],
    "mcp_calls": "3: mcp__github__issue_read x3 (get #28840, get_comments #28840; review 6565504502 not re-read, the card body quotes its should-fix); no write tool",
    "api_writes": "1: this report comment (post-stamped.mjs). No push, no PR.",
    "open_questions": [
    {
    "question": "The registerFlow guard cannot refuse 'a save_notice after a subflow/map whose child can suspend' independently of load order. Which door should carry it?",
    "status": "needs_decision",
    "options": [
    "A: the arming gate, not the registration throw. Extend activateFlowTrigger's existing subflow half (declineOntoDisabledSubflows, engine.ts:5465, called at engine.ts:3882): a record-triggered flow whose save_notice is reachable after a subflow/map whose child can suspend (transitively, cycle-safe) is registered but NOT armed, with a warn naming the subflow node and the child's suspending node, and the reason on /_status (subflowDeclinedFlows-style record). rejudgeSubflowCallers (engine.ts:4565, run on every child (re)registration) re-asks the gate, so the verdict is the same whatever order the flows load in. Today that gate covers packaged callers of packaged subflows only; this widens its reading to every record-triggered caller for this one rule.",
    "B: the registration throw where the child is in hand, plus ordering. Refuse at registerFlow when the child is held, and make the boot pull and re-syncs register children before callers (topological by subflowTargets). A child republished later to add an approval would still reach registered callers unrefused, unless the child's own registration is refused for its callers, which refuses a valid child for another flow's mistake.",
    "C: no door for this half. Keep the run-time refusal by name and the strand answer (resumeFailure RESUME_FAILED), which is loud at the first decision; carry the rule in the #28787 skill teaching only."
    ],
    "analysis": {
    "real_business_need": "Measured on the review's reading and the card: no shipped flow (showcase, dogfood, hotcrm main) has the shape; HotCRM places save_notice before its approval (#28788). The pull is the AI-authoring trap of #28806's class, not a live consumer.",
    "long_term_soundness": "A reuses the platform's existing, order-independent answer to the same class (ADR-0126 §7.3: decline to arm a caller that a subflow would fail mid-run, re-judged when the subflow changes). B adds load-order machinery to two pull paths and still leaves the child-republish hole, or refuses valid children. C leaves the trap until the first decision.",
    "ai_error_resistance": "B gives a 4xx at save when the child exists first (the usual authoring order) but is silent in the other order, which is the inconsistent signal AI authors learn least from. A never refuses the save, but the flow is visibly not armed, with a reason that names the fix, from the first registration; nothing runs and strands. C is loud only at run time.",
    "startup_focus": "New gates default to no. A extends an existing gate's reading and adds no gate; B adds ordering machinery; C adds nothing."
    },
    "recommendation": "A, because it is the one door whose verdict does not depend on load order, it reuses the existing ADR-0126 §7.3 decline-and-rejudge mechanism instead of adding one, and it stops the strand before any record is written. If a refusal at save is required instead, B needs a ruling on the child-republish hole first."
    }
    ],
    "out_of_scope_findings": [
    "class: c · reach: subflowTargets (subflow-guards.ts:59) reads top-level nodes only, so a subflow/map inside a loop/parallel/try_catch body is invisible to every subflow guard; whichever option is chosen should walk regions with collectFlowGraphs · noted, not filed"
    ]
    }

  4. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

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

    Seat ruling · #28840 · option A · 2026-11-02T00:12Z

    This answers the os-dev-report 6565979681 needs_decision.

    Ruled: A. Extend the existing arming gate (declineOntoDisabledSubflows, re-judged by rejudgeSubflowCallers on every child registration). A record-triggered caller whose save_notice is reachable from a subflow / map node whose child can suspend is registered but not armed. It gets a warn, plus a /_status reason that names the subflow / map node and the child's suspending node.

    Why, on the four axes:

    • Real business need: the trap is measured (PR feat(spec)!: refuse at publish a save_notice that a suspending node reaches #28838's round trip strands every decision).
    • Long-term soundness: A is the only option whose verdict does not depend on load order or on a later child republish. It is the ADR-0126 §7.3 precedent.
    • Keeping AI authors from erring: A is loud on /_status and in the log, never silent.
    • No scope creep at the startup stage: A adds no new gate; it widens the reasons of a gate that already declines to arm.

    B leaves the republish hole. C leaves the trap as it is.

    • The posture is "not armed", not "refused at registration". Declare Clause-②: no (narrowing): a flow that arms today will not arm. The CONTRACT_REVIEW_TIER review is still owed.
    • Re-arming: when the child stops suspending (republished), the re-judge arms the caller again, as the disabled-subflow reason does today. Pin both directions.
    • The round-trip test: keep it registering the shape, and pin that the caller is not armed, with the reason. Keep the resumed-leg pin wherever it stays reachable, and say where.
    • Out of scope, filed separately by the seat: subflowTargets reads top-level nodes only, so a subflow / map inside a region body is invisible to every subflow guard. ⛔ Not in this PR.
  5. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · os-project-manager · session_01FEkQ5omF1nCbQTGgW1nbRA
    os-dev-report
    {
    "issue": 28840,
    "status": "done",
    "branch": "claude/project-thread-9kerqb",
    "pr": "#28875",
    "head": "6998341844351372c560b56bc48cb065547a7551",
    "session": "session_01FEkQ5omF1nCbQTGgW1nbRA",
    "premise_still_valid": true,
    "ruling": "seat option A, comment 6565992870: not armed + warn + /_status reason, re-judged on every child registration, re-arm pinned both directions, Clause-② no (narrowing)",
    "summary": "activateFlowTrigger now declines, right after its disabled-subflow half, a flow whose save_notice is reachable (any path over top-level edges, loops included, region bodies judged by their container) from a top-level subflow/map node whose child can suspend: a FLOW_PAUSE_CAPABLE_NODE_TYPES node reachable from the child's start, or a reachable call to a child that can suspend, transitively, cycle-safe. The flow registers, is not armed, warns once per reason, and /_status reports bound:false with a reason naming the save_notice, the subflow/map node and the child's suspending node (and the flow holding it when reached through further calls), unboundCause notice_after_suspending_subflow (new FLOW_UNBOUND_CAUSES value, Eq pin updated). rejudgeSubflowCallers keeps its packaged-caller loop and adds a loop over every transitive caller (any flow), so the decline is load-order independent and re-arms/re-declines when a child or grandchild is republished. The plugin-approvals round-trip test now pins the decline through the real trigger (create starts nothing) and keeps the resumed-leg pin through automation.execute with a hand-built notice channel.",
    "tests": {
    "red_first": "service-automation subflow-notice-arming.test.ts: 12 cases; production sources at base: 9 failed / 3 passed (the 3 controls); head 12/12",
    "ablations": [
    "do not follow subflow targets (src): 9 decline pins red",
    "no transitive call walk in the child: 3 two-calls-away pins red",
    "no save-notice re-judge loop: 4 load-order/re-arm pins red",
    "every call counts as suspending: 8 red incl. non-suspending control and cycle control",
    "notices reachable from start instead of after the call: notice-ahead control red",
    "ignore region container: region pin red",
    "all src ablations via ablation-replace.mjs, restored blob == HEAD",
    "service-automation dist gate call removed: round-trip decline case red (and the resumed-leg case, via the run row case 1's create leaves); dist no-channel refusal disabled: resumed-leg case red; both restored by hand to blob a37ec0dd"
    ],
    "round_trip": "plugin-approvals save-notice-approval-round-trip.integration.test.ts: case 1 = registered, not armed, reason names subflow 'review' and approval 'ask', create starts no run; case 2 = resumed-leg pin via automation.execute (RESUME_FAILED stranded, refusal by name, history row); case 3 = #28806 registerFlow refusal unchanged",
    "suites": {
    "@objectstack/service-automation": "exit 0 (255 files, 3281 tests)",
    "@objectstack/spec": "exit 0 (875 files)",
    "@objectstack/plugin-approvals": "exit 0 (107 files)",
    "@objectstack/trigger-record-change": "exit 0",
    "@objectstack/trigger-schedule": "exit 0",
    "@objectstack/example-showcase": "exit 0",
    "@objectstack/client": "exit 0"
    },
    "typecheck": "pnpm typecheck per package (service-automation, spec, plugin-approvals, client) exit 0; workspace pnpm typecheck with NODE_OPTIONS=--max-old-space-size=6144 exit 0 (190/190); at the default heap the cli test typecheck was OOM-killed (137)"
    },
    "gates": {
    "derived": 85,
    "run": 85,
    "not_measured": 0,
    "unrun": 0,
    "reconcile": "dispatch-gates --ran exit 0, all 85 exit 0",
    "check_changeset_no_major_with_pr_body": "exit 0 (narrowing, none, patch)",
    "eslint": "--no-inline-config over packages/services/service-automation packages/spec packages/plugins/plugin-approvals: exit 0"
    },
    "dogfood_battery": "6998341844 under os-verify-lock: closure build VERDICT command-exit 0 (317s); dogfood test VERDICT command-exit 1 (1255s): 376 passed, 3 skipped, 4 failed files; 3115 passed, 28 skipped, 4 failed tests. The 4 reds (upgrade-18x-harness, audit-key-material, audit-mail-bodies, identity-mail-read-gate; missing sys_activity copies, 'no such table: sys_activity__r20261019') reproduce identically with this PR's service-automation and spec sources at base and both dists rebuilt (4 failed / 45 passed), so they are not this change.",
    "shipped_flows": "examples/** + packages/qa/**: one save_notice (showcase_task_completed: start, script, save_notice, notify, end; no subflow/map); hotcrm origin/main a598256: no save_notice",
    "line_budget": "no budget in the order; diff 9 files changed, 663 insertions(+), 13 deletions(-) (guard module about 200 lines incl. docblocks; engine/definitions/guards about 110 lines incl. comments; the rest tests and changeset)",
    "deviations": [
    "Metadata-protocol declared none (both packages patch): registration, the save door and validate accept the same flows; only runtime arming changes, and the spec change is one value in a runtime status response list no author writes. check-changeset-no-major passes with the PR body.",
    "New FLOW_UNBOUND_CAUSES value notice_after_suspending_subflow in @objectstack/spec contracts: the tuple forbids catch-alls and this cause has its own owner and remedy; no new export.",
    "The #28806 reachability walk is repeated in service-automation (subflow-notice-guard.ts, about 40 lines) instead of exporting spec's internal suspendedReach: the order prefers an internal entry and spec has none; it reuses public collectFlowGraphs and FLOW_PAUSE_CAPABLE_NODE_TYPES.",
    "Touched plugin-approvals save-notice-approval-round-trip.integration.test.ts (test only), as the order's hypothesis named: re-pointed per the ruling.",
    "rejudgeSubflowCallers' body in subflow-guards.ts is edited (its docblock no longer says moved unedited)."
    ],
    "files_changed": [
    ".changeset/28840-subflow-notice-arming.md",
    "packages/plugins/plugin-approvals/src/save-notice-approval-round-trip.integration.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-arming.test.ts",
    "packages/services/service-automation/src/subflow-notice-guard.ts",
    "packages/spec/src/contracts/automation-service.test.ts",
    "packages/spec/src/contracts/automation-service.ts"
    ],
    "mcp_calls": "3: mcp__github__issue_read x3 (get #28840, get_comments #28840 before the needs_decision report, get_comments #28840 for the ruling 6565992870); no write tool",
    "api_writes": "4: the needs_decision report 6565979681 (post-stamped), git push (new branch), fleet-write pr_create (run 41376354791, draft PR #28875, no labels, no assignees, read-back identical 11852 bytes), this report (post-stamped). No GitHub MCP writes.",
    "open_questions": [],
    "out_of_scope_findings": [
    "The 4 dogfood 18.0-fixture reds above also fail with this PR's sources at base; they look environment- or main-side (the r20261019 ceremony table is missing), not investigated further.",
    "#28846 (region-nested subflowTargets) left out as ruled.",
    "#28787 should teach this rule beside the #28806 placement rule; recorded as the PR's Skills-docs line."
    ]
    }

  6. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

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

    ACCEPT → PR #28875 comment 6568021614 · head 3ca80e70 · 2026-11-02T03:03Z · landing sent (ready + auto-merge; Clause-②: yes (narrowing), spec path, CONTRACT_REVIEW_TIER PASS 6567682353 with new-head re-confirm 6568005316). Fixes objectstack-ai/objectstack#28840, so the merge closes this card. Out-of-scope finding filed as #28846.

  7. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

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

    Landing record · #28840 · PR #28875

    domain:server seat 3 · session_01FEkQ5kE5BrPUPNMMyv6oCH · 2026-11-02T03:30Z

    Reading Probe Result
    Queue membership git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-28875-*' 0 refs (released)
    Ancestry git merge-base --is-ancestor 1335c6b8 origin/main yes
    Content git grep -n "declineOntoSuspendingSubflowNotice" origin/main -- packages/services/service-automation/src/engine.ts; git grep -n "notice_after_suspending_subflow" origin/main -- packages/spec/src/contracts/automation-service.ts arming call at line 3901, gate at line 5535; spec cause at line 946
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions