Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/action-descriptor-is-async-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
'@objectstack/spec': major
'@objectstack/service-automation': patch
'@objectstack/plugin-approvals': patch
---

refactor(spec)!: retire `ActionDescriptor.isAsync` — a second spelling of `supportsPause` that nothing ever read (#6748, ADR-0049)

<!-- adr-0087: registered action-descriptor-is-async-retired -->

**FROM → TO:** `isAsync: true` → delete the key; declare `supportsPause: true` (plus the
`resumeAuthority` its pauses need) and return `suspend: true` from `execute()`.
`isAsync: false` → delete the key; there was never anything to preserve.

`ActionDescriptor.isAsync` declared "suspends the flow awaiting an external reply" and no
execution path read it. Measured fresh before removal across all three repos — objectstack,
objectui and cloud — with zero property reads: every hit was the declaration itself, a
generated baseline, one of five shipped descriptors WRITING it, a fixture pinning the
shape, or prose. Declaring it never made a node suspend; omitting it never stopped one.

This is the remove leg of the ADR-0049 disposition its sibling took the other way. The two
keys said the same thing — "this node type can suspend the run" — and #6667 split them by
evidence: `supportsPause` became an enforced fact (`AutomationEngine` now refuses a
suspension whose type does not declare it, at the one seam every suspension passes
through), while `isAsync` had no consumer to grow into. Keeping both would leave the
platform publishing two names for one capability with only one of them honoured — and
`screen` declared BOTH, so a plugin author copying it had no way to tell which.

The retirement kit:

- **Tombstone, not deletion** (`retiredKey()`): `ActionDescriptorSchema` is not `.strict()`,
so a plain delete would let existing descriptors parse clean and lose the key in silence
(the ADR-0104 shape). Authoring `isAsync` now fails `tsc` at the descriptor literal and
fails the parse inside `defineActionDescriptor()` — with the prescription in the message.
- **ADR-0087 D3 `SemanticMigration`** (`action-descriptor-is-async-retired`) plus the exact
`RETIRED_KEYS_BY_MAJOR` entry. No D2 conversion, deliberately: a descriptor is published
from an executor's TypeScript and never stored in stack metadata, so there is no source
for `os migrate meta` to rewrite — the `EnhancedApiError.fieldErrors` disposition.
- The five shipped writers stop writing it (`screen`, `map`, `wait`, `approval`,
`approval_revise`); the descriptors they publish lose the key, which is why the two
runtime packages appear here.
- Generated baselines (`authorable-surface/automation.json` gains `[RETIRED]`,
`authorable-defaults/automation.json` loses the default line), `spec-changes.json`, the
upgrade guide and the reference docs regenerated.

No runtime behaviour changes — that impossibility is the reason for the removal. The same
commit also corrects `supportsPause`'s TSDoc, which still described itself as a declaration
no execution path reads; #6667 made that false (#6749).
2 changes: 1 addition & 1 deletion content/docs/references/automation/node-executor.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Canonical cross-paradigm action/node descriptor (ADR-0018)
| **supportsCancellation** | `boolean` | ✅ | Supports cancellation |
| **supportsRetry** | `boolean` | ✅ | Supports retry on failure |
| **needsOutbox** | `boolean` | ✅ | Dispatch via service-messaging outbox (retry/idempotency/dead-letter) |
| **isAsync** | `boolean` | | Suspends the flow awaiting an external reply |
| **isAsync** | `never` | optional | [REMOVED] `ActionDescriptor.isAsync` was removed in @objectstack/spec 17 (#6748, ADR-0049) — no execution path ever read it, so declaring it never made a node suspend and omitting it never stopped one. Delete the key. The live mechanism is two-part: an executor suspends by RETURNING `suspend: true` from `execute()`, and its descriptor must declare `supportsPause: true` (plus the `resumeAuthority` its pauses need) or the engine refuses that suspension (#6667). Declaring `isAsync: true` alongside `supportsPause: true` was always redundant; declaring it alone was always inert. |
| **handlerContract** | `Enum<'none' \| 'pure'>` | ✅ | Effect contract for author-supplied code this action invokes: 'none' (invokes none) or 'pure' (must not write — it returns a value and the flow graph persists it) |
| **resumeAuthority** | `Enum<'any' \| 'service'>` | optional | Who may resume a run this node suspended: 'any' (the generic resume route) or 'service' (only the owning service, e.g. approvals). Carries no schema default so an omission stays observable — and an omission is fail-CLOSED at run time, equivalent to 'service': a pausing node whose pause is open to the generic route must declare 'any' explicitly (#5561) |
| **maturity** | `Enum<'ga' \| 'beta' \| 'reserved'>` | ✅ | Runtime maturity: ga (shipped), beta, or reserved (contract only — designers grey this out) |
Expand Down
5 changes: 5 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ Finally it narrows the aggregation vocabulary: `array_agg` and `string_agg` leav

One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the shape protocol 12 last used for `api.requireAuth`: an omitted `ActionDescriptor.resumeAuthority` resolves to `'service'` instead of `'any'`, so a pausing node type that never states who may continue its pauses is refused on the generic resume route rather than open to it (#5561, ADR-0044's 2026-07-28 amendment). Nothing is removed and no metadata shape changes — the field has been optional since step one of the same issue — so tsc reports nothing and only the MEANING of silence moved. That is exactly why it needs a ledger entry: a third-party plugin author has no compile error to discover it with, and the one-line prescription (declare `resumeAuthority` on the descriptor) has to arrive before a user meets a run that will not continue.

The same descriptor loses a key in this step, and the pairing is the point (#6748, ADR-0049). `ActionDescriptor.isAsync` and `ActionDescriptor.supportsPause` were two spellings of one capability — "this node type can suspend the run" — and #6667 split them by evidence rather than by preference: `supportsPause` took the ENFORCE leg (the engine now refuses a suspension the descriptor never declared, at the one seam every suspension passes through), and `isAsync` takes the REMOVE leg, because a fresh three-repo measurement found zero readers and no consumer it could grow into. What makes the duplicate worse than an ordinary inert key is that five shipped descriptors WROTE it, so the platform itself modelled a declaration that decided nothing — and a plugin author copying `screen` (which declared BOTH) had no way to tell which of the two the runtime honoured. It is tombstoned rather than deleted, so the answer arrives as a rejection carrying the fix; and because a descriptor lives in executor TypeScript rather than in stored metadata, its prescription is a semantic entry below rather than a conversion `os migrate meta` could replay.

### Mechanical (applied for you)

| Conversion | Surface | Change | Load window |
Expand Down Expand Up @@ -395,6 +397,9 @@ One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the sh
- **`export-field-meta-constraints-retired`** — `@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / .min / .max / .minLength / .maxLength (the map built by `buildFieldMetaMap`, reached as `PreparedImport.metaMap` from `prepareImportRequest`)` → the object schema you already hold — read `fields[name].required` / `.system` / `.readonly` / `.defaultValue` / `.min` / `.max` / `.minLength` / `.maxLength` off the same `ObjectSchema` you passed to `buildFieldMetaMap`, which is where the ENGINE reads them and therefore the only copy that cannot drift
- Why not automatic: ADR-0049 enforce-or-remove. These eight were never a source of truth: `buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller passed in, so the map carried a second copy of facts the caller already held. They existed for exactly one consumer — the import dry run's hand-copied pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, framework#3956) — and #4633 ruling D retired that mirror (PR #6532): the dry run now asks `DataProtocol.validateData` for the engine's verdict, which reads the object's own schema. That left all eight computed on every import and read by NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint vocabulary standing next to the presentation one with no enforcer behind it is precisely the thing an AI-authored consumer mistakes for a contract. Verified zero-reader before removal, per key and by type, across this repo (`packages/rest` itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's identity import forwards `prepared.metaMap` into `runImport` but reads only the presentation keys through `coerceRow`. Why this needs a ledger entry despite that sweep: it is the `findStream` (#4484) / `IStorageService.list` (#5540) / `actor-user-roles-to-positions` (#6011) disposition — a published TS surface with NO spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry a prescription, and the ledger is the only channel that reaches an upgrader. It is if anything blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` 14.5.0) and have been published in every release since, and because they were OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading `meta.required` after the upgrade gets `undefined` with no error at all — tsc reports at the read site only for a typed consumer. Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored or stored metadata changes shape — `required` / `min` / `maxLength` and the rest remain fully authorable on a field definition and fully enforced by the engine, which is where they always lived. The only place these eight are ever spelled is inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach them. ADR-0049 / ADR-0087, #6536 (the sweep PR #6532 deliberately deferred).
- Done when: No code of yours reads any of the eight off a `buildFieldMetaMap` / `prepareImportRequest` result. Grep your sources for `.required` / `.hasDefault` / `.minLength` / `.maxLength` / `.min` / `.max` / `.system` / `.readonly` on an `ExportFieldMeta`-typed value; each hit moves to the object schema you already passed in. ⚠️ Prove it against a RUN, not against tsc: these were optional keys, so an untyped or `any`-typed read compiles clean and silently becomes `undefined` — assert that the constraint your code acts on is still observed on a real import, not merely that the build is green. Note `hasDefault` has no one-to-one replacement key: it was the derived predicate `defaultValue != null`, mirroring the engine's `applyFieldDefaults` gate, so read `fields[name].defaultValue` and apply that same `!= null` test yourself.
- **`action-descriptor-is-async-retired`** — `ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)` → nothing to re-declare — delete the key. Suspension is `execute()` RETURNING `suspend: true`, and permission to suspend is `supportsPause: true` on the same descriptor (with the `resumeAuthority` its pauses need)
- Why not automatic: ADR-0049 enforce-or-remove. `isAsync` declared "this action suspends the flow awaiting an external reply" and NOTHING read it: a fresh three-repo measurement (#6748, re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in #6667 — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down.
- Done when: No descriptor declares `isAsync` — not the five that shipped it (`screen`, `map`, `wait`, `approval`, `approval_revise`), not a plugin's. Every node type that returns `suspend: true` from `execute()` declares `supportsPause: true` on its descriptor together with a `resumeAuthority`, and its runs still pause and resume as before: the behaviour never depended on `isAsync`, so deleting the key changes no run. Authoring `isAsync` fails `tsc` at the descriptor literal and fails `defineActionDescriptor()` at runtime with the prescription, instead of parsing clean and being stripped.

---

Expand Down
3 changes: 1 addition & 2 deletions packages/plugins/plugin-approvals/src/approval-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,7 @@ export function registerApprovalNode(
paradigms: ['flow'],
source: 'plugin',
// Human decision: the run suspends here awaiting an external reply.
supportsPause: true,
isAsync: true,
supportsPause: true, // (`isAsync` stood here — retired in #6748, ADR-0049: nothing read it)
// #3801: this pause is NOT resumable through the generic run-resume
// route. Continuing an approval is a side effect of a DECISION, and the
// decision is the thing that must be authorized (the approver slate),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,7 @@ export function registerApprovalReviseNode(
category: 'human',
paradigms: ['flow'],
source: 'plugin',
supportsPause: true,
isAsync: true,
supportsPause: true, // (`isAsync` stood here — retired in #6748, ADR-0049: nothing read it)
// #3823 / amended ADR-0044: THE point of this node type. The revise
// window is a service-owned continuation, so the #3801 gate must refuse
// a raw resume of it — which it does for any node type declaring this.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -448,8 +448,13 @@ describe('Send back for revision (ADR-0044)', () => {
type: APPROVAL_REVISE_NODE_TYPE,
resumeAuthority: 'service',
supportsPause: true,
isAsync: true,
});
// `isAsync: true` stood alongside those two until #6748 retired it
// (ADR-0049, zero readers). Asserted as ABSENT rather than dropped: the
// line above is `toMatchObject`, which would go on passing if the key
// came back, and the point of the retirement is that the descriptor
// publishes ONE spelling of "this type can suspend".
expect(descriptor).not.toHaveProperty('isAsync');
// The generic `wait` stays open to its external producer — this fix must
// not gate every author-placed wait in the system.
expect(automation.getActionDescriptors().find(d => d.type === 'wait')?.resumeAuthority)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ import { parseNodeConfig } from './parse-config.js';
* - **Request/response (default)** — a synchronous `fetch()` returning
* `{ response, status }` to the flow, preserving the historical `http_request`
* behavior so existing flows that read the response keep working. (The ADR's
* `isAsync` suspend-and-resume variant is future work.)
* suspend-and-resume variant is future work. ADR-0018 called it the `isAsync`
* path; that descriptor key was retired in #6748 — a suspending HTTP node
* would declare `supportsPause: true` plus a `resumeAuthority` and return
* `suspend: true`, which is the mechanism the engine actually enforces.)
*/

/** Structural view of `service-messaging`'s HTTP outbox surface (ADR-0018 M3). */
Expand Down
3 changes: 1 addition & 2 deletions packages/services/service-automation/src/builtin/map-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ export function registerMapNode(engine: AutomationEngine, ctx: PluginContext): v
category: 'logic',
source: 'builtin',
// Each item's subflow may pause, so the map suspends and resumes per item.
supportsPause: true,
isAsync: true,
supportsPause: true, // (`isAsync` stood here — retired in #6748, ADR-0049: nothing read it)
// As with `subflow`, `'any'` here is not the authority that applies: the
// #3801 gate follows the `map:` correlation to the in-flight item's child
// run and judges that node instead — judging the loop rather than the item
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext
description: 'Collect user input via a screen (human-input element).',
icon: 'window', category: 'human', source: 'builtin',
// Human-input nodes suspend the flow awaiting input.
supportsPause: true, isAsync: true,
supportsPause: true, // (`isAsync` stood here — retired in #6748, ADR-0049: nothing read it)
// The generic resume route IS this node's intended door: the flow-runner
// collects the inputs and hands them back as the continuation, so there
// is no service decision to route around (#3801). Stated rather than
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,7 @@ export function registerWaitNode(engine: AutomationEngine, ctx: PluginContext):
category: 'logic',
source: 'builtin',
// Durable pause — the run suspends and resumes later (timer/signal).
supportsPause: true,
isAsync: true,
supportsPause: true, // (`isAsync` stood here — retired in #6748, ADR-0049: nothing read it)
// An external producer is *meant* to resume a signal wait, so the generic
// route is the door (#3801). Stated rather than inherited from a default:
// #3823 is what inheriting it costs — ADR-0044 pointed a revise edge at a
Expand Down
3 changes: 1 addition & 2 deletions packages/services/service-automation/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2592,7 +2592,6 @@ describe('Action Descriptor Registry (ADR-0018)', () => {
supportsCancellation: false,
supportsRetry: true,
needsOutbox: true,
isAsync: false,
source: 'plugin',
deprecated: false,
maturity: 'ga',
Expand All @@ -2616,7 +2615,7 @@ describe('Action Descriptor Registry (ADR-0018)', () => {
type: 'send_sms', version: '1.0.0', name: 'Send SMS',
category: 'io', paradigms: ['flow'], supportsPause: false,
supportsCancellation: false, supportsRetry: true,
needsOutbox: false, isAsync: false, source: 'plugin', deprecated: false, maturity: 'ga',
needsOutbox: false, source: 'plugin', deprecated: false, maturity: 'ga',
},
async execute() { return { success: true }; },
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ async function pauseARun(engine: AutomationEngine) {
type: 'test_pause',
descriptor: defineActionDescriptor({
type: 'test_pause', version: '1.0.0', name: 'Test Pause',
supportsPause: true, isAsync: true,
supportsPause: true,
}),
async execute() { return { success: true, suspend: true, correlation: 'areq_1' }; },
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ function registerPausers(engine: AutomationEngine): void {
type: 'gated_pause',
descriptor: defineActionDescriptor({
type: 'gated_pause', version: '1.0.0', name: 'Gated Pause',
supportsPause: true, isAsync: true, resumeAuthority: 'service',
supportsPause: true, resumeAuthority: 'service',
}),
async execute() { return { success: true, suspend: true, correlation: 'req_1' }; },
});
Expand Down
Loading
Loading