Skip to content

Commit 9b702dc

Browse files
os-zhuangclaude
andauthored
fix(spec,service-automation): the wait executor reads its declared contract only (#4045) (#4161)
* fix(spec,service-automation): the wait executor reads its declared contract only (#4045) `wait` keeps its contract in `waitEventConfig` — a declared, `.describe()`-annotated block on `FlowNodeSchema` that sits in the authorable-field list, reaches the generated reference, and is what the showcase actually authors. Its descriptor publishes no `configSchema`, which is by design rather than the gap it looks like. The executor nevertheless also read six loose `config` keys behind `wec.X ?? loose.X`, two of them (`duration`, `signal`) spellings the spec never declared anywhere. That is the `notify.source` shape #4050 retired: a second de-facto contract announced only by a code comment, so an author who wrote it got a flow that worked forever and was never steered to the declared spelling (PD #12). Nothing in-repo authors it. - New ADR-0087 D2 conversion `flow-node-wait-event-config-lift` lifts config.{eventType,timerDuration,duration,timeoutMs,signalName,signal} onto the declared block, in the executor's own `??` precedence — a declared value wins and its loose counterpart is left shadowed, as `renameConfigKey` treats a shadowed alias. - `eventType` is stamped `'timer'` when the lift would otherwise leave the block without one. Load-bearing, not tidiness: the loader parses the CONVERTED flow (`applyConversionsToFlow` → `FlowSchema.parse`) and `waitEventConfig.eventType` is required once the block exists, so a stored flow carrying only `config: { duration: 'PT1M' }` would have gone from working to failing to load. `'timer'` is the exact default the executor applied to that shape. - The six `?? loose.*` fallbacks are deleted. The surviving `?? 'timer'` is not one: `waitEventConfig` is itself optional, and a node without it is a valid timer wait. Registered in both lists the layer keeps: `CONVERSIONS_BY_MAJOR[17]` and `step17.conversionIds`. The first draft missed the second and the repo's own ratchet caught it — `migrations.test.ts` replays every conversion fixture through the chain, so an unwired conversion fails there rather than shipping inert. Verified at the real seam: the new executor tests author the legacy shape and go through `registerFlow`, which is what applies the conversion. Negative control run by unregistering the conversion — two of the three fail without it; the third passes either way because it pins the executor's half of the precedence, and its name says so. A second negative control pins the `eventType` default: deleting it from the converted output makes `FlowSchema.parse` throw. Regenerated `spec-changes.json` and `docs/protocol-upgrade-guide.md` (the step rationale is a generated-artifact input). All 12 spec gates and 8 root gates pass; spec suite 7097 tests green. Deliberately unchanged and filed as #4158: `waitEventConfig.timeoutMs` is declared a timeout guard but read as a timer duration, and `onTimeout` has zero readers — `wait` has no timeout implementation at all, while the showcase authors `onTimeout: 'continue'`. Implementing or retracting that is a behaviour change, not a contract cleanup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QoA8AV99Ss1RRkLLAYmDzq * fix(examples): the showcase authors `wait` through the declared block (#4045) Corrects a factual claim in the previous commit. It said "Nothing in-repo authors it" of the loose `config` back door — that is wrong. The showcase's own `wait_revision` node authored exactly that shape: config: { eventType: 'signal', signalName: 'budget_revision' } so the back door was not hypothetical, and the example that demonstrates `wait` was itself on the spelling this PR retires. It moves to `waitEventConfig`. The conversion already handled this shape correctly — the executor's behaviour is identical either way, and a new test pins the exact combination the showcase hit: the DECLARED key names sitting in the UNDECLARED location, which is what the candidate ordering has to get right (`signalName` before `signal`). It also asserts the converted flow still parses, since the lift creates the required block. Found while checking whether a stuck `objectstack verify` CI step could be caused by this change rather than by the runner — it could not, but the audit that ruled it out is what surfaced the false claim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QoA8AV99Ss1RRkLLAYmDzq --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f5ab1c7 commit 9b702dc

9 files changed

Lines changed: 449 additions & 9 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/service-automation': patch
4+
---
5+
6+
The `wait` executor reads its declared contract only; the loose `config` back door graduates into the conversion layer (#4045).
7+
8+
`wait` keeps its contract in `waitEventConfig` — a declared, `.describe()`-annotated
9+
block on `FlowNodeSchema` that is in the authorable-field list, reaches the generated
10+
reference, and is what the showcase actually authors. Its descriptor publishes no
11+
`configSchema`, which is by design rather than the gap it first looks like.
12+
13+
The executor nevertheless also read six loose `config` keys behind `wec.X ?? loose.X`,
14+
two of them (`duration`, `signal`) spellings the spec never declared anywhere. That is
15+
the `notify.source` shape #4050 retired: a second de-facto contract announced only by a
16+
code comment, so an author who wrote it got a flow that worked forever and was never
17+
steered to the declared spelling (PD #12). Not hypothetical: the showcase's own
18+
`wait_revision` node authored it (`config: { eventType: 'signal', signalName: … }`) and
19+
moves to the declared block here.
20+
21+
- New ADR-0087 D2 conversion `flow-node-wait-event-config-lift` lifts
22+
`config.{eventType,timerDuration,duration,timeoutMs,signalName,signal}` onto the
23+
declared `waitEventConfig` block, in the executor's own `??` precedence — a declared
24+
value wins and its loose counterpart is left shadowed, exactly as `renameConfigKey`
25+
treats a shadowed alias.
26+
- `eventType` is stamped `'timer'` whenever the lift would otherwise leave the block
27+
without one. This is load-bearing: the loader parses the **converted** flow
28+
(`applyConversionsToFlow``FlowSchema.parse`) and `waitEventConfig.eventType` is
29+
required once the block exists — so a stored flow carrying only
30+
`config: { duration: 'PT1M' }` would have gone from working to failing to load.
31+
`'timer'` is the exact default the executor applied to that shape.
32+
- The executor's six `?? loose.*` fallbacks are deleted. The surviving `?? 'timer'` is
33+
not one: `waitEventConfig` is itself optional, and a wait node without one is a valid
34+
timer wait.
35+
36+
Verified at the real seam: the new executor tests author the legacy shape and go through
37+
`registerFlow`, which is what applies the conversion, so they prove the graduation
38+
end-to-end on a legacy source rather than only that the executor stopped looking. A
39+
negative control pins the `eventType` default — deleting it from the converted output
40+
makes `FlowSchema.parse` throw.
41+
42+
Two things this deliberately does **not** change, filed as #4158 rather than fixed in
43+
passing: `waitEventConfig.timeoutMs` is declared as a timeout guard but read as a timer
44+
duration, and `waitEventConfig.onTimeout` has zero readers anywhere — so `wait` has no
45+
timeout implementation at all, while the showcase authors `onTimeout: 'continue'`.
46+
Implementing or retracting that is a behaviour change, not a contract cleanup.

docs/protocol-upgrade-guide.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ Finally it removes agent `tools` (#3894): the legacy inline `{type,name,descript
124124

125125
Beyond those spec-surface removals, it graduates the seven flow-node config key aliases the executors still tolerated (#3796): the CRUD nodes' `object` (use `objectName`) — the last tenant of the `readAliasedConfig` executor shim, which is deleted with it — plus the six open-coded fallbacks that never went through that shim: notify `to`/`subject`/`body`/`url` (use `recipients`/`title`/`message`/`actionUrl`) and script `functionName`/`input` (use `function`/`inputs`). All are pure key renames with unchanged values and replay losslessly. Like the sharing-rule access level above they keep a load-path acceptance window: none carried a prior deprecation warning, and `FlowNodeSchema.config` is an unconstrained record, so no schema tombstone can reject them — the conversion layer is the only seam that can declare, convert, and retire them.
126126

127+
The same graduation covers `wait`, whose fallback was not a config-to-config rename (#4045). `wait` keeps its contract in the declared `waitEventConfig` block, not in `config` at all — yet the executor also read six loose `config` keys, two of them (`duration`, `signal`) spellings the spec never declared. The conversion lifts them onto the declared block in the executor's own `??` precedence, so a value already declared wins and its loose counterpart is left shadowed. One wrinkle makes this a rewrite rather than a delete: `waitEventConfig.eventType` is required once the block exists, and the loader parses the CONVERTED flow — so a source carrying only `config: { duration }` is stamped with `eventType: 'timer'`, the exact default the executor applied to that shape. Behaviour-preserving in both directions.
128+
127129
And it removes the RLS-policy key `priority` (#3896 security audit): promised "conflict resolution" that cannot exist, because applicable policies OR-combine (most permissive wins) — there is never a conflict to order, and nothing ever read the key (call graph closed across the collection site, the projection round-trip and the compiler). A pure lossless delete: outcomes are identical with or without it; the schema tombstones the key with the same prescription.
128130

129131
The same close-out retires the four inert tool authoring keys (`category`, `permissions`, `active`, `builtIn`): none is part of AIToolDefinition and no execution path read them. Two were misleading in the dangerous direction — `permissions` promised an invocation gate nothing enforced, and `active: false` read as "withdrawn" while the tool kept reaching the LLM tool set. Lossless deletes; the strict ToolSchema rejects each with its prescription.
@@ -146,6 +148,7 @@ The close-out sweep finishes the enforce-or-remove worklist across the remaining
146148
| `sharing-rule-access-level-full-to-edit` | `sharingRule.accessLevel` | sharing-rule accessLevel 'full' → 'edit' (#3865`full` never granted more than `edit`) | live — protocol 17 loader accepts the old shape |
147149
| `flow-node-crud-object-alias` | `flow.node.config.objectName` | CRUD flow-node config key 'object' → 'objectName' (#3796`readAliasedConfig` shim graduation) | live — protocol 17 loader accepts the old shape |
148150
| `flow-node-notify-config-aliases` | `flow.node.notify.config` | notify flow-node config keys 'to' → 'recipients', 'subject' → 'title', 'body' → 'message', 'url' → 'actionUrl' (#3796), and nested 'source: {object, id}' → 'sourceObject' / 'sourceId' (#4045) | live — protocol 17 loader accepts the old shape |
151+
| `flow-node-wait-event-config-lift` | `flow.node.wait.waitEventConfig` | wait flow-node loose config keys → the declared `waitEventConfig` block: 'eventType', 'timerDuration'/'duration' → 'timerDuration', 'signalName'/'signal' → 'signalName', 'timeoutMs' (#4045) | live — protocol 17 loader accepts the old shape |
149152
| `flow-node-script-config-aliases` | `flow.node.script.config` | script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796) | live — protocol 17 loader accepts the old shape |
150153
| `permission-rls-priority-removed` | `permission.rowLevelSecurity.priority` | RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome) | retired — `migrate meta` only |
151154
| `tool-inert-authoring-keys-removed` | `tool.category / tool.permissions / tool.active / tool.builtIn` | tool keys 'category'/'permissions'/'active'/'builtIn' removed (#3896 close-out — authorable and inert; permissions gated nothing, active:false withdrew nothing) | retired — `migrate meta` only |

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,10 @@ export const BudgetApprovalFlow = defineFlow({
206206
id: 'wait_revision',
207207
type: 'wait',
208208
label: 'Awaiting Revision',
209-
config: { eventType: 'signal', signalName: 'budget_revision' },
209+
// `waitEventConfig`, not a loose `config` — the latter is the undeclared
210+
// back door retired in #4045. The conversion layer still rewrites it at
211+
// load, but the showcase should demonstrate the declared spelling.
212+
waitEventConfig: { eventType: 'signal', signalName: 'budget_revision' },
210213
},
211214
{
212215
id: 'needs_exec',

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,101 @@ describe('wait node executor', () => {
140140
});
141141
});
142142

143+
/**
144+
* The loose `config.*` back door the executor used to read alongside
145+
* `waitEventConfig` graduated into the ADR-0087 D2 conversion layer
146+
* (`flow-node-wait-event-config-lift`, #4045), so the executor now reads the
147+
* declared block only (PD #12).
148+
*
149+
* These go through `registerFlow`, which is the seam that applies the conversion
150+
* — so they prove the graduation end-to-end on a legacy source, not merely that
151+
* the executor stopped looking. Both spellings under test (`duration`, `signal`)
152+
* are ones the spec never declared; they existed only as the tail of the `??`
153+
* chains this change deleted.
154+
*/
155+
describe('wait config graduation — legacy loose `config` still works via the conversion (#4045)', () => {
156+
let engine: AutomationEngine;
157+
let ran: string[];
158+
159+
/** The same flow, but authored the legacy way: event keys loose under `config`. */
160+
const looseWaitFlow = (config: Record<string, unknown>) => ({
161+
...waitFlow({ eventType: 'timer' }),
162+
nodes: [
163+
{ id: 'start', type: 'start', label: 'Start' },
164+
{ id: 'pause', type: 'wait', label: 'Wait', config },
165+
{ id: 'after', type: 'mark', label: 'After' },
166+
{ id: 'end', type: 'end', label: 'End' },
167+
],
168+
});
169+
170+
beforeEach(() => {
171+
engine = new AutomationEngine(silentLogger());
172+
ran = [];
173+
engine.registerNodeExecutor(markerExecutor(ran));
174+
});
175+
176+
it('schedules the same timer from a loose `config.duration` (undeclared spelling, no eventType)', async () => {
177+
const { ctx, scheduled } = fakeJobCtx();
178+
registerWaitNode(engine, ctx);
179+
// No eventType anywhere: the conversion stamps the executor's own 'timer'
180+
// default, without which the converted flow would not even parse.
181+
engine.registerFlow('wait_flow', looseWaitFlow({ duration: 'PT2H' }));
182+
183+
const before = Date.now();
184+
const paused = await engine.execute('wait_flow');
185+
expect(paused.status).toBe('paused');
186+
expect(ran).toEqual([]);
187+
188+
expect(scheduled).toHaveLength(1);
189+
const at = new Date(scheduled[0].schedule.at!).getTime();
190+
expect(at).toBeGreaterThanOrEqual(before + 7_200_000 - 1000);
191+
expect(at).toBeLessThanOrEqual(Date.now() + 7_200_000 + 1000);
192+
});
193+
194+
it('suspends on a loose `config.signal` (undeclared spelling) as its declared counterpart', async () => {
195+
registerWaitNode(engine, ctxNoJob());
196+
engine.registerFlow('wait_flow', looseWaitFlow({ eventType: 'signal', signal: 'contract.renewed' }));
197+
198+
const paused = await engine.execute('wait_flow');
199+
expect(paused.status).toBe('paused');
200+
expect(engine.listSuspendedRuns()[0]).toMatchObject({ nodeId: 'pause', correlation: 'contract.renewed' });
201+
202+
const resumed = await engine.resume(paused.runId!);
203+
expect(resumed.success).toBe(true);
204+
expect(ran).toEqual(['after']);
205+
});
206+
207+
// Unlike the two above, this one passes with the conversion unregistered too —
208+
// it pins the EXECUTOR's side of the precedence (a declared value is what gets
209+
// read), not the conversion's. Kept because that is the half a future
210+
// "simplification" of the lift could silently invert.
211+
it('reads the declared value, not its loose counterpart, when both are present', async () => {
212+
const { ctx, scheduled } = fakeJobCtx();
213+
registerWaitNode(engine, ctx);
214+
engine.registerFlow('wait_flow', {
215+
...waitFlow({ eventType: 'timer' }),
216+
nodes: [
217+
{ id: 'start', type: 'start', label: 'Start' },
218+
{
219+
id: 'pause',
220+
type: 'wait',
221+
label: 'Wait',
222+
waitEventConfig: { eventType: 'timer', timerDuration: 'PT1H' },
223+
config: { duration: 'PT9H' },
224+
},
225+
{ id: 'after', type: 'mark', label: 'After' },
226+
{ id: 'end', type: 'end', label: 'End' },
227+
],
228+
});
229+
230+
const before = Date.now();
231+
await engine.execute('wait_flow');
232+
// 1h (declared) — not 9h (loose). Same precedence the deleted `??` had.
233+
const at = new Date(scheduled[0].schedule.at!).getTime();
234+
expect(at).toBeLessThanOrEqual(before + 3_600_000 + 1000);
235+
});
236+
});
237+
143238
describe('rearmSuspendedWaitTimers (cold-boot timer re-arm)', () => {
144239
/** Boot a fresh engine wired to `store` with the wait flow registered — one "process". */
145240
function bootEngine(store: InMemorySuspendedRunStore, ctx: any, waitConfig: Record<string, unknown>) {

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

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,29 @@ export function registerWaitNode(engine: AutomationEngine, ctx: PluginContext):
4949
isAsync: true,
5050
}),
5151
async execute(node, variables, _context) {
52-
// Prefer the spec-structured `waitEventConfig` block; fall back to a loose
53-
// `config` for hand-authored flows that put the same keys under config.
54-
const loose = (node.config ?? {}) as Record<string, unknown>;
52+
// `waitEventConfig` is the whole contract (`FlowNodeSchema`, flow.zod.ts).
53+
// The loose `config.*` back door this used to also read — six keys, two of
54+
// them (`duration`, `signal`) spellings the spec never declared — graduated
55+
// into the ADR-0087 D2 conversion layer as
56+
// `flow-node-wait-event-config-lift` (#4045). It is rewritten at load,
57+
// including the `registerFlow` rehydration seam, so there is nothing left
58+
// to fall back to here (PD #12: no consumer-side fallbacks).
59+
//
60+
// The `?? 'timer'` below is NOT such a fallback: `waitEventConfig` is
61+
// itself optional, and a wait node without one is a valid timer wait.
5562
const wec = (node.waitEventConfig ?? {}) as Record<string, unknown>;
56-
const eventType = String(wec.eventType ?? loose.eventType ?? 'timer');
63+
const eventType = String(wec.eventType ?? 'timer');
5764
const runId = variables.get('$runId');
5865

5966
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.
6072
const durationMs =
61-
parseIsoDuration(wec.timerDuration ?? loose.timerDuration ?? loose.duration) ??
62-
(typeof wec.timeoutMs === 'number' ? wec.timeoutMs : undefined) ??
63-
(typeof loose.timeoutMs === 'number' ? (loose.timeoutMs as number) : undefined);
73+
parseIsoDuration(wec.timerDuration) ??
74+
(typeof wec.timeoutMs === 'number' ? wec.timeoutMs : undefined);
6475

6576
// Persist the wake deadline as node output: the engine writes output
6677
// to variables (`<nodeId>.waitUntil`) *before* snapshotting the
@@ -105,7 +116,7 @@ export function registerWaitNode(engine: AutomationEngine, ctx: PluginContext):
105116

106117
// signal / webhook / manual / condition — suspend; an external producer
107118
// resumes the run when the named event arrives.
108-
const signal = String(wec.signalName ?? loose.signalName ?? loose.signal ?? `wait:${node.id}`);
119+
const signal = String(wec.signalName ?? `wait:${node.id}`);
109120
return { success: true, suspend: true, correlation: signal };
110121
},
111122
});

packages/spec/spec-changes.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,12 @@
104104
"conversionId": "flow-node-notify-config-aliases",
105105
"toMajor": 17
106106
},
107+
{
108+
"surface": "flow.node.wait.waitEventConfig",
109+
"to": "wait flow-node loose config keys → the declared `waitEventConfig` block: 'eventType', 'timerDuration'/'duration' → 'timerDuration', 'signalName'/'signal' → 'signalName', 'timeoutMs' (#4045)",
110+
"conversionId": "flow-node-wait-event-config-lift",
111+
"toMajor": 17
112+
},
107113
{
108114
"surface": "flow.node.script.config",
109115
"to": "script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796)",
@@ -495,6 +501,12 @@
495501
"conversionId": "flow-node-notify-config-aliases",
496502
"toMajor": 17
497503
},
504+
{
505+
"surface": "flow.node.wait.waitEventConfig",
506+
"to": "wait flow-node loose config keys → the declared `waitEventConfig` block: 'eventType', 'timerDuration'/'duration' → 'timerDuration', 'signalName'/'signal' → 'signalName', 'timeoutMs' (#4045)",
507+
"conversionId": "flow-node-wait-event-config-lift",
508+
"toMajor": 17
509+
},
498510
{
499511
"surface": "flow.node.script.config",
500512
"to": "script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796)",

0 commit comments

Comments
 (0)