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