Repository navigation
Commit 0d9349f
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
- .changeset
- packages/services/service-automation/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
0 commit comments