Skip to content

Commit 0d9349f

Browse files
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)
Fixes #20725 Clause-②: yes (narrowing) ADR-0126 §7.3 refuses one state: a packaged flow armed while a packaged flow it calls (the `flowName` of a `subflow` or `map` node) is disabled, so the caller fails at that node on the child's refusal. `toggleFlow` enforced it in both directions (#20678). This PR makes the other doors hold it too, as triage's direction (`5901347976`) reads it, and makes the disable guard's parked-run read complete (the note `5900362155`). ## What changed **Registration: one gate, not a refusal** (`engine.ts`). - `activateFlowTrigger`, the single arming gate since #20677, now also declines a packaged flow while a packaged `subflow` / `map` target is disabled. It reads that through `disabledPackagedSubflows`, the helper `refuseEnableOntoDisabledSubflow` uses. There is no second reading of "A calls B". - The flow still registers. `/_status` reports `enabled: true, bound: false` with a `reason` naming each subflow and its remedy. The `kernel:bootstrapped` audit prints the same reason, never "binding failed". The engine warns once per decline. - Create, republish, upgrade, hot reload, `kernel:ready` trigger registration and the enable toggle all cross this gate. - **The declined caller is re-armed** (D2). Whenever a subflow changes state, the gate is asked again for that subflow's packaged callers: `registerFlow`, `toggleFlow` and hydration all do this. So enabling the subflow arms a declined caller. Republishing the subflow `obsolete`, or the ledger switching it off at boot, disarms an armed caller. - **The cycle exemption** in `disabledPackagedSubflows` now applies only while the caller itself is ledger-disabled, the one state in which the loop closes. The enable refusal only ever asks it of such a caller, so its answers are unchanged. The arming gate asks it of an enabled flow, where no enable order is blocked, so the exemption never applies there. **Removal** (`unregisterFlow`, the `DELETE /api/v1/automation/:name` door). A packaged subflow that a packaged caller can still reach is refused synchronously with #20678's family: `DELETE_RESTRICTED` / `409`, `subflowCallers`, and a step that completes. `refuseDisableUnderReachingCallers` became `refuseUnderReachingCallers(name, act, …)`, and its disable text is byte-identical. - An enabled caller guards: disable it first. - A switched-off caller guards while the subflow is still enabled. The door is synchronous by the `IAutomationService.unregisterFlow` contract, so it cannot read parked runs. It names the disable door instead, which reads them completely and names each one to cancel, then the removal. - Once the subflow is switched off, a switched-off caller does not guard. A run that resumes into a disabled subflow already fails on its refusal, so removal breaks nothing more. **The complete read** (`suspended-run-store.ts`, `engine.ts`). `SuspendedRunStore` gains an optional `listByFlow(flowNames)`, complete by contract or an error. - `ObjectStoreSuspendedRunStore.listByFlow` seek-walks (`keysetWalk`, `id` order, no `offset`) the `(flow_name, status)` index that `sys_automation_run` declares. It reads to the end and throws if the walk cannot advance. - `readSuspendedRuns` takes an optional scope, and `parkedRunsOf` asks for the named callers only. A store without the member is read through `list()`, whose contract is "all". - The deployment-wide listing (`listSuspendedRunsDurable`) and its cap are unchanged. The cap was not raised. **The artifact reload** (`plugin.ts`, one call site, a declared deviation). `resyncFlowsFromProtocol` now removes vanished flows through a new `AutomationEngine.withdrawFlow`, which is not guarded. See D3 below for why. ## The dispatch's mechanism assumptions, measured (tree `01e78dce`, then this branch) - **D1 holds, with one gap closed.** Registration (boot pull, the `kernel:ready` protocol sync, the `metadata:reloaded` resync, and `POST` / `PUT` / clone) reaches the gate through `registerFlow`. The `kernel:ready` trigger reaches it through `registerTrigger`, and the enable toggle through `toggleFlow`. `rollbackFlow` never arms and has no production caller. - **Hydration did not reach the gate.** It only unbound the disabled flows themselves. On a stock boot that is harmless, because triggers register at `kernel:ready`, after hydration. A host with a trigger registered before the pull kept a caller armed, so hydration now re-asks the gate for the callers of what it switched off. - **What the gate declines on:** every packaged `subflow` / `map` target that is not `isFlowEnabled`, whether ledger-disabled or status-disabled (`obsolete` / `invalid`). Only a packaged caller is judged. The cycle exemption never applies at arming (above). Triage's text said "ledger-disabled". Reusing the helper, as triage requires, brings the status case too. - **D2 held.** Nothing re-judged a declined caller, so re-arming is built through the same gate. No new shape was needed. - **D3.** - `unregisterFlow` is `: void` in the spec contract. The route calls it without `await` and then answers `200 { deleted: true }`. An async refusal would have been unhandled and answered 200. - **The shape chosen:** the removal stays synchronous and routes the parked-run question to the disable door. It does not read parked runs a second way (see above). - **Other callers.** The only other production caller is `plugin.ts`'s `metadata:reloaded` resync, which serves package upgrade and uninstall, Studio package publish and dev reload. It is not gated. It removes through `withdrawFlow`, because what it removes is the package's own decision. The next cold boot would not register the flow either, so a gate could only delay the state by one restart. It would also make an uninstall depend on listing order: a caller and its subflow leave together, and whichever is asked first would refuse the other. - **D4.** The store's `list()` reads `{ status: 'paused' }` with `limit: 1000` and no order. The `(flow_name, status)` index exists for exactly this question. The hot cache (`suspendedRuns`) holds every run this process parked, uncapped, so for this process it answers the scoped question completely. It is filtered by the same scope. - **D5** was measured live and is reported to the seat, abstractly (Acceptance notes). It is not fixed here. - **D6: `Clause-②: yes (narrowing)`, `minor`, BREAKING, ADR-0087 `not-required (no-migration-prescription)`.** The claim read `no (narrowing)`, so this is a deviation. - **Why `yes`:** the public surface widens. There is a new public `AutomationEngine.withdrawFlow`, a new public `ObjectStoreSuspendedRunStore.listByFlow`, and a new optional member `SuspendedRunStore.listByFlow`. - **The narrowing:** registration paths now decline what they armed, `unregisterFlow` / `DELETE` refuses what it accepted, and the disable refusal now sees a parked run it missed past the cap. - Nothing an author writes is removed or renamed. ## Live, on a showcase boot (`pnpm dev -- --fresh`, built at `3fa3860a`) - `DELETE /api/v1/automation/showcase_notify_owner` answered `409` `{code: DELETE_RESTRICTED, httpStatus: 409}`, naming `showcase_task_done_notify_owner`, and the flow was still served. - Following the steps: toggling the caller off gave 200. `DELETE` then gave `409`, "Switch 'showcase_notify_owner' off first". Toggling the subflow off gave 200, and `DELETE` gave `200 {deleted: true}`. - Republish: `PUT` of `showcase_project_closure` with status `obsolete`, then toggling `showcase_closure_signoff` off (200), then `PUT` of the caller with status `active`. `/_status` read `enabled: true, bound: false` with the reason, and the server log carried one warning naming the subflow. - Toggling `showcase_closure_signoff` on then read the caller `bound: true`. - The dev server was stopped by its recorded process group. ## Tests (every reading at `3dc488eb`, the head) - **Pins, red first.** `c416228d` held 21 pins; against the unfixed engine, 16 failed for the intended reason and the 5 controls passed. The fix is `3fa3860a`. - `230ef858` added two assertions after the fix: once-per-decline, and `listByFlow` refusing to answer short. Each is red at base by construction, and each is turned red by its own ablation leg. - The pin files are `subflow-guard-every-door.test.ts` (new sibling; the store double gains `$and`, `$gt` and one ascending `orderBy`, refusing everything else) and `suspended-run-store.test.ts`. - Every refusal pin asserts `code` and `status`. - `pnpm --filter @objectstack/service-automation test`: `Test Files 156 passed (156)`, `Tests 1964 passed (1964)`. #20678's and #20677's pins stay green. - `pnpm --filter @objectstack/service-automation run typecheck`: exit 0. `--listFiles` counts both pin files in `tsconfig.json` and in `tsconfig.test.json`. - **Ablation.** 16 legs, through `scripts/ablation-replace.mjs` in wrap mode, with an outer trap on `EXIT` / `INT` / `TERM` restoring by absolute path. Every leg: anchor x1 then x0, the blob changed, then "ok restored: blob == HEAD and git diff HEAD is empty". There is no dist leg: the pins import `./engine.js` relatively. - M1: the gate call removed, 10 red. - M2: every packaged caller declined, 12 red, including the enabled-subflow control and the uninstall control. - M3: the packaged-caller check dropped, 1 red (the customer control). - M4: re-judging disabled, 6 red. - M5: the cycle precondition dropped, 1 red. - M6: warn-once dropped, 1 red. - M7: the `/_status` reason dropped, 3 red. - M8: the removal guard dropped, 3 red. - M9: a switched-off caller never guards removal, 2 red. - M10: the subflow's own switch ignored, 3 red, including the switched-off-removal control. - M11: customer callers counted, 1 red (the control). - M12: the resync through the guarded door, 1 red (uninstall). - M13: the unscoped read, 1 red (the beyond-the-cap guard). - M14: the flow filter dropped, 2 red. - M15: one page only, 2 red. - M16: the truncation refusal dropped, 1 red. - In an earlier round, the first M3 and M11 attempts were no-ops that the tool refused (replacement count unmoved; anchor hit twice). Nothing ran on them, and both were re-anchored. ## Gates (at `3dc488eb`) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` gives 62 commands, identical to 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". - The roster families outside the runnable list, each exit 0: `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing`, `pnpm check:filter-alias-parity`. Also exit 0 for the log-level and recorded-decline edits: `pnpm check:durability-log-level` and `pnpm check:startup-registry-verdict`. - `check-changeset-no-major` and `check-adr-0087-registration` were run against a synthetic `pull_request` payload carrying this body; both exit 0. - **Lint, a declared narrowing.** `eslint --no-inline-config --format json` over the 6 changed files gave 0 errors and 1 warning (the changeset `.md` has no matching config). The population comes from `eslint.config.mjs` `files` `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`. The config enables no 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, because their argv comes from the workflow. ## Changeset `.changeset/20725-subflow-guard-every-door.md`: `@objectstack/service-automation` `minor`, BREAKING, with the `Clause-②` line and the ADR-0087 disposition. The two pending #20678 notes stay true: the enable toggle in a cycle is still not refused, and the parked-run read still covers both stores. They are unchanged. ## Acceptance notes - **class: a · reach: the automation create door, measured live on the showcase.** A create request that asserts package provenance yields a flow that the §7.3 guards treat as shipped, beyond its classification. It holds a shipped subflow's disable and, after this PR, its removal: the "a flow the customer authored does not hold a packaged one hostage" filter does not apply to it. The toggle door accepts it and writes an 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: a create by name overwrites the in-process definition either way, and the assertion decides only whether the overwrite stays classified as packaged. Read-only package treatment does not apply on these doors to either. - The seat files this as its own `security` card, with no request detail anywhere public. It is not fixed here. - **carrier: none · boundary, noted:** `ObjectStoreSuspendedRunStore.list()` still reads one capped page of 1000 `paused` rows. The deployment-wide listing reads it, and so does the boot wait-timer re-arm (`builtin/wait-node.ts`, `rearmSuspendedWaitTimers`). Past that, a wait's timer would not be re-armed. Not measured. - **carrier: none · boundary, noted:** a caller onto a subflow that is not registered at all (withdrawn by an artifact reload, or never shipped) is not declined. The gate reads registered packaged children only, and a missing target's provenance is unknowable. Such a caller fails at its node, as before. - **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, and the price of the synchronous removal contract. - **Base drift:** `origin/main` has moved one commit (`697845d1`, a service-package citation re-anchor and its changeset). It does not touch `service-automation` or the automation route, and it is not merged here. The merge queue validates the merge ref. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cba417a commit 0d9349f

6 files changed

Lines changed: 1066 additions & 26 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
---
4+
5+
fix(service-automation)!: a packaged flow is never armed onto a disabled packaged subflow on any door, removing a packaged subflow its packaged callers can still reach is refused, and the disable refusal reads a caller's parked runs completely (#20725)
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 flows the engine arms, which removals it accepts, and which disables it can see a parked run behind. -->
10+
11+
**BREAKING**: shipped as `minor` under the launch-window convention. ADR-0126 §7.3 refuses one state: a packaged flow armed while a packaged flow it calls (the `flowName` of a `subflow` or `map` node) is disabled, so that the caller fails at that node on the child's refusal. The activation switch (`POST /api/v1/automation/:name/toggle`) already refused it in both directions. It now holds on every other door too.
12+
13+
**Arming declines it; registration is never refused.** Creating, republishing, upgrading or hot-reloading a flow, a cold boot, and a trigger registering at `kernel:ready` all arm through one gate. That gate now leaves a packaged flow **unarmed** while a packaged subflow it calls is disabled, by the activation ledger or by its definition's `status` (`obsolete` / `invalid`). The flow still registers, so a boot or an upgrade never fails on an installation's choice:
14+
15+
- `GET /api/v1/automation/_status` reports it `enabled: true, bound: false`, with a `reason` naming each disabled subflow and the step that re-arms it. The `kernel:bootstrapped` binding audit prints the same reason.
16+
- The engine logs one warning naming each subflow and its remedy (enable it, or publish it with status `active`).
17+
- It is armed the moment its subflow is enabled, through the switch or by republishing the subflow `active`. A flow held back by two subflows is armed when both are on.
18+
- An armed caller is unarmed when its subflow is republished `obsolete` or `invalid`, or when the activation ledger read at boot switches that subflow off.
19+
- In a cycle of flows switched off in the activation ledger, enabling the first one is still accepted, but it stays unarmed until the flow it calls is enabled; then both are armed.
20+
- A flow the customer authored, or a subflow the customer authored, is not judged.
21+
22+
**Removing a packaged subflow is refused while a packaged caller can still reach it.** `AutomationEngine.unregisterFlow`, and so `DELETE /api/v1/automation/:name`, now refuses with `DELETE_RESTRICTED` / `409` and `subflowCallers`, the same refusal the switch gives on disable. Nothing is removed. The message names each caller and the steps:
23+
24+
- **An enabled caller** guards: disable it first.
25+
- **A switched-off caller** guards while the subflow itself is still enabled. The removal door cannot read whether that caller still holds a parked run, so it names the switch instead: switch the subflow off (that refusal names each parked run to cancel), then remove it.
26+
- Once the subflow is switched off and every caller is switched off, the removal completes.
27+
28+
A flow an artifact reload no longer ships (a package upgrade or uninstall, a Studio package publish, a dev reload) is removed through the new `AutomationEngine.withdrawFlow`, which does not take this refusal: the package decided the removal, and the next cold boot would not register the flow either.
29+
30+
**The disable refusal reads a caller's parked runs completely.** Disabling a packaged subflow under a switched-off caller that holds a parked run was decided from the durable store's deployment-wide list of paused runs, which reads at most 1000 rows. A caller whose run lay beyond them read as holding none, and the disable was accepted. The refusal now asks the store for the named callers' runs, all of them: `SuspendedRunStore` gains an optional `listByFlow(flowNames)` (complete by contract, or an error), and `ObjectStoreSuspendedRunStore.listByFlow` reads the `(flow_name, status)` index page by page to its end. A store without it is read through `list()`. The deployment-wide listing (`listSuspendedRunsDurable`) is unchanged.

0 commit comments

Comments
 (0)