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
91 changes: 91 additions & 0 deletions .changeset/17928-wait-node-config-required.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
---
'@objectstack/spec': minor
'@objectstack/service-automation': minor
---

fix(automation): a `wait` node must say what resumes it — the config block is required at the contract, and the executor stops defaulting to a duration-less timer (#17928)

**BREAKING** — a `type: 'wait'` flow node with no `waitEventConfig` block, and a
`type: 'boundary_event'` node with no `boundaryConfig` block, no longer parse.
Under `eventType: 'timer'`, `timerDuration` is now required and may not be blank
— and that half sits on the `waitEventConfig` BLOCK, not on the node type, so it
bites on ANY node carrying the block: a `start` node spelled
`waitEventConfig: { eventType: 'timer' }` parsed before and is refused now. It is
still a narrowing in every direction (no shape starts parsing that did not), and
the block is inert on a node type no executor reads it from, so the practical
reach is `wait`.

`eventType` has been required *inside* each block since protocol 17, so
`waitEventConfig: {}` was already a loud parse error. The block itself was
optional — so "omit the key" and "omit the block" were two documents with two
verdicts, and the accepted one was the silent one. It is also the state a
freshly created node is in, which is what made it reachable from a designer's
default screen rather than only by hand-authoring.

What that document did, measured through a real `engine.execute()` run rather
than read off the source:

```
FROM { id: 'pause', type: 'wait', label: 'Wait' } // parses clean
-> { success: true, suspend: true } // run status: paused
scheduled jobs: [] <- with a job service ANSWERING
variables: no `pause.waitUntil` <- cold boot cannot re-arm
log lines: 0 at any level <- warn, error, info, debug

TO FlowNodeSchema.safeParse(...)
-> { success: false,
issues: [{ code: 'custom', path: ['waitEventConfig'],
message: 'a `wait` node requires a `waitEventConfig` block saying
what resumes it … `waitEventConfig: { eventType: 'timer',
timerDuration: 'PT1H' }` … or `{ eventType: 'signal',
signalName: 'order_paid' }` …' }] }
```

The control — the same node with `{ eventType: 'timer', timerDuration: 'PT1H' }`
— armed the one-shot job and persisted the deadline, so the zeros above are a
reading of this path and not of a dead harness.

**The executor follows the contract.** `wait-node.ts` carried
`(node.waitEventConfig ?? {})` and `String(wec.eventType ?? 'timer')` under a
comment declaring the second one deliberate — "a wait node without one is a
VALID TIMER WAIT". Both fallbacks are retired. A node that still reaches
`execute` without the block (a stored pre-migration document on a path that
skipped the parse) is now a **guard refusal** — `errorClass: 'guard'`, so a
`fault` edge cannot route a metadata defect into a handler that reports success
— and it **logs**, naming the node and the remedy, because the defect being
closed was silence. It never suspends with `success: true` again. Two smaller
corrections ride along in the same return: the timer branch stops answering
`output` as a present key holding `undefined` (it is absent when no deadline was
computed), and the reversed comment is deleted rather than left describing a
behaviour that is gone.

**`screen.mode` now declares the default the executor applies; `http.method`
still declares none.** Both were read by running the executors with the key
absent, not by reading the Zod:

| key | absent ⇒ the runtime applies | declared |
| --- | --- | --- |
| `ScreenConfig.mode` | `'create'` (object-form branch; the flat `fields` branch never reads it) | `.default('create')` |
| `HttpConfig.method` | `GET` inline, **`POST`** when `durable: true` | ⛔ none — two values, no single default |

Declaring `.default('GET')` on `method` would materialise `GET` at parse time,
the durable arm's own `?? 'POST'` would never fire again, and every stored
durable callout that omits the method would silently change verb. That is the
defect this card exists to end, pointed the other way.

**Migration.** A stored `wait` node with no block has no lossless conversion —
the missing value is an intent no artifact records, and the old runtime's pick
(`'timer'` with no duration) was not a wait at all — so this is an ADR-0087 D3
semantic entry rather than a D2 conversion: `os migrate meta --from 17` names
each node to edit. Declare the resume condition and re-publish the flow. ⚠️
Behaviour the fix deliberately changes: a run that used to park forever now
waits the duration you declare or the signal you name.

**`boundary_event` gets the contract half only.** The runtime registers no
executor for that node type at all — a flow reaching one fails with
`NO_EXECUTOR` before any config is read, identically whether the block is
present or absent — so there is no silent executor branch behind it. The
refusal fixes the authoring surface; `try_catch` (ADR-0031) remains the native
construct for error handling.

<!-- adr-0087: registered wait-node-event-config-required -->
4 changes: 2 additions & 2 deletions content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ const result = AutomationApiErrorCode.parse(data);
| **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds |
| **inputSchema** | `Record<string, { type: Enum<'string' \| 'number' \| 'boolean' \| 'object' \| 'array'>; required?: boolean; description?: string }>` | optional | Input parameter schema for this node |
| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption |
| **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes |
| **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption. REQUIRED on a `type: 'wait'` node — the block is the whole contract, and a wait node without one parses into a run that parks forever reporting success. Under `eventType: 'timer'` a `timerDuration` is required too. |
| **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes. REQUIRED on a `type: 'boundary_event'` node — without it the node names neither the activity it watches nor what fires it. |

### Nested Shape: `CreateFlowRequest.edges[number]`

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ Value the variable takes: a string (`{token}` flow interpolation — a sole toke
| **waitForInput** | `boolean` | optional | Pause to show the screen even with no fields; false forces a server pass-through |
| **objectName** | `string` | optional | Render this object's full create/edit form instead of a flat field list |
| **idVariable** | `string` | optional | Object form only: variable bound to the saved record's id |
| **mode** | `Enum<'create' \| 'edit'>` | optional | Object form only: create or edit |
| **mode** | `Enum<'create' \| 'edit'>` | optional (default: `"create"`) | Object form only: create (default) or edit; 'edit' needs a recordId to name its target |
| **recordId** | `string` | optional | Object form only: id of the record to edit (required for mode: 'edit' to be useful) |
| **defaults** | `Record<string, any>` | optional | Object form only: prefilled values |

Expand Down
8 changes: 4 additions & 4 deletions content/docs/references/automation/control-flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ const result = FlowRegionSchema.parse(data);
| **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds |
| **inputSchema** | `Record<string, { type: Enum<'string' \| 'number' \| 'boolean' \| 'object' \| 'array'>; required?: boolean; description?: string }>` | optional | Input parameter schema for this node |
| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption |
| **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes |
| **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption. REQUIRED on a `type: 'wait'` node — the block is the whole contract, and a wait node without one parses into a run that parks forever reporting success. Under `eventType: 'timer'` a `timerDuration` is required too. |
| **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes. REQUIRED on a `type: 'boundary_event'` node — without it the node names neither the activity it watches nor what fires it. |

### Nested Shape: `FlowRegion.edges[number]`

Expand Down Expand Up @@ -182,8 +182,8 @@ const result = FlowRegionSchema.parse(data);
| **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds |
| **inputSchema** | `Record<string, { type: Enum<'string' \| 'number' \| 'boolean' \| 'object' \| 'array'>; required?: boolean; description?: string }>` | optional | Input parameter schema for this node |
| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption |
| **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes |
| **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption. REQUIRED on a `type: 'wait'` node — the block is the whole contract, and a wait node without one parses into a run that parks forever reporting success. Under `eventType: 'timer'` a `timerDuration` is required too. |
| **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes. REQUIRED on a `type: 'boundary_event'` node — without it the node names neither the activity it watches nor what fires it. |

### Nested Shape: `ParallelBranch.edges[number]`

Expand Down
8 changes: 4 additions & 4 deletions content/docs/references/automation/flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ const result = FlowSchema.parse(data);
| **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds |
| **inputSchema** | `Record<string, { type: Enum<'string' \| 'number' \| 'boolean' \| 'object' \| 'array'>; required?: boolean; description?: string }>` | optional | Input parameter schema for this node |
| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption |
| **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes |
| **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption. REQUIRED on a `type: 'wait'` node — the block is the whole contract, and a wait node without one parses into a run that parks forever reporting success. Under `eventType: 'timer'` a `timerDuration` is required too. |
| **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes. REQUIRED on a `type: 'boundary_event'` node — without it the node names neither the activity it watches nor what fires it. |

### Nested Shape: `Flow.edges[number]`

Expand Down Expand Up @@ -145,8 +145,8 @@ const result = FlowSchema.parse(data);
| **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds |
| **inputSchema** | `Record<string, { type: Enum<'string' \| 'number' \| 'boolean' \| 'object' \| 'array'>; required?: boolean; description?: string }>` | optional | Input parameter schema for this node |
| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption |
| **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes |
| **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption. REQUIRED on a `type: 'wait'` node — the block is the whole contract, and a wait node without one parses into a run that parks forever reporting success. Under `eventType: 'timer'` a `timerDuration` is required too. |
| **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes. REQUIRED on a `type: 'boundary_event'` node — without it the node names neither the activity it watches nor what fires it. |

### Nested Shape: `FlowNode.connectorConfig`

Expand Down
13 changes: 11 additions & 2 deletions packages/plugins/plugin-approvals/src/approval-revise.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,8 +702,17 @@ describe('Send back for revision (ADR-0044)', () => {
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'review', type: 'approval', label: 'Review', config: { approvers: [{ type: 'user', value: 'u1' }] } },
// The shape ADR-0044 D3 originally prescribed.
{ id: 'wait_revision', type: 'wait', label: 'Awaiting Revision' },
// The shape ADR-0044 D3 originally prescribed. "Bare" here means the
// node TYPE — a plain `wait` where the revise window must be an
// `approval_revise` node — and that is what this case is about. The
// `waitEventConfig` is not optional decoration: a `wait` node with no
// block stopped parsing when the contract began requiring it, so
// without this the flow never registers and the send-back refusal
// below would be measuring a flow that does not exist.
{
id: 'wait_revision', type: 'wait', label: 'Awaiting Revision',
waitEventConfig: { eventType: 'signal', signalName: 'revision_submitted' },
},
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
Expand Down
Loading
Loading