Skip to content

Commit 7a245bb

Browse files
committed
feat(spec)!: retire waitEventConfig.timeoutMs / .onTimeoutwait never had a timeout (#4158)
Both keys described a timeout and neither delivered one, so protocol 18 removes the pair rather than leaving a promise the runtime does not keep (PD #10). `onTimeout` had ZERO readers. No path ever inspected it, so neither 'fail' nor 'continue' ever happened — and its `.default('fail')` stamped a decision nothing made onto every wait node. The showcase set `onTimeout: 'continue'`, which did nothing; it is removed here. `timeoutMs` said "maximum wait time before timeout" while its only reader used it as the timer DURATION when `timerDuration` was absent. It did something, just not what it claimed. Together they declared a timeout `wait` does not have: a run resumes when its timer elapses or its signal arrives, never on a deadline. Real timeout semantics are left unimplemented deliberately — they should be built to a requirement, not retrofitted to fit two keys that happened to be declared. `timeoutMs` CONVERTS to `timerDuration` rather than being dropped, because that is what it did — stringified on the way, since the target is `z.string()` while `timeoutMs` was `z.number()` and `parseIsoDuration` reads a bare numeric string as milliseconds. Moving the number unstringified would have produced a block that no longer parses; a test pins that. With `timerDuration` already set it is dropped instead, having been dead metadata the executor's `??` never reached. Both leave the load path, which is the split the registry already draws: a key retired for being RENAMED keeps a load window, because punishing an author for a spelling nobody warned them about is pointless; a key that MISDESCRIBED itself does not, because silently absorbing it lets the author keep believing they configured a timeout. `api.requireAuth`, the tool/app/flow inert keys and RLS `priority` all left it for the same reason. Three existing tests asserted the opposite — that both keys parse, and that `onTimeout` defaults to 'fail'. They were encoding a contract the runtime never honoured, so they are flipped rather than deleted: the accept cases drop the retired keys, and a new case asserts they are REJECTED with a prescription rather than silently stripped. One fixture interaction, caught by the harness itself: the #4045 lift fixture used `waitEventConfig.timeoutMs` for its fourth ledger entry, and the fixture harness replays the whole table — so its `after` described an end state protocol 18 makes unreachable. It lifts `eventType` instead. Verified: spec 274 files / 7144 tests, service-automation 460, all 8 generated artifacts current (`check:generated`), root gates pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QoA8AV99Ss1RRkLLAYmDzq
1 parent d7851cf commit 7a245bb

10 files changed

Lines changed: 300 additions & 29 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/spec': major
3+
'@objectstack/service-automation': patch
4+
---
5+
6+
feat(spec)!: retire `waitEventConfig.timeoutMs` / `.onTimeout``wait` never had a timeout (#4158)
7+
8+
Both keys described a timeout and neither delivered one, so protocol 18 removes the pair
9+
rather than leaving a promise the runtime does not keep (PD #10).
10+
11+
- **`onTimeout`** had **zero** readers. No path ever inspected it, so neither `'fail'` nor
12+
`'continue'` ever happened — and its `.default('fail')` stamped a decision nothing made
13+
onto every wait node. The showcase set `onTimeout: 'continue'`, which did nothing.
14+
- **`timeoutMs`** said *"maximum wait time before timeout"* while its only reader used it
15+
as the timer **duration** when `timerDuration` was absent. It did something, just not
16+
what it claimed.
17+
18+
Together they declared a timeout `wait` does not have: a run resumes when its timer
19+
elapses or its signal arrives, never on a deadline. Real timeout semantics are left
20+
unimplemented deliberately — they should be built to a requirement, not retrofitted to
21+
fit two keys that happened to be declared.
22+
23+
`timeoutMs` **converts to `timerDuration`** rather than being dropped, because that is
24+
what it did. It is stringified on the way: the target is `z.string()` while `timeoutMs`
25+
was `z.number()`, and `parseIsoDuration` reads a bare numeric string as milliseconds — so
26+
`timeoutMs: 60000` and `timerDuration: '60000'` are the same wait. Moving the number
27+
unstringified would have produced a block that no longer parses, which a test pins. With
28+
`timerDuration` already set it is dropped instead: the executor's `??` never looked past
29+
the duration, so it was already dead metadata.
30+
31+
Both leave the **load path** (`retiredFromLoadPath`), which is the registry's existing
32+
split: a key retired for being *renamed* keeps a load window, because punishing an author
33+
for a spelling nobody warned them about is pointless; a key that **misdescribed itself**
34+
does not, because silently absorbing it lets the author keep believing they configured a
35+
timeout. That is why `api.requireAuth`, the tool/app/flow inert keys and RLS `priority`
36+
all left it too. The migration chain converts stored sources mechanically; the schema
37+
tombstones name the replacement.
38+
39+
One fixture interaction worth recording: the #4045 lift fixture used
40+
`waitEventConfig.timeoutMs` to demonstrate its fourth ledger entry, and the fixture
41+
harness replays the whole table — so its `after` described an end state protocol 18 makes
42+
unreachable. It now lifts `eventType` instead. The harness caught this itself.

content/docs/references/automation/flow.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ const result = Flow.parse(data);
104104
| **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds |
105105
| **inputSchema** | `Record<string, { type: Enum<'string' \| 'number' \| 'boolean' \| 'object' \| 'array'>; required: boolean; description?: string }>` | optional | Input parameter schema for this node |
106106
| **outputSchema** | `any` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (#3896 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. |
107-
| **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string; timeoutMs?: integer; … }` | optional | Configuration for wait node event resumption |
107+
| **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string; timeoutMs?: any; … }` | optional | Configuration for wait node event resumption |
108108
| **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes |
109109

110110

examples/app-showcase/src/automation/flows/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,7 +554,9 @@ export const TaskFollowUpFlow = defineFlow({
554554
label: 'Wait 1 min',
555555
// Timer wait: suspends the run, then a one-shot job resumes it after the
556556
// duration. ISO-8601 duration; production reminders would use e.g. 'P3D'.
557-
waitEventConfig: { eventType: 'timer', timerDuration: 'PT1M', onTimeout: 'continue' },
557+
// No `onTimeout`: it was retired in #4158 because nothing ever read it —
558+
// `wait` has no timeout, and this run resumes when the timer elapses.
559+
waitEventConfig: { eventType: 'timer', timerDuration: 'PT1M' },
558560
},
559561
{
560562
id: 'remind',

packages/services/service-automation/src/builtin/wait-node.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,9 @@ describe('rearmSuspendedWaitTimers (cold-boot timer re-arm)', () => {
279279

280280
it('resumes an overdue timer immediately (deadline elapsed while down)', async () => {
281281
const store = new InMemorySuspendedRunStore();
282-
const config = { eventType: 'timer', timeoutMs: 1 };
282+
// `timerDuration: '1'`, not the retired `timeoutMs: 1` (#4158) — a bare
283+
// numeric string is milliseconds, so this is the same 1ms deadline.
284+
const config = { eventType: 'timer', timerDuration: '1' };
283285

284286
const a = bootEngine(store, ctxNoJob(), config); // degraded: no job service
285287
const paused = await a.engine.execute('wait_flow');

packages/services/service-automation/src/builtin/wait-node.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,11 @@ export function registerWaitNode(engine: AutomationEngine, ctx: PluginContext):
6464
const runId = variables.get('$runId');
6565

6666
if (eventType === 'timer') {
67-
// `timeoutMs` doubling as a duration is pre-existing behaviour, kept
68-
// deliberately — the declared meaning is a timeout guard, and `wait` has
69-
// no timeout implementation at all (`onTimeout` has zero readers). That
70-
// gap is #4158; changing it here would be a behaviour change riding on a
71-
// contract cleanup.
72-
const durationMs =
73-
parseIsoDuration(wec.timerDuration) ??
74-
(typeof wec.timeoutMs === 'number' ? wec.timeoutMs : undefined);
67+
// One source for the wait length. `timeoutMs` used to double as this
68+
// when `timerDuration` was absent — it was retired in protocol 18 (#4158)
69+
// precisely because that is not what it said it did, and the conversion
70+
// rewrites it to `timerDuration`, so there is one spelling left.
71+
const durationMs = parseIsoDuration(wec.timerDuration);
7572

7673
// Persist the wake deadline as node output: the engine writes output
7774
// to variables (`<nodeId>.waitUntil`) *before* snapshotting the

packages/spec/src/automation/flow.test.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -880,8 +880,6 @@ describe('BPMN — Wait Event Configuration', () => {
880880
waitEventConfig: {
881881
eventType: 'timer',
882882
timerDuration: 'PT1H',
883-
timeoutMs: 7200000,
884-
onTimeout: 'fail',
885883
},
886884
});
887885
expect(result.success).toBe(true);
@@ -899,15 +897,12 @@ describe('BPMN — Wait Event Configuration', () => {
899897
waitEventConfig: {
900898
eventType: 'webhook',
901899
signalName: 'payment_received',
902-
timeoutMs: 86400000,
903-
onTimeout: 'continue',
904900
},
905901
});
906902
expect(result.success).toBe(true);
907903
if (result.success) {
908904
expect(result.data.waitEventConfig?.eventType).toBe('webhook');
909905
expect(result.data.waitEventConfig?.signalName).toBe('payment_received');
910-
expect(result.data.waitEventConfig?.onTimeout).toBe('continue');
911906
}
912907
});
913908

@@ -922,8 +917,28 @@ describe('BPMN — Wait Event Configuration', () => {
922917
},
923918
});
924919
expect(result.success).toBe(true);
925-
if (result.success) {
926-
expect(result.data.waitEventConfig?.onTimeout).toBe('fail'); // default
920+
});
921+
922+
/**
923+
* These three tests used to assert the OPPOSITE — that `timeoutMs` and
924+
* `onTimeout` were accepted, and that `onTimeout` defaulted to `'fail'`. They
925+
* were encoding a contract the runtime never honoured: nothing read `onTimeout`,
926+
* and `timeoutMs` was consumed as the timer duration rather than as a timeout
927+
* (#4158). Retiring the pair is what flipped them, which is the point — the
928+
* schema now says what `wait` actually does.
929+
*/
930+
it('rejects the retired timeout keys instead of stripping them (#4158)', () => {
931+
for (const retired of [{ timeoutMs: 7_200_000 }, { onTimeout: 'fail' }]) {
932+
const result = FlowNodeSchema.safeParse({
933+
id: 'wait_timer',
934+
type: 'wait',
935+
label: 'Wait 1 Hour',
936+
waitEventConfig: { eventType: 'timer', timerDuration: 'PT1H', ...retired },
937+
});
938+
const key = Object.keys(retired)[0];
939+
expect(result.success, `${key} must be rejected, not silently dropped`).toBe(false);
940+
// The prescription names the issue and the replacement (or its absence).
941+
expect(JSON.stringify(result.error?.issues), `${key} guidance`).toMatch(/4158/);
927942
}
928943
});
929944

packages/spec/src/automation/flow.zod.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,40 @@ export const FlowNodeSchema = lazySchema(() => z.object({
204204
timerDuration: z.string().optional().describe('ISO 8601 duration (e.g., "PT1H") or wait time for timer events'),
205205
/** Signal name to listen for — for signal/webhook events */
206206
signalName: z.string().optional().describe('Named signal or webhook event to wait for'),
207-
/** Timeout before auto-failing or continuing — optional guard */
208-
timeoutMs: z.number().int().min(0).optional().describe('Maximum wait time before timeout (ms)'),
209-
/** Action to take on timeout */
210-
onTimeout: z.enum(['fail', 'continue']).default('fail').describe('Behavior when the wait times out'),
207+
208+
/**
209+
* `wait` never had a timeout. Both keys below described one and neither
210+
* delivered it (#4158) — the pair is retired in 18 rather than left standing
211+
* as a promise the runtime does not keep (PD #10).
212+
*
213+
* `timeoutMs` said "maximum wait time" and its ONLY reader used it as the
214+
* timer *duration* when `timerDuration` was absent — so it did something, just
215+
* not what it said. `timerDuration` already expresses that (`parseIsoDuration`
216+
* accepts a bare number as milliseconds), which is why the conversion can move
217+
* it losslessly instead of dropping it.
218+
*
219+
* `onTimeout` had ZERO readers anywhere. Setting it changed nothing, and the
220+
* showcase set it — a declared default (`'fail'`) stamped on every wait node
221+
* that no code ever consulted.
222+
*
223+
* Real timeout semantics — resume the run at a deadline and either fail the
224+
* node or continue past it — remain unimplemented. If they are wanted, they
225+
* should be built to a requirement, not retrofitted to fit two keys that
226+
* happened to be declared.
227+
*/
228+
timeoutMs: retiredKey(
229+
'`waitEventConfig.timeoutMs` was removed in @objectstack/spec 18 (#4158). It documented a '
230+
+ 'timeout guard that never existed: nothing ever failed or resumed a wait on a deadline. Its '
231+
+ 'only reader treated it as the timer DURATION when `timerDuration` was absent, so use '
232+
+ '`timerDuration` — it accepts a bare number as milliseconds, making `timeoutMs: 60000` and '
233+
+ "`timerDuration: 60000` the same wait. Stored flows are converted automatically.",
234+
),
235+
onTimeout: retiredKey(
236+
'`waitEventConfig.onTimeout` was removed in @objectstack/spec 18 (#4158). It had no readers at '
237+
+ 'all — no code path ever inspected it, so neither `fail` nor `continue` ever happened. Delete '
238+
+ 'the key. There is no replacement: `wait` has no timeout, and a wait node resumes only when '
239+
+ 'its timer elapses or its signal arrives.',
240+
),
211241
}).optional().describe('Configuration for wait node event resumption'),
212242

213243
/**

packages/spec/src/conversions/conversions.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,63 @@ describe('conversion layer (ADR-0087 D2)', () => {
232232
});
233233
});
234234

235+
describe('flow-node-wait-timeout-keys-removed (#4158)', () => {
236+
const wecFlow = (waitEventConfig: Record<string, unknown>) => ({
237+
flows: [
238+
{
239+
name: 'settle',
240+
label: 'Settle',
241+
type: 'autolaunched',
242+
edges: [],
243+
nodes: [
244+
{ id: 'n1', type: 'start', label: 'Start' },
245+
{ id: 'w', type: 'wait', label: 'Wait', waitEventConfig },
246+
],
247+
},
248+
],
249+
});
250+
const wecOf = (stack: Record<string, unknown>) => (stack.flows as any[])[0].nodes[1].waitEventConfig;
251+
// Retired from the load path, so the default `applyConversions` skips it —
252+
// exactly like `stack-api-require-auth-removed`.
253+
const convert = (stack: Record<string, unknown>) => collectConversionNotices(stack, { includeRetired: true });
254+
255+
it('moves `timeoutMs` to `timerDuration` as a STRING the schema accepts', () => {
256+
const { stack, notices } = convert(wecFlow({ eventType: 'timer', timeoutMs: 60_000 }));
257+
expect(wecOf(stack)).toEqual({ eventType: 'timer', timerDuration: '60000' });
258+
expect(notices).toHaveLength(1);
259+
// The move is only lossless if the result still parses — `timerDuration` is
260+
// `z.string()`, so carrying the number across would have broken the block.
261+
expect(() => FlowSchema.parse((stack.flows as any[])[0])).not.toThrow();
262+
});
263+
264+
it('drops `timeoutMs` instead of moving it when `timerDuration` already won', () => {
265+
const { stack, notices } = convert(
266+
wecFlow({ eventType: 'timer', timerDuration: 'PT5M', timeoutMs: 999 }),
267+
);
268+
expect(wecOf(stack)).toEqual({ eventType: 'timer', timerDuration: 'PT5M' });
269+
expect(notices).toHaveLength(1);
270+
});
271+
272+
it('drops `onTimeout` — it never had a reader, so there is nothing to preserve', () => {
273+
const { stack, notices } = convert(wecFlow({ eventType: 'timer', timerDuration: 'PT1M', onTimeout: 'continue' }));
274+
expect(wecOf(stack)).toEqual({ eventType: 'timer', timerDuration: 'PT1M' });
275+
expect(notices).toHaveLength(1);
276+
});
277+
278+
it('leaves a block carrying neither key untouched', () => {
279+
const { stack, notices } = convert(wecFlow({ eventType: 'signal', signalName: 'paid' }));
280+
expect(wecOf(stack)).toEqual({ eventType: 'signal', signalName: 'paid' });
281+
expect(notices).toHaveLength(0);
282+
});
283+
284+
it('tombstones both keys so a source that skipped conversion is rejected, not stripped', () => {
285+
for (const bad of [{ timeoutMs: 60_000 }, { onTimeout: 'continue' }]) {
286+
const flow = (wecFlow({ eventType: 'timer', timerDuration: 'PT1M', ...bad }).flows as any[])[0];
287+
expect(() => FlowSchema.parse(flow), `${Object.keys(bad)[0]} must be rejected`).toThrow(/4158/);
288+
}
289+
});
290+
});
291+
235292
describe('flow-node-wait-event-config-lift (PD #12 retirement, #4045)', () => {
236293
/**
237294
* One `wait` node in a flow that `FlowSchema` can actually parse — `label` is

0 commit comments

Comments
 (0)