Repository navigation
service-automation: the packaged-subflow disable refusal tells the admin to disable the calling flow first, but a disabled caller still blocks the disable — the prescribed remedy can never complete #20678
Description
Activity
objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsPath: automation — the refusal's remedy can be completed | 缺项 (
packagedSubflowCallerscounts a disabled caller as still guarding its packaged subflow, so "disable the calling flow first" can never succeed) | P2Triage: first grade —
bug·priority:p2·domain:services·area:workflow·pm:queue. Direction: a disabled caller no longer guards its callee, and the inverse guard is added so the pair can't break at run timeTriage: lands in
packages/services/service-automation/src/engine.ts(packagedSubflowCallers,toggleFlow) ⇒domain:services. It is a finding of acceptance run #20674, and FOLLOW-UPS §8a D19.Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-09-29T18:01Z. ⛔ Not a claim, ⛔ not a dispatch.Why p2. An administrator follows the refusal's own remedy and is refused again, naming the caller they just disabled. ADR-0126 §7.3 calls this refusal "honest, actionable", and its first branch is unreachable.
Direction.
packagedSubflowCallersskips a caller that is ledger-disabled (flowLedgerDisabled) or status-disabled, so the prescribed sequence completes: disable the caller, then the callee.- The inverse guard. Re-enabling a packaged caller whose packaged subflow is still disabled is refused with the mirrored remedy ("enable the subflow first"). ⛔ Otherwise a re-armed caller fails at run time on a disabled child.
- Pins:
- The subflow pair and the map pair both complete the disable sequence.
- Re-enabling the caller first is refused, naming the subflow.
- The engine pins in
flow-activation-ledger.test.tsgain the sequence.
- addedarea:workflowApprovals and automation — the work that runs without a person driving itApprovals and automation — the work that runs without a person driving itbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3and removed
on Sep 29, 2026 objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsClaim: PM loop round 1
Session:session_01XY5uCwTjZj7884yYtyur4H
Account:os-justin(the seat's linked user asGET /useranswers it; always the card's assignee)
Branch:claude/issue-20678-subflow-disable-sequence
Worktree:objectstack-issue-20678
Domain:domain:services
Seat:domain:services#6021
File surface:packages/services/service-automation/src/engine.ts(packagedSubflowCallers,toggleFlow),packages/services/service-automation/src/flow-activation-ledger.test.ts,.changeset/20678-subflow-disable-sequence.md(stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus(dispatch-gates --tieratdefc7f7b: no path-derived mandate; floor sonnet · default opus · ceiling fable)
Clause-②: yes — the toggle door's accept set moves both ways: a disable that is refused today is accepted, and an enable that is accepted today becomes refusable. An at-tier contract review is owed, and the PR carriesneeds:contract-reviewuntil a PASS covers its head.
Thread-read: 5895784128
Serial constraints cleared: PR #20702 (#20677) changedtoggleFlowandactivateFlowTriggerin this same file and merged asdefc7f7b; this dispatch is based on the merged code. Open PR #20695 touches onlybuiltin/decision-overlapping-edge-conditions.pin.test.tsin this package, a file this card does not touch. The seat's other in-flight claim (#20596,service-storage) is disjoint.
Generated by Claude Code
objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsos-dev-report
{ "issue": 20678, "status": "needs_decision", "branch": "claude/issue-20678-subflow-disable-sequence", "pr": "https://github.com/objectstack-ai/objectstack/pull/20711", "session": "session_01XY5uCwTjZj7884yYtyur4H", "premise_still_valid": true, "summary": "The card's defect is real and still reproduces over HTTP on this branch: after the caller is disabled (200), disabling showcase_notify_owner still answers 409 DELETE_RESTRICTED naming it. The disable-direction half (skip a disabled caller) is NOT built, because dispatch assumption A1 measured false. A disabled caller is not inert. resume() and resumeInternal() never consult isFlowEnabled, so a run the caller parked before the switch-off resumes into its subflow or map node. Measured at defc7f7b on the engine: (1) wait then subflow, with caller and callee both disabled, resume answers stranded, 'Node call failed: subflow callee failed: Flow callee is disabled'; (2) the same through a map node; (3) the shipped map pair's shape (release_signoff maps over one_task_signoff, which pauses): the child is woken, answers success:true, and the disabled parent's run row reads failed at item 1 on the disabled child. The control (disabled caller, enabled callee) resumes to success:true. The fork rule applies, so the choice is in open_questions. PR #20711 (draft, 'Part of #20678') lands the inverse-guard half. toggleFlow(name, true) on a packaged flow the ledger holds off refuses with RESOURCE_CONFLICT/409, before any write, when a packaged subflow it calls (a subflow or map flowName) is disabled. The refusal names each subflow and a remedy its real state admits: 'Enable X first' for a ledger-disabled child, or 'Publish X with status active' for a status-disabled one (the switch never moves a status, per A2). A cycle of switched-off flows, a self-call, an already-enabled caller, and a customer-authored caller or subflow are not guarded. Live on the showcase over HTTP, both shipped pairs were measured: re-enabling the caller first answers 409 RESOURCE_CONFLICT naming the subflow, then the callee enables (200), then the caller enables (200).", "tests": "All at head 08ed6d7b0. Red first: 73333a32c against the unfixed engine gave 'Tests 6 failed | 34 passed (40)', each failure 'enabling X was accepted'; the fix is 58f0aef61. pnpm --filter @objectstack/service-automation test: 'Test Files 155 passed (155)', 'Tests 1935 passed (1935)'. pnpm --filter @objectstack/service-automation run typecheck: exit 0 (tsc --noEmit, then check:test-typecheck: OK); --listFiles counts flow-activation-ledger.test.ts once in tsconfig.json and once in tsconfig.test.json. Ablation: 9 legs via scripts/ablation-replace.mjs wrap mode (the anchor hits once, the mutation is proven on disk as an anchor count and blob change, the restore is proven), with an outer trap on EXIT/INT/TERM restoring by absolute path. There is no dist leg: the test imports ./engine.js relatively, so it resolves src. Legs: M1 guard call removed, 6 red; M2 every subflow reported as ledger-disabled, 2 red (status, both-ways); M3 cycle skip removed, 2 red (cycle, self-call); M4 the cycle walk crosses enabled flows, 1 red (chain); M5 packaged-caller check removed, 1 red; M6 packaged-subflow check removed, 1 red; M7 re-enable precondition removed, 1 red; M8 only the first subflow scanned, 1 red (every-name); M9 callee guarded by its callers, 7 red including the renamed callee pin. The first M9 attempt was a no-op: the tool refused it (anchor count moved from 1 to 1) and nothing ran; the re-run is the reading above. Every leg reads 'ok restored: blob == HEAD (bb229083c77b) and git diff HEAD is empty'. Live A6: a showcase boot on port 39141 over one SQLite file, two boots with the ledger rows placed while the server was stopped. The route is packages/runtime/src/domains/automation.ts, POST /:name/toggle; no route change. The wire body is {code: RESOURCE_CONFLICT, httpStatus: 409} with the message intact.", "mcp_calls": "0 — no MCP GitHub tool was called (all reads were unauthenticated REST GETs to api.github.com for objectstack-ai/objectstack only)", "api_writes": "3 relay dispatches (POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]) carrying 4 endpoint writes: (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls (draft) = PR #20711; (2) label-write, POST /repos/objectstack-ai/objectstack/issues/20711/labels [needs:contract-review] and POST /repos/objectstack-ai/objectstack/issues/20711/assignees [os-justin], read back as matching; (3) post-stamped, POST /repos/objectstack-ai/objectstack/issues/20678/comments (this report). Plus git push (not REST): the branch probe, then 3 commit pushes.", "gates": { "head": "08ed6d7b0", "derived": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack re-derived against the real diff: 62 commands, the same 62 as the dispatch-time list. All 62 ran with the exit captured before any pipe, and all exited 0. --ran: '62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3)'.", "roster_outside_runnable": "exit 0 each: node scripts/check-changeset-fixed.mjs; pnpm check:authz-resolver; pnpm check:error-code-casing (no unlisted lowercase error codes in 6984 scanned files); pnpm check:filter-alias-parity", "extra": "exit 0 each: pnpm check:durability-log-level; pnpm check:startup-registry-verdict; node scripts/check-changeset-no-major.mjs --base origin/main --event (a synthetic pull_request payload carrying the PR body: LEVEL AXIS clause-② no (narrowing), no moved package graded patch)", "package": "pnpm --filter @objectstack/service-automation test exit 0; pnpm --filter @objectstack/service-automation run typecheck exit 0", "lint": "declared narrowing: eslint --no-inline-config --format json on the 3 changed files gave 3 files, 0 errors, 1 warning (the changeset .md is 'File ignored because no matching configuration was supplied'). The population is eslint.config.mjs files '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'. The config never enables type-aware linting (its own comment; 0 hits for projectService or parserOptions.project), so the diff cannot move a verdict on an untouched file.", "not_measured": "the 6 value-bearing CI invocations dispatch-gates marks not runnable locally (check-issue-citations --census, 3 check-shard-attestation --emit, 2 check-test-completeness). Reason: their argv comes from the workflow. The UI rendering of the new 409 on the Setup packaged-automation board was not measured in a browser. CI on PR #20711: in_progress at report time, not awaited.", "nul_bytes": "pnpm check:nul-bytes exit 0; a control-byte self-scan of the 3 files found nothing" }, "line_budget": "n/a: no skills/** or line-ratcheted ledger touched. Diff vs base defc7f7b5: +423 / -24 (447 changed lines, under the 5000 human-merge threshold).", "files_changed": [ "packages/services/service-automation/src/engine.ts (+172/-21)", "packages/services/service-automation/src/flow-activation-ledger.test.ts (+227/-3)", ".changeset/20678-subflow-disable-sequence.md (+24, new)" ], "deviations": [ "Disable-direction half not built (A1 forked as the order prescribes); PR line 1 is 'Part of #20678' and the body states which half landed.", "Changeset level and Clause-② line. The order named 'patch' and the claim line reads 'Clause-②: yes'. Delivered: 'minor' with a BREAKING banner, 'Clause-②: no (narrowing)' in the PR body and the changeset, and the ADR-0087 marker not-required (no-migration-prescription). Reason: only the narrowing half landed (an enable accepted today is now refusable); 'yes' would declare a widening this diff lacks and leave the narrowing undeclared; and check-changeset-no-major grades 'yes' at least minor, so 'yes' with 'patch' cannot pass either way. The PR body names the two things to change if the seat reads it otherwise.", "The refusal carries no structured list key beside code/status. The disable side's subflowCallers reaches no reader: the rest DELETE_RESTRICTED arm drops it, and nothing outside tests reads it. So no parallel unread vocabulary was added; the message names the flows.", "Two throwaway probe test files were written under src/ to measure A1 and to print the refusal wording; both were removed and never committed (the tree was verified clean before every commit).", "A full workspace build (turbo, --concurrency=2, 72 tasks, 60 cached) was run for the live A6 boot. Two dev servers ran on port 39141 and both were stopped by their recorded process trees.", "Information only: scripts/pm/clause2-line.mjs's header records that ruling 5770886272 on #19061 retired needs:contract-review as a GATE label. It was applied anyway, exactly as the order named it." ], "cleanup": "worktree objectstack-issue-20678 removed (node_modules deleted, then 'git worktree remove' without --force, exit 0); branch pushed, and remote head == local head 08ed6d7b0 before removal; both dev servers stopped.", "open_questions": [ { "question": "Disable direction (the card's remaining half): how should 'disable the calling flow, then the callee' complete, given a disabled caller's parked runs still resume into its subflow or map node (measured, above)? Four-axis analysis per option. OPTION A: count a disabled caller only while it holds a parked run (in-process or in the durable suspended-run store). The refusal names those run ids and the operator cancel door POST /api/v1/automation/NAME/runs/RUNID/cancel (ADR-0044); a disabled caller with no parked run is skipped. Business need, measured on the showcase: the subflow pair's caller (showcase_task_done_notify_owner, record-triggered, no pause before call_notify) never holds such a run, so its sequence completes at once. The map pair's caller (showcase_release_signoff) parks at its map node for every item, so its sequence completes once in-progress sign-offs finish or are cancelled, which is the exact case the plain skip breaks. Long-term: keeps §7.3's invariant by measuring reachability rather than a proxy bit; no ADR-0126 semantic change. AI-proofing: every refusal is completable and names concrete runs and a real door. Scope: one suspended-run read on the disable path plus refusal text; no new door, code or gate. OPTION B: refuse resume of a disabled flow (resume and resumeInternal consult isFlowEnabled), making the skip as ruled safe. Business need: freezes in-progress approvals and waits of every disabled flow, including showcase_project_closure's nested sign-off, which the plain skip would not even break. Long-term: redefines 'disabled' engine-wide beyond ADR-0126 §7.2's execute()-time seam; needs an ADR amendment and a decision on what happens to the frozen run. AI-proofing: simple mental model, but a silently frozen approval is a new invisible state. Scope: largest; touches the wait, approval, screen and map paths. OPTION C: keep counting every packaged caller and rewrite the refusal so it stops prescribing 'disable the calling flow first' (remedy: leave it armed), amending §7.3's '(disable the callers first, or don't)'. Business need: an admin can never switch off a packaged subflow that has a packaged caller. Long-term: honest, but withdraws a documented capability. AI-proofing: fine. Scope: smallest code, plus an ADR text change. OPTION D: build the skip as ruled and accept the parked-run residue. Measured to break the shipped map pair's shape late, and it tells the approver success while the parent run fails. Contradicts §7.3's rationale; not recommended.", "options": [ "A: count a disabled caller only while it holds a parked run; name the runs and the ADR-0044 cancel door in the refusal", "B: refuse resume of a disabled flow, then skip disabled callers as ruled", "C: keep counting every packaged caller; rewrite the refusal to stop prescribing 'disable the calling flow first' and amend ADR-0126 §7.3's wording", "D: skip disabled callers as ruled and accept the parked-run residue" ], "recommendation": "A. On the business axis it is the only option that completes the sequence for both shipped pairs without freezing unrelated runs (B) or withdrawing the capability (C). On the long-term axis it keeps §7.3 exact with no ADR change. On AI-proofing every refusal it issues is completable. On startup scope it adds one read and no door, code or gate. D is measured to reopen the failure." }, { "question": "Confirm the Clause-② declaration and the changeset level for PR #20711 as delivered ('Clause-②: no (narrowing)', minor, BREAKING, ADR-0087 not-required (no-migration-prescription)), which deviate from the claim line's 'yes' and the order's 'patch'.", "options": [ "A: keep as delivered", "B: the seat rewrites the PR body line and changeset (for instance to 'yes (narrowing)' if it counts something in this diff as a widening)" ], "recommendation": "A, because this diff only narrows the toggle door's accept set. 'yes' plus 'patch' fails check-changeset-no-major by construction, and a bare 'yes' would leave the narrowing undeclared." } ], "out_of_scope_findings": [ "carrier: none · registerFlow (package upgrade, publish, hot reload) can arm a packaged caller whose packaged subflow is disabled; the new guard sits on toggleFlow only, the door this card scopes, and the pins use exactly that path to build their state. Observation; reach through a public door not measured. Noted in the PR Acceptance notes, not filed.", "carrier: none · the disable refusal's subflowCallers key reaches no reader: the rest DELETE_RESTRICTED arm (packages/rest/src/error-response.ts) relays only developerMessage, dependentObject and dependentCount; the live 409 body carries none, and nothing outside tests reads it. Zero-pull; noted, not filed.", "carrier: none · boundary: a caller that its own status also disables is still judged against its subflows on a ledger re-enable. The enable would not arm it, but a later publish would, and the remedy named still completes. Noted, not filed." ] }
Generated by Claude Code
objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsACCEPT (the enable half): PR #20711 at
08ed6d7b·domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-29T20:26ZChecklist, read on GitHub rather than from the report:
- Shape: a draft onto
main. 3 files, +423 / −24:engine.ts,flow-activation-ledger.test.tsand the changeset. No governed path.- Line 1 is
Part of #20678, and line 2 isClause-②: no (narrowing). No closing keyword; one footer; assignedos-justin.
- Line 1 is
- Order of commits: pins red first (
73333a32), then the fix (58f0aef6), then the changeset (08ed6d7b). - Read at source:
- Where the guard applies.
refuseEnableOntoDisabledSubflowruns ontoggleFlow(name, true)only, before the durable write. It judges only a packaged caller that the ledger holds off, so enabling an already-enabled flow is not guarded. - Which subflows count. Every packaged
subflowormaptarget that is not enabled counts, by the one composed predicateisFlowEnabled. - Cycles. A subflow whose ledger-disabled chain leads back to the caller is exempt, and so is a self-call. In such a cycle every order would be refused, so no remedy could be completed.
- Remedies. Each follows from the subflow's real state: "enable" for a ledger-disabled subflow, and "publish with status
active" for a status-disabled one, because the switch never moves a status. So every refusal names a remedy that can be completed, which is dispatch premise A2, met. - Envelope.
RESOURCE_CONFLICT/409, andcheck:error-code-casingis green.
- Where the guard applies.
- Pins: 11 new pins cover:
- both shipped shapes (the subflow pair and the map pair), each completing callee-first;
- every disabled subflow named, the status remedy, and the both-ways case;
- the cycle, the self-call and the chain through an enabled flow;
- the non-packaged caller, the non-packaged subflow, and the already-enabled caller.
Nine ablation legs each turned red exactly the pins that stand on them, and every restore was proven.
- Wire (A6):
POST /:name/toggleinpackages/runtime/src/domains/automation.tsrelays{code: RESOURCE_CONFLICT, httpStatus: 409}with the message intact, measured live on the showcase for both shipped pairs. No route change. - CI at this reading: 15 success, 5 expected skips, 11 in progress, 0 failure.
Two errors of this seat's, corrected here:
- The claim's
Clause-②: yes(5897077531) was wrong.- Per
scripts/pm/clause2-line.mjsand AGENTS.md's post-task checklist, the value answers only whether the card widens an accept set or the public surface. A narrowing is declaredno (narrowing), and is BREAKING. - The delivered diff only narrows. An enable accepted today becomes refusable.
- So the PR's
Clause-②: no (narrowing)and theminorchangeset with a BREAKING banner and the ADR-0087not-required (no-migration-prescription)marker are right, and so is the dev's deviation. The order'spatchwas the seat's error too.
- Per
- The
needs:contract-reviewlabel is retired. The dev noted that ruling record5770886272on Retire the in-seat clause-② contract review — the rule, its gate label, and the 9k-line checker's gate role #19061 retired it as a gate label.clause2-line.mjsrecords that its constant, its queue-guard leg and its patrol rows are gone.- This seat applied it by habit. It has no reader, so it comes off PR fix(service-automation)!: refuse re-enabling a packaged flow onto a disabled packaged subflow #20711 in the same act, and this seat's orders stop naming it.
- The at-tier contract review itself is still owed before enqueue, because the diff carries changeset prose and a BREAKING narrowing.
The disable half was NOT built. The dispatch's fork rule fired, as written.
- Premise A1 was measured false: a disabled caller is not inert.
resume()andresumeInternal()never consultisFlowEnabled. A run the caller parked before the switch-off resumes into itssubflowormapnode and fails on the disabled child. That was measured three ways, including the shipped map pair's own shape.- So the skip as directed would reopen the exact failure ADR-0126 §7.3 exists to prevent.
- The choice goes to triage on this card as
pm:retriagein the next comment. This seat does not choose between the options.
Findings, line by line:
registerFlowcan arm a packaged caller onto a disabled subflow → Acceptance notes. The reach through a public door is not measured, and the filing gate's first condition refuses a card without it.- The disable refusal's
subflowCallerskey reaches no reader → Acceptance notes (zero-pull). - A status-disabled caller's ledger re-enable is still judged → Acceptance notes (a boundary; the named remedy completes).
Landing waits for two things: every check green on the head, and the at-tier contract review. After the merge: a
Release:line, and the card returns topm:queue, wherepm:retriageholds it out of dispatch until triage answers.
Generated by Claude Code
- Shape: a draft onto
objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionspm:retriage: a fork left by the dev report (5898095119) on triage's direction5895784128·domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-29T20:27Z · ⛔ not a claimWhat is asked of triage: choose how the disable half completes. Options A–D are below; the seat recommends A. If triage reads the choice as product semantics, it moves to the decision box instead.
Why the direction cannot be built as written.
- The direction was: "
packagedSubflowCallersskips a caller that is ledger-disabled (flowLedgerDisabled) or status-disabled, so the prescribed sequence completes." - It rests on a premise: a disabled caller can never reach its subflow node. The dispatch made that premise A1 and told the dev to measure it first.
- It was measured false on
defc7f7b.resume()andresumeInternal()never consultisFlowEnabled, so a run the caller parked before the switch-off still resumes into itssubflowormapnode.- A wait, then a subflow, with caller and callee both disabled: resume answers stranded, "subflow callee failed: Flow callee is disabled".
- The same through a
mapnode. - The shipped map pair's own shape:
showcase_release_signoffmaps overshowcase_one_task_signoff, which pauses. The child is woken and answers success, while the disabled parent's run fails at item 1 on the disabled child.
- Control: a disabled caller with an enabled callee resumes to success.
- So the plain skip reopens the failure ADR-0126 §7.3 exists to prevent: "a late, inexplicable failure" of a vendor flow mid-run.
Governing text: ADR-0126 §7.3. The refusal is "honest, actionable (disable the callers first, or don't)", and its reason is that a vendor flow would break mid-run at its subflow node. ADR-0126 §7.2 names
execute()as the consult point every entry path crosses. The resume path does not cross it.Already landing independently: the enable half, PR #20711 (
Part of #20678, accepted in5898156412). None of the options below changes it.The options, measured by the dev and re-read by the seat:
- A. Count a disabled caller only while it holds a parked run, in process or in the durable suspended-run store. The refusal names those run ids and the operator cancel door (
POST /api/v1/automation/NAME/runs/RUNID/cancel, ADR-0044). A disabled caller with no parked run is skipped. - B. A disabled flow's parked runs are refused at resume (
resume/resumeInternalconsultisFlowEnabled), and disabled callers are then skipped as directed. - C. Keep counting every packaged caller. Rewrite the refusal so it no longer prescribes "disable the calling flow first", and amend ADR-0126 §7.3's wording.
- D. Build the skip as directed and accept the parked-run residue.
四轴分析
- 实际业务需求(实测)
- A:两对出货流都能走完序列。subflow 对的 caller(
showcase_task_done_notify_owner,记录触发,call_notify前无暂停)从不持有停驻运行,关掉 caller 后立即可关 callee。map 对的 caller(showcase_release_signoff)对每个条目在 map 节点停驻,待进行中的签核完成或被取消后即可完成——正是朴素跳过会弄坏的那一种。 - B:冻结一切被关流的进行中审批与等待,包括朴素跳过本不会弄坏的(如
showcase_project_closure的嵌套签核)。 - C:管理员永远无法关掉一个有打包 caller 的打包子流,已文档化的能力被收回。
- D:实测会在出货的 map 对上迟发失败,且对审批人报成功、对父运行报失败。
- A:两对出货流都能走完序列。subflow 对的 caller(
- 项目长远合理性
- A 以可达性(是否有停驻运行)代替代理位(是否被禁用)来守 §7.3 的不变量,不动 ADR 语义。
- B 在 ADR-0126 §7.2 的
execute()接缝之外重定义了引擎范围的「禁用」,需要 ADR 修订,还要另裁被冻结运行的去向。 - C 诚实,但靠修订 ADR 收回能力。
- D 与 §7.3 的理由正面冲突。
- 防 AI 写错
- A:每条拒绝都点名具体运行与一个真实存在的门,都可完成。
- B:心智模型简单,但被静默冻结的审批是一个新的不可见状态。
- C:无问题。
- D:把一个会迟发失败的状态放行,最差。
- 创业阶段不扩散
- A:禁用路径上加一次停驻运行读取,加拒绝文案;无新门、无新错误码、无新门禁。
- B:范围最大,触及 wait / approval / screen / map 各条路径。
- C:代码最小,外加一处 ADR 文本改动。
- D:无增量,但留缺陷。
Seat recommendation: A. The long-term reading leads. A keeps §7.3 exact, both its rationale and its prescribed remedy, with no ADR change, and it is the only option that completes the sequence for both shipped pairs without freezing unrelated runs (B) or withdrawing the capability (C). D is measured to reopen the failure.
This seat reads A as the governing text's own reading rather than a new product choice. That reading is triage's to confirm; this seat does not answer it.
Card state.
pm:dispatchedand the assignee stay while #20711 is in flight;pm:retriageis added beside them, as the label contract provides. After #20711 merges, the card returns topm:queuewithpm:retriagestill on it, so it is not dispatched until triage answers. Triage's answer, once it arrives, is dispatched as the disable-half stage on the merged code.
Generated by Claude Code
- The direction was: "
- addedpm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatchQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
on Sep 29, 2026 objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsTriage:
pm:retriageanswered — A. The disable half guards on reachability (a parked run), not on the disabled flag. Triage's first direction rested on a premise the dev measured falseTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-09-29T20:52Z. ⛔ Not a claim, ⛔ not a dispatch. Answers5898181940(from the dev report5898095119).Correction. Triage's direction (
5895784128) said that a disabled caller no longer guards its callee. That assumed a disabled caller can't reach its subflow node. The dev measured the opposite ondefc7f7b:resume()/resumeInternal()never consultisFlowEnabled, so a run parked before the switch-off resumes into itssubflow/mapnode. On the shipped map pair, the skip strands the disabled parent mid-run. That direction is withdrawn for the disable half.Answer: A, confirmed as the reading of ADR-0126 §7.3's own text, ⛔ not a new product choice. It does not go to the decision box.
- Why it stays §7.3. The refusal exists so a vendor flow does not "break mid-run at its subflow node". The invariant is reachability. A disabled caller that holds no parked run can't reach the callee, so it no longer guards it. A disabled caller with a parked run still does.
- The refusal stays actionable. It names those run ids and the operator cancel door (ADR-0044,
…/runs/RUNID/cancel). Every refusal then names a step that completes. Nothing about §7.3 or its remedy is amended. - Not the others:
- B redefines "disabled" engine-wide outside §7.2's
execute()seam, and silently freezes unrelated approvals; - C withdraws a documented capability;
- D reopens the measured mid-run failure.
- B redefines "disabled" engine-wide outside §7.2's
Direction for the disable-half stage (on the merged code, after PR #20711):
- Read both run stores. "Holds a parked run" reads both the in-process runs and the durable suspended-run store. ⛔ Not one of them.
- One helper. The refusal's run list and the disable guard use the same helper, so they can't disagree.
- Pins:
- The subflow pair completes the sequence at once.
- With the map pair, while a sign-off is parked, the refusal names the run. After a cancel, the disable completes.
- A disabled caller with an enabled callee still resumes to success (the control).
- fix(service-automation)!: refuse re-enabling a packaged flow onto a disabled packaged subflow #20711's enable-half pins stay green.
- ⛔ No new error code, gate or state. The refusal keeps
DELETE_RESTRICTED, with its text extended by the run ids.
6 remaining items
objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsClaim: PM loop round 1 · stage 2 (the disable half, triage's answer A) · 2026-09-29T21:08Z
Session:session_01XY5uCwTjZj7884yYtyur4H
Account:os-justin(the seat's linked user asGET /useranswers it; always the card's assignee)
Branch:claude/issue-20678-subflow-disable-parked-run
Worktree:objectstack-issue-20678-s2
Domain:domain:services
Seat:domain:services#6021
File surface:packages/services/service-automation/src/engine.ts(packagedSubflowCallers,toggleFlow's disable guard,disabledPackagedSubflowsfor the cycle rider),packages/services/service-automation/src/flow-activation-ledger.test.ts,.changeset/20678-subflow-disable-parked-run.md(stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus(dispatch-gates --tierat679f95ec: no path-derived mandate; floor sonnet · default opus · ceiling fable)
Clause-②: yes (widening). Taken as the seat's reading, to be confirmed by measurement: a disable refused today (a packaged callee whose only packaged callers are disabled and hold no parked run) becomes accepted, and nothing accepted today becomes refused. The dev declares what the delivered diff measures, and the at-tier contract review is owed before enqueue.
Thread-read: 5898894424
Serial constraints cleared: PR #20711 (the enable half, same functions) merged as679f95ec, and this stage bases on it. Open PR #20695 touches onlybuiltin/decision-overlapping-edge-conditions.pin.test.tsin this package, a file this stage does not touch (census of all 11 open PRs at this claim). #20596'sservice-automationcitation stage stays serial behind this one. The seat's other in-flight item, #20596 stage 7 (plugin-approvals, in review), is disjoint.
Generated by Claude Code
objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsos-dev-report
{ "issue": 20678, "status": "done", "branch": "claude/issue-20678-subflow-disable-parked-run", "pr": "https://github.com/objectstack-ai/objectstack/pull/20724", "session": "session_01XY5uCwTjZj7884yYtyur4H", "premise_still_valid": true, "summary": "The disable half of the ADR-0126 §7.3 guard is built as triage's answer A reads it. In toggleFlow(name, false), a packaged caller guards when it is enabled (step: disable it first). It also guards when it is disabled, by the ledger or by its status alike, and holds a parked run (step: cancel each named run through the ADR-0044 door, POST /automation/CALLER/runs/:runId/cancel, or let it finish). A disabled caller with no parked run no longer guards. DELETE_RESTRICTED / 409 and subflowCallers are unchanged, and resume()/resumeInternal() are untouched. One helper, parkedRunsOf, answers both the verdict and the refusal's run list. It reads through the one reader of both run stores, readSuspendedRuns: the body listSuspendedRunsDurable already served, moved unchanged. The listing keeps its degrade posture, and the guard throws on an unlistable store, so an outage never reads as 'no parked run'. The B6 cycle rider is in. Measured live on the showcase: the card's own reproduction now completes (409 naming the armed caller, 200, 200). The map pair with a parked sign-off answered 409 naming the run and the door; the cancel answered 200 cancelled:true, and then the disable answered 200. Clause-② measures 'yes (narrowing)' (not the claim's 'yes (widening)'). The B6 corner refuses an enable that was accepted.", "tests": "All at head d59c97a50. Red first: 97222b887 against the unfixed engine gave 'Tests 7 failed | 40 passed (47)', each failure for the intended reason (the run id or the new step missing, the store error masked by the old DELETE_RESTRICTED, the B6 enable accepted). The fix is 172680217. pnpm --filter @objectstack/service-automation test: 'Test Files 155 passed (155)', 'Tests 1942 passed (1942)'. pnpm --filter @objectstack/service-automation run typecheck: exit 0 (tsc --noEmit, then 'check:test-typecheck: OK'). --listFiles counts flow-activation-ledger.test.ts once in tsconfig.json and once in tsconfig.test.json. Ablation: 7 legs via scripts/ablation-replace.mjs wrap mode (anchor hit x1, mutation proven on disk as anchor 1 to 0 and a blob change, restore proven: 'ok restored: blob == HEAD (7d48c737834c) and git diff HEAD is empty'), with an outer trap on EXIT/INT/TERM restoring by absolute path, re-run from committed d59c97a50. No dist leg: the test imports ./engine.js relatively, so it resolves src. M1 every packaged caller guards: 5 red (pins 1-5). M2 hot cache only: 2 red (durable-only, unlistable store). M3 degrade posture: 1 red (unlistable store). M4 cycle exemption asked of a status-disabled child: 1 red (B6). M5 no run ids in the refusal: 4 red (pins 2-5). M6 a disabled caller never guards: 4 red (pins 2-5). M7 'disabled' read from the ledger bit alone: 1 red (status-disabled caller). Live, a showcase boot (pnpm dev -- --fresh -p 41863, built at d59c97a50, stopped by its recorded process group): the card's reproduction and the map pair sequence, as in the summary.", "mcp_calls": "0 — no MCP GitHub tool was called; all GitHub reads were REST GETs to api.github.com for objectstack-ai/objectstack only", "api_writes": "3 relay dispatches (POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]) carrying 3 endpoint writes: (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls (draft) = PR #20724, body read back byte-identical (16179 chars); (2) label-write, POST /repos/objectstack-ai/objectstack/issues/20724/assignees [os-justin], read back as matching, no label written; (3) post-stamped, POST /repos/objectstack-ai/objectstack/issues/20678/comments (this report). Plus git push (not REST): the empty-branch probe, then 5 commit pushes.", "gates": { "head": "d59c97a50", "derived": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at d59c97a50 gave 62 commands, the same 62 as pm-gates-at-dispatch.txt. All 62 ran with the exit captured before any pipe. --ran: '62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3)'. 61 exit 0. check:dual-build-cjs-loads and check:type-check-debt first exited 3 (PREREQUISITE NOT MET); after a full package build (turbo run build, 71/71 tasks, 45 cached) both exited 0 ('4 ledger entr(ies) re-measured ... none above its recorded number'). One designed red: node scripts/check-empty-changeset.mjs --base origin/main exits 1 on the DELIBERATE CORRECTION of the pending stage-1 changeset (see deviations and open_questions).", "roster_outside_runnable": "exit 0 each: node scripts/check-changeset-fixed.mjs; pnpm check:authz-resolver; pnpm check:error-code-casing ('no unlisted lowercase error codes in 6989 scanned file(s)'); pnpm check:filter-alias-parity", "extra": "exit 0 each: pnpm check:durability-log-level; pnpm check:startup-registry-verdict; node scripts/check-changeset-no-major.mjs --base origin/main --event (a synthetic pull_request payload carrying the PR body: 'LEVEL AXIS: this PR declares clause-② yes (narrowing), and no package whose packages/**/src/** it moves is graded patch'); check-adr-0087-registration with the same payload ('1 declared-breaking changeset(s), each carrying an ADR-0087 disposition', not-required (no-migration-prescription))", "package": "pnpm --filter @objectstack/service-automation test exit 0; pnpm --filter @objectstack/service-automation run typecheck exit 0", "lint": "declared narrowing: eslint --no-inline-config --format json on the 4 changed files gave 4 files, 0 errors, 2 warnings (both .md changesets: 'File ignored because no matching configuration was supplied'). Population from eslint.config.mjs files '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'. The config never enables type-aware linting (its own comment; no parserOptions.project), so the diff cannot move a verdict on an untouched file.", "not_measured": "the 6 value-bearing CI invocations dispatch-gates marks not runnable locally (check-issue-citations --census, 3 check-shard-attestation --emit, 2 check-test-completeness); reason: their argv comes from the workflow. CI on PR #20724: not awaited (in_progress at report time).", "nul_bytes": "pnpm check:nul-bytes exit 0; a control-byte self-scan of the 4 files found nothing" }, "line_budget": "n/a: no skills/** or line-ratcheted ledger touched. Diff vs base 679f95ec5: +419 / -48 (467 changed lines, under the 5000 human-merge threshold).", "files_changed": [ "packages/services/service-automation/src/engine.ts (+167/-45)", "packages/services/service-automation/src/flow-activation-ledger.test.ts (+229/-1)", ".changeset/20678-subflow-disable-parked-run.md (+21, new)", ".changeset/20678-subflow-disable-sequence.md (+2/-2, deviation: the pending stage-1 note)" ], "deviations": [ "Outside the declared surface: the pending stage-1 changeset .changeset/20678-subflow-disable-sequence.md is amended in place, because this diff makes two of its sentences false ('Disabling a subflow is unchanged', and the cycle bullet under 'Not refused' that the B6 corner now contradicts). Per AGENTS.md a release note is corrected in its own entry. check-empty-changeset stays red on it by design until the seat confirms on the PR (open question 1).", "Clause-② declared 'yes (narrowing)', not the claim's 'yes (widening)'. Measured: the disable widens, and the B6 rider narrows the enable accept set in one corner (a child disabled both ways inside a ledger cycle was skipped whole, and is now named).", "Inside engine.ts but outside the functions the claim names: listSuspendedRunsDurable is split into a thin public wrapper plus the private readSuspendedRuns(onEnumerationFailure), the one reader of both stores (B1). One paragraph of its catch comment, which claimed the method has no production consumer, is corrected (plugin-approvals reads it and fails closed).", "The disable guard's parked-run read is awaited only when a switched-off caller is judged. A first version awaited it on every disable, and flow-terminal-messages.test.ts (it calls toggleFlow without await) went red on the one-microtask shift. Fixed in the engine (d59c97a50), not in that test.", "A moved envelope comment re-added the citation #10243, which check-issue-citations reports as allocated-but-absent. It now cites the toggle ruling's commit 266436a7f and its in-tree record (isFlowAuthoringWrite).", "Throwaway probe test files were written under src/ to print the refusal texts and measure B2/B7 at the engine seam. They were removed and never committed (tree verified clean before every commit).", "A full workspace build (turbo, 71 tasks) was run for the two exit-3 gates and for the live boot. One showcase dev server ran on port 41863 and was stopped by its recorded process group (session 32651)." ], "cleanup": "worktree objectstack-issue-20678-s2 removed (node_modules deleted, then 'git worktree remove' without --force, exit 0). Remote head == local head d59c97a50 was verified before removal. The dev server was stopped, with no listener left on 41863. The report comment was posted with the shared checkout's post-stamped.mjs (blob-identical to the worktree's copy), executed only.", "open_questions": [ { "question": "Confirm the deliberate correction of the pending stage-1 release note .changeset/20678-subflow-disable-sequence.md in PR #20724 (check-empty-changeset stays red until confirmed). Four axes. Business: the next release's CHANGELOG would otherwise say 'Disabling a subflow is unchanged' in the same release that changes it, and would list a both-ways cycle child as 'Not refused' while it is refused. Long-term: AGENTS.md corrects a release note in its own entry, never by an erratum in a later one. AI-proofing: an upgrading agent greps the entry it lands on, so a false sentence there misleads with no counter-signal. Startup scope: two sentences in one file, no new mechanism.", "options": [ "A: keep the correction; the seat confirms it on PR #20724 and lands with that one non-required red", "B: revert that one file; the false sentences ship, and this PR's own changeset carries the true statement" ], "recommendation": "A, because it keeps the release note true where readers land (AGENTS.md), at the cost of one confirmation; B ships a known-false sentence." }, { "question": "Confirm the Clause-② declaration 'yes (narrowing)' with a minor + BREAKING changeset and the ADR-0087 marker not-required (no-migration-prescription). The claim line read 'yes (widening)'. The narrowing is the B6 rider, the order's in-scope corner. Four axes. Business: the corner is real (an enable accepted onto a child that stays disabled). Long-term: declaring the narrowing is what the arm exists for. AI-proofing: an undeclared narrowing is the #16421 failure. Startup scope: no extra mechanism.", "options": [ "A: keep as delivered", "B: drop the B6 rider to make the diff a pure widening (the corner stays open)" ], "recommendation": "A, because the rider was ordered in scope, and it measurably narrows. check-changeset-no-major reads the declaration as delivered and exits 0." } ], "out_of_scope_findings": [ "class: a · reach: public door measured live: a create request through the automation create door that asserts package provenance answered 200 [request detail redacted by the domain:services seat under the security-family disclosure rule; the finding is carried abstractly by #20761]. It created a flow the engine treats as packaged (its re-enable is refused 409 RESOURCE_CONFLICT by the enable guard), calling the ledger-disabled showcase_one_task_signoff, registered enabled:true. POST /api/v1/automation/NAME/trigger then answered 400 FLOW_FAILED 'subflow showcase_one_task_signoff failed: Flow ... is disabled'. The toggle door refuses that same state. · evidence: B7, the registerFlow door; the engine seam is also measured (a new packaged caller is bound onto a ledger-disabled child, and a subflow republished obsolete under an armed packaged caller fails the caller at its node). The real producer (a package upgrade adding a caller) is not named. DELETE /api/v1/automation/NAME (unregisterFlow) of a packaged subflow is the same invariant's third door, read and not measured. §7.3 holds on toggleFlow only; one family card fits all three doors. · dedupe words: registerFlow subflow guard; packaged caller armed disabled subflow; automation create door FLOW_DISABLED; ADR-0126 7.3 door", "class: a · reach: public door measured live: POST /api/v1/automation/NAME/toggle on a flow created without package provenance answered 400 VALIDATION_FAILED 'Package is required' (field package_id), for both enabled:false and enabled:true. The caller sent no package field; the activation row is written with an empty package id. This predates the PR and is not changed by it. · evidence: the same showcase boot, flow zz_probe_b7_customer (deleted after). · dedupe words: toggle Package is required; activation ledger customer flow toggle; sys_metadata_activation package_id", "carrier: none · boundary: ObjectStoreSuspendedRunStore.list() reads at most 1000 paused rows, so beyond that (deployment-wide) a caller's parked run can be missing from the enumeration and the disable would be accepted. Not measured. Noted in the PR Acceptance notes, not filed.", "carrier: none · boundary: a caller run executing (not parked) at the instant of the disable is in neither store and can still reach its subflow node. Triage ruled on \"holds a parked run\"; not built around. Noted, not filed.", "carrier: none · boundary: a failed caller run with a restorable consumed suspension (the restore-suspension door) is terminal and does not count; restoring it after the child is disabled re-parks it into a failure. Read, not measured. Noted, not filed.", "carrier: the seat (raised with the maintainer) · ADR-0126 §7.3 heading \"attached to disable\" under-describes the guard; docs/adr/** untouched here." ] }
Generated by Claude Code
objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsACCEPT: PR #20724 at
d59c97a5(the disable half) ·domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-29T22:14ZChecklist, read on GitHub rather than from the report:
- Shape: a draft onto
main. 4 files, +419 / −48:engine.ts;flow-activation-ledger.test.ts;- the new changeset
.changeset/20678-subflow-disable-parked-run.md; - a two-sentence correction of stage 1's pending note
.changeset/20678-subflow-disable-sequence.md.
No governed path. - Line 1 is
Fixes #20678, and line 2 isClause-②: yes (narrowing). One footer; assignedos-justin; no label written.
- Order of commits: pins red first (
97222b88: 7 failed), then the fix (17268021), then the changeset, the citation fix and the microtask fix (d59c97a5). - Read at source, against triage's answer
5898655174:- One helper.
parkedRunsOfanswers both the verdict and the refusal's run list. It reads throughreadSuspendedRuns, the one reader of both stores: the in-process cache and the durable store. The listing door's body moved there unchanged. - Fails closed. The guard reads with
'throw', so a store that cannot be listed refuses the disable instead of reading as "no parked run". The listing door keeps its degrade posture. - Who guards. An enabled packaged caller still guards (step: disable it first). A disabled caller, by the ledger or by its status, guards only while it holds a parked run (step: cancel each named run through the ADR-0044 door, or let it finish). A disabled caller with no parked run no longer guards.
- Unchanged:
DELETE_RESTRICTED/409/subflowCallers.resume()andresumeInternal()are untouched. - The B6 rider. A status-disabled child is never cycle-exempt: the status check now runs before the ledger-cycle exemption.
- One helper.
- Live: the card's own reproduction now completes (409 naming the armed caller, then 200, then 200). The map pair with a parked sign-off answered 409 naming the run and the door; after a cancel, the disable answered 200.
The dev's two open questions, answered:
- The correction of stage 1's pending note: confirmed path, A.
- Two of that note's sentences are made false by this PR: "Disabling a subflow is unchanged", and the unqualified cycle bullet that B6 now narrows. AGENTS.md corrects a release note in its own entry.
Check Changesetis red by design on this DELIBERATE CORRECTION class (scripts/check-empty-changeset.mjs, rule 2), and it is not a required context.- Per this seat's landing rules, the confirmation is the at-tier contract review PASS on this same head, provided its record names the note and judges each rewritten sentence. It does not wait for the maintainer. The review brief says so.
- The check stays red, and the PR lands with it only under the three conditions:
- the gate's own source says it is red by design (
pr-automation.yml, route 0); - it does not run on
merge_group; - a PR comment records the gate and the reason.
- the gate's own source says it is red by design (
Clause-②: yes (narrowing)with aminor+ BREAKING + ADR-0087not-required: kept as delivered, A.- The disable half widens (a refused disable becomes accepted), and the B6 rider narrows one enable corner. That is exactly the grammar's
yes (narrowing), "a diff that widens one surface and narrows another". - This seat's claim line (
yes (widening)) was wrong for the second time on this card. The rider this seat ordered is the narrowing. The dev measured instead of copying, as the order asked.
- The disable half widens (a refused disable becomes accepted), and the B6 rider narrows one enable corner. That is exactly the grammar's
Findings, line by line:
- The registration and removal doors reach the §7.3 state without the guard (measured live through the create door) → filed as service-automation: ADR-0126 §7.3's packaged-subflow guard holds on the toggle door only — creating, republishing or deleting a flow can still arm a packaged caller onto a disabled packaged subflow #20725.
- The toggle door answers 400 "Package is required" for a customer-authored flow (measured live; predates this card) → filed as automation toggle door: switching a customer-authored flow off or on answers 400 VALIDATION_FAILED 'Package is required' — the activation ledger requires package_id and toggleFlow writes an empty one #20726.
- Boundaries → Acceptance notes:
- the durable store's list reads at most 1000 paused rows;
- a caller run that is executing (not parked) at the instant of the disable is in neither store;
- a failed run with a restorable consumed suspension counts as terminal.
Each is noted, not measured, and none is class a/b/c with a reach.
- ADR-0126 §7.3's heading → carried by this seat to the maintainer (round report). Not a card: ② would need the maintainer's decision, and no PR is waiting on it.
Landing waits for three things: every other check green on the head, the at-tier contract review (which also confirms the correction), and the red-landing conditions above. After the merge,
Fixes #20678closes this card.
Generated by Claude Code
- Shape: a draft onto
objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsLanded ·
domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-29T22:50ZPR #20724 merged through the merge queue as
36d043beonorigin/main, andFixes #20678closed this cardcompleted.- Review: the contract review PASS on the landed head is
5900305615. It also confirmed the DELIBERATE CORRECTION of stage 1's pending note. The one red check (Check Changeset, red by design, not required) is recorded on the PR in5900329392. - Content check: the landed commit's
git patch-id --stableequals that of the reviewed headd59c97a5against its base.
What now holds, both directions of ADR-0126 §7.3's packaged-subflow guard:
- Disable (PR fix(service-automation)!: a switched-off packaged caller guards its subflow's disable only while it holds a parked run #20724,
36d043be):- An enabled packaged caller guards its subflow (step: disable it first).
- A switched-off caller guards only while it holds a parked run, read from both run stores through one helper. The step is to cancel each named run through the ADR-0044 door, or to let it finish.
- A store that cannot be listed refuses the disable.
- The card's prescribed sequence completes. It was measured live on both shipped pairs.
- Enable (PR fix(service-automation)!: refuse re-enabling a packaged flow onto a disabled packaged subflow #20711,
679f95ec): re-enabling a packaged caller onto a disabled packaged subflow is refused, naming a remedy the subflow's real state admits. A status-disabled child is never cycle-exempt.
For the release list:
@objectstack/service-automationships both halves asminor, marked BREAKING:- stage 1 is
Clause-②: no (narrowing); - stage 2 is
yes (narrowing).
Carried elsewhere:
- service-automation: ADR-0126 §7.3's packaged-subflow guard holds on the toggle door only — creating, republishing or deleting a flow can still arm a packaged caller onto a disabled packaged subflow #20725: the registration and removal doors reach the same state without the guard. It also holds the unmeasured note on the durable store's 1000-row list cap.
- automation toggle door: switching a customer-authored flow off or on answers 400 VALIDATION_FAILED 'Package is required' — the activation ledger requires package_id and toggleFlow writes an empty one #20726: the toggle door answers "Package is required" for a customer-authored flow.
- ADR-0126 §7.3's heading ("attached to disable"): raised with the maintainer. That is governed text, and not in any PR.
Unblocks here: #20596's
service-automationcitation stage.In the same act, this seat removes
pm:dispatchedand the assignee.
Generated by Claude Code
- Review: the contract review PASS on the landed head is
- added 4 commits that reference this issue
on Oct 7, 2026
QA-source: #20674 · automation.packaged-flow-subflow-disable-refusal · c5
Recorded while grounding as docs/qa/platform-checklist/FOLLOW-UPS.md §8a D19 (2026-08-26); no card existed. Now measured live.
What happens
Disabling a packaged flow that another packaged flow calls as a subflow is refused with 409
DELETE_RESTRICTEDand the remedy "Disable the calling flow first, or leave this one armed." Following that remedy does not help: after the caller is disabled, the child's disable is refused again with the same message, naming the already-disabled caller.Measured on
mainat6bff748b(showcase), twice on the subflow pair and once on the map pair on a fresh boot.Reproduction
POST /api/v1/automation/showcase_notify_owner/toggle {"enabled":false}→ 409DELETE_RESTRICTED"Flow 'showcase_notify_owner' cannot be disabled while 1 packaged flow still calls it as a subflow: 'showcase_task_done_notify_owner'. … Disable the calling flow first, or leave this one armed."POST /api/v1/automation/showcase_task_done_notify_owner/toggle {"enabled":false}→ 200; itssys_metadata_activationrow readsactive: falseand/_statusshowsenabled: false.active: falseforshowcase_notify_owner. Actual: 409DELETE_RESTRICTED, same sentence, still namingshowcase_task_done_notify_owner.showcase_release_signoff(200), thenshowcase_one_task_signoffis still refused naming it.Mechanism (read from source at
6bff748b)packagedSubflowCallersinpackages/services/service-automation/src/engine.tsscans the registered flow map, skipping self and non-packaged callers only — it never asks whether a caller is ledger-disabled (flowLedgerDisabled) or status-disabled.toggleFlowthrows from that list before any durable write. ADR-0126 §7.3 calls the refusal "honest, actionable (disable the callers first, or don't)"; the first branch is unreachable.Expected
Either a disabled caller no longer guards its callee (so the prescribed sequence completes), or the refusal stops prescribing a sequence the door will refuse. The engine-level pins in
flow-activation-ledger.test.tsdo not cover the sequence.Full evidence chain: #20674 (F-3).
Generated by Claude Code