Repository navigation
Commit 36d043b
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
- .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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
22 | | - | |
| 22 | + | |
23 | 23 | | |
24 | | - | |
| 24 | + | |
0 commit comments