Skip to content

Commit ab16331

Browse files
os-zhuangclaude
andauthored
feat(spec)!: retire waitEventConfig.timeoutMs / .onTimeoutwait never had a timeout (#4158) (#4198)
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. Claude-Session: https://claude.ai/code/session_01QoA8AV99Ss1RRkLLAYmDzq Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
1 parent 38ec8a1 commit ab16331

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
@@ -958,8 +958,6 @@ describe('BPMN — Wait Event Configuration', () => {
958958
waitEventConfig: {
959959
eventType: 'timer',
960960
timerDuration: 'PT1H',
961-
timeoutMs: 7200000,
962-
onTimeout: 'fail',
963961
},
964962
});
965963
expect(result.success).toBe(true);
@@ -977,15 +975,12 @@ describe('BPMN — Wait Event Configuration', () => {
977975
waitEventConfig: {
978976
eventType: 'webhook',
979977
signalName: 'payment_received',
980-
timeoutMs: 86400000,
981-
onTimeout: 'continue',
982978
},
983979
});
984980
expect(result.success).toBe(true);
985981
if (result.success) {
986982
expect(result.data.waitEventConfig?.eventType).toBe('webhook');
987983
expect(result.data.waitEventConfig?.signalName).toBe('payment_received');
988-
expect(result.data.waitEventConfig?.onTimeout).toBe('continue');
989984
}
990985
});
991986

@@ -1000,8 +995,28 @@ describe('BPMN — Wait Event Configuration', () => {
1000995
},
1001996
});
1002997
expect(result.success).toBe(true);
1003-
if (result.success) {
1004-
expect(result.data.waitEventConfig?.onTimeout).toBe('fail'); // default
998+
});
999+
1000+
/**
1001+
* These three tests used to assert the OPPOSITE — that `timeoutMs` and
1002+
* `onTimeout` were accepted, and that `onTimeout` defaulted to `'fail'`. They
1003+
* were encoding a contract the runtime never honoured: nothing read `onTimeout`,
1004+
* and `timeoutMs` was consumed as the timer duration rather than as a timeout
1005+
* (#4158). Retiring the pair is what flipped them, which is the point — the
1006+
* schema now says what `wait` actually does.
1007+
*/
1008+
it('rejects the retired timeout keys instead of stripping them (#4158)', () => {
1009+
for (const retired of [{ timeoutMs: 7_200_000 }, { onTimeout: 'fail' }]) {
1010+
const result = FlowNodeSchema.safeParse({
1011+
id: 'wait_timer',
1012+
type: 'wait',
1013+
label: 'Wait 1 Hour',
1014+
waitEventConfig: { eventType: 'timer', timerDuration: 'PT1H', ...retired },
1015+
});
1016+
const key = Object.keys(retired)[0];
1017+
expect(result.success, `${key} must be rejected, not silently dropped`).toBe(false);
1018+
// The prescription names the issue and the replacement (or its absence).
1019+
expect(JSON.stringify(result.error?.issues), `${key} guidance`).toMatch(/4158/);
10051020
}
10061021
});
10071022

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

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

221251
/**

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)