Skip to content

Commit 679f95e

Browse files
fix(service-automation)!: refuse re-enabling a packaged flow onto a disabled packaged subflow (#20711)
Part of #20678 Clause-②: no (narrowing) **Which half landed.** The card's direction has two halves. This PR lands the second, the inverse guard: re-enabling a packaged caller whose packaged subflow is still disabled is refused. The first half, where `packagedSubflowCallers` skips a disabled caller so that "disable the caller, then the callee" completes, is **not built**. Its premise was measured false (below), and the dispatch's fork rule for that case is to stop and return the choice to the maintainer. So #20678 remains open for the disable-direction half. ## The measurement that stopped the disable half The direction rests on this premise: a disabled caller is inert, so disabling its callee cannot break it mid-run. That premise does not hold. `toggleFlow(caller, false)` stops new runs only. `resume()` / `resumeInternal()` never consult `isFlowEnabled`, so a run the caller parked before the switch-off still resumes and walks into its subflow node. Measured on the engine at `defc7f7b` with a throwaway probe (not committed). The probe used the real subflow, map and wait executors and the in-memory ledger. The callee was switched off out of band, which is the state the proposed skip would let `toggleFlow` produce. - Caller `start → wait → subflow(callee)`: parked at the wait, caller disabled, callee disabled, then `resume(runId)`: `{"success":false,"status":"stranded","error":"Node 'call' failed: subflow 'callee' failed: Flow 'callee' is disabled — it is switched off for this installation in the packaged-metadata activation ledger …"}` - The same with a `map` node: `"Node 'call' failed: map 'call': item 0 (subflow 'callee') failed: Flow 'callee' is disabled …"` - **The shipped map pair's own shape.** `showcase_release_signoff` maps over `showcase_one_task_signoff`, which pauses on an approval. The probe used a caller that maps over a pausing child, with 2 items, parked at item 0. The caller was disabled, then the callee. Then the child's pause was woken (the approval decision or timer path). `resume(childRunId)` answered `{"success":true}`, and the disabled parent's run row reads `failed`: `"Node 'each' failed: map 'each': item 1 (subflow 'per_item') failed: Flow 'per_item' is disabled …"`. This is ADR-0126 §7.3's "late, inexplicable failure". The person who decided the approval is told it succeeded. - Control: the caller disabled with the callee still enabled. The parked run resumes to `{"success":true}`, so a disabled caller's parked run really does continue. The options, analysed on the four axes, are in the dev report on #20678. The recommendation there is to keep counting a disabled caller only while it holds a parked run, and to name those runs and the operator cancel door in the refusal. ## What this PR changes (`packages/services/service-automation/src/engine.ts`) - **`toggleFlow(name, true)` on a packaged flow the ledger holds off** now refuses when a packaged flow it calls is disabled. "Calls" means the `flowName` of a `subflow` node or of a `map` node. The refusal comes before any write: the row still reads off, the trigger stays unbound, and `execute()` still answers `FLOW_DISABLED`. - **Envelope: `RESOURCE_CONFLICT` / 409**, with the message naming the flow and each disabled subflow. That is the standard catalog's generic "conflicts with the current state" member, and no ledger entry was minted. I measured the catalog's 409 members. `DELETE_RESTRICTED` rests on "disabling a shipped flow is functionally equivalent to deleting it", and an enable is the opposite act. `CONCURRENT_MODIFICATION`, `DUPLICATE_RECORD` and `LOCK_CONFLICT` name other conflicts. `PRECONDITION_REQUIRED` (428) is about a request that is missing something, and this request is complete. Precedent: `IJobService.replay()` refuses a delivered window with `RESOURCE_CONFLICT` / 409. - **The remedy follows each subflow's real state** (dispatch assumption A2, binding). The activation switch moves only the ledger bit, so: - switched off in the activation ledger: `Enable 'X' first, then enable this flow — or leave it disabled.` - disabled by its definition's `status` (`obsolete` / `invalid`): `Publish 'X' with status 'active' (this switch never changes a definition's status; for a package that is read-only here, that takes a package version that ships it active) first, then enable this flow …`. It never says "enable 'X'", because that toggle lands and changes nothing. A pin follows it and shows the child still refuses. - both: both steps, in that order. - **A cycle of switched-off flows does not guard itself.** If A calls B and B calls A, and both are switched off, each would refuse the other, and no remedy could complete. A subflow is skipped when the flow being enabled is reachable from it through ledger-disabled packaged flows. A flow that calls itself is the one-node case. A chain back through an enabled flow is not a cycle: there the subflow is still named, and enabling it first completes (pinned). - **Not refused:** enabling a flow that is already enabled (nothing is re-armed), a customer-authored caller, and a customer-authored subflow (the ruling scopes the guard to a packaged subflow). - The "which flows does this flow call" scan is extracted into `subflowTargets()`, which both directions now share. The disable direction's behaviour is unchanged. Its docblock now records the measured reason a disabled caller still counts. - A4: the in-code sentence "Enable is never guarded — arming a flow cannot break a caller" is rewritten. The rewrite says arming a flow never breaks the flows that call it, but re-arming a caller onto a disabled child breaks the caller. ## The wire (A6) Measured on a live showcase boot (`pnpm dev -- -p 39141 --database file:…`, two boots over one SQLite file; built at `08ed6d7b0`). The route is `packages/runtime/src/domains/automation.ts`, `POST /:name/toggle` arm, which calls `automationService.toggleFlow(name, enabled)` and lets the throw reach the dispatcher's error mapping. **No route change was needed.** Between the boots, with the server stopped, a ledger row `active: 0` was written for `showcase_notify_owner` and for `showcase_one_task_signoff`. That is the state a package upgrade that adds a caller after the callee was switched off leaves. Boot 2 hydrated both rows (`/_status`: `enabled:false, bound:false`). - `POST /api/v1/automation/showcase_task_done_notify_owner/toggle {"enabled":true}` answered `HTTP 409` with `{"success":false,"error":{"code":"RESOURCE_CONFLICT","message":"Flow 'showcase_task_done_notify_owner' cannot be enabled while 1 packaged subflow it calls is disabled: 'showcase_notify_owner' (switched off in the activation ledger). … Enable 'showcase_notify_owner' first, then enable this flow — or leave it disabled.","httpStatus":409}}` - Enabling `showcase_notify_owner` answered 200, then re-enabling the caller answered 200. - Map pair: disabling `showcase_release_signoff` answered 200. Re-enabling it answered 409 `RESOURCE_CONFLICT` naming `showcase_one_task_signoff`. Enabling the callee answered 200, then the caller answered 200. - The card's own reproduction, the disable direction, is unchanged on this branch: after disabling the caller (200), disabling `showcase_notify_owner` still answers 409 `DELETE_RESTRICTED` naming `showcase_task_done_notify_owner`. ## Pins (`flow-activation-ledger.test.ts`, new describe "ADR-0126 §7.3 (enable direction)") Every refusal pin asserts `code: 'RESOURCE_CONFLICT'`, `status: 409`, `Flow 'NAME' cannot be enabled`, and the named subflow. - The subflow pair and the map pair: the caller is refused first, naming the subflow. Nothing moved (row, binding, `execute()`). Then callee-first completes, and a real run goes through the node into the re-armed child. - Every disabled subflow is named. - A status-disabled subflow gets the publish remedy, never the toggle. The pin follows the remedy and the caller then enables. - A subflow disabled both ways gets both reasons and both steps, and the steps complete. - A cycle, a self-call, and a chain back through an enabled flow. - Controls: a non-packaged caller, a non-packaged subflow, and an already-enabled caller. - The existing test "ENABLE is never guarded — arming a flow cannot break a caller" is renamed. It now reads: enabling a CALLEE is never guarded by its callers. The body is unchanged. **Red first:** `73333a32c` against the unfixed engine gave `Tests 6 failed | 34 passed (40)`, each failure `enabling 'X' was accepted`. The fix is `58f0aef61`. **Ablations.** Each leg went through `scripts/ablation-replace.mjs` in wrap mode: the anchor hits once, the mutation is proven on disk, and the restore is proven. The script also carried an outer `trap` on EXIT/INT/TERM. The subject resolves from `src/` through the relative `./engine.js` import, so no `dist/` leg applies. Every leg ended with `ok restored: blob == HEAD (bb22908) and git diff HEAD is empty`. | mutation | red | |---|---| | M1 the enable guard call removed | 6: both pairs, every-name, status, both-ways, chain | | M2 every subflow reported as ledger-disabled | 2: status, both-ways | | M3 cycle skip removed | 2: cycle, self-call | | M4 the cycle walk crosses enabled flows | 1: chain | | M5 the packaged-caller check removed | 1: non-packaged caller | | M6 the packaged-subflow check removed | 1: non-packaged subflow | | M7 the re-enable precondition removed | 1: already-enabled | | M8 only the first subflow scanned | 1: every-name | | M9 a callee guarded by its callers on enable | 7, incl. the renamed callee pin | The first M9 attempt was a no-op: its replacement re-contained the anchor, the tool refused it before running anything (its report: the anchor count moved from 1 to 1), and nothing was measured. It was re-run with a distinct replacement, and that re-run is the row above. ## Tests and gates (all at `08ed6d7b0`, the head) - `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 the test file in both `tsconfig.json` and `tsconfig.test.json` (1 each). - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, re-derived against this diff, gives 62 commands. 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 …)`. - The roster families printed outside the runnable list all exited 0: `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing` ("no unlisted lowercase error codes in 6984 scanned file(s)") and `pnpm check:filter-alias-parity`. Also run and exit 0: `pnpm check:durability-log-level` and `pnpm check:startup-registry-verdict`. - `check-adr-0087-registration`: `1 declared-breaking changeset(s), each carrying an ADR-0087 disposition` (`not-required (no-migration-prescription)`). - NOT MEASURED: the six value-bearing CI invocations `dispatch-gates` lists as not runnable locally (`check-issue-citations --census`, the shard attestations, `check-test-completeness`). Reason: their argv comes from the workflow. - **Lint, a declared narrowing** (`pnpm lint` over the repo is CI's): `eslint --no-inline-config --format json` on the three changed files. Result: 3 files, 0 errors, 1 warning, and the warning is the changeset's "File ignored because no matching configuration was supplied". The population is from `eslint.config.mjs` (`files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`), so `.md` is outside it. The config never enables type-aware linting: its own comment says so, and there are 0 hits for `projectService` / `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 three files found nothing. ## Changeset, and a declared deviation `.changeset/20678-subflow-disable-sequence.md` bumps `@objectstack/service-automation` `minor`. It carries **BREAKING** under the launch-window convention, `Clause-②: no (narrowing)`, and the ADR-0087 marker `not-required (no-migration-prescription)`. This deviates from the dispatch in two places. The dispatch named a `patch` changeset, and the claim's line reads `Clause-②: yes`. The claim's reasoning named both halves: "a disable that is refused today is accepted, and an enable that is accepted today becomes refusable". Only the second half lands here, and that is a narrowing with no widening. `yes` would declare a widening this diff does not contain, and it would leave the narrowing undeclared. Also, `check-changeset-no-major` grades `yes` at least `minor`, so `yes` with `patch` cannot pass either way. If the seat reads it differently, this line and the changeset level are the two things to change. ## Acceptance notes - **The registration door can still arm a caller onto a disabled child.** `registerFlow` (a package upgrade, a publish or a hot reload) arms a packaged caller whose packaged subflow is disabled. The guard sits on `toggleFlow` only, the door this card scopes, and the pins use exactly that path to build their state. Observation, not filed: the reach through a public door was not measured. Carrier: none. - **`subflowCallers` on the disable refusal reaches no reader.** The `DELETE_RESTRICTED` arm in `packages/rest/src/error-response.ts` relays only `developerMessage` / `dependentObject` / `dependentCount`. The live 409 body above carries no `subflowCallers`, and outside tests nothing in-process reads it. That is why the enable refusal adds no structured list beside `code`/`status`. Zero-pull, not filed. Carrier: none. - **A status-disabled caller.** Re-enabling the ledger bit of a caller that its own `status` also disables is still judged against its subflows. The enable would not arm it, but a later publish would, and the remedy named still completes. Boundary, not filed. - The Setup packaged-automation board renders the server's message on a failed switch (per the acceptance run on #20674). How it renders this 409 was not measured in a browser. NOT MEASURED. - `origin/main` has moved 3 commits since the base (`735594bea`, `e4e5222b7`, `fa0a4b661`). None touches `packages/services/service-automation`, `packages/runtime/src/domains/automation.ts`, `packages/rest/src/error-response.ts` or the error catalog, so the branch is not merged forward. CI and the merge queue validate the merge ref. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 10c36cc commit 679f95e

3 files changed

Lines changed: 423 additions & 24 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
---
4+
5+
fix(service-automation)!: re-enabling a packaged flow is refused while a packaged subflow it calls is disabled, and the refusal names a remedy that subflow's state admits (#20678)
6+
7+
Clause-②: no (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 enable calls the activation switch accepts. -->
10+
11+
**BREAKING**: shipped as `minor` under the launch-window convention. `toggleFlow(name, true)` on the automation service, and so `POST /api/v1/automation/:name/toggle` with `{"enabled": true}`, now refuses an enable it used to accept.
12+
13+
**What changed.** A packaged flow switched off in the activation ledger could be switched back on while a packaged flow it calls (the `flowName` of a `subflow` node, or of a `map` node) was itself disabled. The enable was accepted, and every run of the flow then failed at that node on the child's `FLOW_DISABLED` refusal. That enable is now refused with `RESOURCE_CONFLICT` / `409`, before anything is written: the ledger row still reads off, the trigger stays unbound, and runs are still refused. The message names each disabled subflow and what holds it off, and the remedy follows from that:
14+
15+
- **Switched off in the activation ledger**: enable that subflow first, then this flow.
16+
- **Disabled by its own definition's `status`** (`obsolete` or `invalid`): the activation switch never changes a status, so enabling the subflow through it would change nothing. Publish the subflow with status `active` (for a package that is read-only in this environment, that takes a package version that ships it active), then enable this flow.
17+
18+
**Not refused:**
19+
20+
- Enabling a flow that is already enabled. Nothing is re-armed.
21+
- 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.
23+
24+
Disabling a subflow is unchanged.

‎packages/services/service-automation/src/engine.ts‎

Lines changed: 172 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4580,13 +4580,17 @@ export class AutomationEngine implements IAutomationService {
45804580
}
45814581

45824582
/**
4583-
* [ADR-0126 §7.3] Packaged flows that invoke `name` as a subflow.
4584-
*
4585-
* A DEFINITION SCAN, run at disable time — ⛔ deliberately not an index.
4586-
* ADR-0126 §9 records that no reference index exists and that building one
4587-
* is not chartered (#11665 §3.2); the flow map is small, this runs once per
4588-
* disable, and an index would be a durable structure to keep correct
4589-
* forever for a check that happens when an administrator clicks a switch.
4583+
* [ADR-0126 §7.3] The flow names `flow` invokes as a subflow — the ONE
4584+
* reading of "flow A calls flow B" that both directions of the subflow
4585+
* guard share ({@link packagedSubflowCallers} on disable,
4586+
* {@link disabledPackagedSubflows} on enable), so the two can never
4587+
* disagree about which pairs exist.
4588+
*
4589+
* A DEFINITION SCAN — ⛔ deliberately not an index. ADR-0126 §9 records
4590+
* that no reference index exists and that building one is not chartered
4591+
* (#11665 §3.2); the flow map is small, the guard runs once per flip, and
4592+
* an index would be a durable structure to keep correct forever for a
4593+
* check that happens when an administrator clicks a switch.
45904594
*
45914595
* Two node types invoke another flow by name, and BOTH count: `subflow`
45924596
* (config.flowName) and `map`, whose own descriptor calls its per-item
@@ -4599,29 +4603,169 @@ export class AutomationEngine implements IAutomationService {
45994603
* at load, so only the canonical key reaches a registered definition;
46004604
* re-reading the alias in this consumer would be a tolerant fallback
46014605
* papering over a producer that is already correct.
4606+
*/
4607+
private subflowTargets(flow: FlowParsed): string[] {
4608+
const nodes = (flow as { nodes?: unknown }).nodes;
4609+
if (!Array.isArray(nodes)) return [];
4610+
const targets: string[] = [];
4611+
for (const node of nodes) {
4612+
const n = node as { type?: unknown; config?: { flowName?: unknown } } | null;
4613+
if (!n || (n.type !== 'subflow' && n.type !== 'map')) continue;
4614+
if (typeof n.config?.flowName === 'string') targets.push(n.config.flowName);
4615+
}
4616+
return targets;
4617+
}
4618+
4619+
/**
4620+
* [ADR-0126 §7.3] Packaged flows that invoke `name` as a subflow — the
4621+
* callers the DISABLE direction of the guard refuses on.
46024622
*
46034623
* Only PACKAGED callers are reported: §7.3's rationale is that a VENDOR
46044624
* flow would break mid-run at its subflow node. A caller the customer
46054625
* authored is theirs to fix, and refusing on it would make the packaged
46064626
* artifact hostage to a tenant's own flow.
4627+
*
4628+
* A caller counts whatever its own activation state. Switching a caller
4629+
* off stops its NEW runs only: a run it had already parked — at a `wait`,
4630+
* an `approval`, a `screen`, or between two items of a `map` — resumes
4631+
* through {@link resume}, which does not consult activation, and walks on
4632+
* into its subflow node. So a switched-off caller is not yet an inert one,
4633+
* and skipping it here would reopen the mid-run failure §7.3 refuses.
46074634
*/
46084635
private packagedSubflowCallers(name: string): string[] {
46094636
const callers: string[] = [];
46104637
for (const [callerName, flow] of this.flows) {
46114638
if (callerName === name) continue;
46124639
if (describeFlowContender(flow).source !== 'package') continue;
4613-
const nodes = (flow as { nodes?: unknown }).nodes;
4614-
if (!Array.isArray(nodes)) continue;
4615-
const invokes = nodes.some((node) => {
4616-
const n = node as { type?: unknown; config?: { flowName?: unknown } } | null;
4617-
if (!n || (n.type !== 'subflow' && n.type !== 'map')) return false;
4618-
return n.config?.flowName === name;
4619-
});
4620-
if (invokes) callers.push(callerName);
4640+
if (this.subflowTargets(flow).includes(name)) callers.push(callerName);
46214641
}
46224642
return callers;
46234643
}
46244644

4645+
/**
4646+
* [ADR-0126 §7.3, the enable direction] The packaged subflows a packaged
4647+
* flow `name` calls that may not run now ({@link isFlowEnabled}), each
4648+
* with the disable dimension(s) holding it off — the enable direction of
4649+
* the guard refuses on these, because a caller re-armed onto a disabled
4650+
* child fails at its subflow node on the child's `FLOW_DISABLED`: the same
4651+
* late, inexplicable failure the disable direction refuses to cause.
4652+
*
4653+
* Both dimensions are reported, not just the verdict, because the remedy
4654+
* differs and a refusal must prescribe one the caller can complete: the
4655+
* activation switch moves only the LEDGER bit, so "enable the subflow
4656+
* first" re-arms a ledger-disabled child and does nothing for one whose
4657+
* definition's `status` disables it.
4658+
*
4659+
* ⛔ A child in a CYCLE of switched-off flows with `name` is not reported.
4660+
* Enabling that child goes through this same guard, which would refuse it
4661+
* on the chain leading back to `name` — so every order is refused and no
4662+
* sequence the refusal could name completes. Only a chain of LEDGER-
4663+
* disabled flows closes that loop: a status-disabled link is re-armed by
4664+
* publishing its definition, a door this guard does not sit on, and an
4665+
* enabled link does not refuse at all.
4666+
*/
4667+
private disabledPackagedSubflows(name: string): Array<{ name: string; ledger: boolean; status?: string }> {
4668+
const flow = this.flows.get(name);
4669+
if (!flow) return [];
4670+
const disabled: Array<{ name: string; ledger: boolean; status?: string }> = [];
4671+
for (const target of new Set(this.subflowTargets(flow))) {
4672+
const child = this.flows.get(target);
4673+
if (!child || describeFlowContender(child).source !== 'package') continue;
4674+
if (this.isFlowEnabled(target)) continue;
4675+
const ledger = this.flowLedgerDisabled.has(target);
4676+
if (ledger && this.ledgerDisabledChainReaches(target, name)) continue;
4677+
const status = this.flowStatusDisabled.get(target) === true
4678+
? String((child as { status?: unknown }).status)
4679+
: undefined;
4680+
disabled.push({ name: target, ledger, ...(status !== undefined ? { status } : {}) });
4681+
}
4682+
return disabled;
4683+
}
4684+
4685+
/**
4686+
* Whether `to` is reachable from `from` along packaged subflow calls
4687+
* whose every intermediate flow is ledger-disabled — the loop
4688+
* {@link disabledPackagedSubflows} must not guard on.
4689+
*/
4690+
private ledgerDisabledChainReaches(from: string, to: string): boolean {
4691+
const seen = new Set<string>([from]);
4692+
const pending = [from];
4693+
while (pending.length > 0) {
4694+
const flow = this.flows.get(pending.pop()!);
4695+
if (!flow) continue;
4696+
for (const target of this.subflowTargets(flow)) {
4697+
if (target === to) return true;
4698+
if (seen.has(target) || !this.flowLedgerDisabled.has(target)) continue;
4699+
const next = this.flows.get(target);
4700+
if (!next || describeFlowContender(next).source !== 'package') continue;
4701+
seen.add(target);
4702+
pending.push(target);
4703+
}
4704+
}
4705+
return false;
4706+
}
4707+
4708+
/**
4709+
* [ADR-0126 §7.3, the enable direction] Refuse re-arming a packaged caller
4710+
* onto a packaged subflow that may not run, naming each child and the
4711+
* remedy its real state admits.
4712+
*
4713+
* Only a RE-enable is judged: a flow the ledger holds off. Enabling a flow
4714+
* that is already armed re-arms nothing, and refusing it would report a
4715+
* running flow as one that "cannot be enabled". Only a PACKAGED caller is
4716+
* judged, for the disable direction's reason: a flow the customer
4717+
* authored is theirs to arm.
4718+
*
4719+
* ADR-0112 envelope: code AND status. `RESOURCE_CONFLICT` (409) is the
4720+
* standard catalog's generic "the request conflicts with the resource's
4721+
* current state" member — ⛔ no new ledger entry is minted. Not the
4722+
* disable direction's `DELETE_RESTRICTED`: its `DELETE_` prefix rests on
4723+
* disabling a shipped flow being equivalent to deleting it, and enabling
4724+
* is the opposite act.
4725+
*/
4726+
private refuseEnableOntoDisabledSubflow(name: string, flow: FlowParsed): void {
4727+
if (!this.flowLedgerDisabled.has(name)) return;
4728+
if (describeFlowContender(flow).source !== 'package') return;
4729+
const disabled = this.disabledPackagedSubflows(name);
4730+
if (disabled.length === 0) return;
4731+
4732+
const quote = (names: string[]) =>
4733+
names.length === 1
4734+
? `'${names[0]}'`
4735+
: `${names.slice(0, -1).map((n) => `'${n}'`).join(', ')} and '${names[names.length - 1]}'`;
4736+
const reasons = disabled.map((d) => {
4737+
const why = [
4738+
...(d.ledger ? ['switched off in the activation ledger'] : []),
4739+
...(d.status !== undefined ? [`its definition's status is '${d.status}'`] : []),
4740+
].join(', and ');
4741+
return `'${d.name}' (${why})`;
4742+
});
4743+
// The remedy follows each child's REAL state: the switch re-arms the
4744+
// ledger bit, and only the definition moves a status.
4745+
const byStatus = disabled.filter((d) => d.status !== undefined).map((d) => d.name);
4746+
const byLedger = disabled.filter((d) => d.ledger).map((d) => d.name);
4747+
const remedy = [
4748+
...(byStatus.length > 0
4749+
? [
4750+
`publish ${quote(byStatus)} with status 'active' (this switch never changes a definition's ` +
4751+
`status; for a package that is read-only here, that takes a package version that ships ` +
4752+
`${byStatus.length === 1 ? 'it' : 'them'} active)`,
4753+
]
4754+
: []),
4755+
...(byLedger.length > 0 ? [`enable ${quote(byLedger)}`] : []),
4756+
].join(' and ');
4757+
const many = disabled.length !== 1;
4758+
throw Object.assign(
4759+
new Error(
4760+
`Flow '${name}' cannot be enabled while ${disabled.length} packaged subflow${many ? 's' : ''} it calls ` +
4761+
`${many ? 'are' : 'is'} disabled: ${reasons.join(', ')}. Enabled, it would fail at its subflow node ` +
4762+
`on a disabled child — the late, inexplicable failure ADR-0126 §7.3 refuses to cause. ` +
4763+
`${remedy.charAt(0).toUpperCase()}${remedy.slice(1)} first, then enable this flow — or leave it disabled.`,
4764+
),
4765+
{ code: 'RESOURCE_CONFLICT', status: 409 },
4766+
);
4767+
}
4768+
46254769
/**
46264770
* [ADR-0126 §7.2] Flip a flow's activation — THE sanctioned off-switch.
46274771
*
@@ -4647,19 +4791,26 @@ export class AutomationEngine implements IAutomationService {
46474791
* every other service method; the wire is the untrusted surface, and the
46484792
* wire goes through the gate.
46494793
*
4650-
* @throws when the flow is unknown, when §7.3's subflow guard refuses, or
4651-
* when the durable write fails — a reported flip that did not persist is
4652-
* the failure mode this whole leg exists to remove.
4794+
* @throws when the flow is unknown, when §7.3's subflow guard refuses in
4795+
* either direction, or when the durable write fails — a reported flip
4796+
* that did not persist is the failure mode this whole leg exists to
4797+
* remove.
46534798
*/
46544799
async toggleFlow(name: string, enabled: boolean): Promise<void> {
46554800
const flow = this.flows.get(name);
46564801
if (!flow) {
46574802
throw new Error(`Flow '${name}' not found`);
46584803
}
46594804

4660-
// [ADR-0126 §7.3] The subflow cascade guard, on DISABLE only. Enable is
4661-
// never guarded — arming a flow cannot break a caller.
4662-
if (!enabled) {
4805+
// [ADR-0126 §7.3] The subflow guard runs in BOTH directions, because
4806+
// a subflow pair breaks from either end. Disabling a child breaks the
4807+
// callers that still reach it; re-arming a caller whose child is off
4808+
// breaks the caller itself, at its subflow node, on the child's
4809+
// refusal. Arming a flow never breaks the flows that call IT, so
4810+
// enabling a child is not guarded by its callers.
4811+
if (enabled) {
4812+
this.refuseEnableOntoDisabledSubflow(name, flow);
4813+
} else {
46634814
const callers = this.packagedSubflowCallers(name);
46644815
if (callers.length > 0) {
46654816
const list = callers.map((c) => `'${c}'`).join(', ');

0 commit comments

Comments
 (0)