Repository navigation
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
Description
Activity
objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsA further boundary of the same invariant, recorded here and not filed separately ·
domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-29T22:32Z · ⛔ not a claimThe at-tier review of #20678's disable half (PR #20724, record
5900305615) escalated this.- The reader behind the guard is capped.
ObjectStoreSuspendedRunStore.list()reads at most 1000pausedrows, deployment-wide. The disable guard reads parked runs through it (via the engine's one reader of both run stores). - Consequence: past that cap, a switched-off caller's parked run can be missing from the enumeration, and the disable of its packaged subflow would be accepted. That is the §7.3 state this card is about, reached by an incomplete read instead of an unguarded door.
- Not measured. No deployment with more than 1000 paused runs was read, so the filing gate's reach condition is not met for a card of its own. It is recorded here because it is the same invariant, and whoever takes this card should decide whether the guard's read must be complete (a filtered read by flow name, or paging) or whether the cap is acceptable and says so in the refusal path.
Generated by Claude Code
- The reader behind the guard is capped.
objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsPath: automation — an installation's switch-off holds on every door | 缺项 (ADR-0126 §7.3's packaged-subflow invariant is enforced on
toggleFlowonly, so registration and removal can still leave a packaged caller armed onto a disabled or missing packaged subflow, which fails at its subflow node) | P2Triage: first grade —
bug·priority:p2·domain:services·area:workflow·pm:queue. Direction: one invariant, enforced at the one arming seam and at the removal door, through the same reading the toggle guard uses. Registration never fails bootTriage: lands in
packages/services/service-automation/src/engine.ts(activateFlowTrigger,unregisterFlow, the §7.3 helpers) ⇒domain:services. The runtimeDELETEroute is consulted, not edited.Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-09-29T23:59Z. ⛔ Not a claim, ⛔ not a dispatch.Why p2. It takes #20678's grade (p2): the same §7.3 invariant, reached through other doors.
- It is measured once through a public door, and the failure is loud:
FLOW_FAILEDat the subflow node. - The natural producer is a package upgrade that adds a caller onto a subflow the installation holds off. It is not measured.
Direction.
- Registration: one gate, not a refusal.
activateFlowTrigger, the single arming gate since service-automation: a restart re-arms the trigger of a ledger-disabled packaged flow — registerTrigger ignores the activation ledger, and every matching event then logs an ERROR claiming a run-history record that is never written #20677, also declines to arm a packaged caller whose packagedsubflow/maptarget is ledger-disabled.- It reads that through the same helper
refuseEnableOntoDisabledSubflowuses. ⛔ No second reading of "A calls B". - Create, republish, upgrade and hot reload then all inherit it. The caller registers and stays unarmed:
/_statusreports itbound: false, and the engine logs a warning that names the disabled subflow. - ⛔ Registration itself is not refused: boot and upgrade must never fail on an installation's choice.
- It reads that through the same helper
- Removal.
unregisterFlowof a packaged subflow that an armed packaged caller still calls is refused throughDELETEwith 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's refusal family and remedies. If an upgrade or uninstall path also callsunregisterFlow, the dev reads it and states whether it is gated the same way. - A complete read (the note on this card,
5900362155). A guard that decides from a truncated list is not a guard. The parked-run read behind the disable guard asks for the named callers' runs; it does not read a capped deployment-wide page. ⛔ Don't just raise the cap. - Package provenance on create: measure it, report it abstractly. The dev measures whether a create request that asserts package provenance changes anything beyond this classification. If it does (for instance, which protections apply to the flow), that is filed as its own
securitycard, abstract and with no request detail. ⛔ Not fixed inside this card. - Pins:
- one per door, each reaching the state through that door and asserting the refusal, or the unarmed caller with its warning;
- an enabled subflow still arms its caller (the control);
- 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's and service-automation: a restart re-arms the trigger of a ledger-disabled packaged flow — registerTrigger ignores the activation ledger, and every matching event then logs an ERROR claiming a run-history record that is never written #20677's pins stay green.
- Serial. PR fix(service-automation)!: a switched-off packaged caller guards its subflow's disable only while it holds a parked run #20724 (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) merged, so this is clear. Coordinate with 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 (same file): whichever lands second rebases.
- It is measured once through a public door, and the failure is loud:
- 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 30, 2026 objectstack-fleet commented
on Sep 30, 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-20725-subflow-guard-every-door
Worktree:objectstack-issue-20725
Domain:domain:services
Seat:domain:services#6021
File surface:packages/services/service-automation/src/engine.ts:activateFlowTrigger,unregisterFlow, and the §7.3 helpers.packages/services/service-automation/src/suspended-run-store.ts: the complete parked-run read, replacing the capped page.packages/services/service-automation/src/flow-activation-ledger.test.ts, or a sibling pin file..changeset/20725-subflow-guard-every-door.md.- The runtime
DELETEroute (packages/runtime/src/domains/automation.ts) is consulted, not edited.
(stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus(dispatch-gates --tierat01e78dce: no path-derived mandate; floor sonnet · default opus · ceiling fable)
Clause-②: no (narrowing). This is the seat's reading, to be confirmed by measurement: theDELETEdoor refuses a removal it accepts today, and arming declines a state it accepts today. Nothing is widened. The dev declares what the delivered diff measures, because this seat misread this line twice on 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. The at-tier contract review is owed before enqueue.
Thread-read: 5901347976
Serial constraints cleared: - fix(objectql,service-automation,runtime): the card's named warnings and endpoint hints state each decision in words instead of a tracker number #20738 ([finding] runtime warnings outside the migration ledger print tracker numbers to authors and operators: the
AutomationEngineresumeAuthority boot warning (#3801/#5561/#3823) and two objectql data-event warnings (#4639/#4626) #20513,domain:engine) edited a warning string in this sameengine.ts. It merged as5a23096c, and this dispatch bases on it. - Open PRs: none of the 5 open PRs touches
service-automationor the runtime automation route (census at this claim). - 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 (same file, same
area:workflow) stays inpm:queue. It is dispatched after this card's PR lands, by the rule of one in flight per area when the file surfaces intersect. Triage's "whichever lands second rebases" is then moot. - dead tracker citations in the
domain:servicespackages (629 sites, 112 numbers, 152 files): the ruling C+D stage for this lane (from #20556) #20596'sservice-automationcitation stage waits for both. - The seat's other in-flight item, dead tracker citations in the
domain:servicespackages (629 sites, 112 numbers, 152 files): the ruling C+D stage for this lane (from #20556) #20596 stage 10 (service-package), is disjoint.
Generated by Claude Code
objectstack-fleet commented
on Sep 30, 2026 ContributorAuthorMore actionsos-dev-report
{ "issue": 20725, "status": "done", "branch": "claude/issue-20725-subflow-guard-every-door", "pr": "https://github.com/objectstack-ai/objectstack/pull/20759", "session": "session_01XY5uCwTjZj7884yYtyur4H", "premise_still_valid": true, "summary": "ADR-0126 §7.3 now holds on every door. (1) The arming gate: activateFlowTrigger declines a packaged caller while a packaged subflow/map target is disabled (ledger or status), read through disabledPackagedSubflows. Registration is never refused. /_status reads enabled:true, bound:false with a reason naming the subflow and its remedy; the kernel:bootstrapped audit carries the same reason; one warning per decline. Create, republish, upgrade/hot reload (metadata:reloaded), kernel:ready trigger registration and the enable toggle all cross it. Hydration now re-asks the gate for the callers of what it switched off. (2) D2: a declined caller is re-offered to the gate whenever its subflow changes state (toggleFlow, registerFlow, hydration), so enabling or republishing the subflow active arms it, and republishing it obsolete disarms an armed caller. The ledger-cycle exemption applies only while the caller is itself ledger-disabled, which leaves the enable refusal's answers unchanged. (3) Removal: unregisterFlow / DELETE refuses a packaged subflow a packaged caller can still reach, synchronously, with DELETE_RESTRICTED / 409 / subflowCallers. An enabled caller guards; a switched-off caller guards while the subflow is enabled, and the step named is the disable door, which reads parked runs. The metadata:reloaded resync removes through a new unguarded withdrawFlow. (4) The disable guard's parked-run read asks for the named callers' runs only, via an optional SuspendedRunStore.listByFlow; ObjectStoreSuspendedRunStore seek-walks the (flow_name, status) index to its end and throws rather than answer short. Measured live on the showcase: DELETE 409 and its remedy chain completing; a republished caller unarmed with its reason, then armed on the subflow's enable.", "tests": "All at head 3dc488eb4. Red first: c416228d9 against the unfixed engine gave 'Tests 16 failed | 57 passed (73)'; each failure was the intended one (bound where unbound was expected, removal accepted, listByFlow absent, disable accepted past the cap); the 5 controls passed. The fix is 3fa3860a0. 230ef8581 added two assertions (warn-once, listByFlow refusing to answer short), each red at base by construction and each turned red by its own ablation leg. pnpm --filter @objectstack/service-automation test: 'Test Files 156 passed (156)', 'Tests 1964 passed (1964)' (base 01e78dcee: 155 / 1942). #20678's and #20677's pins stay green. pnpm --filter @objectstack/service-automation run typecheck: exit 0 ('check:test-typecheck: OK'); --listFiles counts both pin files in tsconfig.json and in tsconfig.test.json. Ablation: 16 legs via scripts/ablation-replace.mjs wrap mode, with an outer trap on EXIT/INT/TERM restoring by absolute path, all re-run at head 3dc488eb4. Every leg: anchor x1 then x0, blob changed, 'ok restored: blob == HEAD and git diff HEAD is empty'. No dist leg: the pins import ./engine.js relatively. M1 gate call removed: 10 red. M2 every packaged caller declined: 12 red. M3 packaged-caller check dropped: 1 red. M4 re-judge disabled: 6 red. M5 cycle precondition dropped: 1 red. M6 warn-once dropped: 1 red. M7 /_status reason dropped: 3 red. M8 removal guard dropped: 3 red. M9 switched-off callers never guard: 2 red. M10 subflow's own switch ignored: 3 red. M11 customer callers counted: 1 red. M12 resync through the guarded door: 1 red. M13 unscoped read: 1 red. M14 flow filter dropped: 2 red. M15 one page only: 2 red. M16 truncation refusal dropped: 1 red. In an earlier round (230ef8581), the first M3 and M11 attempts were refused by the tool (replacement count unmoved; anchor hit twice) and nothing ran; both were re-anchored. Live, on a showcase boot (pnpm dev -- --fresh -p 41977, built at 3fa3860a0), over HTTP: DELETE /api/v1/automation/showcase_notify_owner answered 409 {code: DELETE_RESTRICTED, httpStatus: 409} naming showcase_task_done_notify_owner. The remedy chain: toggle caller off 200; DELETE 409 'Switch ... off first'; toggle subflow off 200; DELETE 200 deleted:true. Republish door: PUT showcase_project_closure obsolete, toggle showcase_closure_signoff off, PUT caller active; /_status enabled:true bound:false with the reason, and one warning line. Toggling the subflow on then read bound:true.", "mcp_calls": "11 — read-only GitHub MCP calls, no write tool: issue_read get (#20725, #20678, #20677), issue_read get_comments (#20725, #20678, and one read-back of this report), pull_request_read get_comments (#20724, #20711), pull_request_read get (#20702, #20759 body read-back), list_pull_requests (to find #20759). Only objectstack-ai/objectstack was read.", "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), giving PR #20759, body read back intact with one footer; (2) label-write --assign, POST /repos/objectstack-ai/objectstack/issues/20759/assignees [os-justin], read back as matching, with no label written; (3) post-stamped, POST /repos/objectstack-ai/objectstack/issues/20725/comments (this report). Plus git push, which is not REST: the empty-branch probe, then 5 commit pushes.", "gates": { "head": "3dc488eb4", "derived": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 3dc488eb4 gave 62 commands, identical after sort to pm-gates-at-dispatch.txt. All 62 ran with the exit captured before any pipe, and all 62 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)'. The same 62 were also all green at bff1f45a3.", "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 7003 scanned file(s)'); pnpm check:filter-alias-parity", "extra": "exit 0 each: pnpm check:durability-log-level; pnpm check:startup-registry-verdict ('none recording a verdict the boot can contradict'); node scripts/check-changeset-no-major.mjs --base origin/main --event (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 ('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 over the 6 changed files gave 6 files, 0 errors, 1 warning (the changeset .md: 'File ignored because no matching configuration was supplied'). The population comes from eslint.config.mjs files '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'. The config never enables type-aware linting (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 as not runnable locally (check-issue-citations --census, 3 check-shard-attestation --emit, 2 check-test-completeness), because their argv comes from the workflow. CI on PR #20759: not awaited.", "nul_bytes": "pnpm check:nul-bytes exit 0; a control-byte self-scan of the 6 changed files found nothing" }, "line_budget": "n/a: no skills/** or line-ratcheted ledger touched. Diff vs base 01e78dcee: +1066 / -26 (1092 changed lines, under the 5000 human-merge threshold).", "files_changed": [ "packages/services/service-automation/src/engine.ts (+309/-20)", "packages/services/service-automation/src/suspended-run-store.ts (+44)", "packages/services/service-automation/src/plugin.ts (+4/-1, deviation)", "packages/services/service-automation/src/subflow-guard-every-door.test.ts (+527, new sibling pin file)", "packages/services/service-automation/src/suspended-run-store.test.ts (+152/-5)", ".changeset/20725-subflow-guard-every-door.md (+30, new)" ], "deviations": [ "Outside the declared surface: plugin.ts, one call site. resyncFlowsFromProtocol now removes vanished flows through the new AutomationEngine.withdrawFlow. With the guard inside unregisterFlow, the old call would have made an uninstall depend on listing order: a caller and its subflow leave together, and whichever is asked first refuses the other. The refusal would then have been swallowed by the call's best-effort catch, leaving the subflow registered (and armed) until restart. Pinned by the UNINSTALL pin (both orders) and ablation M12.", "Clause-② measured 'yes (narrowing)', not the claim's 'no (narrowing)'. The public surface widens: a new public AutomationEngine.withdrawFlow, a new public ObjectStoreSuspendedRunStore.listByFlow, and a new optional member SuspendedRunStore.listByFlow. Arming declines, removal refusals and the complete read are narrowings. The changeset is minor with a BREAKING banner, and the ADR-0087 disposition is not-required (no-migration-prescription).", "D1: the gate declines on status-disabled children too (the helper brings them, as the order anticipated). The ledger-cycle exemption now requires the caller itself to be ledger-disabled; without that, the reused helper would have armed an enabled caller onto a disabled child in a cycle where no enable order was blocked.", "D3 shape: unregisterFlow stays synchronous, because the spec contract is ': void' and the route answers 200 after an un-awaited call. The parked-run question is routed to the disable door ('switch it off first'), not read a second way.", "InMemorySuspendedRunStore deliberately does not implement listByFlow, because its list() is uncapped. The engine reads it through list() filtered, which keeps #20678's unlistable-store pin (it monkeypatches list) meaningful.", "Two assertions were added after the fix commit (230ef8581). A later refactor (3dc488eb4: the decline record holds only its reason) was followed by re-running the suite, the typecheck, all 16 ablation legs and all gates at that head.", "Round-1 ablation no-ops: M3 (replacement count unmoved) and M11 (anchor hit twice) were refused by the tool and ran nothing; both were re-anchored. The final round at the head has all 16 legs landed and restored.", "A mistyped extra gate, 'pnpm check:degradation-log-level-vocab' (no such script, exit 254), is not a measurement and is not counted.", "Full builds were run for the live boot: turbo, the showcase closure (61 tasks) plus the 8 packages the dev prereq check named (41 tasks). One showcase dev server ran on port 41977 and was stopped by its recorded process group (group empty, port closed).", "Code comments cite '[#20725, ADR-0126 §7.3]'. Runtime strings carry ADR ids only, with no tracker number.", "Base drift: origin/main moved past 01e78dcee (697845d19 and later: a service-package citation re-anchor and its changeset). None of it touches service-automation or the automation route, and it is not merged here." ], "cleanup": "Worktree objectstack-issue-20725 removed: node_modules deleted, then 'git worktree remove' without --force, exit 0. Remote head == local head 3dc488eb4 was verified before removal. The dev server was stopped. This report was posted with the shared checkout's post-stamped.mjs (blob-identical to the worktree's copy), executed only.", "open_questions": [ { "question": "Confirm the removal door's shape (D3). DELETE / unregisterFlow cannot await the parked-run read, because IAutomationService.unregisterFlow is ': void' and the route answers 200 after an un-awaited call. Delivered: an enabled packaged caller guards; a switched-off caller guards while the subflow is enabled, and the step named is the disable door, which reads parked runs completely; after that switch-off the removal completes. Business, measured live: the showcase subflow pair's removal completes in three named steps. Long-term: one family, with no second reading of reachability. AI-proofing: every refusal names a completable step. Startup scope: no contract change. The cost is one extra step when a switched-off caller holds no parked run.", "options": [ "A: keep as delivered", "B: make IAutomationService.unregisterFlow async in packages/spec (a public-contract change, which also updates the route and the resync) so the removal reads parked runs directly" ], "recommendation": "A. It closes the state with completable steps and no spec change. B trades one operator step for a public contract change, with no measured pull." }, { "question": "Confirm 'Clause-②: yes (narrowing)' with a minor, BREAKING changeset and the ADR-0087 disposition not-required (no-migration-prescription). The claim read 'no (narrowing)'.", "options": [ "A: keep as delivered", "B: 'no (narrowing)', only if the seat does not count the new public methods and the optional interface member as surface widening" ], "recommendation": "A. withdrawFlow, ObjectStoreSuspendedRunStore.listByFlow and SuspendedRunStore.listByFlow are new exported surface, and check-changeset-no-major reads the declaration as delivered and exits 0." } ], "out_of_scope_findings": [ "class: a · reach: public door measured live — the automation create door on a showcase boot. A create request that asserts package provenance yields a flow the ADR-0126 §7.3 guards treat as shipped, beyond its classification. It holds a shipped subflow's disable and (with this PR) its removal, bypassing the packaged-only filter that keeps customer-authored flows from holding shipped ones hostage. The toggle door also accepts it and writes a deployment-wide activation row attributed to the asserted package, where an unasserted flow is refused. The same request without the assertion does neither. Precedence against a same-named shipped flow does not depend on the assertion: create-by-name overwrites the in-process definition either way. No read-only package treatment applies on these doors to either. For the seat to file as a security card; not fixed here. Evidence is kept in the dev's scratchpad only, and no request detail is written here. · dedupe words: package provenance asserted on create; automation create door packaged classification; subflow guard hostage customer flow; activation row asserted package", "carrier: none · boundary, not measured: ObjectStoreSuspendedRunStore.list() still reads one capped page of 1000 paused rows. It backs the deployment-wide listing and the boot wait-timer re-arm (builtin/wait-node.ts rearmSuspendedWaitTimers), so past 1000 live suspensions a wait's timer would not be re-armed at boot. Noted in the PR's Acceptance notes, not filed.", "carrier: none · boundary: a packaged caller onto a subflow that is not registered at all (withdrawn by an artifact reload, or never shipped) is not declined, because the gate reads registered packaged children only and a missing target's provenance is unknowable. It fails at its node, as before. Noted, not filed.", "carrier: none · boundary, by construction: while a subflow is enabled, removing it asks for it to be switched off first even when its switched-off callers hold no parked run. That is one extra completable step (open question 1). Noted, not filed." ] }
Generated by Claude Code
objectstack-fleet commented
on Sep 30, 2026 ContributorAuthorMore actionsACCEPT: PR #20759 at
3dc488eb·domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-30T02:59ZChecklist, read on GitHub rather than from the report:
- Shape: a draft onto
main. 6 files, +1066 / −26 (1092 lines, under the 5000 threshold). No governed path.- The files:
engine.ts,suspended-run-store.ts,plugin.ts(one call site, a declared deviation), a new sibling pin file, the store's test and the changeset. - Line 1 is
Fixes #20725, and line 2 isClause-②: yes (narrowing). One footer; assignedos-justin. - Neither the PR body nor the dev report carries any request detail for the provenance finding.
- The files:
- Order of commits: pins red first (
c416228d: 16 failed, each for the intended reason), then the fix (3fa3860a), then the changeset. Two assertions were added after (230ef858), and a refactor (3dc488eb) after which the suite, the typecheck, all 16 ablation legs and all gates were re-run at the head. - Read at source, against triage's direction
5901347976:- One arming gate.
activateFlowTriggerdeclines a packaged caller throughdeclineOntoDisabledSubflows, which readsdisabledPackagedSubflows, the enable guard's own helper. It warns once per distinct decline, and/_statusreportsbound: falsewith the reason.- Registration is never refused.
- It declines on status-disabled children too, which is what the shared helper brings (D1 as anticipated).
- The ledger-cycle exemption now applies only while the caller is itself ledger-disabled. Otherwise the reused helper would arm an enabled caller onto a disabled child.
- D2, re-arming a declined caller.
rejudgeSubflowCallersre-offers a declined caller whenever its subflow changes state (toggleFlow,registerFlow, hydration). Enabling the subflow arms it, and republishing the subflowobsoletedisarms an armed caller. - The removal door.
unregisterFlowrefuses a packaged subflow with the sharedrefuseUnderReachingCallers(…, 'remove', …), withDELETE_RESTRICTED/409/subflowCallers.- An enabled subflow's switched-off callers are named with the step "switch it off first", which goes through the disable door's complete parked-run read.
- A subflow already switched off is guarded by its armed callers only. That is consistent: its disable already cleared the parked runs, and no enable onto it is accepted.
- The complete read.
readSuspendedRuns('throw', flowNames)goes through the new optionalSuspendedRunStore.listByFlow.ObjectStoreSuspendedRunStoreseek-walks the(flow_name, status)index to its end and throws rather than answer short. The in-memory store keeps its uncappedlist(), filtered.
- One arming gate.
- Live, on the showcase:
DELETEanswered 409 with the remedy chain, and completed through it.- A republished caller came up unarmed with its reason, and was armed on the subflow's enable.
- CI at this reading: 14 success, 3 expected skips, 14 in progress, 0 failure.
The dev's two open questions, answered here. Both are implementation shape inside triage's direction, and neither changes product semantics, so neither goes to the decision box.
- The removal door's shape: A, keep as delivered.
IAutomationService.unregisterFlowis: voidinpackages/spec, and the route answers 200 after an un-awaited call. So the removal routes the parked-run question to the disable door instead of reading it a second way. Triage asked for exactly that: "⛔ No second reading of 'A calls B'", and 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's family of refusals and remedies.- Every refusal names a completable step. The cost is one extra step when a switched-off caller holds no parked run.
- B (an async public contract) would be a
packages/specchange with no measured pull.
Clause-②: yes (narrowing),minor, BREAKING, ADR-0087not-required: A, keep as delivered.AutomationEngineis exported from the package root, so the new publicwithdrawFlow, the new publicObjectStoreSuspendedRunStore.listByFlowand the optionalSuspendedRunStore.listByFlowmember widen the published surface.- The declined arming, the removal refusal and the complete read are the narrowings.
- This seat's claim line (
no (narrowing)) was wrong, the third misreading of this line in this lane today. Each time it came from not counting a surface the fix itself adds or narrows. The dev measured, as the order asked.
Deviation accepted:
plugin.ts, one call site.resyncFlowsFromProtocolremoves vanished flows through the newwithdrawFlow.- Through the guarded
unregisterFlow, an uninstall would have depended on listing order: a caller and its subflow leave together, and whichever is asked first refuses the other. The refusal would then have been swallowed by the best-effort catch, leaving the subflow registered until restart. - It is pinned in both orders and by ablation M12.
Findings, line by line:
- Provenance asserted on create gets package-shipped treatment by the guards and the activation ledger (measured live, class a, security family). → Filed as automation: a flow created through the authoring door can assert package provenance, and the ADR-0126 guards and the activation ledger then treat it as package-shipped #20761, abstract, with no request detail, as triage's direction requires.
ObjectStoreSuspendedRunStore.list()still reads one capped page. It backs the deployment-wide listing and the boot re-arm of wait timers (builtin/wait-node.ts), so past 1000 live suspensions a wait's timer would not be re-armed at boot. → Acceptance notes. Its reach is not measured, and the filing gate's exceptions do not cover it.carrier: none.- A caller onto a subflow that is not registered at all is not declined, because a missing target's provenance is unknowable. → Acceptance notes.
- The removal door's extra step (question 1). → Acceptance notes.
Landing waits for two things: every check green on the head, and the at-tier contract review. After the merge,
Fixes #20725closes this card, and #20726 (sameengine.ts, samearea:workflow) is dispatched on the merged code.
Generated by Claude Code
- Shape: a draft onto
objectstack-fleet commented
on Sep 30, 2026 ContributorAuthorMore actionsLanded ·
domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-30T03:34ZPR #20759 merged through the merge queue as
0d9349feonorigin/main, andFixes #20725closed this cardcompleted.- Review: the contract review PASS on the landed head is
5903322757. - Content check: the landed commit's
git patch-id --stableequals that of the reviewed head3dc488ebagainst its base.
What now holds. ADR-0126 §7.3's packaged-subflow invariant is enforced on every door, through one reading:
- Arming.
activateFlowTriggerdeclines a packaged caller whose packaged subflow is disabled. It warns once,/_statusreportsbound: falsewith the reason, and registration is never refused. The caller is re-offered to the gate whenever its subflow changes state. - Removal.
unregisterFlow/DELETErefuses a packaged subflow that a packaged caller can still reach (DELETE_RESTRICTED/409), with completable steps. The artifact resync removes throughwithdrawFlow. - The parked-run read behind the disable guard asks for the named callers' runs completely, and refuses rather than answer short.
For the release list:
@objectstack/service-automationships this as aminormarked BREAKING (Clause-②: yes (narrowing)).Carried elsewhere:
- automation: a flow created through the authoring door can assert package provenance, and the ADR-0126 guards and the activation ledger then treat it as package-shipped #20761 (security family): client-asserted package provenance on create. It is abstract, and two earlier surfaces were redacted (
5903378405). - The capped
list()behind the boot re-arm of wait timers: PR fix(service-automation)!: ADR-0126 §7.3 holds on the registration and removal doors, and the disable guard reads a caller's parked runs completely #20759's Acceptance notes,carrier: none, reach not measured. - ADR-0126 §7.3's heading: raised with the maintainer. That is governed text.
Next on the same file: #20726 (same
engine.ts, samearea:workflow) is dispatched on this merged code.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 a commit that references this issue
on Oct 7, 2026
Filing gate: ① a product defect with a measured reach. Class a. Filed by the
domain:servicesseat (#6021, sessionsession_01XY5uCwTjZj7884yYtyur4H), from the out-of-scope findings of #20678's disable-half dev report (5900020200).What happens
subflowormaptarget, is disabled. It is enforced ontoggleFlow(packages/services/service-automation/src/engine.ts), in both directions after 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:registerFlow): creating a flow, republishing one, a package upgrade that adds a caller, or a hot reload.unregisterFlow,DELETE /api/v1/automation/NAME) of a packaged subflow that an armed packaged caller still calls.Reach, measured once through a public door
On a showcase boot at
d59c97a5(#20678's disable-half head), by that stage's dev:POST /api/v1/automationwas treated by the engine as packaged: its later re-enable was refused by the enable guard withRESOURCE_CONFLICT/409.showcase_one_task_signoffand was registered enabled.POST /api/v1/automation/NAME/triggerthen answered 400FLOW_FAILED, "subflow showcase_one_task_signoff failed: Flow … is disabled".At the engine seam, the same stage also measured two things:
obsoleteunder an armed packaged caller fails the caller at its node.The removal door was read, not measured.
Not measured, and for triage
Direction (proposed, triage's to set)
active, or cancel the parked runs.Reader who acts
Triage's first grade. Then the
domain:servicesseat dispatches it onengine.ts, serial after #20678's PR #20724.Dedupe (queries run before filing, closed included)
loop { parallel }clause can only score blocked(fixture) #16356, runtime: carry the caller-scope record-load signal into a flow action's context — dispatchFlowAction spreads actionRecordLoadSignal on both doors (the runtime half of #14244) #15168, Package-provided flows are read-only in Studio — no design mode and no node inspector on any node, which makes every checklist clause needing a node panel on a *shipped* flow unreachable (fixture limit for QA, but a product question worth a ruling) #7571, service-automation: a restart re-arms the trigger of a ledger-disabled packaged flow — registerTrigger ignores the activation ledger, and every matching event then logs an ERROR claiming a run-history record that is never written #20677 and L3: runtime consult at the execute() seam + trigger unbind + retire the #10243 flowEnabled map (ADR-0126 §7.2) #12158 (closed). None covers another door.duplicatePackagestill mints pre-protocol flow rows — the "strictly shrinking" premise does not hold for flows #4498. None covers it.Generated by Claude Code