@sensigo/realm 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/engine/apply-resume.d.ts.map +1 -1
  2. package/dist/engine/apply-resume.js +5 -1
  3. package/dist/engine/apply-resume.js.map +1 -1
  4. package/dist/engine/execution-loop.d.ts.map +1 -1
  5. package/dist/engine/execution-loop.js +8 -3
  6. package/dist/engine/execution-loop.js.map +1 -1
  7. package/dist/engine/run-health.d.ts +1 -1
  8. package/dist/engine/run-health.d.ts.map +1 -1
  9. package/dist/engine/run-health.js +89 -0
  10. package/dist/engine/run-health.js.map +1 -1
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/store/failed-attempt-store.d.ts +9 -1
  16. package/dist/store/failed-attempt-store.d.ts.map +1 -1
  17. package/dist/store/failed-attempt-store.js +12 -1
  18. package/dist/store/failed-attempt-store.js.map +1 -1
  19. package/dist/store/json-file-store.d.ts +19 -2
  20. package/dist/store/json-file-store.d.ts.map +1 -1
  21. package/dist/store/json-file-store.js +57 -5
  22. package/dist/store/json-file-store.js.map +1 -1
  23. package/dist/store/per-run-artifact-store.d.ts +42 -1
  24. package/dist/store/per-run-artifact-store.d.ts.map +1 -1
  25. package/dist/store/trace-buffer-store.d.ts +32 -6
  26. package/dist/store/trace-buffer-store.d.ts.map +1 -1
  27. package/dist/store/trace-buffer-store.js +39 -6
  28. package/dist/store/trace-buffer-store.js.map +1 -1
  29. package/dist/types/response-envelope.d.ts +0 -1
  30. package/dist/types/response-envelope.d.ts.map +1 -1
  31. package/dist/types/run-record.d.ts +78 -0
  32. package/dist/types/run-record.d.ts.map +1 -1
  33. package/dist/types/workflow-definition.d.ts +11 -1
  34. package/dist/types/workflow-definition.d.ts.map +1 -1
  35. package/dist/types/workflow-definition.js +1 -0
  36. package/dist/types/workflow-definition.js.map +1 -1
  37. package/dist/types/workflow-error.d.ts +44 -0
  38. package/dist/types/workflow-error.d.ts.map +1 -1
  39. package/dist/types/workflow-error.js +30 -1
  40. package/dist/types/workflow-error.js.map +1 -1
  41. package/dist/workflow/diagnostics.d.ts +51 -13
  42. package/dist/workflow/diagnostics.d.ts.map +1 -1
  43. package/dist/workflow/diagnostics.js +50 -32
  44. package/dist/workflow/diagnostics.js.map +1 -1
  45. package/dist/workflow/registrar.d.ts +26 -0
  46. package/dist/workflow/registrar.d.ts.map +1 -1
  47. package/dist/workflow/registrar.js +35 -5
  48. package/dist/workflow/registrar.js.map +1 -1
  49. package/dist/workflow/source-positions.d.ts +39 -0
  50. package/dist/workflow/source-positions.d.ts.map +1 -0
  51. package/dist/workflow/source-positions.js +107 -0
  52. package/dist/workflow/source-positions.js.map +1 -0
  53. package/dist/workflow/yaml-loader.d.ts +17 -0
  54. package/dist/workflow/yaml-loader.d.ts.map +1 -1
  55. package/dist/workflow/yaml-loader.js +1386 -1087
  56. package/dist/workflow/yaml-loader.js.map +1 -1
  57. package/package.json +1 -1
@@ -7,6 +7,7 @@ import { Ajv } from 'ajv';
7
7
  import { KNOWN_STEP_KEYS, KNOWN_WORKFLOW_KEYS, KNOWN_RETRY_KEYS, KNOWN_GATE_KEYS, } from '../types/workflow-definition.js';
8
8
  import { WorkflowError } from '../types/workflow-error.js';
9
9
  import { findUnknownKeys, renderLoaderWarning, resolveSeverity, closestKey, } from './diagnostics.js';
10
+ import { createSourcePositionCollector } from './source-positions.js';
10
11
  import { resolveTemplates } from './template-resolver.js';
11
12
  import { normalizeTriggerFilter, validateTriggerStructure } from './trigger-schema.js';
12
13
  import { splitComparison, isPathShaped } from '../engine/comparison-expr.js';
@@ -18,20 +19,27 @@ import { assessStructuredOutputEligibility, renderIneligibleMessage, } from './s
18
19
  * used at runtime). Rejects compound `and`/`or`, multiple operators, and non-path LHS. For `when`,
19
20
  * also enforces the direct-`depends_on` reference check (Change 2). Pushes actionable errors.
20
21
  */
21
- function validateConditionLeaf(surface, leaf, stepName, dependsOn, errors) {
22
+ function validateConditionLeaf(surface, leaf, stepName, dependsOn, errors,
23
+ /**
24
+ * Appends the step's source line to a message (issue #392). REQUIRED rather than optional so
25
+ * the compiler names every call site if this ever gains another one — an omitted resolver
26
+ * would silently drop positions, which is exactly the kind of quiet gap this repo keeps
27
+ * finding the hard way.
28
+ */
29
+ withLine) {
22
30
  const split = splitComparison(leaf);
23
31
  if (split.kind === 'invalid') {
24
32
  if (split.reason === 'compound_and' || split.reason === 'compound_or') {
25
33
  const kw = split.reason === 'compound_and' ? 'and' : 'or';
26
34
  const listForm = (split.parts ?? [leaf]).map((p) => ` - "${p}"`).join('\n');
27
- errors.push(`Step '${stepName}': '${surface}' uses unsupported '${kw}' — write it as a list:\n` +
28
- ` ${surface}:\n${listForm}`);
35
+ errors.push(withLine(stepName, `Step '${stepName}': '${surface}' uses unsupported '${kw}' — write it as a list:\n` +
36
+ ` ${surface}:\n${listForm}`));
29
37
  }
30
38
  else if (split.reason === 'multiple_operators') {
31
- errors.push(`Step '${stepName}': '${surface}' leaf '${leaf}' has multiple comparison operators — each leaf must be a single comparison.`);
39
+ errors.push(withLine(stepName, `Step '${stepName}': '${surface}' leaf '${leaf}' has multiple comparison operators — each leaf must be a single comparison.`));
32
40
  }
33
41
  else {
34
- errors.push(`Step '${stepName}': '${surface}' leaf must not be empty.`);
42
+ errors.push(withLine(stepName, `Step '${stepName}': '${surface}' leaf must not be empty.`));
35
43
  }
36
44
  return;
37
45
  }
@@ -42,7 +50,7 @@ function validateConditionLeaf(surface, leaf, stepName, dependsOn, errors) {
42
50
  // $settlement one-hop reason (issue #220 §4c pin kk: the precondition witness for a
43
51
  // $settlement leaf must use the comparison spelling).
44
52
  if (surface === 'preconditions') {
45
- errors.push(`Step '${stepName}': precondition '${leaf}' must be a comparison (e.g. "step.field >= 1").`);
53
+ errors.push(withLine(stepName, `Step '${stepName}': precondition '${leaf}' must be a comparison (e.g. "step.field >= 1").`));
46
54
  return;
47
55
  }
48
56
  // issue #220 §4c (PR-3): `$settlement.<dep>.<field>` handling lives HERE, in
@@ -52,28 +60,28 @@ function validateConditionLeaf(surface, leaf, stepName, dependsOn, errors) {
52
60
  // `surface === 'when'` — an arm there would never fire for abort_unless/preconditions). This
53
61
  // fires on ALL THREE surfaces since it runs BEFORE the generic isPathShaped check below.
54
62
  if (split.path.split('.')[0] === '$settlement') {
55
- validateSettlementReference(split.path, surface, leaf, stepName, dependsOn, errors);
63
+ validateSettlementReference(split.path, surface, leaf, stepName, dependsOn, errors, withLine);
56
64
  return;
57
65
  }
58
66
  if (!isPathShaped(split.path)) {
59
- errors.push(`Step '${stepName}': '${surface}' leaf '${leaf}' is not a valid path or comparison.`);
67
+ errors.push(withLine(stepName, `Step '${stepName}': '${surface}' leaf '${leaf}' is not a valid path or comparison.`));
60
68
  return;
61
69
  }
62
70
  if (surface === 'when')
63
- validateWhenReference(split.path, stepName, dependsOn, errors);
71
+ validateWhenReference(split.path, stepName, dependsOn, errors, withLine);
64
72
  return;
65
73
  }
66
74
  // comparison
67
75
  if (split.lhsPath.split('.')[0] === '$settlement') {
68
- validateSettlementReference(split.lhsPath, surface, leaf, stepName, dependsOn, errors);
76
+ validateSettlementReference(split.lhsPath, surface, leaf, stepName, dependsOn, errors, withLine);
69
77
  return;
70
78
  }
71
79
  if (!isPathShaped(split.lhsPath)) {
72
- errors.push(`Step '${stepName}': '${surface}' leaf '${leaf}' must have a path on the left-hand side (got '${split.lhsPath}').`);
80
+ errors.push(withLine(stepName, `Step '${stepName}': '${surface}' leaf '${leaf}' must have a path on the left-hand side (got '${split.lhsPath}').`));
73
81
  return;
74
82
  }
75
83
  if (surface === 'when')
76
- validateWhenReference(split.lhsPath, stepName, dependsOn, errors);
84
+ validateWhenReference(split.lhsPath, stepName, dependsOn, errors, withLine);
77
85
  }
78
86
  /**
79
87
  * issue #220 §4c (PR-3): validates a `$settlement.<dep>.<field>` reference reached from ANY of
@@ -83,7 +91,14 @@ function validateConditionLeaf(surface, leaf, stepName, dependsOn, errors) {
83
91
  * `surface === 'when'` gate. The caller must NOT fall through to the generic `isPathShaped` check
84
92
  * afterward (which rejects `$` outright) — this function's callers always `return` immediately.
85
93
  */
86
- function validateSettlementReference(path, surface, leaf, stepName, dependsOn, errors) {
94
+ function validateSettlementReference(path, surface, leaf, stepName, dependsOn, errors,
95
+ /**
96
+ * Appends the step's source line to a message (issue #392). REQUIRED rather than optional so
97
+ * the compiler names every call site if this ever gains another one — an omitted resolver
98
+ * would silently drop positions, which is exactly the kind of quiet gap this repo keeps
99
+ * finding the hard way.
100
+ */
101
+ withLine) {
87
102
  // Path-shape: a NARROWING for this ONE prefix only (never a general `$` allowance) — the
88
103
  // remainder after `$settlement` must itself be path-shaped. Rejects `$foo`, a bare `$`, and
89
104
  // garbage remainders like `$settlement.a b`.
@@ -96,21 +111,21 @@ function validateSettlementReference(path, surface, leaf, stepName, dependsOn, e
96
111
  // no backtracking. Accepts every valid `$settlement.<dep>.<field>` path identically; stricter
97
112
  // only on pathological consecutive dots (`$settlement.dep..field`), which is more correct.
98
113
  if (!/^\$settlement(\.[A-Za-z_][A-Za-z0-9_-]*)*$/.test(path)) {
99
- errors.push(`Step '${stepName}': '${surface}' leaf '${leaf}' has an invalid '$settlement' reference ` +
100
- `'${path}' — expected '$settlement.<dep>.<field>'.`);
114
+ errors.push(withLine(stepName, `Step '${stepName}': '${surface}' leaf '${leaf}' has an invalid '$settlement' reference ` +
115
+ `'${path}' — expected '$settlement.<dep>.<field>'.`));
101
116
  return;
102
117
  }
103
118
  // One-hop (§4c-S4): the SECOND segment must be a DIRECT dependency of this step.
104
119
  const dep = path.split('.')[1];
105
120
  if (dep === undefined) {
106
- errors.push(`Step '${stepName}': '${surface}' leaf '${leaf}' references '$settlement' with no ` +
107
- `dependency segment — expected '$settlement.<dep>.<field>' where '<dep>' is a direct dependency.`);
121
+ errors.push(withLine(stepName, `Step '${stepName}': '${surface}' leaf '${leaf}' references '$settlement' with no ` +
122
+ `dependency segment — expected '$settlement.<dep>.<field>' where '<dep>' is a direct dependency.`));
108
123
  return;
109
124
  }
110
125
  if (!dependsOn.includes(dep)) {
111
- errors.push(`Step '${stepName}': '${surface}' references '$settlement.${dep}' — '${dep}' is not in ` +
126
+ errors.push(withLine(stepName, `Step '${stepName}': '${surface}' references '$settlement.${dep}' — '${dep}' is not in ` +
112
127
  `its depends_on [${dependsOn.join(', ')}]. '$settlement' paths must reference a direct ` +
113
- `dependency (one-hop rule).`);
128
+ `dependency (one-hop rule).`));
114
129
  }
115
130
  }
116
131
  /**
@@ -118,16 +133,23 @@ function validateSettlementReference(path, surface, leaf, stepName, dependsOn, e
118
133
  * step in this step's DIRECT `depends_on` (one-hop membership — no graph traversal). Field names are
119
134
  * not checked (agent-step outputs aren't statically declared).
120
135
  */
121
- function validateWhenReference(path, stepName, dependsOn, errors) {
136
+ function validateWhenReference(path, stepName, dependsOn, errors,
137
+ /**
138
+ * Appends the step's source line to a message (issue #392). REQUIRED rather than optional so
139
+ * the compiler names every call site if this ever gains another one — an omitted resolver
140
+ * would silently drop positions, which is exactly the kind of quiet gap this repo keeps
141
+ * finding the hard way.
142
+ */
143
+ withLine) {
122
144
  const first = path.split('.')[0];
123
145
  if (first === 'run') {
124
146
  if (!(path === 'run.params' || path.startsWith('run.params.'))) {
125
- errors.push(`Step '${stepName}': 'when' references '${path}' — only 'run.params.*' is available from 'run'.`);
147
+ errors.push(withLine(stepName, `Step '${stepName}': 'when' references '${path}' — only 'run.params.*' is available from 'run'.`));
126
148
  }
127
149
  return;
128
150
  }
129
151
  if (!dependsOn.includes(first)) {
130
- errors.push(`Step '${stepName}': 'when' references step '${first}' which is not in its depends_on [${dependsOn.join(', ')}]. Add it to depends_on or use 'run.params.*'.`);
152
+ errors.push(withLine(stepName, `Step '${stepName}': 'when' references step '${first}' which is not in its depends_on [${dependsOn.join(', ')}]. Add it to depends_on or use 'run.params.*'.`));
131
153
  }
132
154
  }
133
155
  /** Bumped on every breaking change to WorkflowDefinition's serialized format. */
@@ -225,13 +247,39 @@ export function findTrustRoot(dir) {
225
247
  current = parent;
226
248
  }
227
249
  }
250
+ /**
251
+ * issue #424 — attaches the live loader warnings to an error on its way out.
252
+ *
253
+ * Two chokepoints call this (one in `parseWorkflowString`, one in `loadWorkflowFromFileCore`),
254
+ * which is why it exists rather than each throw site building its own error with a `warnings`
255
+ * option: there are eleven throw sites across this file plus four more in template-resolver.ts,
256
+ * the warnings array is out of scope at most of them, and a chokepoint covers every future one
257
+ * for free. The non-empty guard makes the classification automatic — a throw that happens before
258
+ * any warning could exist attaches nothing, by construction rather than by a rule someone has to
259
+ * remember.
260
+ *
261
+ * Attach-once: an inner chokepoint's attachment survives the outer one re-catching the same
262
+ * error, so a file-based load reports the warnings from the parse that produced it rather than
263
+ * an emptier outer set.
264
+ */
265
+ export function attachLoaderWarnings(err, warnings) {
266
+ if (warnings.length === 0)
267
+ return;
268
+ if (err.warnings !== undefined)
269
+ return;
270
+ err.warnings = warnings;
271
+ }
228
272
  /**
229
273
  * Pure core of loadWorkflowFromFile (issue #169): parses + resolves everything a file-based load
230
274
  * needs, but never prints and never chooses between the two public presentations — it always
231
275
  * returns the definition alongside every collected LoaderWarning. `loadWorkflowFromFile` (prints
232
- * via renderLoaderWarning, returns just the definition byte-identical default behavior, the
233
- * non-breaking invariant) and `loadWorkflowFromFileWithDiagnostics` (prints nothing, returns both)
234
- * are both thin wrappers over this.
276
+ * via renderLoaderWarning, returns just the definition) and `loadWorkflowFromFileWithDiagnostics`
277
+ * (prints nothing, returns both) are both thin wrappers over this.
278
+ *
279
+ * The #169-era "byte-identical default behavior" claim was retired in issue #444 (2026-08-31):
280
+ * renderLoaderWarning now prefixes `⚠ ` for every code, so this printer's advisory lines gained
281
+ * the prefix they lacked. The SHAPE of the contract is unchanged — one printing wrapper, one
282
+ * silent one — and the text after the prefix is untouched.
235
283
  * @throws WorkflowError on read failure or structural validation errors.
236
284
  */
237
285
  function loadWorkflowFromFileCore(filePath, registry) {
@@ -251,107 +299,124 @@ function loadWorkflowFromFileCore(filePath, registry) {
251
299
  const { definition, warnings } = parseWorkflowString(content, registry, {
252
300
  allowExtensions: true,
253
301
  });
254
- // Resolve agent profiles only possible when we have a file path.
255
- const workflowDir = dirname(resolve(filePath));
256
- const profilesDir = definition.profiles_dir !== undefined
257
- ? resolve(workflowDir, definition.profiles_dir)
258
- : join(workflowDir, 'profiles');
259
- const resolvedProfiles = {};
260
- const profileErrors = [];
261
- for (const [stepName, step] of Object.entries(definition.steps)) {
262
- if (step.agent_profile === undefined)
263
- continue;
264
- const profileName = step.agent_profile;
265
- if (profileName in resolvedProfiles)
266
- continue;
267
- const profilePath = join(profilesDir, `${profileName}.md`);
268
- let profileContent;
269
- try {
270
- profileContent = readFileSync(profilePath, 'utf8');
271
- }
272
- catch {
273
- profileErrors.push(`Step '${stepName}': agent_profile '${profileName}' not found. Searched: ${profilePath}`);
274
- continue;
275
- }
276
- const contentHash = createHash('sha256').update(profileContent).digest('hex');
277
- resolvedProfiles[profileName] = { content: profileContent, content_hash: contentHash };
278
- }
279
- if (profileErrors.length > 0) {
280
- throw new WorkflowError(`Invalid workflow: ${profileErrors.join('; ')}`, {
281
- code: 'VALIDATION_WORKFLOW_SCHEMA',
282
- category: 'VALIDATION',
283
- agentAction: 'report_to_user',
284
- retryable: false,
285
- });
286
- }
287
- if (Object.keys(resolvedProfiles).length > 0) {
288
- definition.resolved_profiles = resolvedProfiles;
289
- }
290
- // Validate context_wrapper if present.
291
- if (definition.context_wrapper !== undefined) {
292
- const VALID_WRAPPER_FORMATS = new Set(['xml', 'brackets', 'none']);
293
- if (!VALID_WRAPPER_FORMATS.has(definition.context_wrapper)) {
294
- throw new WorkflowError(`Invalid context_wrapper '${String(definition.context_wrapper)}'; must be 'xml', 'brackets', or 'none'`, {
302
+ // issue #424CHOKEPOINT 2. Everything past the parse can throw with `warnings` already
303
+ // populated (a missing agent_profile, a bad workflow_context source), and those warnings used
304
+ // to unwind with the stack: `register` on a file with a typo AND a missing profile printed the
305
+ // profile error alone, so the author fixed it, re-ran, and only then learned about the typo.
306
+ // The parse call itself is deliberately OUTSIDE this try — chokepoint 1 inside
307
+ // `parseWorkflowString` owns those throws and has already attached, and attach-once means its
308
+ // richer set survives.
309
+ try {
310
+ // Resolve agent profiles — only possible when we have a file path.
311
+ const workflowDir = dirname(resolve(filePath));
312
+ const profilesDir = definition.profiles_dir !== undefined
313
+ ? resolve(workflowDir, definition.profiles_dir)
314
+ : join(workflowDir, 'profiles');
315
+ const resolvedProfiles = {};
316
+ const profileErrors = [];
317
+ for (const [stepName, step] of Object.entries(definition.steps)) {
318
+ if (step.agent_profile === undefined)
319
+ continue;
320
+ const profileName = step.agent_profile;
321
+ if (profileName in resolvedProfiles)
322
+ continue;
323
+ const profilePath = join(profilesDir, `${profileName}.md`);
324
+ let profileContent;
325
+ try {
326
+ profileContent = readFileSync(profilePath, 'utf8');
327
+ }
328
+ catch {
329
+ profileErrors.push(`Step '${stepName}': agent_profile '${profileName}' not found. Searched: ${profilePath}`);
330
+ continue;
331
+ }
332
+ const contentHash = createHash('sha256').update(profileContent).digest('hex');
333
+ resolvedProfiles[profileName] = { content: profileContent, content_hash: contentHash };
334
+ }
335
+ if (profileErrors.length > 0) {
336
+ throw new WorkflowError(`Invalid workflow: ${profileErrors.join('; ')}`, {
337
+ // issue #425: the pre-join strings, so a render can list them one per line. Two missing
338
+ // profiles are two problems, not one long sentence.
339
+ errors: [...profileErrors],
295
340
  code: 'VALIDATION_WORKFLOW_SCHEMA',
296
341
  category: 'VALIDATION',
297
342
  agentAction: 'report_to_user',
298
343
  retryable: false,
299
344
  });
300
345
  }
301
- }
302
- // Validate and resolve workflow_context entry paths.
303
- if (definition.workflow_context !== undefined) {
304
- for (const [name, entry] of Object.entries(definition.workflow_context)) {
305
- if (name.endsWith('.raw')) {
306
- throw new WorkflowError(`workflow_context entry names must not end with '.raw' (found: '${name}')`, {
307
- code: 'VALIDATION_WORKFLOW_SCHEMA',
308
- category: 'VALIDATION',
309
- agentAction: 'report_to_user',
310
- retryable: false,
311
- });
312
- }
313
- if (!/^[\w.]+$/.test(name)) {
314
- throw new WorkflowError(`workflow_context entry name '${name}' is invalid; names must match [\\w.]+ (underscores and dots only — no hyphens)`, {
346
+ if (Object.keys(resolvedProfiles).length > 0) {
347
+ definition.resolved_profiles = resolvedProfiles;
348
+ }
349
+ // Validate context_wrapper if present.
350
+ if (definition.context_wrapper !== undefined) {
351
+ const VALID_WRAPPER_FORMATS = new Set(['xml', 'brackets', 'none']);
352
+ if (!VALID_WRAPPER_FORMATS.has(definition.context_wrapper)) {
353
+ throw new WorkflowError(`Invalid context_wrapper '${String(definition.context_wrapper)}'; must be 'xml', 'brackets', or 'none'`, {
315
354
  code: 'VALIDATION_WORKFLOW_SCHEMA',
316
355
  category: 'VALIDATION',
317
356
  agentAction: 'report_to_user',
318
357
  retryable: false,
319
358
  });
320
359
  }
321
- const rawEntry = entry;
322
- const rawSource = rawEntry['source'];
323
- if (rawSource === undefined || typeof rawSource['path'] !== 'string') {
324
- throw new WorkflowError(`workflow_context.${name}.source.path is required`, {
325
- code: 'VALIDATION_WORKFLOW_SCHEMA',
326
- category: 'VALIDATION',
327
- agentAction: 'report_to_user',
328
- retryable: false,
329
- });
360
+ }
361
+ // Validate and resolve workflow_context entry paths.
362
+ if (definition.workflow_context !== undefined) {
363
+ for (const [name, entry] of Object.entries(definition.workflow_context)) {
364
+ if (name.endsWith('.raw')) {
365
+ throw new WorkflowError(`workflow_context entry names must not end with '.raw' (found: '${name}')`, {
366
+ code: 'VALIDATION_WORKFLOW_SCHEMA',
367
+ category: 'VALIDATION',
368
+ agentAction: 'report_to_user',
369
+ retryable: false,
370
+ });
371
+ }
372
+ if (!/^[\w.]+$/.test(name)) {
373
+ throw new WorkflowError(`workflow_context entry name '${name}' is invalid; names must match [\\w.]+ (underscores and dots only — no hyphens)`, {
374
+ code: 'VALIDATION_WORKFLOW_SCHEMA',
375
+ category: 'VALIDATION',
376
+ agentAction: 'report_to_user',
377
+ retryable: false,
378
+ });
379
+ }
380
+ const rawEntry = entry;
381
+ const rawSource = rawEntry['source'];
382
+ if (rawSource === undefined || typeof rawSource['path'] !== 'string') {
383
+ throw new WorkflowError(`workflow_context.${name}.source.path is required`, {
384
+ code: 'VALIDATION_WORKFLOW_SCHEMA',
385
+ category: 'VALIDATION',
386
+ agentAction: 'report_to_user',
387
+ retryable: false,
388
+ });
389
+ }
390
+ // Resolve relative path to absolute.
391
+ entry.source.path = resolve(workflowDir, rawSource['path']);
330
392
  }
331
- // Resolve relative path to absolute.
332
- entry.source.path = resolve(workflowDir, rawSource['path']);
333
393
  }
394
+ // Auto-register schema.json if present and not explicitly declared.
395
+ const schemaPath = join(workflowDir, 'schema.json');
396
+ if (existsSync(schemaPath) && definition.workflow_context?.['schema'] === undefined) {
397
+ definition.workflow_context ??= {};
398
+ definition.workflow_context['schema'] = {
399
+ source: { path: schemaPath },
400
+ description: 'Auto-registered schema.json from workflow directory',
401
+ };
402
+ }
403
+ // Resolution metadata: stamped for EVERY file-loaded definition (v0.14) — trust_root is
404
+ // the deployment-manifest anchor (`<trust_root>/realm.yaml`), needed by extension-free
405
+ // workflows that consume manifest-constructed adapters by name. Core resolves/stores
406
+ // PATHS only — it never imports modules or reads the manifest; that is the CLI's job.
407
+ definition.source_dir = workflowDir;
408
+ definition.trust_root = findTrustRoot(workflowDir);
409
+ if (definition.extensions !== undefined) {
410
+ definition.extensions =
411
+ typeof definition.extensions === 'string' ? [definition.extensions] : definition.extensions;
412
+ }
413
+ definition.origin = 'human';
334
414
  }
335
- // Auto-register schema.json if present and not explicitly declared.
336
- const schemaPath = join(workflowDir, 'schema.json');
337
- if (existsSync(schemaPath) && definition.workflow_context?.['schema'] === undefined) {
338
- definition.workflow_context ??= {};
339
- definition.workflow_context['schema'] = {
340
- source: { path: schemaPath },
341
- description: 'Auto-registered schema.json from workflow directory',
342
- };
343
- }
344
- // Resolution metadata: stamped for EVERY file-loaded definition (v0.14) — trust_root is
345
- // the deployment-manifest anchor (`<trust_root>/realm.yaml`), needed by extension-free
346
- // workflows that consume manifest-constructed adapters by name. Core resolves/stores
347
- // PATHS only — it never imports modules or reads the manifest; that is the CLI's job.
348
- definition.source_dir = workflowDir;
349
- definition.trust_root = findTrustRoot(workflowDir);
350
- if (definition.extensions !== undefined) {
351
- definition.extensions =
352
- typeof definition.extensions === 'string' ? [definition.extensions] : definition.extensions;
415
+ catch (err) {
416
+ if (err instanceof WorkflowError)
417
+ attachLoaderWarnings(err, warnings);
418
+ throw err;
353
419
  }
354
- definition.origin = 'human';
355
420
  return { definition, warnings };
356
421
  }
357
422
  /**
@@ -478,9 +543,14 @@ function detectDependencyCycles(edges) {
478
543
  */
479
544
  function parseWorkflowString(content, registry, opts) {
480
545
  // Step 1: Parse YAML
546
+ //
547
+ // issue #392: the position collector rides THIS parse via js-yaml's own listener — there is no
548
+ // second parse and no parser change. If the parse throws, `finish()` is never reached and every
549
+ // position is simply absent, which is the correct answer for a file that did not parse.
550
+ const positions = createSourcePositionCollector();
481
551
  let raw;
482
552
  try {
483
- raw = load(content);
553
+ raw = load(content, { listener: positions.listener });
484
554
  }
485
555
  catch (err) {
486
556
  throw new WorkflowError(`YAML parse error: ${err instanceof Error ? err.message : String(err)}`, {
@@ -492,1099 +562,1328 @@ function parseWorkflowString(content, registry, opts) {
492
562
  }
493
563
  const errors = [];
494
564
  const warnings = [];
495
- // Step 2: Top-level validation
496
- if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
497
- throw new WorkflowError('Invalid workflow: Workflow must be a non-null object', {
498
- code: 'VALIDATION_WORKFLOW_SCHEMA',
499
- category: 'VALIDATION',
500
- agentAction: 'report_to_user',
501
- retryable: false,
502
- });
503
- }
504
- const doc = raw;
505
- // WARN (do not reject) on a key that isn't authorable — checked against KNOWN_WORKFLOW_KEYS
506
- // ONLY (not RUNTIME_ONLY_WORKFLOW_KEYS), and BEFORE any loader-stamped field is added below.
507
- // Deliberately excluding runtime-only keys from "known" here means hand-authoring one (e.g.
508
- // `schema_version:` or `model:` in YAML) warns too those fields are stamped by the loader
509
- // and any authored value is silently overwritten/ignored, which is exactly the kind of mistake
510
- // this check exists to surface (issue #144). Non-breaking by design: siblings #170
511
- // (hard-reject) and #169 (structured warnings channel) are deliberately out of scope here.
512
- {
513
- const workflowId = typeof doc['id'] === 'string' ? doc['id'] : '<unknown>';
514
- warnings.push(...findUnknownKeys(doc, KNOWN_WORKFLOW_KEYS, {
515
- scope: 'workflow',
516
- code: 'UNKNOWN_WORKFLOW_KEY',
517
- id: workflowId,
518
- }));
519
- }
520
- // Project extensions: hard error for string-based loading (fires before any other
521
- // processing); shape validation (string | string[], relative-only) for file-based loading.
522
- if ('extensions' in doc && doc['extensions'] !== undefined) {
523
- if (!opts.allowExtensions) {
524
- throw new WorkflowError(`Invalid workflow: 'extensions' requires file-based loading no directory context is ` +
525
- `available to resolve extension module paths. Register this workflow from its YAML ` +
526
- `file (realm workflow register <path>).`, {
565
+ // issue #424 CHOKEPOINT 1. Every throw from here down unwinds past a populated `warnings`
566
+ // array, and used to drop it: a workflow with a prohibited key AND a `dependson` typo reported
567
+ // the prohibition alone. This also covers the template-resolver's own throws, which cross this
568
+ // frame and are unreachable from any sweep of this file.
569
+ try {
570
+ // Finalised after the parse succeeded; resolves a semantic path to its place in the source.
571
+ const sourceMap = positions.finish();
572
+ /**
573
+ * Appends ` (step at line N)` when the step's own key can be placed, and nothing when it cannot
574
+ * (issue #392). Used at PUSH time, never at join time — once messages are joined into one
575
+ * string the step each came from is no longer recoverable.
576
+ *
577
+ * The suffix names the STEP because that is the only position this helper ever has, and saying
578
+ * so is the point (issue #420). Across the loader the two forms are univocal:
579
+ *
580
+ * `(line N)` — the OFFENDING KEY's own line. Minted by `withKeyLine`'s first rung
581
+ * below, and by the unknown-key warnings (`renderUnknownKeyMessage`),
582
+ * which is key-exact-or-absent by construction: every `findUnknownKeys`
583
+ * call site passes a `positionOf` that resolves the offending key's own
584
+ * path, with no step fallback anywhere.
585
+ * `(step at line N)` — the STEP's line. This helper, and `withKeyLine`'s fallback rung.
586
+ *
587
+ * Before that split both rungs rendered `(line N)`, so an author could not tell a cite that
588
+ * pointed AT the refused field from one that pointed at the declaration above it. The
589
+ * structured channel (`line`/`column`/`endLine`/`endColumn`) is unaffected — it always carried
590
+ * the distinction; only the prose was ambiguous.
591
+ */
592
+ const withStepLine = (stepName, message) => {
593
+ const line = sourceMap.posOf(['steps', stepName])?.line;
594
+ return line === undefined ? message : `${message} (step at line ${line})`;
595
+ };
596
+ /**
597
+ * Like `withStepLine`, but names the OFFENDING KEY's own line (issue #417).
598
+ *
599
+ * For a key-scoped refusal the step's line is the wrong place to send someone: a long step has
600
+ * the key twenty lines below its own name, and the author reading `(line 40)` looks at the
601
+ * declaration rather than at the field being refused. The position map records every pairable
602
+ * mapping key, so the key's own line is available wherever the step's is.
603
+ *
604
+ * Falls back to the step's line, and then to no position at all — and the two real shapes land
605
+ * on DIFFERENT rungs, which is why both are pinned. A step body assembled through a merge key
606
+ * (`<<: *anchor`) leaves the KEY unpairable while the step's own name is still placeable, so it
607
+ * falls back to the step's line. A `use_template` step, whose keys are synthesized, exists at no
608
+ * line in the file at all and carries no position. Neither guesses — a wrong line number sends
609
+ * an author confidently to the wrong place, which is worse than sending them nowhere.
610
+ *
611
+ * The two rungs render DIFFERENTLY (issue #420): rung 1 is ` (line N)`, the key's own line;
612
+ * rung 2 is ` (step at line N)`, the step's — the same vocabulary `withStepLine` above
613
+ * documents in full. They were previously indistinguishable, which made the fallback silently
614
+ * claim to be a key-exact cite. The two lookups are separate rather than one `??` chain for
615
+ * exactly that reason: a single chain cannot report WHICH rung answered.
616
+ */
617
+ const withKeyLine = (stepName, key, message) => {
618
+ const keyLine = sourceMap.posOf(['steps', stepName, key])?.line;
619
+ if (keyLine !== undefined)
620
+ return `${message} (line ${keyLine})`;
621
+ const stepLine = sourceMap.posOf(['steps', stepName])?.line;
622
+ if (stepLine !== undefined)
623
+ return `${message} (step at line ${stepLine})`;
624
+ return message;
625
+ };
626
+ // Step 2: Top-level validation
627
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
628
+ throw new WorkflowError('Invalid workflow: Workflow must be a non-null object', {
527
629
  code: 'VALIDATION_WORKFLOW_SCHEMA',
528
630
  category: 'VALIDATION',
529
631
  agentAction: 'report_to_user',
530
632
  retryable: false,
531
633
  });
532
634
  }
533
- errors.push(...validateExtensionsDeclaration(doc['extensions']));
534
- }
535
- const REQUIRED_TOP_LEVEL = ['id', 'name', 'version', 'steps'];
536
- for (const field of REQUIRED_TOP_LEVEL) {
537
- if (!(field in doc)) {
538
- errors.push(`Missing required field: '${field}'`);
539
- }
540
- }
541
- if ('version' in doc && typeof doc['version'] !== 'number') {
542
- errors.push(`'version' must be a number`);
543
- }
544
- if ('steps' in doc &&
545
- (typeof doc['steps'] !== 'object' || doc['steps'] === null || Array.isArray(doc['steps']))) {
546
- errors.push(`'steps' must be a non-null object`);
547
- }
548
- if (errors.length > 0) {
549
- throw new WorkflowError(`Invalid workflow: ${errors.join('; ')}`, {
550
- code: 'VALIDATION_WORKFLOW_SCHEMA',
551
- category: 'VALIDATION',
552
- agentAction: 'report_to_user',
553
- retryable: false,
554
- });
555
- }
556
- // Step 1b: Resolve template instantiations before validation.
557
- const rawTemplates = (doc['templates'] ?? {});
558
- if (Object.keys(rawTemplates).length > 0 || hasUseTemplateInSteps(doc['steps'])) {
559
- doc['steps'] = resolveTemplates(doc['steps'], rawTemplates);
560
- }
561
- const stepsRaw = doc['steps'];
562
- // Finalizer-bearing workflows write every claim `deadline: null` (issue #101), so a per-step
563
- // `idempotent` hint is INERT there (never cron-reclaimable). Used only to WARN below.
564
- const hasFinalizerStep = Object.values(stepsRaw).some((s) => typeof s === 'object' &&
565
- s !== null &&
566
- s['execution'] === 'finalizer');
567
- // Step 3: Per-step validation
568
- for (const [stepName, stepRaw] of Object.entries(stepsRaw)) {
569
- if (typeof stepRaw !== 'object' || stepRaw === null || Array.isArray(stepRaw)) {
570
- errors.push(`Step '${stepName}' must be an object`);
571
- continue;
572
- }
573
- const step = stepRaw;
574
- // issue #220 §4c (PR-3): HOISTED out of the `when`-only block below (was block-local there) so
575
- // ALL THREE condition surfaces (when/abort_unless/preconditions) can thread the real
576
- // depends_on list into validateConditionLeaf's `$settlement` one-hop check. Previously
577
- // abort_unless/preconditions passed a literal `[]` (no reference validation existed for them
578
- // at all); the legacy when-only depends_on/run.params check (validateWhenReference) is
579
- // UNCHANGED — it still fires ONLY for `surface === 'when'`. This is a LIFT, not a new
580
- // computation — byte-identical to the previous block-local `dependsOn` for `when`'s own use.
581
- const dependsOn = Array.isArray(step['depends_on'])
582
- ? step['depends_on'].filter((d) => typeof d === 'string')
583
- : [];
584
- // WARN (do not reject) on an unknown step key — runs after template resolution above, so a
585
- // template-expanded step's keys are checked too. Same non-breaking posture as the
586
- // workflow-level check (issue #144).
587
- warnings.push(...findUnknownKeys(step, KNOWN_STEP_KEYS, {
588
- scope: 'step',
589
- code: 'UNKNOWN_STEP_KEY',
590
- step: stepName,
591
- }));
592
- // issue #220 §4c PR ordering interlock: `$settlement` is reserved NOW (PR-1) even though the
593
- // namespace it names is not minted until a later PR — else the inter-PR gap could register a
594
- // `$settlement`-named step that becomes a load-refused fossil the instant the mint ships.
595
- if (stepName === 'run' || stepName === 'context' || stepName === '$settlement') {
596
- errors.push(`Step name '${stepName}' is reserved and cannot be used as a step identifier`);
597
- }
598
- // Reject integer-like step names: JS object iteration reorders integer-like keys ahead
599
- // of insertion order, which would silently break the declaration-order guarantees the
600
- // eligibility loops and finalizer drain rely on (both iterate via Object.entries).
601
- if (/^\d+$/.test(stepName)) {
602
- errors.push(`Step name '${stepName}' is invalid: integer-like names reorder under JS object ` +
603
- `iteration and would break declaration-order execution. Use a non-numeric name.`);
604
- }
605
- const REQUIRED_STEP = ['description', 'execution'];
606
- for (const field of REQUIRED_STEP) {
607
- if (!(field in step)) {
608
- errors.push(`Step '${stepName}': missing required field '${field}'`);
609
- }
610
- }
611
- if ('execution' in step && !VALID_EXECUTIONS.has(step['execution'])) {
612
- errors.push(`Step '${stepName}': invalid execution value '${String(step['execution'])}'; must be 'auto', 'agent', 'guard', or 'finalizer'`);
613
- }
614
- // Finalizer step constraints (a workflow-level try/catch/finally). handler-only in v1.
615
- if (step['execution'] === 'finalizer') {
616
- const prohibited = [
617
- 'depends_on',
618
- 'trigger_rule',
619
- 'abort_unless',
620
- 'abort_message',
621
- 'output_schema',
622
- 'agent_profile',
623
- 'tools',
624
- 'uses_service',
625
- 'service_method',
626
- 'operation',
627
- 'input_map',
628
- 'when',
629
- 'retry',
630
- ];
631
- for (const field of prohibited) {
632
- if (step[field] !== undefined) {
633
- errors.push(`Step '${stepName}': '${field}' is not valid on execution: finalizer steps`);
634
- }
635
- }
636
- // A finalizer must not gate — reject any human-gate trust level.
637
- if (step['trust'] !== undefined && step['trust'] !== 'auto') {
638
- errors.push(`Step '${stepName}': 'trust: ${String(step['trust'])}' is not valid on execution: finalizer steps (a finalizer must not gate)`);
639
- }
640
- // v1 is handler-only.
641
- if (step['handler'] === undefined) {
642
- errors.push(`Step '${stepName}': execution: finalizer requires 'handler' (handler-only in v1)`);
643
- }
644
- // on_outcome is required, non-empty, every value in the FinalizerTrigger enum.
645
- const rawOutcome = step['on_outcome'];
646
- if (rawOutcome === undefined) {
647
- errors.push(`Step '${stepName}': execution: finalizer requires 'on_outcome'`);
648
- }
649
- else {
650
- const outcomes = Array.isArray(rawOutcome) ? rawOutcome : [rawOutcome];
651
- if (outcomes.length === 0) {
652
- errors.push(`Step '${stepName}': 'on_outcome' must not be empty`);
653
- }
654
- for (const o of outcomes) {
655
- if (typeof o !== 'string' || !VALID_FINALIZER_TRIGGERS.has(o)) {
656
- errors.push(`Step '${stepName}': invalid on_outcome value '${String(o)}'; must be one of ${[...VALID_FINALIZER_TRIGGERS].join(', ')}`);
657
- }
658
- }
635
+ const doc = raw;
636
+ // WARN (do not reject) on a key that isn't authorable — checked against KNOWN_WORKFLOW_KEYS
637
+ // ONLY (not RUNTIME_ONLY_WORKFLOW_KEYS), and BEFORE any loader-stamped field is added below.
638
+ // Deliberately excluding runtime-only keys from "known" here means hand-authoring one (e.g.
639
+ // `schema_version:` or `model:` in YAML) warns too — those fields are stamped by the loader
640
+ // and any authored value is silently overwritten/ignored, which is exactly the kind of mistake
641
+ // this check exists to surface (issue #144). Non-breaking by design: siblings #170
642
+ // (hard-reject) and #169 (structured warnings channel) are deliberately out of scope here.
643
+ {
644
+ const workflowId = typeof doc['id'] === 'string' ? doc['id'] : '<unknown>';
645
+ warnings.push(...findUnknownKeys(doc, KNOWN_WORKFLOW_KEYS, {
646
+ scope: 'workflow',
647
+ code: 'UNKNOWN_WORKFLOW_KEY',
648
+ id: workflowId,
649
+ positionOf: (key) => sourceMap.posOf([key]),
650
+ }));
651
+ }
652
+ // Project extensions: hard error for string-based loading (fires before any other
653
+ // processing); shape validation (string | string[], relative-only) for file-based loading.
654
+ if ('extensions' in doc && doc['extensions'] !== undefined) {
655
+ if (!opts.allowExtensions) {
656
+ throw new WorkflowError(`Invalid workflow: 'extensions' requires file-based loading — no directory context is ` +
657
+ `available to resolve extension module paths. Register this workflow from its YAML ` +
658
+ `file (realm workflow register <path>).`, {
659
+ code: 'VALIDATION_WORKFLOW_SCHEMA',
660
+ category: 'VALIDATION',
661
+ agentAction: 'report_to_user',
662
+ retryable: false,
663
+ });
659
664
  }
665
+ errors.push(...validateExtensionsDeclaration(doc['extensions']));
660
666
  }
661
- // on_outcome is only valid on execution: finalizer steps.
662
- if (step['on_outcome'] !== undefined && step['execution'] !== 'finalizer') {
663
- errors.push(`Step '${stepName}': 'on_outcome' is only valid on execution: finalizer steps`);
664
- }
665
- // Guard step constraints.
666
- if (step['execution'] === 'guard') {
667
- const prohibited = [
668
- 'uses_service',
669
- 'handler',
670
- 'input_schema',
671
- 'output_schema',
672
- 'trust',
673
- 'agent_profile',
674
- 'trigger_rule',
675
- 'timeout_seconds',
676
- 'service_method',
677
- 'operation',
678
- 'input_map',
679
- 'tools',
680
- ];
681
- for (const field of prohibited) {
682
- if (step[field] !== undefined) {
683
- errors.push(`Step '${stepName}': '${field}' is not valid on execution: guard steps`);
684
- }
685
- }
686
- if (step['abort_unless'] === undefined) {
687
- errors.push(`Step '${stepName}': execution: guard requires 'abort_unless'`);
667
+ const REQUIRED_TOP_LEVEL = ['id', 'name', 'version', 'steps'];
668
+ for (const field of REQUIRED_TOP_LEVEL) {
669
+ if (!(field in doc)) {
670
+ errors.push(`Missing required field: '${field}'`);
688
671
  }
689
672
  }
690
- // abort_unless and abort_message are only valid on execution: guard steps.
691
- if (step['abort_unless'] !== undefined && step['execution'] !== 'guard') {
692
- errors.push(`Step '${stepName}': 'abort_unless' is only valid on execution: guard steps`);
693
- }
694
- if (step['abort_message'] !== undefined && step['execution'] !== 'guard') {
695
- errors.push(`Step '${stepName}': 'abort_message' is only valid on execution: guard steps`);
673
+ if ('version' in doc && typeof doc['version'] !== 'number') {
674
+ errors.push(`'version' must be a number`);
696
675
  }
697
- // agent_profile is only valid on agent steps.
698
- if ('agent_profile' in step && step['execution'] !== 'agent') {
699
- errors.push(`Step '${stepName}': 'agent_profile' is only valid on execution: agent steps`);
676
+ if ('steps' in doc &&
677
+ (typeof doc['steps'] !== 'object' || doc['steps'] === null || Array.isArray(doc['steps']))) {
678
+ errors.push(`'steps' must be a non-null object`);
700
679
  }
701
- // idempotent (issue #101 Phase 2) is only valid on execution: auto steps — the reliably
702
- // time-boundable, deadline-carrying class. It is inert (no concrete deadline is ever written)
703
- // on agent/guard/finalizer, so it is rejected there rather than silently ignored.
704
- if (step['idempotent'] !== undefined && step['execution'] !== 'auto') {
705
- errors.push(`Step '${stepName}': 'idempotent' is only valid on execution: auto steps`);
680
+ if (errors.length > 0) {
681
+ throw new WorkflowError(`Invalid workflow: ${errors.join('; ')}`, {
682
+ // issue #425: the pre-join strings see the profile collector above.
683
+ errors: [...errors],
684
+ code: 'VALIDATION_WORKFLOW_SCHEMA',
685
+ category: 'VALIDATION',
686
+ agentAction: 'report_to_user',
687
+ retryable: false,
688
+ });
706
689
  }
707
- // WARN (do not reject): an idempotent auto step in a finalizer-bearing workflow gets
708
- // `deadline: null` (issue #101), so the RECLAIM function is inert — `realm run reclaim --all`
709
- // can never select it. The author should know it stays per-step-manual-reclaim-only.
710
- //
711
- // Issue #140 C5 (variant-aware reword): `idempotent` now has a SECOND function — gating
712
- // `retry.on_timeout` and that GATE function is live in every workflow, finalizer-bearing or
713
- // not (shouldEnforceTimeout has no finalizer conjunct). A single unconditional message would
714
- // either keep a falsehood (claiming idempotent is wholly inert when on_timeout is ALSO
715
- // declared) or gratuitously mention a gate the author never declared (idempotent-alone case)
716
- // so the message is keyed on `step.retry?.on_timeout`, pinned by both-variant loader tests.
717
- if (step['idempotent'] === true && step['execution'] === 'auto' && hasFinalizerStep) {
718
- const stepRetry = typeof step['retry'] === 'object' && step['retry'] !== null
719
- ? step['retry']
720
- : undefined;
721
- const onTimeoutDeclared = stepRetry?.['on_timeout'] === true;
722
- warnings.push({
723
- code: 'IDEMPOTENT_INERT_IN_FINALIZER',
724
- severity: resolveSeverity('IDEMPOTENT_INERT_IN_FINALIZER'),
690
+ // Step 1b: Resolve template instantiations before validation.
691
+ const rawTemplates = (doc['templates'] ?? {});
692
+ if (Object.keys(rawTemplates).length > 0 || hasUseTemplateInSteps(doc['steps'])) {
693
+ doc['steps'] = resolveTemplates(doc['steps'], rawTemplates);
694
+ }
695
+ const stepsRaw = doc['steps'];
696
+ // Finalizer-bearing workflows write every claim `deadline: null` (issue #101), so a per-step
697
+ // `idempotent` hint is INERT there (never cron-reclaimable). Used only to WARN below.
698
+ const hasFinalizerStep = Object.values(stepsRaw).some((s) => typeof s === 'object' &&
699
+ s !== null &&
700
+ s['execution'] === 'finalizer');
701
+ // Step 3: Per-step validation
702
+ for (const [stepName, stepRaw] of Object.entries(stepsRaw)) {
703
+ if (typeof stepRaw !== 'object' || stepRaw === null || Array.isArray(stepRaw)) {
704
+ errors.push(withStepLine(stepName, `Step '${stepName}' must be an object`));
705
+ continue;
706
+ }
707
+ const step = stepRaw;
708
+ // issue #220 §4c (PR-3): HOISTED out of the `when`-only block below (was block-local there) so
709
+ // ALL THREE condition surfaces (when/abort_unless/preconditions) can thread the real
710
+ // depends_on list into validateConditionLeaf's `$settlement` one-hop check. Previously
711
+ // abort_unless/preconditions passed a literal `[]` (no reference validation existed for them
712
+ // at all); the legacy when-only depends_on/run.params check (validateWhenReference) is
713
+ // UNCHANGED — it still fires ONLY for `surface === 'when'`. This is a LIFT, not a new
714
+ // computation — byte-identical to the previous block-local `dependsOn` for `when`'s own use.
715
+ const dependsOn = Array.isArray(step['depends_on'])
716
+ ? step['depends_on'].filter((d) => typeof d === 'string')
717
+ : [];
718
+ // WARN (do not reject) on an unknown step key — runs after template resolution above, so a
719
+ // template-expanded step's keys are checked too. Same non-breaking posture as the
720
+ // workflow-level check (issue #144).
721
+ warnings.push(...findUnknownKeys(step, KNOWN_STEP_KEYS, {
725
722
  scope: 'step',
723
+ code: 'UNKNOWN_STEP_KEY',
726
724
  step: stepName,
727
- message: `Step '${stepName}': 'idempotent: true' cannot enable auto-reclaim in a finalizer-bearing ` +
728
- `workflow (its claim carries no deadline, so 'realm run reclaim --all' can never select ` +
729
- `it). Recover it with 'realm run reclaim <run-id> --step ${stepName} --force'.` +
730
- (onTimeoutDeclared
731
- ? ` Its 'retry.on_timeout' gate role is unaffected timeout retries remain active.`
732
- : ''),
733
- });
734
- }
735
- // output_schema is only valid on execution: agent steps.
736
- if (step['output_schema'] !== undefined && step['execution'] !== 'agent') {
737
- errors.push(`Step '${stepName}': 'output_schema' is only valid on execution: agent steps`);
738
- }
739
- // issue #236 (L0 prevention layer): structured_output is only valid on execution: agent
740
- // steps (mirrors output_schema's rule above), and its only legal value is the literal
741
- // 'strict'. On an opted-in step, Phase A REJECTS an ineligible verdict at load time — the
742
- // API provably rejects some legal schemas and silently weakens others, so authoring never
743
- // ships a schema the gate already knows is unsafe. Caveats are NOT rejected (informational
744
- // only, surfaced by validate's nudge — Deliverable 7); this loader block only ever REJECTS.
745
- if (step['structured_output'] !== undefined) {
746
- if (step['execution'] !== 'agent') {
747
- errors.push(`Step '${stepName}': 'structured_output' is only valid on execution: agent steps`);
725
+ positionOf: (key) => sourceMap.posOf(['steps', stepName, key]),
726
+ }));
727
+ // issue #220 §4c PR ordering interlock: `$settlement` is reserved NOW (PR-1) even though the
728
+ // namespace it names is not minted until a later PR — else the inter-PR gap could register a
729
+ // `$settlement`-named step that becomes a load-refused fossil the instant the mint ships.
730
+ if (stepName === 'run' || stepName === 'context' || stepName === '$settlement') {
731
+ errors.push(`Step name '${stepName}' is reserved and cannot be used as a step identifier`);
748
732
  }
749
- else if (step['structured_output'] !== 'strict') {
750
- errors.push(`Step '${stepName}': 'structured_output' must be the literal string 'strict' (got ${JSON.stringify(step['structured_output'])})`);
733
+ // Reject integer-like step names: JS object iteration reorders integer-like keys ahead
734
+ // of insertion order, which would silently break the declaration-order guarantees the
735
+ // eligibility loops and finalizer drain rely on (both iterate via Object.entries).
736
+ if (/^\d+$/.test(stepName)) {
737
+ errors.push(`Step name '${stepName}' is invalid: integer-like names reorder under JS object ` +
738
+ `iteration and would break declaration-order execution. Use a non-numeric name.`);
751
739
  }
752
- else {
753
- const verdict = assessStructuredOutputEligibility({
754
- ...(step['output_schema'] !== undefined
755
- ? { output_schema: step['output_schema'] }
756
- : {}),
757
- ...(step['input_schema'] !== undefined
758
- ? { input_schema: step['input_schema'] }
759
- : {}),
760
- ...(step['tools'] !== undefined ? { tools: step['tools'] } : {}),
761
- });
762
- if (verdict.verdict === 'ineligible') {
763
- errors.push(`Step '${stepName}': 'structured_output: strict' is not eligible for this step's ` +
764
- `schema — ${renderIneligibleMessage(verdict.reasons)}`);
740
+ const REQUIRED_STEP = ['description', 'execution'];
741
+ for (const field of REQUIRED_STEP) {
742
+ if (!(field in step)) {
743
+ errors.push(withStepLine(stepName, `Step '${stepName}': missing required field '${field}'`));
765
744
  }
766
745
  }
767
- }
768
- // issue #220 (PR-2): validation_exhaustion is only valid on execution: agent steps the
769
- // countable rejection set (VALIDATION_INPUT_SCHEMA/VALIDATION_OUTPUT_SCHEMA) is agent-only by
770
- // construction (execution-loop.ts's countRejection). Full rule table: `mode` value validated
771
- // (REFUSE on an unrecognized value — unvalidatable posture, fail-closed); `mode: 'default'`
772
- // requires `default_output` (REFUSE — nothing to substitute) which in turn requires the step's
773
- // own `output_schema` (REFUSE — B5, an unvalidatable default) against which `default_output` is
774
- // then AJV-proven AT LOAD TIME (REFUSE — B10, reusing the runtime validator so load-time and
775
- // runtime verdicts can never diverge); `default_output` present without `mode: 'default'` WARNS
776
- // as dead config (never rejects — it's simply inert); an unknown sub-key WARNS.
777
- if (step['validation_exhaustion'] !== undefined) {
778
- if (step['execution'] !== 'agent') {
779
- errors.push(`Step '${stepName}': 'validation_exhaustion' is only valid on execution: agent steps`);
780
- }
781
- else if (typeof step['validation_exhaustion'] !== 'object' ||
782
- step['validation_exhaustion'] === null) {
783
- errors.push(`Step '${stepName}': 'validation_exhaustion' must be an object`);
746
+ if ('execution' in step && !VALID_EXECUTIONS.has(step['execution'])) {
747
+ errors.push(withStepLine(stepName, `Step '${stepName}': invalid execution value '${String(step['execution'])}'; must be 'auto', 'agent', 'guard', or 'finalizer'`));
784
748
  }
785
- else {
786
- const exhaustionBlock = step['validation_exhaustion'];
787
- // WARN (do not reject) on an unknown validation_exhaustion sub-key — the retry-block-style
788
- // pattern (issue #140's UNKNOWN_RETRY_KEY), its OWN code (issue #220 PR-2 mints
789
- // UNKNOWN_VALIDATION_EXHAUSTION_KEY, replacing PR-1's UNKNOWN_STEP_KEY noun-override —
790
- // closes the #170-flip incoherence against the structurally identical retry-key family).
791
- warnings.push(...findUnknownKeys(exhaustionBlock, KNOWN_VALIDATION_EXHAUSTION_KEYS, {
792
- scope: 'step',
793
- code: 'UNKNOWN_VALIDATION_EXHAUSTION_KEY',
794
- step: stepName,
795
- noun: 'validation_exhaustion',
796
- }));
797
- if ('threshold' in exhaustionBlock &&
798
- (!Number.isInteger(exhaustionBlock['threshold']) ||
799
- exhaustionBlock['threshold'] < 1)) {
800
- errors.push(`Step '${stepName}': 'validation_exhaustion.threshold' must be a positive integer ` +
801
- `(1 is legal — it disables in-drive schema-repair, since the first rejection ` +
802
- `already meets it)`);
803
- }
804
- const modeValue = exhaustionBlock['mode'];
805
- if (modeValue !== undefined && modeValue !== 'fail' && modeValue !== 'default') {
806
- errors.push(`Step '${stepName}': 'validation_exhaustion.mode' must be 'fail' or 'default' ` +
807
- `(got: ${JSON.stringify(modeValue)})`);
808
- }
809
- const hasDefaultOutput = 'default_output' in exhaustionBlock;
810
- if (modeValue === 'default') {
811
- if (!hasDefaultOutput) {
812
- errors.push(`Step '${stepName}': 'validation_exhaustion.mode: default' requires ` +
813
- `'default_output' (nothing to substitute on exhaustion)`);
814
- }
815
- else if (step['output_schema'] === undefined) {
816
- errors.push(`Step '${stepName}': 'validation_exhaustion.default_output' requires the step to ` +
817
- `declare 'output_schema' (an undeclared schema makes the default unvalidatable)`);
818
- }
819
- else {
820
- // B10 — load-time AJV proof: REUSE the runtime validator so the load-time verdict can
821
- // never diverge from the runtime verdict for the exact same (default_output,
822
- // output_schema) pair. This is the loader's first load-time Ajv compile of an
823
- // output_schema (today output_schema is only compiled at runtime), so the catch below
824
- // legitimately sees TWO different populations: a VALIDATION_OUTPUT_SCHEMA
825
- // WorkflowError (default_output fails the schema) and a raw Ajv schema-compilation
826
- // Error (a structurally malformed output_schema) — both fail-closed to a load refusal,
827
- // but they carry their detail DIFFERENTLY: a WorkflowError has `.details.errors`; a raw
828
- // Error has NO `.details` at all (reading `.details.errors` on it throws a TypeError
829
- // that would escape the loader mid-walk — verified empirically). Discriminate.
830
- try {
831
- validateOutputSchema(exhaustionBlock['default_output'], step['output_schema'], stepName);
832
- }
833
- catch (err) {
834
- const detail = err instanceof WorkflowError
835
- ? JSON.stringify(err.details['errors'])
836
- : err instanceof Error
837
- ? err.message
838
- : String(err);
839
- errors.push(`Step '${stepName}': 'validation_exhaustion.default_output' does not validate ` +
840
- `against the step's own 'output_schema': ${detail}`);
841
- }
749
+ // Finalizer step constraints (a workflow-level try/catch/finally). handler-only in v1.
750
+ if (step['execution'] === 'finalizer') {
751
+ const prohibited = [
752
+ 'depends_on',
753
+ 'trigger_rule',
754
+ 'abort_unless',
755
+ 'abort_message',
756
+ 'output_schema',
757
+ 'agent_profile',
758
+ 'tools',
759
+ 'uses_service',
760
+ 'service_method',
761
+ 'operation',
762
+ 'input_map',
763
+ 'when',
764
+ 'retry',
765
+ ];
766
+ for (const field of prohibited) {
767
+ if (step[field] !== undefined) {
768
+ errors.push(withStepLine(stepName, `Step '${stepName}': '${field}' is not valid on execution: finalizer steps`));
842
769
  }
843
770
  }
844
- else if (hasDefaultOutput) {
845
- // default_output present without mode: 'default' (mode: 'fail' or absent) — inert, not
846
- // an error: WARN as dead config rather than silently ignoring it.
847
- warnings.push({
848
- code: 'DEAD_VALIDATION_EXHAUSTION_CONFIG',
849
- severity: resolveSeverity('DEAD_VALIDATION_EXHAUSTION_CONFIG'),
850
- scope: 'step',
851
- step: stepName,
852
- message: `Step '${stepName}': 'validation_exhaustion.default_output' is ignored without ` +
853
- `'mode: default' — set 'validation_exhaustion.mode: default' to enable it, or ` +
854
- `remove 'default_output'.`,
855
- });
771
+ // A finalizer must not gate — reject any human-gate trust level.
772
+ if (step['trust'] !== undefined && step['trust'] !== 'auto') {
773
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'trust: ${String(step['trust'])}' is not valid on execution: finalizer steps (a finalizer must not gate)`));
856
774
  }
857
- }
858
- }
859
- // WARN (do not reject): an agent step declaring BOTH input_schema and output_schema has its
860
- // submitted output validated against BOTH (execution-loop.ts validateInputSchema AND
861
- // validateOutputSchema) — a divergence between the two degrades to a confusing recoverable
862
- // VALIDATION_*_SCHEMA error rather than a clean failure. Detection/warn only; no schema change.
863
- if (step['execution'] === 'agent' &&
864
- step['input_schema'] !== undefined &&
865
- step['output_schema'] !== undefined) {
866
- warnings.push({
867
- code: 'DUAL_SCHEMA_DECLARED',
868
- severity: resolveSeverity('DUAL_SCHEMA_DECLARED'),
869
- scope: 'step',
870
- step: stepName,
871
- message: `Step '${stepName}': declares both input_schema and output_schema; the agent's submitted ` +
872
- `output is validated against both — prefer one to avoid divergence.`,
873
- });
874
- }
875
- // trace_schema is only valid on execution: agent steps.
876
- if (step['trace_schema'] !== undefined && step['execution'] !== 'agent') {
877
- errors.push(`Step '${stepName}': 'trace_schema' is only valid on execution: agent steps`);
878
- }
879
- // trace_validation_mode is only valid on execution: agent steps.
880
- if (step['trace_validation_mode'] !== undefined && step['execution'] !== 'agent') {
881
- errors.push(`Step '${stepName}': 'trace_validation_mode' is only valid on execution: agent steps`);
882
- }
883
- // trace_validation_mode must be 'warn' or 'enforce' when provided.
884
- if (step['trace_validation_mode'] !== undefined &&
885
- step['trace_validation_mode'] !== 'warn' &&
886
- step['trace_validation_mode'] !== 'enforce') {
887
- errors.push(`Step '${stepName}': invalid trace_validation_mode '${String(step['trace_validation_mode'])}'; must be 'warn' or 'enforce'`);
888
- }
889
- // issue #291 (authorable gate timeout — the FIRST validation the `gate:` block has ever had):
890
- // the E2 positive-integer checks on timeout_seconds/reminder_seconds/reminder_max, the
891
- // on_expiry enum, default_choice's required-iff + choice-set validation, and the dead-config
892
- // warn cells. Runs regardless of `trust` (a `gate:` block with no gate trust is already inert
893
- // — no separate rejection needed; the existing render/mint paths never read it without a
894
- // trust value).
895
- if (step['gate'] !== undefined) {
896
- if (typeof step['gate'] !== 'object' || step['gate'] === null) {
897
- errors.push(`Step '${stepName}': 'gate' must be an object`);
898
- }
899
- else {
900
- const gate = step['gate'];
901
- // WARN (do not reject) on an unknown gate-block key — same non-breaking posture as
902
- // retry/validation_exhaustion (issues #140/#220).
903
- warnings.push(...findUnknownKeys(gate, KNOWN_GATE_KEYS, {
904
- scope: 'step',
905
- code: 'UNKNOWN_GATE_KEY',
906
- step: stepName,
907
- noun: 'gate',
908
- }));
909
- // E2: timeout_seconds/reminder_seconds/reminder_max must each be a positive integer
910
- // (yaml-loader :1164-1174 precedent — the SAME convention as retry.total_timeout_seconds).
911
- if ('timeout_seconds' in gate &&
912
- (!Number.isInteger(gate['timeout_seconds']) || gate['timeout_seconds'] <= 0)) {
913
- errors.push(`Step '${stepName}': 'gate.timeout_seconds' must be a positive integer`);
775
+ // v1 is handler-only.
776
+ if (step['handler'] === undefined) {
777
+ errors.push(withStepLine(stepName, `Step '${stepName}': execution: finalizer requires 'handler' (handler-only in v1)`));
914
778
  }
915
- if ('reminder_seconds' in gate &&
916
- (!Number.isInteger(gate['reminder_seconds']) || gate['reminder_seconds'] <= 0)) {
917
- errors.push(`Step '${stepName}': 'gate.reminder_seconds' must be a positive integer`);
779
+ // on_outcome is required, non-empty, every value in the FinalizerTrigger enum.
780
+ const rawOutcome = step['on_outcome'];
781
+ if (rawOutcome === undefined) {
782
+ errors.push(withStepLine(stepName, `Step '${stepName}': execution: finalizer requires 'on_outcome'`));
918
783
  }
919
- if ('reminder_max' in gate &&
920
- (!Number.isInteger(gate['reminder_max']) || gate['reminder_max'] <= 0)) {
921
- errors.push(`Step '${stepName}': 'gate.reminder_max' must be a positive integer`);
922
- }
923
- // on_expiry must be 'settle_default' or 'abort' when provided.
924
- const onExpiry = gate['on_expiry'];
925
- if (onExpiry !== undefined && onExpiry !== 'settle_default' && onExpiry !== 'abort') {
926
- errors.push(`Step '${stepName}': 'gate.on_expiry' must be 'settle_default' or 'abort' (got: ${JSON.stringify(onExpiry)})`);
927
- }
928
- // default_choice: REQUIRED iff on_expiry === 'settle_default' (E2-style hard error,
929
- // mirroring validation_exhaustion.mode:'default' requiring default_output); validated
930
- // against the step's own EFFECTIVE STATIC choice set — the EXACT same three-source
931
- // derivation the engine mints PendingGate.choices from (execution-loop.ts's gate-open
932
- // site: gate.choices ?? input_schema.properties.choice.enum ?? ['approve','reject']) —
933
- // so a load-time-legal default_choice can NEVER fail at enactment time.
934
- const hasDefaultChoice = 'default_choice' in gate;
935
- if (onExpiry === 'settle_default') {
936
- if (!hasDefaultChoice) {
937
- errors.push(`Step '${stepName}': 'gate.on_expiry: settle_default' requires 'gate.default_choice' ` +
938
- `(nothing to resolve the gate with on expiry)`);
784
+ else {
785
+ const outcomes = Array.isArray(rawOutcome) ? rawOutcome : [rawOutcome];
786
+ if (outcomes.length === 0) {
787
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'on_outcome' must not be empty`));
939
788
  }
940
- else {
941
- const choicesRaw = gate['choices'] ??
942
- step['input_schema']?.properties?.['choice']?.enum;
943
- const effectiveChoices = Array.isArray(choicesRaw)
944
- ? choicesRaw
945
- : ['approve', 'reject'];
946
- if (!effectiveChoices.includes(gate['default_choice'])) {
947
- errors.push(`Step '${stepName}': 'gate.default_choice' (${JSON.stringify(gate['default_choice'])}) ` +
948
- `is not one of the step's effective choices: ${effectiveChoices.join(', ')}`);
789
+ for (const o of outcomes) {
790
+ if (typeof o !== 'string' || !VALID_FINALIZER_TRIGGERS.has(o)) {
791
+ errors.push(withStepLine(stepName, `Step '${stepName}': invalid on_outcome value '${String(o)}'; must be one of ${[...VALID_FINALIZER_TRIGGERS].join(', ')}`));
949
792
  }
950
793
  }
951
794
  }
952
- else if (hasDefaultChoice) {
953
- // default_choice with on_expiry:'abort' or with no on_expiry at all — inert, not an
954
- // error: WARN as dead config (the #220 DEAD_VALIDATION_EXHAUSTION_CONFIG precedent).
955
- warnings.push({
956
- code: 'DEAD_GATE_CONFIG',
957
- severity: resolveSeverity('DEAD_GATE_CONFIG'),
958
- scope: 'step',
959
- step: stepName,
960
- message: `Step '${stepName}': 'gate.default_choice' is ignored without ` +
961
- `'gate.on_expiry: settle_default' set it, or remove 'gate.default_choice'.`,
962
- });
795
+ }
796
+ // on_outcome is only valid on execution: finalizer steps.
797
+ if (step['on_outcome'] !== undefined && step['execution'] !== 'finalizer') {
798
+ errors.push(
799
+ // Consumer: settlement.ts:145 (`finalizerTriggers`) — it is read only when selecting
800
+ // which finalizers a run's outcome should fire.
801
+ withKeyLine(stepName, 'on_outcome', `Step '${stepName}': 'on_outcome' is only valid on execution: finalizer steps — it ` +
802
+ 'selects which finalizers run for a given outcome, and only finalizers are selected ' +
803
+ 'that way, so here it would decide nothing. Move it to the finalizer that should ' +
804
+ 'react to the outcome, or remove it.'));
805
+ }
806
+ // Guard step constraints.
807
+ if (step['execution'] === 'guard') {
808
+ const prohibited = [
809
+ 'uses_service',
810
+ 'handler',
811
+ 'input_schema',
812
+ 'output_schema',
813
+ 'trust',
814
+ 'agent_profile',
815
+ 'trigger_rule',
816
+ 'timeout_seconds',
817
+ 'service_method',
818
+ 'operation',
819
+ 'input_map',
820
+ 'tools',
821
+ ];
822
+ for (const field of prohibited) {
823
+ if (step[field] !== undefined) {
824
+ errors.push(withStepLine(stepName, `Step '${stepName}': '${field}' is not valid on execution: guard steps`));
825
+ }
963
826
  }
964
- // Dead config: on_expiry declared but no timeout_seconds — nothing will ever trigger the
965
- // enforce clock, so the declared disposition can never enact.
966
- if (onExpiry !== undefined && gate['timeout_seconds'] === undefined) {
967
- warnings.push({
968
- code: 'DEAD_GATE_CONFIG',
969
- severity: resolveSeverity('DEAD_GATE_CONFIG'),
970
- scope: 'step',
971
- step: stepName,
972
- message: `Step '${stepName}': 'gate.on_expiry' is ignored without 'gate.timeout_seconds' — ` +
973
- `set a timeout, or remove 'gate.on_expiry'.`,
974
- });
827
+ if (step['abort_unless'] === undefined) {
828
+ errors.push(withStepLine(stepName, `Step '${stepName}': execution: guard requires 'abort_unless'`));
975
829
  }
976
- // Dead notification ([F-A2-5]): reminder_seconds >= timeout_seconds means the FIRST
977
- // reminder occurrence would never fire before the enforce clock expires.
978
- if (typeof gate['reminder_seconds'] === 'number' &&
979
- typeof gate['timeout_seconds'] === 'number' &&
980
- gate['reminder_seconds'] >= gate['timeout_seconds']) {
981
- warnings.push({
982
- code: 'DEAD_GATE_CONFIG',
983
- severity: resolveSeverity('DEAD_GATE_CONFIG'),
984
- scope: 'step',
985
- step: stepName,
986
- message: `Step '${stepName}': 'gate.reminder_seconds' (${String(gate['reminder_seconds'])}) ` +
987
- `>= 'gate.timeout_seconds' (${String(gate['timeout_seconds'])}) the first reminder ` +
988
- `would never fire before the gate expires.`,
989
- });
830
+ // issue #369: `preconditions` gets its OWN error rather than joining `prohibited` above,
831
+ // because the generic message ("'x' is not valid on execution: guard steps") would not say
832
+ // the thing that matters — this field was ACCEPTED and INERT before this check existed, so
833
+ // an author who wrote one has a workflow that looks guarded and never was. The generic
834
+ // list's own message style is issue #366's territory; the other twelve are left alone.
835
+ //
836
+ // The claim "never evaluates it there" rests on `checkPreconditions` having exactly one
837
+ // engine call site (execution-loop.ts:1380, inside `executeStep`), which `executeGuardStep`
838
+ // never reaches. A test pins that count so a second call site reds this message.
839
+ if (step['preconditions'] !== undefined) {
840
+ errors.push(withKeyLine(stepName, 'preconditions', `Step '${stepName}': 'preconditions' is not valid on execution: guard steps — the ` +
841
+ `engine never evaluates it there (a guard's execution evaluates only 'abort_unless'), ` +
842
+ `so the run would LOOK guarded while the declared check never ran. Move the condition ` +
843
+ `into 'abort_unless'. Whether guards gain a live condition surface is an open design ` +
844
+ `question (issue #366) — if admitted later, existing workflows are unaffected.`));
990
845
  }
991
846
  }
992
- }
993
- if ('uses_service' in step && typeof step['uses_service'] === 'string') {
994
- const services = doc['services'];
995
- if (typeof services !== 'object' ||
996
- services === null ||
997
- !(step['uses_service'] in services)) {
998
- errors.push(`Step '${stepName}': uses_service '${step['uses_service']}' is not defined in 'services'`);
847
+ // abort_unless and abort_message are only valid on execution: guard steps.
848
+ if (step['abort_unless'] !== undefined && step['execution'] !== 'guard') {
849
+ errors.push(
850
+ // Consumer: execution-loop.ts:4828 the condition list a guard evaluates before the
851
+ // run is allowed to continue.
852
+ withKeyLine(stepName, 'abort_unless', `Step '${stepName}': 'abort_unless' is only valid on execution: guard steps — it is ` +
853
+ 'the condition list a guard evaluates before letting the run continue, and only ' +
854
+ 'guard steps are evaluated that way, so here it would gate nothing. Put the check ' +
855
+ 'on a guard step, or remove it.'));
999
856
  }
1000
- }
1001
- // Validate retry: backoff must be a recognised value when present.
1002
- if (step['retry'] !== undefined) {
1003
- if (typeof step['retry'] !== 'object' || step['retry'] === null) {
1004
- errors.push(`Step '${stepName}': 'retry' must be an object`);
857
+ if (step['abort_message'] !== undefined && step['execution'] !== 'guard') {
858
+ errors.push(
859
+ // Consumer: execution-loop.ts:4943 the text reported when a guard aborts the run.
860
+ // The clause is about READERSHIP, not about who aborts: `handler_abort` and
861
+ // `gate_expiry_abort` are seal arms too (types/run-record.ts:603-617), so "only a guard
862
+ // aborts" would be false. What is true is that every reader of this key is a guard path.
863
+ withKeyLine(stepName, 'abort_message', `Step '${stepName}': 'abort_message' is only valid on execution: guard steps — it is ` +
864
+ 'the text reported when a guard aborts the run, and nothing but a guard reads it, ' +
865
+ 'so here it would never be read. Move it to the guard that performs the abort, or ' +
866
+ 'remove it.'));
867
+ }
868
+ // agent_profile is only valid on agent steps.
869
+ if ('agent_profile' in step && step['execution'] !== 'agent') {
870
+ errors.push(
871
+ // Consumer: run-agent.ts:584 — resolved into the model prompt for the step.
872
+ withKeyLine(stepName, 'agent_profile', `Step '${stepName}': 'agent_profile' is only valid on execution: agent steps — its ` +
873
+ 'content is resolved into the model prompt, and only an agent step makes a model ' +
874
+ 'request, so here it would reach no model. Move it to the agent step whose prompt ' +
875
+ 'it should shape, or remove it.'));
876
+ }
877
+ // llm_timeout_seconds (issue #401) is only valid on agent steps — no other execution kind
878
+ // makes a model request, so the key would be silently inert anywhere else. One `!== 'agent'`
879
+ // check covers auto/guard/finalizer.
880
+ if (step['llm_timeout_seconds'] !== undefined && step['execution'] !== 'agent') {
881
+ errors.push(
882
+ // Consumer: run-agent.ts:501-507 — the per-step clock resolution, which is the
883
+ // per-attempt bound on the step's model request. The range names the resolution rather
884
+ // than each read: :501 and :507 read the KEY, :503 reads the CLI flag it overrides.
885
+ withKeyLine(stepName, 'llm_timeout_seconds', `Step '${stepName}': 'llm_timeout_seconds' is only valid on execution: agent steps — ` +
886
+ 'it bounds one model request, and no other kind makes one, so here it would bound ' +
887
+ 'nothing. Move it to the agent step whose request it should bound, or remove it. ' +
888
+ "An auto step's dispatch is bounded by 'timeout_seconds', and a " +
889
+ "finalizer's handler by its own 'timeout_seconds'."));
890
+ }
891
+ // ...and when present it must be a positive integer (the same convention as
892
+ // retry.total_timeout_seconds and gate.timeout_seconds).
893
+ if (step['llm_timeout_seconds'] !== undefined &&
894
+ (!Number.isInteger(step['llm_timeout_seconds']) ||
895
+ step['llm_timeout_seconds'] <= 0)) {
896
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'llm_timeout_seconds' must be a positive integer`));
897
+ }
898
+ // timeout_seconds is NOT valid on an agent step (issue #402). Nothing enforces it there:
899
+ // `shouldEnforceTimeout` is `execution === 'auto'`, and agent dispatch is never wrapped in
900
+ // `withTimeout` at all. The key is now inert as well as unenforced — issue #412 deleted the
901
+ // `expected_timeout` display that used to render it into the NextAction, which is what made
902
+ // it actively misleading rather than merely useless. The error stays: an author who writes a
903
+ // bound should be told it does nothing, not left to find out. The message names both bounds
904
+ // that DO exist, scoped to realm's own drive (an externally driven step gets neither), on
905
+ // the RETRY_INERT_NON_AUTO precedent below.
906
+ //
907
+ // `=== 'agent'` EXACTLY, never `!== 'auto'`: finalizers consume this key twice — the drain
908
+ // lease (execution-loop.ts:5226) and the handler's own bound (:5030) — and guards already
909
+ // reject it in the prohibited-fields list above.
910
+ if (step['timeout_seconds'] !== undefined && step['execution'] === 'agent') {
911
+ errors.push(withKeyLine(stepName, 'timeout_seconds', `Step '${stepName}': 'timeout_seconds' is not valid on execution: agent steps — ` +
912
+ 'the engine never enforces it there (agent dispatch is never wrapped in a timeout), ' +
913
+ 'so the step would LOOK time-bounded while nothing enforced the bound. ' +
914
+ "In realm's own drive the model request is bounded by 'llm_timeout_seconds' " +
915
+ "(or --llm-timeout) and tool calls by 'tool_timeout'."));
1005
916
  }
1006
- else {
1007
- const retry = step['retry'];
1008
- // WARN (do not reject) on an unknown retry-block key same non-breaking posture as the
1009
- // step/workflow-level checks (issue #140). Noun overridden to 'retry' (not 'step') since
1010
- // this is a nested block, not the step itself.
1011
- warnings.push(...findUnknownKeys(retry, KNOWN_RETRY_KEYS, {
917
+ // idempotent (issue #101 Phase 2) is only valid on execution: auto steps — the reliably
918
+ // time-boundable, deadline-carrying class. It is inert (no concrete deadline is ever written)
919
+ // on agent/guard/finalizer, so it is rejected there rather than silently ignored.
920
+ if (step['idempotent'] !== undefined && step['execution'] !== 'auto') {
921
+ errors.push(
922
+ // Consumers: execution-loop.ts:2526 (the `willRetry` conjunct gating `retry.on_timeout`;
923
+ // the :2115 advisory mirrors the rule for loader-bypassing definitions and, by its own
924
+ // header, never gates) and reclaim.ts:73 (reclaim eligibility) — both act on auto
925
+ // dispatch.
926
+ withKeyLine(stepName, 'idempotent', `Step '${stepName}': 'idempotent' is only valid on execution: auto steps — it gates ` +
927
+ "'retry.on_timeout' and reclaim eligibility, and both act on auto dispatch, so here " +
928
+ 'it would gate nothing. Remove it, or move the work to an auto step if you need ' +
929
+ 'either.'));
930
+ }
931
+ // WARN (do not reject): an idempotent auto step in a finalizer-bearing workflow gets
932
+ // `deadline: null` (issue #101), so the RECLAIM function is inert — `realm run reclaim --all`
933
+ // can never select it. The author should know it stays per-step-manual-reclaim-only.
934
+ //
935
+ // Issue #140 C5 (variant-aware reword): `idempotent` now has a SECOND function — gating
936
+ // `retry.on_timeout` — and that GATE function is live in every workflow, finalizer-bearing or
937
+ // not (shouldEnforceTimeout has no finalizer conjunct). A single unconditional message would
938
+ // either keep a falsehood (claiming idempotent is wholly inert when on_timeout is ALSO
939
+ // declared) or gratuitously mention a gate the author never declared (idempotent-alone case)
940
+ // — so the message is keyed on `step.retry?.on_timeout`, pinned by both-variant loader tests.
941
+ if (step['idempotent'] === true && step['execution'] === 'auto' && hasFinalizerStep) {
942
+ const stepRetry = typeof step['retry'] === 'object' && step['retry'] !== null
943
+ ? step['retry']
944
+ : undefined;
945
+ const onTimeoutDeclared = stepRetry?.['on_timeout'] === true;
946
+ warnings.push({
947
+ code: 'IDEMPOTENT_INERT_IN_FINALIZER',
948
+ severity: resolveSeverity('IDEMPOTENT_INERT_IN_FINALIZER'),
1012
949
  scope: 'step',
1013
- code: 'UNKNOWN_RETRY_KEY',
1014
950
  step: stepName,
1015
- noun: 'retry',
1016
- }));
1017
- if ('backoff' in retry &&
1018
- retry['backoff'] !== 'fixed' &&
1019
- retry['backoff'] !== 'linear' &&
1020
- retry['backoff'] !== 'exponential') {
1021
- errors.push(`Step '${stepName}': 'retry.backoff' must be 'fixed', 'linear', or 'exponential'`);
1022
- }
1023
- if ('max_attempts' in retry &&
1024
- (!Number.isInteger(retry['max_attempts']) || retry['max_attempts'] < 1)) {
1025
- errors.push(`Step '${stepName}': 'retry.max_attempts' must be a positive integer`);
1026
- }
1027
- if ('base_delay_ms' in retry &&
1028
- (typeof retry['base_delay_ms'] !== 'number' || retry['base_delay_ms'] < 0)) {
1029
- errors.push(`Step '${stepName}': 'retry.base_delay_ms' must be a non-negative number`);
951
+ message: `Step '${stepName}': 'idempotent: true' cannot enable auto-reclaim in a finalizer-bearing ` +
952
+ `workflow (its claim carries no deadline, so 'realm run reclaim --all' can never select ` +
953
+ `it). Recover it with 'realm run reclaim <run-id> --step ${stepName} --force'.` +
954
+ (onTimeoutDeclared
955
+ ? ` Its 'retry.on_timeout' gate role is unaffected — timeout retries remain active.`
956
+ : ''),
957
+ });
958
+ }
959
+ // output_schema is only valid on execution: agent steps.
960
+ if (step['output_schema'] !== undefined && step['execution'] !== 'agent') {
961
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'output_schema' is only valid on execution: agent steps`));
962
+ }
963
+ // issue #236 (L0 prevention layer): structured_output is only valid on execution: agent
964
+ // steps (mirrors output_schema's rule above), and its only legal value is the literal
965
+ // 'strict'. On an opted-in step, Phase A REJECTS an ineligible verdict at load time — the
966
+ // API provably rejects some legal schemas and silently weakens others, so authoring never
967
+ // ships a schema the gate already knows is unsafe. Caveats are NOT rejected (informational
968
+ // only, surfaced by validate's nudge — Deliverable 7); this loader block only ever REJECTS.
969
+ if (step['structured_output'] !== undefined) {
970
+ if (step['execution'] !== 'agent') {
971
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'structured_output' is only valid on execution: agent steps`));
1030
972
  }
1031
- if ('max_delay_ms' in retry &&
1032
- (typeof retry['max_delay_ms'] !== 'number' || retry['max_delay_ms'] < 0)) {
1033
- errors.push(`Step '${stepName}': 'retry.max_delay_ms' must be a non-negative number`);
973
+ else if (step['structured_output'] !== 'strict') {
974
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'structured_output' must be the literal string 'strict' (got ${JSON.stringify(step['structured_output'])})`));
1034
975
  }
1035
- // --- issue #140: on_timeout / total_timeout_seconds --------------------------------
1036
- // E3: on_timeout must be a boolean (kills the 'on_timeout: "true"' silent-inert case).
1037
- if ('on_timeout' in retry && typeof retry['on_timeout'] !== 'boolean') {
1038
- errors.push(`Step '${stepName}': 'retry.on_timeout' must be a boolean`);
976
+ else {
977
+ const verdict = assessStructuredOutputEligibility({
978
+ ...(step['output_schema'] !== undefined
979
+ ? { output_schema: step['output_schema'] }
980
+ : {}),
981
+ ...(step['input_schema'] !== undefined
982
+ ? { input_schema: step['input_schema'] }
983
+ : {}),
984
+ ...(step['tools'] !== undefined ? { tools: step['tools'] } : {}),
985
+ });
986
+ if (verdict.verdict === 'ineligible') {
987
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'structured_output: strict' is not eligible for this step's ` +
988
+ `schema — ${renderIneligibleMessage(verdict.reasons)}`));
989
+ }
1039
990
  }
1040
- // E2: total_timeout_seconds must be a positive integer — same convention as
1041
- // timeout_seconds (0 is rejected here at load; a hand-built definition bypassing the
1042
- // loader may still set 0 and have the engine's resolveCapMs honor it as a present cap).
1043
- if ('total_timeout_seconds' in retry &&
1044
- (!Number.isInteger(retry['total_timeout_seconds']) ||
1045
- retry['total_timeout_seconds'] <= 0)) {
1046
- errors.push(`Step '${stepName}': 'retry.total_timeout_seconds' must be a positive integer`);
991
+ }
992
+ // issue #220 (PR-2): validation_exhaustion is only valid on execution: agent steps the
993
+ // countable rejection set (VALIDATION_INPUT_SCHEMA/VALIDATION_OUTPUT_SCHEMA) is agent-only by
994
+ // construction (execution-loop.ts's countRejection). Full rule table: `mode` value validated
995
+ // (REFUSE on an unrecognized value — unvalidatable posture, fail-closed); `mode: 'default'`
996
+ // requires `default_output` (REFUSE — nothing to substitute) which in turn requires the step's
997
+ // own `output_schema` (REFUSE B5, an unvalidatable default) against which `default_output` is
998
+ // then AJV-proven AT LOAD TIME (REFUSE — B10, reusing the runtime validator so load-time and
999
+ // runtime verdicts can never diverge); `default_output` present without `mode: 'default'` WARNS
1000
+ // as dead config (never rejects — it's simply inert); an unknown sub-key WARNS.
1001
+ if (step['validation_exhaustion'] !== undefined) {
1002
+ if (step['execution'] !== 'agent') {
1003
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion' is only valid on execution: agent steps`));
1047
1004
  }
1048
- // E1: on_timeout: true requires idempotent: true — declared, never inferred. Strict
1049
- // `=== true` on both loci, provably matching the engine's own conjunct.
1050
- if (retry['on_timeout'] === true && step['idempotent'] !== true) {
1051
- errors.push(`Step '${stepName}': 'retry.on_timeout: true' requires 'idempotent: true' declared ` +
1052
- `on the step — a timeout-retry can run concurrently with the still-in-flight ` +
1053
- `original attempt, so the step must explicitly attest that any partial prior ` +
1054
- `application is harmless to re-apply. Declare 'idempotent: true' or remove ` +
1055
- `'on_timeout'.`);
1005
+ else if (typeof step['validation_exhaustion'] !== 'object' ||
1006
+ step['validation_exhaustion'] === null) {
1007
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion' must be an object`));
1056
1008
  }
1057
- // W5 (CAP-ONLY advisory — the on_timeout half of this is already an E1 hard error, so
1058
- // it never reaches here as a warning): the total-time cap only bounds `execution: 'auto'`
1059
- // dispatch inert on any other step type that legally declares `retry:` today.
1060
- if (step['execution'] !== 'auto' && retry['total_timeout_seconds'] !== undefined) {
1061
- warnings.push({
1062
- code: 'TOTAL_TIMEOUT_NON_AUTO',
1063
- severity: resolveSeverity('TOTAL_TIMEOUT_NON_AUTO'),
1009
+ else {
1010
+ const exhaustionBlock = step['validation_exhaustion'];
1011
+ // WARN (do not reject) on an unknown validation_exhaustion sub-key the retry-block-style
1012
+ // pattern (issue #140's UNKNOWN_RETRY_KEY), its OWN code (issue #220 PR-2 mints
1013
+ // UNKNOWN_VALIDATION_EXHAUSTION_KEY, replacing PR-1's UNKNOWN_STEP_KEY noun-override —
1014
+ // closes the #170-flip incoherence against the structurally identical retry-key family).
1015
+ warnings.push(...findUnknownKeys(exhaustionBlock, KNOWN_VALIDATION_EXHAUSTION_KEYS, {
1064
1016
  scope: 'step',
1017
+ code: 'UNKNOWN_VALIDATION_EXHAUSTION_KEY',
1065
1018
  step: stepName,
1066
- message: `Step '${stepName}': 'retry.total_timeout_seconds' is inert on execution: ` +
1067
- `'${String(step['execution'])}' steps — the cap only bounds 'execution: auto' ` +
1068
- `dispatch, which is the only dispatch ever wrapped in a timeout.`,
1069
- });
1019
+ noun: 'validation_exhaustion',
1020
+ positionOf: (key) => sourceMap.posOf(['steps', stepName, 'validation_exhaustion', key]),
1021
+ }));
1022
+ if ('threshold' in exhaustionBlock &&
1023
+ (!Number.isInteger(exhaustionBlock['threshold']) ||
1024
+ exhaustionBlock['threshold'] < 1)) {
1025
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.threshold' must be a positive integer ` +
1026
+ `(1 is legal — it disables in-drive schema-repair, since the first rejection ` +
1027
+ `already meets it)`));
1028
+ }
1029
+ const modeValue = exhaustionBlock['mode'];
1030
+ if (modeValue !== undefined && modeValue !== 'fail' && modeValue !== 'default') {
1031
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.mode' must be 'fail' or 'default' ` +
1032
+ `(got: ${JSON.stringify(modeValue)})`));
1033
+ }
1034
+ const hasDefaultOutput = 'default_output' in exhaustionBlock;
1035
+ if (modeValue === 'default') {
1036
+ if (!hasDefaultOutput) {
1037
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.mode: default' requires ` +
1038
+ `'default_output' (nothing to substitute on exhaustion)`));
1039
+ }
1040
+ else if (step['output_schema'] === undefined) {
1041
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.default_output' requires the step to ` +
1042
+ `declare 'output_schema' (an undeclared schema makes the default unvalidatable)`));
1043
+ }
1044
+ else {
1045
+ // B10 — load-time AJV proof: REUSE the runtime validator so the load-time verdict can
1046
+ // never diverge from the runtime verdict for the exact same (default_output,
1047
+ // output_schema) pair. This is the loader's first load-time Ajv compile of an
1048
+ // output_schema (today output_schema is only compiled at runtime), so the catch below
1049
+ // legitimately sees TWO different populations: a VALIDATION_OUTPUT_SCHEMA
1050
+ // WorkflowError (default_output fails the schema) and a raw Ajv schema-compilation
1051
+ // Error (a structurally malformed output_schema) — both fail-closed to a load refusal,
1052
+ // but they carry their detail DIFFERENTLY: a WorkflowError has `.details.errors`; a raw
1053
+ // Error has NO `.details` at all (reading `.details.errors` on it throws a TypeError
1054
+ // that would escape the loader mid-walk — verified empirically). Discriminate.
1055
+ try {
1056
+ validateOutputSchema(exhaustionBlock['default_output'], step['output_schema'], stepName);
1057
+ }
1058
+ catch (err) {
1059
+ const detail = err instanceof WorkflowError
1060
+ ? JSON.stringify(err.details['errors'])
1061
+ : err instanceof Error
1062
+ ? err.message
1063
+ : String(err);
1064
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.default_output' does not validate ` +
1065
+ `against the step's own 'output_schema': ${detail}`));
1066
+ }
1067
+ }
1068
+ }
1069
+ else if (hasDefaultOutput) {
1070
+ // default_output present without mode: 'default' (mode: 'fail' or absent) — inert, not
1071
+ // an error: WARN as dead config rather than silently ignoring it.
1072
+ warnings.push({
1073
+ code: 'DEAD_VALIDATION_EXHAUSTION_CONFIG',
1074
+ severity: resolveSeverity('DEAD_VALIDATION_EXHAUSTION_CONFIG'),
1075
+ scope: 'step',
1076
+ step: stepName,
1077
+ message: `Step '${stepName}': 'validation_exhaustion.default_output' is ignored without ` +
1078
+ `'mode: default' — set 'validation_exhaustion.mode: default' to enable it, or ` +
1079
+ `remove 'default_output'.`,
1080
+ });
1081
+ }
1082
+ }
1083
+ }
1084
+ // WARN (do not reject): an agent step declaring BOTH input_schema and output_schema has its
1085
+ // submitted output validated against BOTH (execution-loop.ts validateInputSchema AND
1086
+ // validateOutputSchema) — a divergence between the two degrades to a confusing recoverable
1087
+ // VALIDATION_*_SCHEMA error rather than a clean failure. Detection/warn only; no schema change.
1088
+ if (step['execution'] === 'agent' &&
1089
+ step['input_schema'] !== undefined &&
1090
+ step['output_schema'] !== undefined) {
1091
+ warnings.push({
1092
+ code: 'DUAL_SCHEMA_DECLARED',
1093
+ severity: resolveSeverity('DUAL_SCHEMA_DECLARED'),
1094
+ scope: 'step',
1095
+ step: stepName,
1096
+ message: `Step '${stepName}': declares both input_schema and output_schema; the agent's submitted ` +
1097
+ `output is validated against both — prefer one to avoid divergence.`,
1098
+ });
1099
+ }
1100
+ // trace_schema is only valid on execution: agent steps.
1101
+ if (step['trace_schema'] !== undefined && step['execution'] !== 'agent') {
1102
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'trace_schema' is only valid on execution: agent steps`));
1103
+ }
1104
+ // trace_validation_mode is only valid on execution: agent steps.
1105
+ if (step['trace_validation_mode'] !== undefined && step['execution'] !== 'agent') {
1106
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'trace_validation_mode' is only valid on execution: agent steps`));
1107
+ }
1108
+ // trace_validation_mode must be 'warn' or 'enforce' when provided.
1109
+ if (step['trace_validation_mode'] !== undefined &&
1110
+ step['trace_validation_mode'] !== 'warn' &&
1111
+ step['trace_validation_mode'] !== 'enforce') {
1112
+ errors.push(withStepLine(stepName, `Step '${stepName}': invalid trace_validation_mode '${String(step['trace_validation_mode'])}'; must be 'warn' or 'enforce'`));
1113
+ }
1114
+ // issue #291 (authorable gate timeout — the FIRST validation the `gate:` block has ever had):
1115
+ // the E2 positive-integer checks on timeout_seconds/reminder_seconds/reminder_max, the
1116
+ // on_expiry enum, default_choice's required-iff + choice-set validation, and the dead-config
1117
+ // warn cells. Runs regardless of `trust` (a `gate:` block with no gate trust is already inert
1118
+ // — no separate rejection needed; the existing render/mint paths never read it without a
1119
+ // trust value).
1120
+ if (step['gate'] !== undefined) {
1121
+ if (typeof step['gate'] !== 'object' || step['gate'] === null) {
1122
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'gate' must be an object`));
1070
1123
  }
1071
- // issue #218 (extends the W5 family): the BARE-KEYS advisory — no explicit
1072
- // total_timeout_seconds (that shape is W5's, above), but retry: is present on a step the
1073
- // built-in dispatch path never wraps in a throwing retry loop at all. Complementary to
1074
- // W5's own `!== undefined` conjunct on the SAME `execution !== 'auto'` gate, so for any
1075
- // non-auto retry block that reaches this point (finalizer+retry and invalid-cap shapes
1076
- // already hard-errored above; on_timeout: true already hard-errored via E1 unless
1077
- // idempotent is also declared, which is itself rejected by the pre-existing
1078
- // idempotent-non-auto check) exactly ONE of {W5, RETRY_INERT_NON_AUTO} ever fires — never
1079
- // both, never neither.
1080
- if (step['execution'] !== 'auto' && retry['total_timeout_seconds'] === undefined) {
1081
- const isAgent = step['execution'] === 'agent';
1082
- const message = isAgent
1083
- ? `Step '${stepName}': 'retry' is inert on execution: 'agent' steps — the built-in ` +
1084
- `dispatch path never throws for agent steps, so this block can never mint a second ` +
1085
- `attempt here (for schema-repair budgets, use the CLI drive's '--schema-retries' ` +
1086
- `flag instead). An embedder-supplied throwing dispatcher may still consume this ` +
1087
- `config — a deliberate public-API capability, not an invalid one.`
1088
- : `Step '${stepName}': 'retry' is inert on execution: '${String(step['execution'])}' ` +
1089
- `steps — the built-in dispatch path never throws for these steps, so this block can ` +
1090
- `never mint a second attempt here. An embedder-supplied throwing dispatcher may ` +
1091
- `still consume this config — a deliberate public-API capability, not an invalid one.`;
1092
- warnings.push({
1093
- code: 'RETRY_INERT_NON_AUTO',
1094
- severity: resolveSeverity('RETRY_INERT_NON_AUTO'),
1124
+ else {
1125
+ const gate = step['gate'];
1126
+ // WARN (do not reject) on an unknown gate-block key same non-breaking posture as
1127
+ // retry/validation_exhaustion (issues #140/#220).
1128
+ warnings.push(...findUnknownKeys(gate, KNOWN_GATE_KEYS, {
1095
1129
  scope: 'step',
1130
+ code: 'UNKNOWN_GATE_KEY',
1096
1131
  step: stepName,
1097
- message,
1098
- });
1132
+ noun: 'gate',
1133
+ positionOf: (key) => sourceMap.posOf(['steps', stepName, 'gate', key]),
1134
+ }));
1135
+ // E2: timeout_seconds/reminder_seconds/reminder_max must each be a positive integer
1136
+ // (yaml-loader :1164-1174 precedent — the SAME convention as retry.total_timeout_seconds).
1137
+ if ('timeout_seconds' in gate &&
1138
+ (!Number.isInteger(gate['timeout_seconds']) || gate['timeout_seconds'] <= 0)) {
1139
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.timeout_seconds' must be a positive integer`));
1140
+ }
1141
+ if ('reminder_seconds' in gate &&
1142
+ (!Number.isInteger(gate['reminder_seconds']) ||
1143
+ gate['reminder_seconds'] <= 0)) {
1144
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.reminder_seconds' must be a positive integer`));
1145
+ }
1146
+ if ('reminder_max' in gate &&
1147
+ (!Number.isInteger(gate['reminder_max']) || gate['reminder_max'] <= 0)) {
1148
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.reminder_max' must be a positive integer`));
1149
+ }
1150
+ // on_expiry must be 'settle_default' or 'abort' when provided.
1151
+ const onExpiry = gate['on_expiry'];
1152
+ if (onExpiry !== undefined && onExpiry !== 'settle_default' && onExpiry !== 'abort') {
1153
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.on_expiry' must be 'settle_default' or 'abort' (got: ${JSON.stringify(onExpiry)})`));
1154
+ }
1155
+ // default_choice: REQUIRED iff on_expiry === 'settle_default' (E2-style hard error,
1156
+ // mirroring validation_exhaustion.mode:'default' requiring default_output); validated
1157
+ // against the step's own EFFECTIVE STATIC choice set — the EXACT same three-source
1158
+ // derivation the engine mints PendingGate.choices from (execution-loop.ts's gate-open
1159
+ // site: gate.choices ?? input_schema.properties.choice.enum ?? ['approve','reject']) —
1160
+ // so a load-time-legal default_choice can NEVER fail at enactment time.
1161
+ const hasDefaultChoice = 'default_choice' in gate;
1162
+ if (onExpiry === 'settle_default') {
1163
+ if (!hasDefaultChoice) {
1164
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.on_expiry: settle_default' requires 'gate.default_choice' ` +
1165
+ `(nothing to resolve the gate with on expiry)`));
1166
+ }
1167
+ else {
1168
+ const choicesRaw = gate['choices'] ??
1169
+ step['input_schema']?.properties?.['choice']?.enum;
1170
+ const effectiveChoices = Array.isArray(choicesRaw)
1171
+ ? choicesRaw
1172
+ : ['approve', 'reject'];
1173
+ if (!effectiveChoices.includes(gate['default_choice'])) {
1174
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.default_choice' (${JSON.stringify(gate['default_choice'])}) ` +
1175
+ `is not one of the step's effective choices: ${effectiveChoices.join(', ')}`));
1176
+ }
1177
+ }
1178
+ }
1179
+ else if (hasDefaultChoice) {
1180
+ // default_choice with on_expiry:'abort' or with no on_expiry at all — inert, not an
1181
+ // error: WARN as dead config (the #220 DEAD_VALIDATION_EXHAUSTION_CONFIG precedent).
1182
+ warnings.push({
1183
+ code: 'DEAD_GATE_CONFIG',
1184
+ severity: resolveSeverity('DEAD_GATE_CONFIG'),
1185
+ scope: 'step',
1186
+ step: stepName,
1187
+ message: `Step '${stepName}': 'gate.default_choice' is ignored without ` +
1188
+ `'gate.on_expiry: settle_default' — set it, or remove 'gate.default_choice'.`,
1189
+ });
1190
+ }
1191
+ // Dead config: on_expiry declared but no timeout_seconds — nothing will ever trigger the
1192
+ // enforce clock, so the declared disposition can never enact.
1193
+ if (onExpiry !== undefined && gate['timeout_seconds'] === undefined) {
1194
+ warnings.push({
1195
+ code: 'DEAD_GATE_CONFIG',
1196
+ severity: resolveSeverity('DEAD_GATE_CONFIG'),
1197
+ scope: 'step',
1198
+ step: stepName,
1199
+ message: `Step '${stepName}': 'gate.on_expiry' is ignored without 'gate.timeout_seconds' — ` +
1200
+ `set a timeout, or remove 'gate.on_expiry'.`,
1201
+ });
1202
+ }
1203
+ // Dead notification ([F-A2-5]): reminder_seconds >= timeout_seconds means the FIRST
1204
+ // reminder occurrence would never fire before the enforce clock expires.
1205
+ if (typeof gate['reminder_seconds'] === 'number' &&
1206
+ typeof gate['timeout_seconds'] === 'number' &&
1207
+ gate['reminder_seconds'] >= gate['timeout_seconds']) {
1208
+ warnings.push({
1209
+ code: 'DEAD_GATE_CONFIG',
1210
+ severity: resolveSeverity('DEAD_GATE_CONFIG'),
1211
+ scope: 'step',
1212
+ step: stepName,
1213
+ message: `Step '${stepName}': 'gate.reminder_seconds' (${String(gate['reminder_seconds'])}) ` +
1214
+ `>= 'gate.timeout_seconds' (${String(gate['timeout_seconds'])}) — the first reminder ` +
1215
+ `would never fire before the gate expires.`,
1216
+ });
1217
+ }
1099
1218
  }
1100
- // W1: on_timeout with an effective max_attempts of 1 (explicit OR absent, since the
1101
- // loader admits an absent max_attempts and the engine then defaults it to 1) — there is
1102
- // no second attempt for the opt-in to retry into.
1103
- const effectiveMaxAttempts = typeof retry['max_attempts'] === 'number' ? retry['max_attempts'] : 1;
1104
- if (retry['on_timeout'] === true && effectiveMaxAttempts === 1) {
1105
- warnings.push({
1106
- code: 'ON_TIMEOUT_SINGLE_ATTEMPT',
1107
- severity: resolveSeverity('ON_TIMEOUT_SINGLE_ATTEMPT'),
1219
+ }
1220
+ if ('uses_service' in step && typeof step['uses_service'] === 'string') {
1221
+ const services = doc['services'];
1222
+ if (typeof services !== 'object' ||
1223
+ services === null ||
1224
+ !(step['uses_service'] in services)) {
1225
+ errors.push(withStepLine(stepName, `Step '${stepName}': uses_service '${step['uses_service']}' is not defined in 'services'`));
1226
+ }
1227
+ }
1228
+ // Validate retry: backoff must be a recognised value when present.
1229
+ if (step['retry'] !== undefined) {
1230
+ if (typeof step['retry'] !== 'object' || step['retry'] === null) {
1231
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'retry' must be an object`));
1232
+ }
1233
+ else {
1234
+ const retry = step['retry'];
1235
+ // WARN (do not reject) on an unknown retry-block key — same non-breaking posture as the
1236
+ // step/workflow-level checks (issue #140). Noun overridden to 'retry' (not 'step') since
1237
+ // this is a nested block, not the step itself.
1238
+ warnings.push(...findUnknownKeys(retry, KNOWN_RETRY_KEYS, {
1108
1239
  scope: 'step',
1240
+ code: 'UNKNOWN_RETRY_KEY',
1109
1241
  step: stepName,
1110
- message: `Step '${stepName}': 'retry.on_timeout: true' has no effect with an effective ` +
1111
- `'max_attempts' of 1 there is no second attempt to retry into.`,
1112
- });
1113
- }
1114
- // W2: the cap can never cover even a single full-length attempt — (a) an EXPLICIT cap
1115
- // below an EXPLICIT timeout_seconds, or (b) on_timeout: true with a cap at-or-below the
1116
- // effective per-attempt timeout (retry-defeating: the opt-in can never yield a viable
1117
- // second attempt). Both conditions require an EXPLICIT total_timeout_seconds — the
1118
- // AMENDED default cap (the worst-case schedule) is, by construction, never below a
1119
- // single attempt for max_attempts 2, so this never fires on the bare 3600s-default
1120
- // population.
1121
- const explicitCapSeconds = typeof retry['total_timeout_seconds'] === 'number'
1122
- ? retry['total_timeout_seconds']
1123
- : undefined;
1124
- if (explicitCapSeconds !== undefined) {
1125
- const explicitTimeoutSeconds = typeof step['timeout_seconds'] === 'number' ? step['timeout_seconds'] : undefined;
1126
- const effectivePerAttemptSeconds = explicitTimeoutSeconds ?? DEFAULT_EXECUTION_TIMEOUT_SECONDS;
1127
- const belowExplicitAttempt = explicitTimeoutSeconds !== undefined && explicitCapSeconds < explicitTimeoutSeconds;
1128
- const capTooTightForRetry = retry['on_timeout'] === true && explicitCapSeconds <= effectivePerAttemptSeconds;
1129
- if (belowExplicitAttempt || capTooTightForRetry) {
1242
+ noun: 'retry',
1243
+ positionOf: (key) => sourceMap.posOf(['steps', stepName, 'retry', key]),
1244
+ }));
1245
+ if ('backoff' in retry &&
1246
+ retry['backoff'] !== 'fixed' &&
1247
+ retry['backoff'] !== 'linear' &&
1248
+ retry['backoff'] !== 'exponential') {
1249
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.backoff' must be 'fixed', 'linear', or 'exponential'`));
1250
+ }
1251
+ if ('max_attempts' in retry &&
1252
+ (!Number.isInteger(retry['max_attempts']) || retry['max_attempts'] < 1)) {
1253
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.max_attempts' must be a positive integer`));
1254
+ }
1255
+ if ('base_delay_ms' in retry &&
1256
+ (typeof retry['base_delay_ms'] !== 'number' || retry['base_delay_ms'] < 0)) {
1257
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.base_delay_ms' must be a non-negative number`));
1258
+ }
1259
+ if ('max_delay_ms' in retry &&
1260
+ (typeof retry['max_delay_ms'] !== 'number' || retry['max_delay_ms'] < 0)) {
1261
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.max_delay_ms' must be a non-negative number`));
1262
+ }
1263
+ // --- issue #140: on_timeout / total_timeout_seconds --------------------------------
1264
+ // E3: on_timeout must be a boolean (kills the 'on_timeout: "true"' silent-inert case).
1265
+ if ('on_timeout' in retry && typeof retry['on_timeout'] !== 'boolean') {
1266
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.on_timeout' must be a boolean`));
1267
+ }
1268
+ // E2: total_timeout_seconds must be a positive integer — same convention as
1269
+ // timeout_seconds (0 is rejected here at load; a hand-built definition bypassing the
1270
+ // loader may still set 0 and have the engine's resolveCapMs honor it as a present cap).
1271
+ if ('total_timeout_seconds' in retry &&
1272
+ (!Number.isInteger(retry['total_timeout_seconds']) ||
1273
+ retry['total_timeout_seconds'] <= 0)) {
1274
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.total_timeout_seconds' must be a positive integer`));
1275
+ }
1276
+ // E1: on_timeout: true requires idempotent: true — declared, never inferred. Strict
1277
+ // `=== true` on both loci, provably matching the engine's own conjunct.
1278
+ if (retry['on_timeout'] === true && step['idempotent'] !== true) {
1279
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.on_timeout: true' requires 'idempotent: true' declared ` +
1280
+ `on the step — a timeout-retry can run concurrently with the still-in-flight ` +
1281
+ `original attempt, so the step must explicitly attest that any partial prior ` +
1282
+ `application is harmless to re-apply. Declare 'idempotent: true' or remove ` +
1283
+ `'on_timeout'.`));
1284
+ }
1285
+ // W5 (CAP-ONLY advisory — the on_timeout half of this is already an E1 hard error, so
1286
+ // it never reaches here as a warning): the total-time cap only bounds `execution: 'auto'`
1287
+ // dispatch — inert on any other step type that legally declares `retry:` today.
1288
+ if (step['execution'] !== 'auto' && retry['total_timeout_seconds'] !== undefined) {
1289
+ warnings.push({
1290
+ code: 'TOTAL_TIMEOUT_NON_AUTO',
1291
+ severity: resolveSeverity('TOTAL_TIMEOUT_NON_AUTO'),
1292
+ scope: 'step',
1293
+ step: stepName,
1294
+ message: `Step '${stepName}': 'retry.total_timeout_seconds' is inert on execution: ` +
1295
+ `'${String(step['execution'])}' steps — the cap only bounds 'execution: auto' ` +
1296
+ `dispatch, which is the only dispatch ever wrapped in a timeout.`,
1297
+ });
1298
+ }
1299
+ // issue #218 (extends the W5 family): the BARE-KEYS advisory — no explicit
1300
+ // total_timeout_seconds (that shape is W5's, above), but retry: is present on a step the
1301
+ // built-in dispatch path never wraps in a throwing retry loop at all. Complementary to
1302
+ // W5's own `!== undefined` conjunct on the SAME `execution !== 'auto'` gate, so for any
1303
+ // non-auto retry block that reaches this point (finalizer+retry and invalid-cap shapes
1304
+ // already hard-errored above; on_timeout: true already hard-errored via E1 unless
1305
+ // idempotent is also declared, which is itself rejected by the pre-existing
1306
+ // idempotent-non-auto check) exactly ONE of {W5, RETRY_INERT_NON_AUTO} ever fires — never
1307
+ // both, never neither.
1308
+ if (step['execution'] !== 'auto' && retry['total_timeout_seconds'] === undefined) {
1309
+ const isAgent = step['execution'] === 'agent';
1310
+ const message = isAgent
1311
+ ? `Step '${stepName}': 'retry' is inert on execution: 'agent' steps — the built-in ` +
1312
+ `dispatch path never throws for agent steps, so this block can never mint a second ` +
1313
+ `attempt here (for schema-repair budgets, use the CLI drive's '--schema-retries' ` +
1314
+ `flag instead). An embedder-supplied throwing dispatcher may still consume this ` +
1315
+ `config — a deliberate public-API capability, not an invalid one.`
1316
+ : `Step '${stepName}': 'retry' is inert on execution: '${String(step['execution'])}' ` +
1317
+ `steps — the built-in dispatch path never throws for these steps, so this block can ` +
1318
+ `never mint a second attempt here. An embedder-supplied throwing dispatcher may ` +
1319
+ `still consume this config — a deliberate public-API capability, not an invalid one.`;
1130
1320
  warnings.push({
1131
- code: 'TOTAL_TIMEOUT_BELOW_ATTEMPT',
1132
- severity: resolveSeverity('TOTAL_TIMEOUT_BELOW_ATTEMPT'),
1321
+ code: 'RETRY_INERT_NON_AUTO',
1322
+ severity: resolveSeverity('RETRY_INERT_NON_AUTO'),
1133
1323
  scope: 'step',
1134
1324
  step: stepName,
1135
- message: `Step '${stepName}': 'retry.total_timeout_seconds: ${explicitCapSeconds}' is at ` +
1136
- `or below its own effective per-attempt timeout (${effectivePerAttemptSeconds}s) ` +
1137
- `— the cap can never cover a single full-length attempt, so a retry can never ` +
1138
- `occur before the cap fires.`,
1325
+ message,
1139
1326
  });
1140
1327
  }
1328
+ // W1: on_timeout with an effective max_attempts of 1 (explicit OR absent, since the
1329
+ // loader admits an absent max_attempts and the engine then defaults it to 1) — there is
1330
+ // no second attempt for the opt-in to retry into.
1331
+ const effectiveMaxAttempts = typeof retry['max_attempts'] === 'number' ? retry['max_attempts'] : 1;
1332
+ if (retry['on_timeout'] === true && effectiveMaxAttempts === 1) {
1333
+ warnings.push({
1334
+ code: 'ON_TIMEOUT_SINGLE_ATTEMPT',
1335
+ severity: resolveSeverity('ON_TIMEOUT_SINGLE_ATTEMPT'),
1336
+ scope: 'step',
1337
+ step: stepName,
1338
+ message: `Step '${stepName}': 'retry.on_timeout: true' has no effect with an effective ` +
1339
+ `'max_attempts' of 1 — there is no second attempt to retry into.`,
1340
+ });
1341
+ }
1342
+ // W2: the cap can never cover even a single full-length attempt — (a) an EXPLICIT cap
1343
+ // below an EXPLICIT timeout_seconds, or (b) on_timeout: true with a cap at-or-below the
1344
+ // effective per-attempt timeout (retry-defeating: the opt-in can never yield a viable
1345
+ // second attempt). Both conditions require an EXPLICIT total_timeout_seconds — the
1346
+ // AMENDED default cap (the worst-case schedule) is, by construction, never below a
1347
+ // single attempt for max_attempts ≥ 2, so this never fires on the bare 3600s-default
1348
+ // population.
1349
+ const explicitCapSeconds = typeof retry['total_timeout_seconds'] === 'number'
1350
+ ? retry['total_timeout_seconds']
1351
+ : undefined;
1352
+ if (explicitCapSeconds !== undefined) {
1353
+ const explicitTimeoutSeconds = typeof step['timeout_seconds'] === 'number' ? step['timeout_seconds'] : undefined;
1354
+ const effectivePerAttemptSeconds = explicitTimeoutSeconds ?? DEFAULT_EXECUTION_TIMEOUT_SECONDS;
1355
+ const belowExplicitAttempt = explicitTimeoutSeconds !== undefined && explicitCapSeconds < explicitTimeoutSeconds;
1356
+ const capTooTightForRetry = retry['on_timeout'] === true && explicitCapSeconds <= effectivePerAttemptSeconds;
1357
+ if (belowExplicitAttempt || capTooTightForRetry) {
1358
+ warnings.push({
1359
+ code: 'TOTAL_TIMEOUT_BELOW_ATTEMPT',
1360
+ severity: resolveSeverity('TOTAL_TIMEOUT_BELOW_ATTEMPT'),
1361
+ scope: 'step',
1362
+ step: stepName,
1363
+ message: `Step '${stepName}': 'retry.total_timeout_seconds: ${explicitCapSeconds}' is at ` +
1364
+ `or below its own effective per-attempt timeout (${effectivePerAttemptSeconds}s) ` +
1365
+ `— the cap can never cover a single full-length attempt, so a retry can never ` +
1366
+ `occur before the cap fires.`,
1367
+ });
1368
+ }
1369
+ }
1141
1370
  }
1142
1371
  }
1143
- }
1144
- if ('service_method' in step && !VALID_SERVICE_METHODS.has(step['service_method'])) {
1145
- errors.push(`Step '${stepName}': invalid service_method '${String(step['service_method'])}'; must be 'fetch', 'create', 'update', or 'delete'`);
1146
- }
1147
- // Validate input_map: only valid on execution: auto steps (both uses_service and handler).
1148
- if (step['input_map'] !== undefined) {
1149
- if (step['execution'] !== 'auto') {
1150
- errors.push(`Step '${stepName}': 'input_map' is only valid on execution: auto steps`);
1151
- }
1152
- else {
1153
- validateInputMapNode(step['input_map'], `Step '${stepName}': input_map`, errors, 0);
1372
+ if ('service_method' in step &&
1373
+ !VALID_SERVICE_METHODS.has(step['service_method'])) {
1374
+ errors.push(withStepLine(stepName, `Step '${stepName}': invalid service_method '${String(step['service_method'])}'; must be 'fetch', 'create', 'update', or 'delete'`));
1154
1375
  }
1155
- }
1156
- // Step config may hold any JSON value (scalars, arrays, nested objects). It is passed through
1157
- // opaquely to handlers (context.config) and merged into adapter config for uses_service steps;
1158
- // the adapter's config_schema (below) remains the real validator for uses_service config.
1159
- // Validate step config against adapter config_schema (requires registry).
1160
- if (step['config'] !== undefined && step['uses_service'] !== undefined) {
1161
- const serviceName = step['uses_service'];
1162
- const services = doc['services'];
1163
- const service = services?.[serviceName];
1164
- const adapterName = service?.['adapter'];
1165
- const adapter = adapterName !== undefined ? registry?.getAdapter(adapterName) : undefined;
1166
- if (adapter !== undefined && adapter.config_schema === undefined) {
1167
- errors.push(`Step '${stepName}': 'config' declared but adapter '${adapterName}' does not declare 'config_schema'`);
1376
+ // Validate input_map: only valid on execution: auto steps (both uses_service and handler).
1377
+ if (step['input_map'] !== undefined) {
1378
+ if (step['execution'] !== 'auto') {
1379
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'input_map' is only valid on execution: auto steps`));
1380
+ }
1381
+ else {
1382
+ // issue #392: input_map's errors are minted deep inside a recursive walk that knows only
1383
+ // its path string, not the step's position. Collected here and suffixed on the way out,
1384
+ // so ONE step's error list never mixes positioned and bare messages — a reader seeing
1385
+ // "(step at line 12)" on three of five errors would reasonably wonder what is different about
1386
+ // the other two, and nothing is.
1387
+ const inputMapErrors = [];
1388
+ validateInputMapNode(step['input_map'], `Step '${stepName}': input_map`, inputMapErrors, 0);
1389
+ errors.push(...inputMapErrors.map((e) => withStepLine(stepName, e)));
1390
+ }
1168
1391
  }
1169
- else if (adapter?.config_schema !== undefined) {
1170
- const ajv = new Ajv();
1171
- const valid = ajv.validate(adapter.config_schema, step['config']);
1172
- if (!valid) {
1173
- const errMessages = ajv.errors?.map((e) => e.message ?? '').join('; ') ?? 'unknown error';
1174
- errors.push(`Step '${stepName}': config validation failed against adapter config_schema: ${errMessages}`);
1392
+ // Step config may hold any JSON value (scalars, arrays, nested objects). It is passed through
1393
+ // opaquely to handlers (context.config) and merged into adapter config for uses_service steps;
1394
+ // the adapter's config_schema (below) remains the real validator for uses_service config.
1395
+ // Validate step config against adapter config_schema (requires registry).
1396
+ if (step['config'] !== undefined && step['uses_service'] !== undefined) {
1397
+ const serviceName = step['uses_service'];
1398
+ const services = doc['services'];
1399
+ const service = services?.[serviceName];
1400
+ const adapterName = service?.['adapter'];
1401
+ const adapter = adapterName !== undefined ? registry?.getAdapter(adapterName) : undefined;
1402
+ if (adapter !== undefined && adapter.config_schema === undefined) {
1403
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'config' declared but adapter '${adapterName}' does not declare 'config_schema'`));
1404
+ }
1405
+ else if (adapter?.config_schema !== undefined) {
1406
+ const ajv = new Ajv();
1407
+ const valid = ajv.validate(adapter.config_schema, step['config']);
1408
+ if (!valid) {
1409
+ const errMessages = ajv.errors?.map((e) => e.message ?? '').join('; ') ?? 'unknown error';
1410
+ errors.push(withStepLine(stepName, `Step '${stepName}': config validation failed against adapter config_schema: ${errMessages}`));
1411
+ }
1175
1412
  }
1176
1413
  }
1177
- }
1178
- // Validate uses_resources: each listed step ID must exist in the workflow.
1179
- if (step['handler'] !== undefined && registry !== undefined) {
1180
- const handlerName = step['handler'];
1181
- const handler = registry.getHandler(handlerName);
1182
- if (handler !== undefined && handler.uses_resources !== undefined) {
1183
- for (const resourceStepId of handler.uses_resources) {
1184
- if (!(resourceStepId in stepsRaw)) {
1185
- errors.push(`Step '${stepName}': handler '${handlerName}' declares uses_resources '${resourceStepId}' ` +
1186
- `but no step with that ID exists in this workflow`);
1414
+ // Validate uses_resources: each listed step ID must exist in the workflow.
1415
+ if (step['handler'] !== undefined && registry !== undefined) {
1416
+ const handlerName = step['handler'];
1417
+ const handler = registry.getHandler(handlerName);
1418
+ if (handler !== undefined && handler.uses_resources !== undefined) {
1419
+ for (const resourceStepId of handler.uses_resources) {
1420
+ if (!(resourceStepId in stepsRaw)) {
1421
+ errors.push(withStepLine(stepName, `Step '${stepName}': handler '${handlerName}' declares uses_resources '${resourceStepId}' ` +
1422
+ `but no step with that ID exists in this workflow`));
1423
+ }
1187
1424
  }
1188
1425
  }
1189
1426
  }
1190
- }
1191
- // Validate trigger_rule.
1192
- if ('trigger_rule' in step) {
1193
- if (!VALID_TRIGGER_RULES.has(step['trigger_rule'])) {
1194
- errors.push(`Step '${stepName}': invalid trigger_rule '${String(step['trigger_rule'])}'; must be one of ${[...VALID_TRIGGER_RULES].join(', ')}`);
1427
+ // Validate trigger_rule.
1428
+ if ('trigger_rule' in step) {
1429
+ if (!VALID_TRIGGER_RULES.has(step['trigger_rule'])) {
1430
+ errors.push(withStepLine(stepName, `Step '${stepName}': invalid trigger_rule '${String(step['trigger_rule'])}'; must be one of ${[...VALID_TRIGGER_RULES].join(', ')}`));
1431
+ }
1195
1432
  }
1196
- }
1197
- // Validate depends_on: must be an array of existing step names.
1198
- if ('depends_on' in step && step['depends_on'] !== undefined) {
1199
- if (!Array.isArray(step['depends_on'])) {
1200
- errors.push(`Step '${stepName}': 'depends_on' must be an array`);
1433
+ // Validate depends_on: must be an array of existing step names.
1434
+ if ('depends_on' in step && step['depends_on'] !== undefined) {
1435
+ if (!Array.isArray(step['depends_on'])) {
1436
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'depends_on' must be an array`));
1437
+ }
1438
+ else {
1439
+ for (const dep of step['depends_on']) {
1440
+ if (typeof dep !== 'string') {
1441
+ errors.push(withStepLine(stepName, `Step '${stepName}': depends_on entries must be strings`));
1442
+ }
1443
+ else if (dep === stepName) {
1444
+ errors.push(withStepLine(stepName, `Step '${stepName}': a step cannot depend on itself`));
1445
+ }
1446
+ else if (!(dep in stepsRaw)) {
1447
+ errors.push(withStepLine(stepName, `Step '${stepName}': depends_on references unknown step '${dep}'`));
1448
+ }
1449
+ else if (stepsRaw[dep]['execution'] === 'finalizer') {
1450
+ // A domain step depending on a held-out finalizer would deadlock: the finalizer
1451
+ // never enters the eligible set, so this step never becomes eligible and the run
1452
+ // never seals.
1453
+ errors.push(withStepLine(stepName, `Step '${stepName}': depends_on references finalizer step '${dep}' — finalizers ` +
1454
+ `run at the terminal transition and are held out of the DAG; a step cannot depend on one.`));
1455
+ }
1456
+ }
1457
+ }
1201
1458
  }
1202
- else {
1203
- for (const dep of step['depends_on']) {
1204
- if (typeof dep !== 'string') {
1205
- errors.push(`Step '${stepName}': depends_on entries must be strings`);
1459
+ // Validate when: string | string[] of single-comparison/bare-path leaves (implicit AND).
1460
+ if ('when' in step && step['when'] !== undefined) {
1461
+ const rawWhen = step['when'];
1462
+ if (typeof rawWhen === 'string') {
1463
+ if (rawWhen.trim() === '') {
1464
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'when' must be a non-empty string`));
1206
1465
  }
1207
- else if (dep === stepName) {
1208
- errors.push(`Step '${stepName}': a step cannot depend on itself`);
1466
+ else {
1467
+ validateConditionLeaf('when', rawWhen, stepName, dependsOn, errors, withStepLine);
1209
1468
  }
1210
- else if (!(dep in stepsRaw)) {
1211
- errors.push(`Step '${stepName}': depends_on references unknown step '${dep}'`);
1469
+ }
1470
+ else if (Array.isArray(rawWhen)) {
1471
+ if (rawWhen.length === 0) {
1472
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'when' array must not be empty`));
1212
1473
  }
1213
- else if (stepsRaw[dep]['execution'] === 'finalizer') {
1214
- // A domain step depending on a held-out finalizer would deadlock: the finalizer
1215
- // never enters the eligible set, so this step never becomes eligible and the run
1216
- // never seals.
1217
- errors.push(`Step '${stepName}': depends_on references finalizer step '${dep}' — finalizers ` +
1218
- `run at the terminal transition and are held out of the DAG; a step cannot depend on one.`);
1474
+ else {
1475
+ for (const leaf of rawWhen) {
1476
+ if (typeof leaf !== 'string' || leaf.trim() === '') {
1477
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'when' array entries must be non-empty strings`));
1478
+ }
1479
+ else {
1480
+ validateConditionLeaf('when', leaf, stepName, dependsOn, errors, withStepLine);
1481
+ }
1482
+ }
1219
1483
  }
1220
1484
  }
1485
+ else {
1486
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'when' must be a string or an array of strings`));
1487
+ }
1221
1488
  }
1222
- }
1223
- // Validate when: string | string[] of single-comparison/bare-path leaves (implicit AND).
1224
- if ('when' in step && step['when'] !== undefined) {
1225
- const rawWhen = step['when'];
1226
- if (typeof rawWhen === 'string') {
1227
- if (rawWhen.trim() === '') {
1228
- errors.push(`Step '${stepName}': 'when' must be a non-empty string`);
1489
+ // Validate abort_unless leaf shape (guard steps only; the LEGACY depends_on/run.params
1490
+ // reference check is when-only but issue #220 §4c's `$settlement` one-hop check fires here
1491
+ // too, via the hoisted `dependsOn`, SCOPED to `$settlement.`-prefixed paths only).
1492
+ if (step['abort_unless'] !== undefined && step['execution'] === 'guard') {
1493
+ const rawAbort = step['abort_unless'];
1494
+ if (typeof rawAbort === 'string') {
1495
+ if (rawAbort.trim() === '') {
1496
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' must be a non-empty string`));
1497
+ }
1498
+ else {
1499
+ validateConditionLeaf('abort_unless', rawAbort, stepName, dependsOn, errors, withStepLine);
1500
+ }
1501
+ }
1502
+ else if (Array.isArray(rawAbort)) {
1503
+ if (rawAbort.length === 0) {
1504
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' array must not be empty`));
1505
+ }
1506
+ else {
1507
+ for (const leaf of rawAbort) {
1508
+ if (typeof leaf !== 'string' || leaf.trim() === '') {
1509
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' array entries must be non-empty strings`));
1510
+ }
1511
+ else {
1512
+ validateConditionLeaf('abort_unless', leaf, stepName, dependsOn, errors, withStepLine);
1513
+ }
1514
+ }
1515
+ }
1229
1516
  }
1230
1517
  else {
1231
- validateConditionLeaf('when', rawWhen, stepName, dependsOn, errors);
1518
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' must be a string or an array of strings`));
1232
1519
  }
1233
1520
  }
1234
- else if (Array.isArray(rawWhen)) {
1235
- if (rawWhen.length === 0) {
1236
- errors.push(`Step '${stepName}': 'when' array must not be empty`);
1521
+ // Validate preconditions leaf shape (each must be a single comparison). Reference check is
1522
+ // `$settlement`-scoped only (issue #220 §4c) — a non-`$settlement` precondition has no
1523
+ // depends_on/run.params check (unchanged from before this PR).
1524
+ if (step['preconditions'] !== undefined) {
1525
+ const rawPre = step['preconditions'];
1526
+ if (!Array.isArray(rawPre)) {
1527
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'preconditions' must be an array of strings`));
1237
1528
  }
1238
1529
  else {
1239
- for (const leaf of rawWhen) {
1530
+ for (const leaf of rawPre) {
1240
1531
  if (typeof leaf !== 'string' || leaf.trim() === '') {
1241
- errors.push(`Step '${stepName}': 'when' array entries must be non-empty strings`);
1532
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'preconditions' entries must be non-empty strings`));
1242
1533
  }
1243
1534
  else {
1244
- validateConditionLeaf('when', leaf, stepName, dependsOn, errors);
1535
+ validateConditionLeaf('preconditions', leaf, stepName, dependsOn, errors, withStepLine);
1245
1536
  }
1246
1537
  }
1247
1538
  }
1248
1539
  }
1249
- else {
1250
- errors.push(`Step '${stepName}': 'when' must be a string or an array of strings`);
1251
- }
1252
- }
1253
- // Validate abort_unless leaf shape (guard steps only; the LEGACY depends_on/run.params
1254
- // reference check is when-only but issue #220 §4c's `$settlement` one-hop check fires here
1255
- // too, via the hoisted `dependsOn`, SCOPED to `$settlement.`-prefixed paths only).
1256
- if (step['abort_unless'] !== undefined && step['execution'] === 'guard') {
1257
- const rawAbort = step['abort_unless'];
1258
- if (typeof rawAbort === 'string') {
1259
- if (rawAbort.trim() === '') {
1260
- errors.push(`Step '${stepName}': 'abort_unless' must be a non-empty string`);
1540
+ // issue #362 — REJECT A PROVABLY-DEAD FAILURE CONDITION.
1541
+ //
1542
+ // `$settlement.<dep>.failed == true` under a trigger rule that structurally excludes a failed
1543
+ // `<dep>` can never be true. The trigger gate runs BEFORE the condition gate, and both
1544
+ // `all_success` and `none_failed` carry an explicit "no dep in failed_steps" conjunct — so if
1545
+ // the rule is satisfied, `<dep>` did not fail, and the condition is false by construction.
1546
+ //
1547
+ // The author's compensation step therefore never runs. It is not silent at runtime — the run
1548
+ // record says `trigger_rule_unsatisfiable` — but it names the RULE and the blocking dep, never
1549
+ // the condition the author wrote, so the diagnosis points away from the mistake. This is an
1550
+ // authoring-time error precisely because the fix is one word and no legitimate use of the
1551
+ // shape exists.
1552
+ dead_condition: {
1553
+ const rule = step['execution'] === 'guard' ? 'all_success' : (step['trigger_rule'] ?? 'all_success');
1554
+ // A guard may not declare `trigger_rule` at all (it is a prohibited field), so a guard that
1555
+ // declares one must NOT be able to suppress this check by doing so.
1556
+ const ruleDeclared = step['execution'] !== 'guard' && step['trigger_rule'] !== undefined;
1557
+ if (step['execution'] !== 'guard' && !VALID_TRIGGER_RULES.has(rule)) {
1558
+ break dead_condition; // an invalid rule already has its own error — adding noise helps nobody
1261
1559
  }
1262
- else {
1263
- validateConditionLeaf('abort_unless', rawAbort, stepName, dependsOn, errors);
1264
- }
1265
- }
1266
- else if (Array.isArray(rawAbort)) {
1267
- if (rawAbort.length === 0) {
1268
- errors.push(`Step '${stepName}': 'abort_unless' array must not be empty`);
1269
- }
1270
- else {
1271
- for (const leaf of rawAbort) {
1272
- if (typeof leaf !== 'string' || leaf.trim() === '') {
1273
- errors.push(`Step '${stepName}': 'abort_unless' array entries must be non-empty strings`);
1560
+ if (rule !== 'all_success' && rule !== 'none_failed')
1561
+ break dead_condition;
1562
+ const asLeaves = (v) => Array.isArray(v)
1563
+ ? v.filter((x) => typeof x === 'string')
1564
+ : typeof v === 'string'
1565
+ ? [v]
1566
+ : [];
1567
+ const surfaces = [
1568
+ { surface: 'when', leaves: asLeaves(step['when']) },
1569
+ // `abort_unless` is only validated (and only meaningful) on guards; on anything else the
1570
+ // loader leaves it alone and the engine never reads it.
1571
+ {
1572
+ surface: 'abort_unless',
1573
+ leaves: step['execution'] === 'guard' ? asLeaves(step['abort_unless']) : [],
1574
+ },
1575
+ // `preconditions` is collected for EVERY step kind, but it is INERT on a guard: the sole
1576
+ // `checkPreconditions` call site is `executeStep` (execution-loop.ts:1380), and a guard
1577
+ // goes through `executeGuardStep`, which evaluates only `abort_unless`. That is why the
1578
+ // guard arm's consequence below is forked — collapsing it back into one shared string
1579
+ // would make the error claim a wedge that cannot happen.
1580
+ //
1581
+ // Post-#369 a guard declaring `preconditions` is REFUSED outright by the guard block
1582
+ // above, so this arm now only ever fires ALONGSIDE that refusal: errors accumulate rather
1583
+ // than short-circuit, and the guard block runs first, so both messages reach the author
1584
+ // with the prohibition printed above this one. The arm is kept, not deleted — it is what
1585
+ // stops the dead-condition message from claiming a wedge that a guard cannot have, and a
1586
+ // definition reaching this code by any path other than a fresh YAML load (a
1587
+ // store-registered definition, an inline object) is never re-parsed and never sees the
1588
+ // prohibition at all.
1589
+ { surface: 'preconditions', leaves: asLeaves(step['preconditions']) },
1590
+ ];
1591
+ for (const { surface, leaves } of surfaces) {
1592
+ for (const leaf of leaves) {
1593
+ const split = splitComparison(leaf);
1594
+ // Two spellings are equally dead: the explicit `== true`, and the BARE PATH, which the
1595
+ // engine coerces with `Boolean()`. `preconditions` refuses bare paths anyway.
1596
+ const lhsPath = split.kind === 'comparison' && split.op === '==' && split.rhsRaw.trim() === 'true'
1597
+ ? split.lhsPath
1598
+ : split.kind === 'path'
1599
+ ? split.path
1600
+ : undefined;
1601
+ if (lhsPath === undefined)
1602
+ continue;
1603
+ // EXACTLY three segments. `$settlement.x.failed.deep` is also dead, but for a different
1604
+ // reason, so the trigger-rule remedy would be wrong advice there.
1605
+ const segments = lhsPath.trim().split('.');
1606
+ if (segments.length !== 3 ||
1607
+ segments[0] !== '$settlement' ||
1608
+ segments[2] !== 'failed') {
1609
+ continue;
1610
+ }
1611
+ const dep = segments[1];
1612
+ // Without the dep actually being a dependency, the one-hop error fires on its own AND
1613
+ // the trigger gate returns true unconditionally for a step with no deps — so the leaf
1614
+ // is not dead-by-trigger here and the remedy would be false advice.
1615
+ if (!dependsOn.includes(dep))
1616
+ continue;
1617
+ const ruleText = ruleDeclared ? `'${rule}'` : `the default '${rule}'`;
1618
+ const guardPrecondition = step['execution'] === 'guard' && surface === 'preconditions';
1619
+ const consequence = surface === 'when'
1620
+ ? `if '${dep}' fails the step is skipped as trigger_rule_unsatisfiable before the condition is evaluated; if '${dep}' succeeds the condition evaluates to false (when_false) — either way the step never runs`
1621
+ : surface === 'preconditions'
1622
+ ? guardPrecondition
1623
+ ? // NOT the wedge: an unevaluated condition cannot block anything.
1624
+ `the run behaves identically whether this condition is present or absent`
1625
+ : `the step never settles — the run WEDGES in a blocked envelope`
1626
+ : `the guard aborts the run on every execution`;
1627
+ if (step['execution'] === 'guard') {
1628
+ // Guards cannot declare a trigger rule, so the trigger-rule remedy is wrong advice
1629
+ // here. This is a v1 SCOPE narrowing, not an architectural statement — issue #366
1630
+ // carries the design question.
1631
+ //
1632
+ // The middle clause forks with the consequence: "by the time this is evaluated" is
1633
+ // itself false for `preconditions`, which a guard never evaluates at all.
1634
+ const cause = guardPrecondition
1635
+ ? `and on an execution: guard step it is never evaluated at all: a guard evaluates ` +
1636
+ `only 'abort_unless', so this condition is inert (${consequence})`
1637
+ : `a guard runs under ${ruleText} and 'trigger_rule' is not a valid field on ` +
1638
+ `execution: guard steps, so '${dep}' has always succeeded by the time this is ` +
1639
+ `evaluated (${consequence})`;
1640
+ errors.push(withStepLine(stepName, `Step '${stepName}': '${surface}' condition "${leaf}" can never be true — ${cause}. ` +
1641
+ `Guards run only when their dependencies succeeded; for work that must happen AFTER a ` +
1642
+ `failure, use an 'execution: finalizer' step (see issue #366 for widening guards).`));
1274
1643
  }
1275
1644
  else {
1276
- validateConditionLeaf('abort_unless', leaf, stepName, dependsOn, errors);
1645
+ const remedies = ['all_done', 'one_failed'];
1646
+ if (new Set(dependsOn).size === 1)
1647
+ remedies.push('all_failed');
1648
+ const tail = new Set(dependsOn).size > 1
1649
+ ? ` ('all_failed' fires only if EVERY dependency fails; 'one_success' only if at least one other dependency succeeds.)`
1650
+ : '';
1651
+ errors.push(withStepLine(stepName, `Step '${stepName}': '${surface}' condition "${leaf}" can never be true — under ` +
1652
+ `${ruleText} trigger rule, '${dep}' can never be in failed_steps when this step is ` +
1653
+ `evaluated (${consequence}). To run this step when '${dep}' fails, set trigger_rule to ` +
1654
+ `one of: ${remedies.join(', ')}.${tail}`));
1277
1655
  }
1278
1656
  }
1279
1657
  }
1280
1658
  }
1281
- else {
1282
- errors.push(`Step '${stepName}': 'abort_unless' must be a string or an array of strings`);
1659
+ // Validate tools: only valid on execution: agent steps without handler.
1660
+ if (step['tools'] !== undefined &&
1661
+ (step['execution'] !== 'agent' || step['handler'] !== undefined)) {
1662
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'tools' is only valid on execution: agent steps without 'handler' defined`));
1283
1663
  }
1284
- }
1285
- // Validate preconditions leaf shape (each must be a single comparison). Reference check is
1286
- // `$settlement`-scoped only (issue #220 §4c) a non-`$settlement` precondition has no
1287
- // depends_on/run.params check (unchanged from before this PR).
1288
- if (step['preconditions'] !== undefined) {
1289
- const rawPre = step['preconditions'];
1290
- if (!Array.isArray(rawPre)) {
1291
- errors.push(`Step '${stepName}': 'preconditions' must be an array of strings`);
1664
+ // issue #413: `tool_timeout` requires `tools`. It bounds ONE tool call inside the agentic
1665
+ // loop (run-agent.ts), and a step with no tools never enters that loop — so the key sits
1666
+ // there bounding nothing while its author believes tool calls are capped.
1667
+ //
1668
+ // An EMPTY list counts as missing, and that is not pedantry: run-agent gates the tools path
1669
+ // on `tools.length > 0`, so `tools: []` is exactly as toolless at runtime as no key at all.
1670
+ // This one helper is also the shape check's complement further down, which is what makes
1671
+ // "exactly one error" true by construction rather than by coincidence.
1672
+ //
1673
+ // NOT extended to non-array `tools` spellings — that is #391, still open. Under the
1674
+ // `!toolsMissing` complement below, a non-array `tools` still lets the shape check fire, so
1675
+ // nothing is silently exempted here.
1676
+ const toolsMissing = step['tools'] === undefined ||
1677
+ (Array.isArray(step['tools']) && step['tools'].length === 0);
1678
+ if (step['tool_timeout'] !== undefined && toolsMissing) {
1679
+ errors.push(withKeyLine(stepName, 'tool_timeout', `Step '${stepName}': 'tool_timeout' requires 'tools' (a declared, non-empty list) — ` +
1680
+ 'without tool calls there is ' +
1681
+ 'nothing for it to bound, so the step would carry a bound with nothing to bind. ' +
1682
+ "In realm's own drive each tool call is capped at tool_timeout seconds (default " +
1683
+ '30); declare at least one tool or remove the key.'));
1292
1684
  }
1293
- else {
1294
- for (const leaf of rawPre) {
1295
- if (typeof leaf !== 'string' || leaf.trim() === '') {
1296
- errors.push(`Step '${stepName}': 'preconditions' entries must be non-empty strings`);
1297
- }
1298
- else {
1299
- validateConditionLeaf('preconditions', leaf, stepName, dependsOn, errors);
1685
+ // Validate tools: requires input_schema.
1686
+ if (step['tools'] !== undefined && step['input_schema'] === undefined) {
1687
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'tools' requires 'input_schema' to be defined — the agentic loop needs a schema for final output extraction`));
1688
+ }
1689
+ // Validate tools: entries must be in server_id:tool_name format.
1690
+ if (step['tools'] !== undefined && Array.isArray(step['tools'])) {
1691
+ for (const entry of step['tools']) {
1692
+ if (!/^[^:]+:[^:]+$/.test(entry)) {
1693
+ errors.push(withStepLine(stepName, `Step '${stepName}': tools entry '${entry}' must be in 'server_id:tool_name' format`));
1300
1694
  }
1301
1695
  }
1302
1696
  }
1303
- }
1304
- // issue #362REJECT A PROVABLY-DEAD FAILURE CONDITION.
1305
- //
1306
- // `$settlement.<dep>.failed == true` under a trigger rule that structurally excludes a failed
1307
- // `<dep>` can never be true. The trigger gate runs BEFORE the condition gate, and both
1308
- // `all_success` and `none_failed` carry an explicit "no dep in failed_steps" conjunct — so if
1309
- // the rule is satisfied, `<dep>` did not fail, and the condition is false by construction.
1310
- //
1311
- // The author's compensation step therefore never runs. It is not silent at runtime — the run
1312
- // record says `trigger_rule_unsatisfiable` but it names the RULE and the blocking dep, never
1313
- // the condition the author wrote, so the diagnosis points away from the mistake. This is an
1314
- // authoring-time error precisely because the fix is one word and no legitimate use of the
1315
- // shape exists.
1316
- dead_condition: {
1317
- const rule = step['execution'] === 'guard' ? 'all_success' : (step['trigger_rule'] ?? 'all_success');
1318
- // A guard may not declare `trigger_rule` at all (it is a prohibited field), so a guard that
1319
- // declares one must NOT be able to suppress this check by doing so.
1320
- const ruleDeclared = step['execution'] !== 'guard' && step['trigger_rule'] !== undefined;
1321
- if (step['execution'] !== 'guard' && !VALID_TRIGGER_RULES.has(rule)) {
1322
- break dead_condition; // an invalid rule already has its own error — adding noise helps nobody
1697
+ // issue #338: the check below only runs when an `mcp_servers` block EXISTS, so the absent-block
1698
+ // variant loaded clean and every disclosure this loader has for tools lives inside that same
1699
+ // fork, so the corner produced no error, no warning, and a run where the declared tools were
1700
+ // simply never offered. ONE error per step, not one per entry: the entries are not individually
1701
+ // wrong, the workflow is.
1702
+ if (step['tools'] !== undefined &&
1703
+ Array.isArray(step['tools']) &&
1704
+ step['tools'].length > 0 &&
1705
+ !Array.isArray(doc['mcp_servers'])) {
1706
+ errors.push(withStepLine(stepName, `Step '${stepName}': declares tools but the workflow defines no mcp_servers no drive ` +
1707
+ `can ever offer these tools, so the declaration can never be satisfied. Define an ` +
1708
+ `mcp_servers block, or remove 'tools'.`));
1323
1709
  }
1324
- if (rule !== 'all_success' && rule !== 'none_failed')
1325
- break dead_condition;
1326
- const asLeaves = (v) => Array.isArray(v)
1327
- ? v.filter((x) => typeof x === 'string')
1328
- : typeof v === 'string'
1329
- ? [v]
1330
- : [];
1331
- const surfaces = [
1332
- { surface: 'when', leaves: asLeaves(step['when']) },
1333
- // `abort_unless` is only validated (and only meaningful) on guards; on anything else the
1334
- // loader leaves it alone and the engine never reads it.
1335
- {
1336
- surface: 'abort_unless',
1337
- leaves: step['execution'] === 'guard' ? asLeaves(step['abort_unless']) : [],
1338
- },
1339
- // `preconditions` is collected for EVERY step kind, but it is INERT on a guard: the sole
1340
- // `checkPreconditions` call site is `executeStep` (execution-loop.ts:1371), and a guard
1341
- // goes through `executeGuardStep`, which evaluates only `abort_unless`. That is why the
1342
- // guard arm's consequence below is forked — collapsing it back into one shared string
1343
- // would make the error claim a wedge that cannot happen. (The inertness itself is a real
1344
- // adjacent gap, tracked as issue #369; this check only refuses to lie about it.)
1345
- { surface: 'preconditions', leaves: asLeaves(step['preconditions']) },
1346
- ];
1347
- for (const { surface, leaves } of surfaces) {
1348
- for (const leaf of leaves) {
1349
- const split = splitComparison(leaf);
1350
- // Two spellings are equally dead: the explicit `== true`, and the BARE PATH, which the
1351
- // engine coerces with `Boolean()`. `preconditions` refuses bare paths anyway.
1352
- const lhsPath = split.kind === 'comparison' && split.op === '==' && split.rhsRaw.trim() === 'true'
1353
- ? split.lhsPath
1354
- : split.kind === 'path'
1355
- ? split.path
1356
- : undefined;
1357
- if (lhsPath === undefined)
1358
- continue;
1359
- // EXACTLY three segments. `$settlement.x.failed.deep` is also dead, but for a different
1360
- // reason, so the trigger-rule remedy would be wrong advice there.
1361
- const segments = lhsPath.trim().split('.');
1362
- if (segments.length !== 3 || segments[0] !== '$settlement' || segments[2] !== 'failed') {
1363
- continue;
1364
- }
1365
- const dep = segments[1];
1366
- // Without the dep actually being a dependency, the one-hop error fires on its own AND
1367
- // the trigger gate returns true unconditionally for a step with no deps — so the leaf
1368
- // is not dead-by-trigger here and the remedy would be false advice.
1369
- if (!dependsOn.includes(dep))
1370
- continue;
1371
- const ruleText = ruleDeclared ? `'${rule}'` : `the default '${rule}'`;
1372
- const guardPrecondition = step['execution'] === 'guard' && surface === 'preconditions';
1373
- const consequence = surface === 'when'
1374
- ? `if '${dep}' fails the step is skipped as trigger_rule_unsatisfiable before the condition is evaluated; if '${dep}' succeeds the condition evaluates to false (when_false) — either way the step never runs`
1375
- : surface === 'preconditions'
1376
- ? guardPrecondition
1377
- ? // NOT the wedge: an unevaluated condition cannot block anything.
1378
- `the run behaves identically whether this condition is present or absent`
1379
- : `the step never settles — the run WEDGES in a blocked envelope`
1380
- : `the guard aborts the run on every execution`;
1381
- if (step['execution'] === 'guard') {
1382
- // Guards cannot declare a trigger rule, so the trigger-rule remedy is wrong advice
1383
- // here. This is a v1 SCOPE narrowing, not an architectural statement — issue #366
1384
- // carries the design question.
1385
- //
1386
- // The middle clause forks with the consequence: "by the time this is evaluated" is
1387
- // itself false for `preconditions`, which a guard never evaluates at all.
1388
- const cause = guardPrecondition
1389
- ? `and on an execution: guard step it is never evaluated at all: a guard evaluates ` +
1390
- `only 'abort_unless', so this condition is inert (${consequence})`
1391
- : `a guard runs under ${ruleText} and 'trigger_rule' is not a valid field on ` +
1392
- `execution: guard steps, so '${dep}' has always succeeded by the time this is ` +
1393
- `evaluated (${consequence})`;
1394
- errors.push(`Step '${stepName}': '${surface}' condition "${leaf}" can never be true — ${cause}. ` +
1395
- `Guards run only when their dependencies succeeded; for work that must happen AFTER a ` +
1396
- `failure, use an 'execution: finalizer' step (see issue #366 for widening guards).`);
1397
- }
1398
- else {
1399
- const remedies = ['all_done', 'one_failed'];
1400
- if (new Set(dependsOn).size === 1)
1401
- remedies.push('all_failed');
1402
- const tail = new Set(dependsOn).size > 1
1403
- ? ` ('all_failed' fires only if EVERY dependency fails; 'one_success' only if at least one other dependency succeeds.)`
1404
- : '';
1405
- errors.push(`Step '${stepName}': '${surface}' condition "${leaf}" can never be true — under ` +
1406
- `${ruleText} trigger rule, '${dep}' can never be in failed_steps when this step is ` +
1407
- `evaluated (${consequence}). To run this step when '${dep}' fails, set trigger_rule to ` +
1408
- `one of: ${remedies.join(', ')}.${tail}`);
1710
+ // Validate tools: server_id must reference a defined mcp_server.
1711
+ if (step['tools'] !== undefined &&
1712
+ Array.isArray(step['tools']) &&
1713
+ Array.isArray(doc['mcp_servers'])) {
1714
+ const serverIds = new Set(doc['mcp_servers'].map((s) => s.id));
1715
+ for (const entry of step['tools']) {
1716
+ const serverId = entry.split(':')[0] ?? '';
1717
+ if (!serverIds.has(serverId)) {
1718
+ errors.push(withStepLine(stepName, `Step '${stepName}': tools entry '${entry}' references unknown MCP server '${serverId}'`));
1409
1719
  }
1410
1720
  }
1411
1721
  }
1412
- }
1413
- // Validate tools: only valid on execution: agent steps without handler.
1414
- if (step['tools'] !== undefined &&
1415
- (step['execution'] !== 'agent' || step['handler'] !== undefined)) {
1416
- errors.push(`Step '${stepName}': 'tools' is only valid on execution: agent steps without 'handler' defined`);
1417
- }
1418
- // Validate tools: requires input_schema.
1419
- if (step['tools'] !== undefined && step['input_schema'] === undefined) {
1420
- errors.push(`Step '${stepName}': 'tools' requires 'input_schema' to be defined — the agentic loop needs a schema for final output extraction`);
1421
- }
1422
- // Validate tools: entries must be in server_id:tool_name format.
1423
- if (step['tools'] !== undefined && Array.isArray(step['tools'])) {
1424
- for (const entry of step['tools']) {
1425
- if (!/^[^:]+:[^:]+$/.test(entry)) {
1426
- errors.push(`Step '${stepName}': tools entry '${entry}' must be in 'server_id:tool_name' format`);
1427
- }
1722
+ // Validate max_tool_calls: must be a positive integer.
1723
+ if (step['max_tool_calls'] !== undefined &&
1724
+ (!Number.isInteger(step['max_tool_calls']) || step['max_tool_calls'] <= 0)) {
1725
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'max_tool_calls' must be a positive integer`));
1726
+ }
1727
+ // Validate max_fan_out: must be a positive integer.
1728
+ if (step['max_fan_out'] !== undefined &&
1729
+ (!Number.isInteger(step['max_fan_out']) || step['max_fan_out'] <= 0)) {
1730
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'max_fan_out' must be a positive integer`));
1731
+ }
1732
+ // Validate tool_timeout: must be a positive integer. Skipped where the key is not valid at
1733
+ // all (issue #413's requires-tools check above already reported that) — the same convention
1734
+ // as `timeout_seconds` below: an author told BOTH that the key does not belong here and that
1735
+ // its value has the wrong shape is being pointed at the shape, which is not the problem.
1736
+ // The `!toolsMissing` complement is the SAME helper the prohibition keys on, so the two are
1737
+ // exhaustive and disjoint by construction: `tools: []` with a negative value reports once.
1738
+ if (step['tool_timeout'] !== undefined &&
1739
+ !toolsMissing &&
1740
+ (!Number.isInteger(step['tool_timeout']) || step['tool_timeout'] <= 0)) {
1741
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'tool_timeout' must be a positive integer`));
1742
+ }
1743
+ // Validate timeout_seconds: must be a positive integer (issue A3). Skipped on
1744
+ // execution: guard — the guard-prohibited-fields check above already flatly rejects
1745
+ // 'timeout_seconds' there ('is not valid on execution: guard steps'); re-checking its
1746
+ // shape here would double-report the same root cause under a second, confusing message.
1747
+ // Same suppression for the agent prohibition (issue #402), for the same reason: an author
1748
+ // told BOTH that the key is invalid here and that its value has the wrong shape is being
1749
+ // pointed at the shape, which is not the problem.
1750
+ if (step['timeout_seconds'] !== undefined &&
1751
+ step['execution'] !== 'guard' &&
1752
+ step['execution'] !== 'agent' &&
1753
+ (!Number.isInteger(step['timeout_seconds']) || step['timeout_seconds'] <= 0)) {
1754
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'timeout_seconds' must be a positive integer`));
1428
1755
  }
1429
1756
  }
1430
- // Validate tools: server_id must reference a defined mcp_server.
1431
- if (step['tools'] !== undefined &&
1432
- Array.isArray(step['tools']) &&
1433
- Array.isArray(doc['mcp_servers'])) {
1434
- const serverIds = new Set(doc['mcp_servers'].map((s) => s.id));
1435
- for (const entry of step['tools']) {
1436
- const serverId = entry.split(':')[0] ?? '';
1437
- if (!serverIds.has(serverId)) {
1438
- errors.push(`Step '${stepName}': tools entry '${entry}' references unknown MCP server '${serverId}'`);
1757
+ // Require at least one non-finalizer step: a workflow of only finalizers is meaningless
1758
+ // (nothing runs in the DAG, so it would seal immediately with no domain work).
1759
+ const stepEntries = Object.values(stepsRaw).filter((s) => typeof s === 'object' && s !== null && !Array.isArray(s));
1760
+ if (stepEntries.length > 0 && stepEntries.every((s) => s['execution'] === 'finalizer')) {
1761
+ errors.push(`Workflow has only finalizer steps at least one non-finalizer step is required ` +
1762
+ `(finalizers run at the terminal transition of the DAG's domain steps).`);
1763
+ }
1764
+ // Reject depends_on cycles (issue #153): a transitive cycle among otherwise-valid edges is
1765
+ // loadable today (the per-step check above only validates one hop at a time), and at runtime
1766
+ // the cyclic steps are mutually ineligible forever — the run silently seals `completed` with
1767
+ // the stranded steps in NO step set and zero evidence. Build the graph over VALID edges only
1768
+ // (dep exists, isn't self, isn't a finalizer) — the self/unknown/finalizer-dep cases are
1769
+ // already reported by the per-step check above; a real cycle runs entirely through valid
1770
+ // edges, so excluding the already-errored ones here avoids double-reporting them.
1771
+ const dependencyEdges = new Map();
1772
+ for (const stepName of Object.keys(stepsRaw)) {
1773
+ dependencyEdges.set(stepName, []);
1774
+ }
1775
+ for (const [stepName, stepRaw] of Object.entries(stepsRaw)) {
1776
+ if (typeof stepRaw !== 'object' || stepRaw === null || Array.isArray(stepRaw))
1777
+ continue;
1778
+ const dependsOn = stepRaw['depends_on'];
1779
+ if (!Array.isArray(dependsOn))
1780
+ continue;
1781
+ for (const dep of dependsOn) {
1782
+ if (typeof dep === 'string' &&
1783
+ dep !== stepName &&
1784
+ dep in stepsRaw &&
1785
+ stepsRaw[dep]['execution'] !== 'finalizer') {
1786
+ dependencyEdges.get(stepName).push(dep);
1439
1787
  }
1440
1788
  }
1441
1789
  }
1442
- // Validate max_tool_calls: must be a positive integer.
1443
- if (step['max_tool_calls'] !== undefined &&
1444
- (!Number.isInteger(step['max_tool_calls']) || step['max_tool_calls'] <= 0)) {
1445
- errors.push(`Step '${stepName}': 'max_tool_calls' must be a positive integer`);
1446
- }
1447
- // Validate max_fan_out: must be a positive integer.
1448
- if (step['max_fan_out'] !== undefined &&
1449
- (!Number.isInteger(step['max_fan_out']) || step['max_fan_out'] <= 0)) {
1450
- errors.push(`Step '${stepName}': 'max_fan_out' must be a positive integer`);
1790
+ for (const cycle of detectDependencyCycles(dependencyEdges)) {
1791
+ errors.push(`Workflow has a dependency cycle: ${cycle.join(' ')}`);
1451
1792
  }
1452
- // Validate tool_timeout: must be a positive integer.
1453
- if (step['tool_timeout'] !== undefined &&
1454
- (!Number.isInteger(step['tool_timeout']) || step['tool_timeout'] <= 0)) {
1455
- errors.push(`Step '${stepName}': 'tool_timeout' must be a positive integer`);
1456
- }
1457
- // Validate timeout_seconds: must be a positive integer (issue A3). Skipped on
1458
- // execution: guard — the guard-prohibited-fields check above already flatly rejects
1459
- // 'timeout_seconds' there ('is not valid on execution: guard steps'); re-checking its
1460
- // shape here would double-report the same root cause under a second, confusing message.
1461
- if (step['timeout_seconds'] !== undefined &&
1462
- step['execution'] !== 'guard' &&
1463
- (!Number.isInteger(step['timeout_seconds']) || step['timeout_seconds'] <= 0)) {
1464
- errors.push(`Step '${stepName}': 'timeout_seconds' must be a positive integer`);
1465
- }
1466
- }
1467
- // Require at least one non-finalizer step: a workflow of only finalizers is meaningless
1468
- // (nothing runs in the DAG, so it would seal immediately with no domain work).
1469
- const stepEntries = Object.values(stepsRaw).filter((s) => typeof s === 'object' && s !== null && !Array.isArray(s));
1470
- if (stepEntries.length > 0 && stepEntries.every((s) => s['execution'] === 'finalizer')) {
1471
- errors.push(`Workflow has only finalizer steps — at least one non-finalizer step is required ` +
1472
- `(finalizers run at the terminal transition of the DAG's domain steps).`);
1473
- }
1474
- // Reject depends_on cycles (issue #153): a transitive cycle among otherwise-valid edges is
1475
- // loadable today (the per-step check above only validates one hop at a time), and at runtime
1476
- // the cyclic steps are mutually ineligible forever — the run silently seals `completed` with
1477
- // the stranded steps in NO step set and zero evidence. Build the graph over VALID edges only
1478
- // (dep exists, isn't self, isn't a finalizer) — the self/unknown/finalizer-dep cases are
1479
- // already reported by the per-step check above; a real cycle runs entirely through valid
1480
- // edges, so excluding the already-errored ones here avoids double-reporting them.
1481
- const dependencyEdges = new Map();
1482
- for (const stepName of Object.keys(stepsRaw)) {
1483
- dependencyEdges.set(stepName, []);
1484
- }
1485
- for (const [stepName, stepRaw] of Object.entries(stepsRaw)) {
1486
- if (typeof stepRaw !== 'object' || stepRaw === null || Array.isArray(stepRaw))
1487
- continue;
1488
- const dependsOn = stepRaw['depends_on'];
1489
- if (!Array.isArray(dependsOn))
1490
- continue;
1491
- for (const dep of dependsOn) {
1492
- if (typeof dep === 'string' &&
1493
- dep !== stepName &&
1494
- dep in stepsRaw &&
1495
- stepsRaw[dep]['execution'] !== 'finalizer') {
1496
- dependencyEdges.get(stepName).push(dep);
1497
- }
1498
- }
1499
- }
1500
- for (const cycle of detectDependencyCycles(dependencyEdges)) {
1501
- errors.push(`Workflow has a dependency cycle: ${cycle.join(' → ')}`);
1502
- }
1503
- // Validate mcp_servers: ids must be unique (workflow-level check).
1504
- if (Array.isArray(doc['mcp_servers'])) {
1505
- const seen = new Set();
1506
- for (const server of doc['mcp_servers']) {
1507
- if (seen.has(server.id)) {
1508
- errors.push(`mcp_servers: duplicate server id '${server.id}'`);
1793
+ // Validate mcp_servers: ids must be unique (workflow-level check).
1794
+ if (Array.isArray(doc['mcp_servers'])) {
1795
+ const seen = new Set();
1796
+ for (const server of doc['mcp_servers']) {
1797
+ if (seen.has(server.id)) {
1798
+ errors.push(`mcp_servers: duplicate server id '${server.id}'`);
1799
+ }
1800
+ seen.add(server.id);
1509
1801
  }
1510
- seen.add(server.id);
1511
1802
  }
1512
- }
1513
- // Validate services: Ajv-strict entry schema (closed key set) + rate_limit fields.
1514
- if (typeof doc['services'] === 'object' && doc['services'] !== null) {
1515
- for (const [serviceName, serviceRaw] of Object.entries(doc['services'])) {
1516
- if (typeof serviceRaw !== 'object' || serviceRaw === null)
1517
- continue;
1518
- const service = serviceRaw;
1519
- // PERMANENT targeted rejection must win over the generic unknown-key error.
1520
- if ('auth' in service || 'token_from' in service) {
1521
- errors.push(`Service '${serviceName}': 'auth.token_from' was removed in v0.14.0 bind ` +
1522
- `credentials in your deployment manifest (realm.yaml); see the migration note.`);
1523
- }
1524
- else {
1525
- const serviceAjv = new Ajv({ strict: true, allErrors: true });
1526
- if (!serviceAjv.validate(SERVICE_ENTRY_JSON_SCHEMA, service)) {
1527
- for (const err of serviceAjv.errors ?? []) {
1528
- const detail = err.keyword === 'additionalProperties'
1529
- ? `unknown key '${String(err.params.additionalProperty)}'`
1530
- : `${err.instancePath.replace(/^\//, '').replace(/\//g, '.') || 'entry'} ${err.message ?? 'invalid'}`;
1531
- errors.push(`Service '${serviceName}': ${detail}`);
1803
+ // Validate services: Ajv-strict entry schema (closed key set) + rate_limit fields.
1804
+ if (typeof doc['services'] === 'object' && doc['services'] !== null) {
1805
+ for (const [serviceName, serviceRaw] of Object.entries(doc['services'])) {
1806
+ if (typeof serviceRaw !== 'object' || serviceRaw === null)
1807
+ continue;
1808
+ const service = serviceRaw;
1809
+ // PERMANENT targeted rejection — must win over the generic unknown-key error.
1810
+ if ('auth' in service || 'token_from' in service) {
1811
+ errors.push(`Service '${serviceName}': 'auth.token_from' was removed in v0.14.0 bind ` +
1812
+ `credentials in your deployment manifest (realm.yaml); see the migration note.`);
1813
+ }
1814
+ else {
1815
+ const serviceAjv = new Ajv({ strict: true, allErrors: true });
1816
+ if (!serviceAjv.validate(SERVICE_ENTRY_JSON_SCHEMA, service)) {
1817
+ for (const err of serviceAjv.errors ?? []) {
1818
+ const detail = err.keyword === 'additionalProperties'
1819
+ ? `unknown key '${String(err.params.additionalProperty)}'`
1820
+ : `${err.instancePath.replace(/^\//, '').replace(/\//g, '.') || 'entry'} ${err.message ?? 'invalid'}`;
1821
+ errors.push(`Service '${serviceName}': ${detail}`);
1822
+ }
1532
1823
  }
1533
1824
  }
1534
- }
1535
- const rateLimit = service['rate_limit'];
1536
- if (rateLimit === undefined)
1537
- continue;
1538
- if (typeof rateLimit !== 'object' || rateLimit === null) {
1539
- errors.push(`Service '${serviceName}': 'rate_limit' must be an object`);
1540
- continue;
1541
- }
1542
- const rl = rateLimit;
1543
- if ('requests_per_second' in rl &&
1544
- (!Number.isInteger(rl['requests_per_second']) || rl['requests_per_second'] < 1)) {
1545
- errors.push(`Service '${serviceName}': 'rate_limit.requests_per_second' must be a positive integer (≥ 1)`);
1546
- }
1547
- if ('burst' in rl) {
1548
- if (!Number.isInteger(rl['burst']) || rl['burst'] < 1) {
1549
- errors.push(`Service '${serviceName}': 'rate_limit.burst' must be a positive integer (≥ 1)`);
1825
+ const rateLimit = service['rate_limit'];
1826
+ if (rateLimit === undefined)
1827
+ continue;
1828
+ if (typeof rateLimit !== 'object' || rateLimit === null) {
1829
+ errors.push(`Service '${serviceName}': 'rate_limit' must be an object`);
1830
+ continue;
1550
1831
  }
1551
- if (!('requests_per_second' in rl)) {
1552
- errors.push(`Service '${serviceName}': 'rate_limit.burst' requires 'rate_limit.requests_per_second' to be set`);
1832
+ const rl = rateLimit;
1833
+ if ('requests_per_second' in rl &&
1834
+ (!Number.isInteger(rl['requests_per_second']) ||
1835
+ rl['requests_per_second'] < 1)) {
1836
+ errors.push(`Service '${serviceName}': 'rate_limit.requests_per_second' must be a positive integer (≥ 1)`);
1837
+ }
1838
+ if ('burst' in rl) {
1839
+ if (!Number.isInteger(rl['burst']) || rl['burst'] < 1) {
1840
+ errors.push(`Service '${serviceName}': 'rate_limit.burst' must be a positive integer (≥ 1)`);
1841
+ }
1842
+ if (!('requests_per_second' in rl)) {
1843
+ errors.push(`Service '${serviceName}': 'rate_limit.burst' requires 'rate_limit.requests_per_second' to be set`);
1844
+ }
1845
+ }
1846
+ if ('fallback_retry_seconds' in rl &&
1847
+ (typeof rl['fallback_retry_seconds'] !== 'number' ||
1848
+ rl['fallback_retry_seconds'] <= 0)) {
1849
+ errors.push(`Service '${serviceName}': 'rate_limit.fallback_retry_seconds' must be a positive number (> 0)`);
1850
+ }
1851
+ if ('min_retry_seconds' in rl &&
1852
+ (typeof rl['min_retry_seconds'] !== 'number' || rl['min_retry_seconds'] <= 0)) {
1853
+ errors.push(`Service '${serviceName}': 'rate_limit.min_retry_seconds' must be a positive number (> 0)`);
1854
+ }
1855
+ if ('max_retry_seconds' in rl &&
1856
+ (!Number.isInteger(rl['max_retry_seconds']) || rl['max_retry_seconds'] < 1)) {
1857
+ errors.push(`Service '${serviceName}': 'rate_limit.max_retry_seconds' must be a positive integer (≥ 1)`);
1553
1858
  }
1554
- }
1555
- if ('fallback_retry_seconds' in rl &&
1556
- (typeof rl['fallback_retry_seconds'] !== 'number' ||
1557
- rl['fallback_retry_seconds'] <= 0)) {
1558
- errors.push(`Service '${serviceName}': 'rate_limit.fallback_retry_seconds' must be a positive number (> 0)`);
1559
- }
1560
- if ('min_retry_seconds' in rl &&
1561
- (typeof rl['min_retry_seconds'] !== 'number' || rl['min_retry_seconds'] <= 0)) {
1562
- errors.push(`Service '${serviceName}': 'rate_limit.min_retry_seconds' must be a positive number (> 0)`);
1563
- }
1564
- if ('max_retry_seconds' in rl &&
1565
- (!Number.isInteger(rl['max_retry_seconds']) || rl['max_retry_seconds'] < 1)) {
1566
- errors.push(`Service '${serviceName}': 'rate_limit.max_retry_seconds' must be a positive integer (≥ 1)`);
1567
1859
  }
1568
1860
  }
1861
+ // Step 3b: Trigger block validation (schema-driven — see trigger-schema.ts)
1862
+ const triggerRaw = doc['trigger'];
1863
+ if (triggerRaw !== undefined) {
1864
+ normalizeTriggerFilter(triggerRaw); // canonicalise shorthand BEFORE validation
1865
+ errors.push(...validateTriggerStructure(triggerRaw));
1866
+ }
1867
+ if (errors.length > 0) {
1868
+ throw new WorkflowError(`Invalid workflow: ${errors.join('; ')}`, {
1869
+ // issue #425: the pre-join strings — see the profile collector above.
1870
+ errors: [...errors],
1871
+ code: 'VALIDATION_WORKFLOW_SCHEMA',
1872
+ category: 'VALIDATION',
1873
+ agentAction: 'report_to_user',
1874
+ retryable: false,
1875
+ });
1876
+ }
1877
+ // Step 4: Stamp schema version and return typed result
1878
+ const definition = doc;
1879
+ definition.schema_version = CURRENT_WORKFLOW_SCHEMA_VERSION;
1880
+ return { definition, warnings };
1569
1881
  }
1570
- // Step 3b: Trigger block validation (schema-driven — see trigger-schema.ts)
1571
- const triggerRaw = doc['trigger'];
1572
- if (triggerRaw !== undefined) {
1573
- normalizeTriggerFilter(triggerRaw); // canonicalise shorthand BEFORE validation
1574
- errors.push(...validateTriggerStructure(triggerRaw));
1575
- }
1576
- if (errors.length > 0) {
1577
- throw new WorkflowError(`Invalid workflow: ${errors.join('; ')}`, {
1578
- code: 'VALIDATION_WORKFLOW_SCHEMA',
1579
- category: 'VALIDATION',
1580
- agentAction: 'report_to_user',
1581
- retryable: false,
1582
- });
1882
+ catch (err) {
1883
+ if (err instanceof WorkflowError)
1884
+ attachLoaderWarnings(err, warnings);
1885
+ throw err;
1583
1886
  }
1584
- // Step 4: Stamp schema version and return typed result
1585
- const definition = doc;
1586
- definition.schema_version = CURRENT_WORKFLOW_SCHEMA_VERSION;
1587
- return { definition, warnings };
1588
1887
  }
1589
1888
  /** Returns true if any step in the raw steps map declares use_template. */
1590
1889
  function hasUseTemplateInSteps(steps) {