Skip to content

Commit 36d043b

Browse files
fix(service-automation)!: a switched-off packaged caller guards its subflow's disable only while it holds a parked run (#20724)
Fixes #20678 Clause-②: yes (narrowing) **This completes the card.** Stage 1 (PR #20711, merged as `679f95ec`) landed the enable half. This PR lands the disable half, as triage's answer A (comment 5898655174 on #20678) reads ADR-0126 §7.3: a switched-off packaged caller guards the disable of its packaged subflow only while it holds a parked run. `resume()` and `resumeInternal()` are unchanged, and no ADR text is touched. ## What changed (`packages/services/service-automation/src/engine.ts`) `toggleFlow(name, false)` now judges each packaged caller of `name` (the `flowName` of a `subflow` or `map` node) on reachability: - **Enabled** (`isFlowEnabled`): it guards, as before. The refusal names it, with the step "disable the calling flow first". - **Disabled, by the activation ledger or by its definition's `status`, and holding a parked run**: it guards. The refusal names each parked run id and the operator cancel door, `POST /automation/CALLER/runs/:runId/cancel` (ADR-0044): "cancel it, or let it finish". - **Disabled with no parked run**: it no longer guards. So "disable the caller, then the callee" completes. The envelope is unchanged: `DELETE_RESTRICTED` / `409`, and `subflowCallers`, which now lists exactly the callers that guard. No new error code, gate, state or structured key. **One helper.** `parkedRunsOf(flowNames)` answers "holds a parked run". The verdict and the refusal's run list both come from its single answer, so they cannot disagree. `toggleFlow` reads it only when a switched-off caller is being judged. A flow whose callers are all armed never touches the run stores, and every other disable flips exactly as before: synchronously, up to its durable write. A first version awaited the read on every disable. That moved the flip one microtask later, and `flow-terminal-messages.test.ts`, which calls `toggleFlow` without awaiting it, measured the shift (fixed in `d59c97a50`). **B6 rider (enable direction).** In `disabledPackagedSubflows` the ledger-cycle exemption is now asked only of a child whose `status` does not also disable it. A child disabled both ways inside a ledger-disabled cycle is named, with both reasons and both steps, including its publish remedy. ## The mechanism premises, measured (B1 to B8) - **B1, the reader.** `listSuspendedRunsDurable()` already answers from both stores. It merges `store.list()` (the durable rows, including runs a previous process parked and this one never loaded) with this process's hot map, and the durable row wins an id collision. Its body moves unchanged into a private `readSuspendedRuns(onEnumerationFailure)`. The public listing calls it with `'degrade'`, so its behaviour and its warning are unchanged. `parkedRunsOf` calls it with `'throw'`. The reason: under `'degrade'` a store outage answers from the cache alone, and for this guard an absent run means "accept the disable". That would fail open onto exactly the run §7.3 protects. Pinned both ways: a run only in the durable store guards, and an unlistable store refuses with its own error while writing nothing. - **B2, a map parent.** It is an ordinary suspended run parked AT its map node. The measured durable row is `flowName: parent, nodeId: call, nodeType: map, correlation: map:CHILDRUNID`. No distinct state exists. Terminal runs are in neither store, so they never count. The subflow-pair pin runs the caller to completion first. - **B3, status-disabled callers.** A status-disabled caller's parked run still resumes. The pin republishes the caller `obsolete`, gets the refusal naming the run, then `resume()` answers success. The ledger-disabled case is the control pin. An enabled caller guards as before, and a customer-authored caller is still not counted. - **B4, the cancel door.** The route file is `packages/runtime/src/domains/automation.ts`, arm `POST /:name/runs/:runId/cancel`, gated by `isRunLifecycleWrite` on the platform-operator rung. It calls `cancelRun(runId, reason)`. The map-pair pin cancels the named run and the disable completes. This was also measured live, below. The run list is not bounded, because no reason to bound it was measured (the shipped map pair parks one run per release). - **B5, the text.** Here is one refusal as measured live: > Flow 'showcase_one_task_signoff' cannot be disabled while 1 packaged flow still calls it as a subflow: 'showcase_release_signoff'. Disabling it would break that caller mid-run at its subflow node with a late, inexplicable failure (ADR-0126 §7.3). 'showcase_release_signoff' is disabled but still holds a parked run that resumes into its subflow node ('run_b2bf74fc-0125-4284-8728-6223bf59dd5f'): cancel it through the operator cancel door, POST /automation/showcase_release_signoff/runs/:runId/cancel (ADR-0044), or let it finish — or leave this one armed. An enabled caller reads "Disable the calling flow 'X' first — or leave this one armed." When both kinds are present, the steps are joined with `;`. - **B6** is above. It is pinned, and ablation M4 turns it red. - **B7, the `registerFlow` door.** Measured, not built. See Acceptance notes. - **B8, `Clause-②`.** Measured against this diff: - **Widening:** a disable of a packaged subflow whose packaged callers are all switched off and hold no parked run was refused, and is now accepted. - **Narrowing:** re-enabling a ledger-disabled packaged caller whose packaged subflow is disabled both ways inside a ledger-disabled cycle was accepted (the child was skipped whole), and is now refused. This is the B6 corner. - So the declaration is `yes (narrowing)`, BREAKING, and `minor` under the launch-window convention. The claim's `yes (widening)` misses the B6 narrowing. - One refusal changes shape without changing the accept set. When the durable store cannot be listed while a switched-off caller is judged, the disable was refused `409 DELETE_RESTRICTED` (every caller guarded). It is now refused with the store's own error. It is refused either way. ## Pins (`flow-activation-ledger.test.ts`) Every refusal pin asserts `code: 'DELETE_RESTRICTED'`, `status: 409`, `subflowCallers`, and the named run id with the cancel door. 1. The subflow pair: a completed caller run does not count, and caller-then-callee completes at once. 2. The map pair: while a per-item sign-off is parked, the refusal names the caller's run. After `cancelRun`, the disable completes. 3. Control: a switched-off caller with an enabled callee resumes its parked run to success. Once that run has finished, the disable completes. 4. A status-disabled caller with a parked run (B3). 5. A run in the durable store only, parked by a previous engine over the same store, with an empty hot cache (B1). 6. An unlistable durable store: the disable throws the store's own error and writes nothing. 7. Enable direction: a child disabled both ways inside a ledger cycle is named with its publish remedy, and the remedy completes (B6). **Red first:** `97222b887` ran against the unfixed engine: `Tests 7 failed | 40 passed (47)`. Each failure was the intended one: the run id or the new step missing, the store's error masked by the old refusal, and the B6 enable accepted. The fix is `172680217`, and `d59c97a50` is the head. #20711's enable-half pins are unchanged and green. **Ablations.** Each leg used `scripts/ablation-replace.mjs` in wrap mode. The anchor hit exactly once, the mutation was proven on disk (anchor 1 to 0, blob changed), and the restore was proven: `ok restored: blob == HEAD (7d48c73) and git diff HEAD is empty`. An outer `trap` on EXIT/INT/TERM restored by absolute path. There is no `dist/` leg: the test imports `./engine.js` relatively, so it resolves `src/`. All legs were re-run from committed `d59c97a50`. | leg | mutation | red | |---|---|---| | M1 | every packaged caller guards (the pre-fix rule) | 5: pins 1 to 5 | | M2 | the reader sees the hot cache only | 2: pins 5 and 6 | | M3 | the guard takes the `'degrade'` posture | 1: pin 6 | | M4 | the cycle exemption asked of a status-disabled child | 1: pin 7 | | M5 | the refusal names no run ids | 4: pins 2 to 5 | | M6 | a disabled caller never guards (the plain skip) | 4: pins 2 to 5 | | M7 | "disabled" read from the ledger bit alone | 1: pin 4 | ## Live measurement (showcase, `pnpm dev -- --fresh -p 41863`, built at `d59c97a50`) - **The card's own reproduction.** Disabling `showcase_notify_owner` answered 409, naming its armed caller. Disabling `showcase_task_done_notify_owner` answered 200. Disabling `showcase_notify_owner` again answered **200**, and `/_status` read `enabled: false` for both. - **The map pair.** `POST /api/v1/automation/showcase_release_signoff/trigger` over two seeded tasks answered `paused`, with run `run_b2bf74fc-…`. - Disabling `showcase_one_task_signoff` answered 409, naming the armed caller. - Disabling `showcase_release_signoff` answered 200. - Disabling `showcase_one_task_signoff` answered 409, naming the run and the door (the text quoted above). - `POST /api/v1/automation/showcase_release_signoff/runs/RUNID/cancel` answered 200 `cancelled: true`. - Disabling `showcase_one_task_signoff` then answered **200**. - The server was stopped by its recorded process group, and the worktree is clean. ## Tests and gates (at `d59c97a50`) - `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 the test file once in `tsconfig.json` and once in `tsconfig.test.json`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` gives 62 commands, the same 62 as the dispatch-time list. Every command ran with its exit code captured before any pipe. `--ran` gives `62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED`. - 61 exited 0. `check:dual-build-cjs-loads` and `check:type-check-debt` first exited 3 (PREREQUISITE NOT MET). They were re-run after a full package build (`turbo run build`, 71 of 71 tasks) and both exited 0. - One exit is red by design: `check-empty-changeset.mjs --base origin/main` exits 1, as a DELIBERATE CORRECTION of a pending release note. See Deviations. - The four roster families that `dispatch-gates` prints outside its runnable list exited 0: `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)"), and `pnpm check:filter-alias-parity`. `pnpm check:durability-log-level` and `pnpm check:startup-registry-verdict` also exited 0. - `check-changeset-no-major.mjs` and `check-adr-0087-registration.mjs` were run against a synthetic `pull_request` payload that carries this body. Both exited 0. - **Lint, a declared narrowing** (a repo-wide `pnpm lint` is CI's run): `eslint --no-inline-config --format json` over the 4 changed files gave 4 files, 0 errors and 2 warnings. Both warnings are the `.md` changesets: "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 (its own comment, and no `parserOptions.project`), so this diff cannot move a verdict on an untouched file. - `pnpm check:nul-bytes`: exit 0. A control-byte self-scan of the 4 files found nothing. - NOT MEASURED: the 6 value-bearing CI invocations that `dispatch-gates` marks as not runnable locally (`check-issue-citations --census`, the three shard attestations, and the two `check-test-completeness` runs). Their argv comes from the workflow. ## Deviations - **An unreleased changeset from stage 1 is amended in place.** This is `.changeset/20678-subflow-disable-sequence.md`, outside the declared surface. This diff makes two of its sentences false. The first is "Disabling a subflow is unchanged". The second is the cycle bullet under "Not refused", which the B6 corner now contradicts. Per `AGENTS.md`, a release note is corrected in its own entry, never by an erratum in a later one. So the cycle bullet gains the status corner, and the closing sentence now points at this PR's entry. **`check-empty-changeset` stays red on this by design until the seat confirms it on this PR.** If the seat declines, revert that one file. This PR's own changeset then still states the narrowing. - **`readSuspendedRuns` is extracted from `listSuspendedRunsDurable`**, in `engine.ts` but outside the functions the claim names. That is the one-reader requirement (B1). One paragraph of the catch-arm comment is corrected, because it claimed the method has no production consumer. `plugin-approvals` reads it, and fails closed on an absent entry. - A moved envelope comment re-added a citation that `check-issue-citations` reports as allocated-but-absent. It now cites the toggle ruling's commit (`266436a7f`) and its in-tree record, `isFlowAuthoringWrite`. ## Acceptance notes - **B7, the `registerFlow` door (class a: finding, for the seat to file).** - **Engine seam:** `registerFlow` arms a new packaged caller onto a ledger-disabled packaged subflow (`bound: true`), and a run then fails at the subflow node with the composed `FLOW_DISABLED`. The same holds for a subflow republished `obsolete` under an armed packaged caller. - **reach, a public door measured live:** `POST /api/v1/automation` with a body that carries `_packageId: com.example.showcase` and `_provenance: package` answered 200. `GET /api/v1/automation/NAME` serves both keys, and the spec allowlists them as write-path envelope state. The body called the ledger-disabled `showcase_one_task_signoff`. The engine treats the new flow as packaged: its re-enable is refused 409 `RESOURCE_CONFLICT` by the enable guard. Yet it was registered `enabled: true`, and `POST /api/v1/automation/NAME/trigger` answered 400 `FLOW_FAILED` "subflow 'showcase_one_task_signoff' failed: Flow 'showcase_one_task_signoff' is disabled …". - The toggle door refuses that same state. So the §7.3 invariant holds on `toggleFlow` only. - The real producer, a package upgrade that adds a caller while its child is switched off, is not named or measured. `DELETE /api/v1/automation/NAME` (`unregisterFlow`) on a packaged subflow is the same invariant's third door. That door was read, not measured. - Dedupe words: `registerFlow subflow guard`, `packaged caller armed disabled subflow`, `automation create door FLOW_DISABLED`, `ADR-0126 7.3 door`. - **Toggling a customer-authored flow (class a: finding, for the seat to file).** Measured incidentally, on the same live boot. `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 behaviour predates this PR, which does not change it. Dedupe words: `toggle Package is required`, `activation ledger customer flow toggle`, `sys_metadata_activation package_id`. - **Boundary, the listing cap.** `ObjectStoreSuspendedRunStore.list()` reads at most 1000 `paused` rows. Beyond that, deployment-wide, a caller's parked run can be missing from the enumeration, and the disable would be accepted. Not measured. Carrier: none. - **Boundary, runs in flight.** A caller run executing (not parked) at the instant of the disable is in neither store, and can still reach its subflow node. "Holds a parked run" is triage's rule, and this PR does not build around it. Carrier: none. - **Boundary, restored strands.** A failed caller run with a restorable consumed suspension (the restore-suspension door) is terminal and does not count. Restoring it after the child's disable re-parks it into a failure. Read, not measured. Carrier: none. - **ADR-0126 §7.3's heading, "attached to disable".** It now under-describes the guard, as the seat recorded after #20711. `docs/adr/**` is not touched here, and the seat raises it with the maintainer. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b291fcd commit 36d043b

4 files changed

Lines changed: 419 additions & 48 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
---
4+
5+
fix(service-automation)!: disabling a packaged subflow completes once its packaged callers are switched off and hold no parked run, and the refusal names the parked runs and the cancel door (#20678)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata changes shape and nothing an author wrote is renamed or removed, so `objectstack migrate meta` has nothing to rewrite. What moves is which calls the activation switch accepts, in both directions. -->
10+
11+
**BREAKING**: shipped as `minor` under the launch-window convention. `toggleFlow(name, enabled)` on the automation service, and so `POST /api/v1/automation/:name/toggle`, now accepts some disables it used to refuse (the widening) and refuses one corner of enables it used to accept (the narrowing).
12+
13+
**The disable direction (the widening).** Disabling a packaged flow that a packaged flow calls as a subflow (the `flowName` of a `subflow` or `map` node) was refused while any such caller existed, even one already switched off. So the refusal's own remedy, "disable the calling flow first", could never complete. A caller now guards the disable only while it can still reach the subflow node:
14+
15+
- **An enabled caller** guards, as before. The refusal names it, and the step is to disable it first.
16+
- **A disabled caller** (switched off in the activation ledger, or disabled by its definition's `status`) guards only while it holds a **parked run**: a run paused at a wait, an approval, a screen, or at a `map` node between items. Switching a flow off stops its new runs only, and a parked run still resumes into its subflow node. The refusal names each parked run id and the operator cancel door, `POST /api/v1/automation/:name/runs/:runId/cancel` (ADR-0044). Cancel those runs, or let them finish, and the disable completes.
17+
- **A disabled caller with no parked run** no longer guards, so "disable the caller, then the callee" completes.
18+
19+
Parked runs are read from both the in-process runs and the durable suspended-run store, including runs a previous process parked. If the durable store cannot be listed at that moment, the disable fails with the store's own error and nothing is written; it is never read as "no parked run". The refusal keeps `DELETE_RESTRICTED` / `409` and its `subflowCallers` list, which now names exactly the callers that guard.
20+
21+
**The enable direction (the narrowing).** A subflow in a cycle of ledger-switched-off flows with the flow being enabled was skipped whole, even when its definition's `status` also disabled it. So the enable was accepted onto a subflow that stays disabled, and the publish remedy was never named. Such a subflow is now named, with both reasons and both steps (publish it with status `active`, then enable it). The cycle exemption covers the activation switch only, because no enable order changes a status.

‎.changeset/20678-subflow-disable-sequence.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,6 @@ Clause-②: no (narrowing)
1919

2020
- Enabling a flow that is already enabled. Nothing is re-armed.
2121
- A flow the customer authored, or a subflow the customer authored.
22-
- A subflow in a cycle of switched-off flows with the flow being enabled, including a flow that calls itself. Each flow in such a cycle would refuse the others, so no order could complete.
22+
- A subflow in a cycle of switched-off flows with the flow being enabled, including a flow that calls itself. Each flow in such a cycle would refuse the others, so no order could complete. A subflow in such a cycle whose definition's `status` also disables it is still named, with its publish remedy: no enable order changes a status.
2323

24-
Disabling a subflow is unchanged.
24+
The disable direction of the same guard is described in its own entry, "disabling a packaged subflow completes once its packaged callers are switched off and hold no parked run".

0 commit comments

Comments
 (0)