@sensigo/realm 0.40.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/adapters/gorgias-adapter.d.ts.map +1 -1
  2. package/dist/adapters/gorgias-adapter.js +39 -10
  3. package/dist/adapters/gorgias-adapter.js.map +1 -1
  4. package/dist/engine/execution-loop.d.ts.map +1 -1
  5. package/dist/engine/execution-loop.js +124 -21
  6. package/dist/engine/execution-loop.js.map +1 -1
  7. package/dist/engine/run-health.d.ts +1 -1
  8. package/dist/engine/run-health.d.ts.map +1 -1
  9. package/dist/engine/run-health.js +49 -4
  10. package/dist/engine/run-health.js.map +1 -1
  11. package/dist/index.d.ts +6 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +7 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/types/run-record.d.ts +11 -1
  16. package/dist/types/run-record.d.ts.map +1 -1
  17. package/dist/types/run-record.js.map +1 -1
  18. package/dist/types/workflow-definition.d.ts +146 -10
  19. package/dist/types/workflow-definition.d.ts.map +1 -1
  20. package/dist/types/workflow-definition.js +225 -0
  21. package/dist/types/workflow-definition.js.map +1 -1
  22. package/dist/types/workflow-error.d.ts +45 -1
  23. package/dist/types/workflow-error.d.ts.map +1 -1
  24. package/dist/types/workflow-error.js +30 -1
  25. package/dist/types/workflow-error.js.map +1 -1
  26. package/dist/workflow/diagnostics.d.ts +19 -15
  27. package/dist/workflow/diagnostics.d.ts.map +1 -1
  28. package/dist/workflow/diagnostics.js +19 -28
  29. package/dist/workflow/diagnostics.js.map +1 -1
  30. package/dist/workflow/registrar.d.ts +68 -0
  31. package/dist/workflow/registrar.d.ts.map +1 -1
  32. package/dist/workflow/registrar.js +92 -5
  33. package/dist/workflow/registrar.js.map +1 -1
  34. package/dist/workflow/step-key-registry.d.ts +1258 -0
  35. package/dist/workflow/step-key-registry.d.ts.map +1 -0
  36. package/dist/workflow/step-key-registry.js +1217 -0
  37. package/dist/workflow/step-key-registry.js.map +1 -0
  38. package/dist/workflow/yaml-loader.d.ts +33 -0
  39. package/dist/workflow/yaml-loader.d.ts.map +1 -1
  40. package/dist/workflow/yaml-loader.js +1485 -1242
  41. package/dist/workflow/yaml-loader.js.map +1 -1
  42. package/package.json +1 -1
@@ -4,7 +4,7 @@ import { dirname, resolve, join, isAbsolute } from 'node:path';
4
4
  import { createHash } from 'node:crypto';
5
5
  import { load } from 'js-yaml';
6
6
  import { Ajv } from 'ajv';
7
- import { KNOWN_STEP_KEYS, KNOWN_WORKFLOW_KEYS, KNOWN_RETRY_KEYS, KNOWN_GATE_KEYS, } from '../types/workflow-definition.js';
7
+ import { KNOWN_STEP_KEYS, KNOWN_WORKFLOW_KEYS, KNOWN_RETRY_KEYS, KNOWN_GATE_KEYS, SERVICE_TRUST_LEVELS, isGateTrust, classifyStepTrust, buildTrustRefusal, renderTrustValue, } from '../types/workflow-definition.js';
8
8
  import { WorkflowError } from '../types/workflow-error.js';
9
9
  import { findUnknownKeys, renderLoaderWarning, resolveSeverity, closestKey, } from './diagnostics.js';
10
10
  import { createSourcePositionCollector } from './source-positions.js';
@@ -14,12 +14,39 @@ import { splitComparison, isPathShaped } from '../engine/comparison-expr.js';
14
14
  import { DEFAULT_EXECUTION_TIMEOUT_SECONDS } from '../engine/claim-liveness.js';
15
15
  import { validateOutputSchema } from '../validation/input-schema.js';
16
16
  import { assessStructuredOutputEligibility, renderIneligibleMessage, } from './structured-output-eligibility.js';
17
+ import { STEP_KEY_REGISTRY, CONSUMED_HOME, prohibitedKeysFor, consumedKindsFor, homeText, } from './step-key-registry.js';
18
+ /** #517 (the drive-flip): render ONE minted kind-prohibition from registry data. message_data
19
+ * cells render their recorded bespoke text verbatim (byte-identical to the pre-flip checks —
20
+ * golden-proven at the flip). Generic cells render the per-cell FRONT clause (the two-shape
21
+ * truth: 'not_valid' keeps the old loop grammar against THIS kind; 'only_valid' keeps the old
22
+ * twin grammar against the DERIVED consumed-kind set) and append the rung-2 consequence clause
23
+ * from CONSUMED_HOME — witness-backed message truth (its claims are conformance-tested data). */
24
+ function renderRegistryProhibition(key, kind, cell) {
25
+ if (cell.message_data !== undefined)
26
+ return cell.message_data;
27
+ const home = CONSUMED_HOME[key];
28
+ /* istanbul ignore next -- conformance guarantees totality over generic minted keys */
29
+ if (home === undefined)
30
+ return `'${key}' is not valid on execution: ${kind} steps`;
31
+ const front = cell.front === 'only_valid'
32
+ ? `'${key}' is only valid on execution: ${consumedKindsFor(key).join('/')} steps`
33
+ : `'${key}' is not valid on execution: ${kind} steps`;
34
+ return `${front} — ${homeText(home.mechanism, kind)} ${homeText(home.remedy, kind)}`;
35
+ }
17
36
  /**
18
37
  * Validate one condition leaf at load time using the shared quote-aware splitter (the SAME split
19
38
  * used at runtime). Rejects compound `and`/`or`, multiple operators, and non-path LHS. For `when`,
20
39
  * also enforces the direct-`depends_on` reference check (Change 2). Pushes actionable errors.
21
40
  */
22
- function validateConditionLeaf(surface, leaf, stepName, dependsOn, errors,
41
+ function validateConditionLeaf(surface, leaf, stepName, dependsOn,
42
+ /**
43
+ * The step's declared `execution` kind (undefined when missing/malformed — those steps are
44
+ * already refused by the invalid-execution error, and the remedy below keeps its generic
45
+ * form). Threaded through so `validateWhenReference` can fork its remedy tail on the
46
+ * registry's own `depends_on` cell — a kind where `depends_on` is prohibited must never be
47
+ * told to add one (the wrong-remedy composition class this correction fixes).
48
+ */
49
+ kind, errors,
23
50
  /**
24
51
  * Appends the step's source line to a message (issue #392). REQUIRED rather than optional so
25
52
  * the compiler names every call site if this ever gains another one — an omitted resolver
@@ -68,7 +95,7 @@ withLine) {
68
95
  return;
69
96
  }
70
97
  if (surface === 'when')
71
- validateWhenReference(split.path, stepName, dependsOn, errors, withLine);
98
+ validateWhenReference(split.path, stepName, dependsOn, kind, errors, withLine);
72
99
  return;
73
100
  }
74
101
  // comparison
@@ -81,7 +108,7 @@ withLine) {
81
108
  return;
82
109
  }
83
110
  if (surface === 'when')
84
- validateWhenReference(split.lhsPath, stepName, dependsOn, errors, withLine);
111
+ validateWhenReference(split.lhsPath, stepName, dependsOn, kind, errors, withLine);
85
112
  }
86
113
  /**
87
114
  * issue #220 §4c (PR-3): validates a `$settlement.<dep>.<field>` reference reached from ANY of
@@ -133,7 +160,9 @@ withLine) {
133
160
  * step in this step's DIRECT `depends_on` (one-hop membership — no graph traversal). Field names are
134
161
  * not checked (agent-step outputs aren't statically declared).
135
162
  */
136
- function validateWhenReference(path, stepName, dependsOn, errors,
163
+ function validateWhenReference(path, stepName, dependsOn,
164
+ /** @see validateConditionLeaf — forks the remedy tail on the registry's `depends_on` cell. */
165
+ kind, errors,
137
166
  /**
138
167
  * Appends the step's source line to a message (issue #392). REQUIRED rather than optional so
139
168
  * the compiler names every call site if this ever gains another one — an omitted resolver
@@ -149,7 +178,16 @@ withLine) {
149
178
  return;
150
179
  }
151
180
  if (!dependsOn.includes(first)) {
152
- errors.push(withLine(stepName, `Step '${stepName}': 'when' references step '${first}' which is not in its depends_on [${dependsOn.join(', ')}]. Add it to depends_on or use 'run.params.*'.`));
181
+ // The remedy's first arm is forked on the registry's own depends_on cell: on a kind where
182
+ // depends_on is prohibited (today exactly finalizer), 'Add it to depends_on' is a dead
183
+ // pointer — following it mints a second refusal (probe-executed; the wrong-remedy
184
+ // composition class this correction fixes). Derived from the cell so the fork can never
185
+ // drift from the mint.
186
+ const dependsOnLegal = kind === undefined || STEP_KEY_REGISTRY.depends_on[kind].c === 'consumed';
187
+ const remedyTail = dependsOnLegal
188
+ ? `Add it to depends_on or use 'run.params.*'.`
189
+ : `Use 'run.params.*' — 'depends_on' is not valid on this step's kind.`;
190
+ errors.push(withLine(stepName, `Step '${stepName}': 'when' references step '${first}' which is not in its depends_on [${dependsOn.join(', ')}]. ${remedyTail}`));
153
191
  }
154
192
  }
155
193
  /** Bumped on every breaking change to WorkflowDefinition's serialized format. */
@@ -166,11 +204,20 @@ const SERVICE_ENTRY_JSON_SCHEMA = {
166
204
  required: ['adapter'],
167
205
  properties: {
168
206
  adapter: { type: 'string', minLength: 1 },
169
- trust: { enum: ['engine_delivered', 'engine_managed', 'agent_provided'] },
207
+ trust: { enum: [...SERVICE_TRUST_LEVELS] },
170
208
  rate_limit: { type: 'object' },
171
209
  },
172
210
  };
173
211
  const VALID_EXECUTIONS = new Set(['auto', 'agent', 'guard', 'finalizer']);
212
+ // issue #517 (the drive-flip): the two kind-prohibition sets are DERIVED from the consumption
213
+ // registry — every key whose cell on the kind is prohibited WITHOUT an except arm. Their meaning
214
+ // upgraded with #517 from "the loop's array" to "the prohibited set": they now also carry the
215
+ // keys whose refusals used to live in per-key only-valid-on checks (guard 12→20 members,
216
+ // finalizer 13→19), and their declared type widened from a literal tuple to a computed readonly
217
+ // array — both disclosed in the changelog. The prohibition loops that consumed the old literal
218
+ // arrays are deleted; the registry-driven mint below is the single enforcement mechanism.
219
+ export const FINALIZER_PROHIBITED_STEP_KEYS = prohibitedKeysFor('finalizer');
220
+ export const GUARD_PROHIBITED_STEP_KEYS = prohibitedKeysFor('guard');
174
221
  const VALID_FINALIZER_TRIGGERS = new Set([
175
222
  'complete',
176
223
  'fail',
@@ -248,33 +295,41 @@ export function findTrustRoot(dir) {
248
295
  }
249
296
  }
250
297
  /**
251
- * Pure core of loadWorkflowFromFile (issue #169): parses + resolves everything a file-based load
252
- * needs, but never prints and never chooses between the two public presentations — it always
253
- * 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.
257
- * @throws WorkflowError on read failure or structural validation errors.
298
+ * issue #424 attaches the live loader warnings to an error on its way out.
299
+ *
300
+ * Two chokepoints call this (one in `parseWorkflowString`, one in `loadWorkflowFromFileCore`),
301
+ * which is why it exists rather than each throw site building its own error with a `warnings`
302
+ * option: there are eleven throw sites across this file plus four more in template-resolver.ts,
303
+ * the warnings array is out of scope at most of them, and a chokepoint covers every future one
304
+ * for free. The non-empty guard makes the classification automatic — a throw that happens before
305
+ * any warning could exist attaches nothing, by construction rather than by a rule someone has to
306
+ * remember.
307
+ *
308
+ * Attach-once: an inner chokepoint's attachment survives the outer one re-catching the same
309
+ * error, so a file-based load reports the warnings from the parse that produced it rather than
310
+ * an emptier outer set.
258
311
  */
259
- function loadWorkflowFromFileCore(filePath, registry) {
260
- let content;
261
- try {
262
- content = readFileSync(filePath, 'utf8');
263
- }
264
- catch (err) {
265
- const message = err instanceof Error ? err.message : String(err);
266
- throw new WorkflowError(`Failed to read workflow file: ${message}`, {
267
- code: 'RESOURCE_FETCH_FAILED',
268
- category: 'RESOURCE',
269
- agentAction: 'report_to_user',
270
- retryable: false,
271
- });
272
- }
273
- const { definition, warnings } = parseWorkflowString(content, registry, {
274
- allowExtensions: true,
275
- });
276
- // Resolve agent profiles only possible when we have a file path.
277
- const workflowDir = dirname(resolve(filePath));
312
+ export function attachLoaderWarnings(err, warnings) {
313
+ if (warnings.length === 0)
314
+ return;
315
+ if (err.warnings !== undefined)
316
+ return;
317
+ err.warnings = warnings;
318
+ }
319
+ /**
320
+ * Resolves every `agent_profile` a definition declares against `<workflowDir>/<profiles_dir>`
321
+ * (default `profiles/`), stamping `resolved_profiles` on the definition and refusing — one
322
+ * `Invalid workflow:` error, one entry per missing profile (issue #425) — when any is absent.
323
+ *
324
+ * Exported (issue #553) because this is the ONE check a workflow's text cannot answer: it needs
325
+ * the source tree. `loadWorkflowFromFileCore` calls it with the file's directory; `validate
326
+ * --registered` calls it with the `source_dir` the registrar recorded, so the stored copy is
327
+ * audited by the same rule instead of by a synthesized file path (#493's snapshot doctrine: a
328
+ * synthesized path would audit the FILE, not the stored copy). Semantics are those of the
329
+ * former inline loop, byte for byte the `Searched: <path>` sentence included.
330
+ * @throws WorkflowError (`VALIDATION_WORKFLOW_SCHEMA`) naming every missing profile.
331
+ */
332
+ export function resolveAgentProfiles(definition, workflowDir) {
278
333
  const profilesDir = definition.profiles_dir !== undefined
279
334
  ? resolve(workflowDir, definition.profiles_dir)
280
335
  : join(workflowDir, 'profiles');
@@ -300,6 +355,9 @@ function loadWorkflowFromFileCore(filePath, registry) {
300
355
  }
301
356
  if (profileErrors.length > 0) {
302
357
  throw new WorkflowError(`Invalid workflow: ${profileErrors.join('; ')}`, {
358
+ // issue #425: the pre-join strings, so a render can list them one per line. Two missing
359
+ // profiles are two problems, not one long sentence.
360
+ errors: [...profileErrors],
303
361
  code: 'VALIDATION_WORKFLOW_SCHEMA',
304
362
  category: 'VALIDATION',
305
363
  agentAction: 'report_to_user',
@@ -309,71 +367,89 @@ function loadWorkflowFromFileCore(filePath, registry) {
309
367
  if (Object.keys(resolvedProfiles).length > 0) {
310
368
  definition.resolved_profiles = resolvedProfiles;
311
369
  }
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'`, {
317
- code: 'VALIDATION_WORKFLOW_SCHEMA',
318
- category: 'VALIDATION',
319
- agentAction: 'report_to_user',
320
- retryable: false,
321
- });
322
- }
370
+ }
371
+ /**
372
+ * Pure core of loadWorkflowFromFile (issue #169): parses + resolves everything a file-based load
373
+ * needs, but never prints and never chooses between the two public presentations — it always
374
+ * returns the definition alongside every collected LoaderWarning. `loadWorkflowFromFile` (prints
375
+ * via renderLoaderWarning, returns just the definition) and `loadWorkflowFromFileWithDiagnostics`
376
+ * (prints nothing, returns both) are both thin wrappers over this.
377
+ *
378
+ * The #169-era "byte-identical default behavior" claim was retired in issue #444 (2026-08-31):
379
+ * renderLoaderWarning now prefixes `⚠ ` for every code, so this printer's advisory lines gained
380
+ * the prefix they lacked. The SHAPE of the contract is unchanged — one printing wrapper, one
381
+ * silent one — and the text after the prefix is untouched.
382
+ * @throws WorkflowError on read failure or structural validation errors.
383
+ */
384
+ function loadWorkflowFromFileCore(filePath, registry) {
385
+ let content;
386
+ try {
387
+ content = readFileSync(filePath, 'utf8');
323
388
  }
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)`, {
337
- code: 'VALIDATION_WORKFLOW_SCHEMA',
338
- category: 'VALIDATION',
339
- agentAction: 'report_to_user',
340
- retryable: false,
341
- });
342
- }
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
- });
389
+ catch (err) {
390
+ const message = err instanceof Error ? err.message : String(err);
391
+ throw new WorkflowError(`Failed to read workflow file: ${message}`, {
392
+ code: 'RESOURCE_FETCH_FAILED',
393
+ category: 'RESOURCE',
394
+ agentAction: 'report_to_user',
395
+ retryable: false,
396
+ });
397
+ }
398
+ const { definition, warnings } = parseWorkflowString(content, registry, {
399
+ allowExtensions: true,
400
+ });
401
+ // issue #424 CHOKEPOINT 2. Everything past the parse can throw with `warnings` already
402
+ // populated (a missing agent_profile, a bad workflow_context source), and those warnings used
403
+ // to unwind with the stack: `register` on a file with a typo AND a missing profile printed the
404
+ // profile error alone, so the author fixed it, re-ran, and only then learned about the typo.
405
+ // The parse call itself is deliberately OUTSIDE this try — chokepoint 1 inside
406
+ // `parseWorkflowString` owns those throws and has already attached, and attach-once means its
407
+ // richer set survives.
408
+ try {
409
+ // Resolve agent profiles — only possible when we have a source tree. Every check in this
410
+ // file-only block is DELEGATED to a named exported resolver (issue #553): `validate
411
+ // --registered` supplies the recorded source tree to the same function, so the stored copy
412
+ // is audited by the rule register applied, not by a paraphrase. An inline `throw` here is
413
+ // exactly what admission-context.test.ts (cli) refuses.
414
+ const workflowDir = dirname(resolve(filePath));
415
+ resolveAgentProfiles(definition, workflowDir);
416
+ // Resolve workflow_context entry paths. The four context-free rules (context_wrapper enum,
417
+ // `.raw` names, the name charset, `source.path` required) live in `parseWorkflowString`
418
+ // Step 3c since issue #553 — every surface, file or string, refuses them identically. Only
419
+ // the TRANSFORM needs `workflowDir`, so only the transform is here.
420
+ if (definition.workflow_context !== undefined) {
421
+ for (const entry of Object.values(definition.workflow_context)) {
422
+ entry.source.path = resolve(workflowDir, entry.source.path);
352
423
  }
353
- // Resolve relative path to absolute.
354
- entry.source.path = resolve(workflowDir, rawSource['path']);
355
424
  }
425
+ // Auto-register schema.json if present and not explicitly declared.
426
+ const schemaPath = join(workflowDir, 'schema.json');
427
+ if (existsSync(schemaPath) && definition.workflow_context?.['schema'] === undefined) {
428
+ definition.workflow_context ??= {};
429
+ definition.workflow_context['schema'] = {
430
+ source: { path: schemaPath },
431
+ description: 'Auto-registered schema.json from workflow directory',
432
+ };
433
+ }
434
+ // Resolution metadata: stamped for EVERY file-loaded definition (v0.14) — trust_root is
435
+ // the deployment-manifest anchor (`<trust_root>/realm.yaml`), needed by extension-free
436
+ // workflows that consume manifest-constructed adapters by name. Core resolves/stores
437
+ // PATHS only — it never imports modules or reads the manifest; that is the CLI's job.
438
+ // `validate --registered` reads these two back to supply the source tree the stored copy's
439
+ // context-dependent checks need (issue #553).
440
+ definition.source_dir = workflowDir;
441
+ definition.trust_root = findTrustRoot(workflowDir);
442
+ if (definition.extensions !== undefined) {
443
+ definition.extensions =
444
+ typeof definition.extensions === 'string' ? [definition.extensions] : definition.extensions;
445
+ }
446
+ definition.origin = 'human';
356
447
  }
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;
448
+ catch (err) {
449
+ if (err instanceof WorkflowError)
450
+ attachLoaderWarnings(err, warnings);
451
+ throw err;
375
452
  }
376
- definition.origin = 'human';
377
453
  return { definition, warnings };
378
454
  }
379
455
  /**
@@ -519,1282 +595,1449 @@ function parseWorkflowString(content, registry, opts) {
519
595
  }
520
596
  const errors = [];
521
597
  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>).`, {
598
+ // issue #424 CHOKEPOINT 1. Every throw from here down unwinds past a populated `warnings`
599
+ // array, and used to drop it: a workflow with a prohibited key AND a `dependson` typo reported
600
+ // the prohibition alone. This also covers the template-resolver's own throws, which cross this
601
+ // frame and are unreachable from any sweep of this file.
602
+ try {
603
+ // Finalised after the parse succeeded; resolves a semantic path to its place in the source.
604
+ const sourceMap = positions.finish();
605
+ /**
606
+ * Appends ` (step at line N)` when the step's own key can be placed, and nothing when it cannot
607
+ * (issue #392). Used at PUSH time, never at join time — once messages are joined into one
608
+ * string the step each came from is no longer recoverable.
609
+ *
610
+ * The suffix names the STEP because that is the only position this helper ever has, and saying
611
+ * so is the point (issue #420). Across the loader the two forms are univocal:
612
+ *
613
+ * `(line N)` — the OFFENDING KEY's own line. Minted by `withKeyLine`'s first rung
614
+ * below, and by the unknown-key warnings (`renderUnknownKeyMessage`),
615
+ * which is key-exact-or-absent by construction: every `findUnknownKeys`
616
+ * call site passes a `positionOf` that resolves the offending key's own
617
+ * path, with no step fallback anywhere.
618
+ * `(step at line N)` the STEP's line. This helper, and `withKeyLine`'s fallback rung.
619
+ *
620
+ * Before that split both rungs rendered `(line N)`, so an author could not tell a cite that
621
+ * pointed AT the refused field from one that pointed at the declaration above it. The
622
+ * structured channel (`line`/`column`/`endLine`/`endColumn`) is unaffected it always carried
623
+ * the distinction; only the prose was ambiguous.
624
+ */
625
+ const withStepLine = (stepName, message) => {
626
+ const line = sourceMap.posOf(['steps', stepName])?.line;
627
+ return line === undefined ? message : `${message} (step at line ${line})`;
628
+ };
629
+ /**
630
+ * Like `withStepLine`, but names the OFFENDING KEY's own line (issue #417).
631
+ *
632
+ * For a key-scoped refusal the step's line is the wrong place to send someone: a long step has
633
+ * the key twenty lines below its own name, and the author reading `(line 40)` looks at the
634
+ * declaration rather than at the field being refused. The position map records every pairable
635
+ * mapping key, so the key's own line is available wherever the step's is.
636
+ *
637
+ * Falls back to the step's line, and then to no position at all — and the two real shapes land
638
+ * on DIFFERENT rungs, which is why both are pinned. A step body assembled through a merge key
639
+ * (`<<: *anchor`) leaves the KEY unpairable while the step's own name is still placeable, so it
640
+ * falls back to the step's line. A `use_template` step, whose keys are synthesized, exists at no
641
+ * line in the file at all and carries no position. Neither guesses a wrong line number sends
642
+ * an author confidently to the wrong place, which is worse than sending them nowhere.
643
+ *
644
+ * The two rungs render DIFFERENTLY (issue #420): rung 1 is ` (line N)`, the key's own line;
645
+ * rung 2 is ` (step at line N)`, the step's — the same vocabulary `withStepLine` above
646
+ * documents in full. They were previously indistinguishable, which made the fallback silently
647
+ * claim to be a key-exact cite. The two lookups are separate rather than one `??` chain for
648
+ * exactly that reason: a single chain cannot report WHICH rung answered.
649
+ */
650
+ const withKeyLine = (stepName, key, message) => {
651
+ const keyLine = sourceMap.posOf(['steps', stepName, key])?.line;
652
+ if (keyLine !== undefined)
653
+ return `${message} (line ${keyLine})`;
654
+ const stepLine = sourceMap.posOf(['steps', stepName])?.line;
655
+ if (stepLine !== undefined)
656
+ return `${message} (step at line ${stepLine})`;
657
+ return message;
658
+ };
659
+ /**
660
+ * The TOP-LEVEL sibling of `withStepLine`/`withKeyLine` (issue #553): names the line of a
661
+ * workflow-level key such as `context_wrapper` or `workflow_context.<name>`. No step
662
+ * fallback — there is no step — and no position at all when the key cannot be placed:
663
+ * absent-never-wrong, the loader's standing cite doctrine.
664
+ */
665
+ const withTopLevelLine = (path, message) => {
666
+ const line = sourceMap.posOf(path)?.line;
667
+ return line !== undefined ? `${message} (line ${line})` : message;
668
+ };
669
+ // Step 2: Top-level validation
670
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
671
+ throw new WorkflowError('Invalid workflow: Workflow must be a non-null object', {
585
672
  code: 'VALIDATION_WORKFLOW_SCHEMA',
586
673
  category: 'VALIDATION',
587
674
  agentAction: 'report_to_user',
588
675
  retryable: false,
589
676
  });
590
677
  }
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;
630
- }
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`);
656
- }
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.`);
663
- }
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}'`));
678
+ const doc = raw;
679
+ // WARN (do not reject) on a key that isn't authorable — checked against KNOWN_WORKFLOW_KEYS
680
+ // ONLY (not RUNTIME_ONLY_WORKFLOW_KEYS), and BEFORE any loader-stamped field is added below.
681
+ // Deliberately excluding runtime-only keys from "known" here means hand-authoring one (e.g.
682
+ // `schema_version:` or `model:` in YAML) warns too — those fields are stamped by the loader
683
+ // and any authored value is silently overwritten/ignored, which is exactly the kind of mistake
684
+ // this check exists to surface (issue #144). Non-breaking by design: siblings #170
685
+ // (hard-reject) and #169 (structured warnings channel) are deliberately out of scope here.
686
+ {
687
+ const workflowId = typeof doc['id'] === 'string' ? doc['id'] : '<unknown>';
688
+ warnings.push(...findUnknownKeys(doc, KNOWN_WORKFLOW_KEYS, {
689
+ scope: 'workflow',
690
+ code: 'UNKNOWN_WORKFLOW_KEY',
691
+ id: workflowId,
692
+ positionOf: (key) => sourceMap.posOf([key]),
693
+ }));
694
+ }
695
+ // Project extensions: hard error for string-based loading (fires before any other
696
+ // processing); shape validation (string | string[], relative-only) for file-based loading.
697
+ if ('extensions' in doc && doc['extensions'] !== undefined) {
698
+ if (!opts.allowExtensions) {
699
+ throw new WorkflowError(`Invalid workflow: 'extensions' requires file-based loading — no directory context is ` +
700
+ `available to resolve extension module paths. Register this workflow from its YAML ` +
701
+ `file (realm workflow register <path>).`, {
702
+ code: 'VALIDATION_WORKFLOW_SCHEMA',
703
+ category: 'VALIDATION',
704
+ agentAction: 'report_to_user',
705
+ retryable: false,
706
+ });
668
707
  }
708
+ errors.push(...validateExtensionsDeclaration(doc['extensions']));
669
709
  }
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'`));
672
- }
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
- }
694
- }
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)`));
698
- }
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)`));
702
- }
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'`));
707
- }
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`));
712
- }
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(', ')}`));
716
- }
717
- }
710
+ const REQUIRED_TOP_LEVEL = ['id', 'name', 'version', 'steps'];
711
+ for (const field of REQUIRED_TOP_LEVEL) {
712
+ if (!(field in doc)) {
713
+ errors.push(`Missing required field: '${field}'`);
718
714
  }
719
715
  }
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.'));
716
+ if ('version' in doc && typeof doc['version'] !== 'number') {
717
+ errors.push(`'version' must be a number`);
729
718
  }
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`));
749
- }
750
- }
751
- if (step['abort_unless'] === undefined) {
752
- errors.push(withStepLine(stepName, `Step '${stepName}': execution: guard requires 'abort_unless'`));
753
- }
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.`));
769
- }
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.'));
719
+ if ('steps' in doc &&
720
+ (typeof doc['steps'] !== 'object' || doc['steps'] === null || Array.isArray(doc['steps']))) {
721
+ errors.push(`'steps' must be a non-null object`);
800
722
  }
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.'));
723
+ if (errors.length > 0) {
724
+ throw new WorkflowError(`Invalid workflow: ${errors.join('; ')}`, {
725
+ // issue #425: the pre-join strings — see the profile collector above.
726
+ errors: [...errors],
727
+ code: 'VALIDATION_WORKFLOW_SCHEMA',
728
+ category: 'VALIDATION',
729
+ agentAction: 'report_to_user',
730
+ retryable: false,
731
+ });
854
732
  }
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']
733
+ // Step 1b: Resolve template instantiations before validation.
734
+ const rawTemplates = (doc['templates'] ?? {});
735
+ if (Object.keys(rawTemplates).length > 0 || hasUseTemplateInSteps(doc['steps'])) {
736
+ doc['steps'] = resolveTemplates(doc['steps'], rawTemplates);
737
+ }
738
+ const stepsRaw = doc['steps'];
739
+ // Finalizer-bearing workflows write every claim `deadline: null` (issue #101), so a per-step
740
+ // `idempotent` hint is INERT there (never cron-reclaimable). Used only to WARN below.
741
+ const hasFinalizerStep = Object.values(stepsRaw).some((s) => typeof s === 'object' &&
742
+ s !== null &&
743
+ s['execution'] === 'finalizer');
744
+ // Step 3: Per-step validation
745
+ for (const [stepName, stepRaw] of Object.entries(stepsRaw)) {
746
+ if (typeof stepRaw !== 'object' || stepRaw === null || Array.isArray(stepRaw)) {
747
+ errors.push(withStepLine(stepName, `Step '${stepName}' must be an object`));
748
+ continue;
749
+ }
750
+ const step = stepRaw;
751
+ // issue #220 §4c (PR-3): HOISTED out of the `when`-only block below (was block-local there) so
752
+ // ALL THREE condition surfaces (when/abort_unless/preconditions) can thread the real
753
+ // depends_on list into validateConditionLeaf's `$settlement` one-hop check. Previously
754
+ // abort_unless/preconditions passed a literal `[]` (no reference validation existed for them
755
+ // at all); the legacy when-only depends_on/run.params check (validateWhenReference) is
756
+ // UNCHANGED — it still fires ONLY for `surface === 'when'`. This is a LIFT, not a new
757
+ // computation — byte-identical to the previous block-local `dependsOn` for `when`'s own use.
758
+ // The step's kind, once, for every kind-forked check below (undefined = malformed or
759
+ // missing execution — already refused by the invalid-execution/required error).
760
+ const stepKind = VALID_EXECUTIONS.has(step['execution'])
761
+ ? step['execution']
868
762
  : undefined;
869
- const onTimeoutDeclared = stepRetry?.['on_timeout'] === true;
870
- warnings.push({
871
- code: 'IDEMPOTENT_INERT_IN_FINALIZER',
872
- severity: resolveSeverity('IDEMPOTENT_INERT_IN_FINALIZER'),
763
+ const dependsOn = Array.isArray(step['depends_on'])
764
+ ? step['depends_on'].filter((d) => typeof d === 'string')
765
+ : [];
766
+ // WARN (do not reject) on an unknown step key — runs after template resolution above, so a
767
+ // template-expanded step's keys are checked too. Same non-breaking posture as the
768
+ // workflow-level check (issue #144).
769
+ warnings.push(...findUnknownKeys(step, KNOWN_STEP_KEYS, {
873
770
  scope: 'step',
771
+ code: 'UNKNOWN_STEP_KEY',
874
772
  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`));
773
+ positionOf: (key) => sourceMap.posOf(['steps', stepName, key]),
774
+ }));
775
+ // issue #220 §4c PR ordering interlock: `$settlement` is reserved NOW (PR-1) even though the
776
+ // namespace it names is not minted until a later PR — else the inter-PR gap could register a
777
+ // `$settlement`-named step that becomes a load-refused fossil the instant the mint ships.
778
+ if (stepName === 'run' || stepName === 'context' || stepName === '$settlement') {
779
+ errors.push(`Step name '${stepName}' is reserved and cannot be used as a step identifier`);
896
780
  }
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'])})`));
781
+ // Reject integer-like step names: JS object iteration reorders integer-like keys ahead
782
+ // of insertion order, which would silently break the declaration-order guarantees the
783
+ // eligibility loops and finalizer drain rely on (both iterate via Object.entries).
784
+ if (/^\d+$/.test(stepName)) {
785
+ errors.push(`Step name '${stepName}' is invalid: integer-like names reorder under JS object ` +
786
+ `iteration and would break declaration-order execution. Use a non-numeric name.`);
899
787
  }
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)}`));
788
+ const REQUIRED_STEP = ['description', 'execution'];
789
+ for (const field of REQUIRED_STEP) {
790
+ if (!(field in step)) {
791
+ errors.push(withStepLine(stepName, `Step '${stepName}': missing required field '${field}'`));
913
792
  }
914
793
  }
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`));
794
+ if ('execution' in step && !VALID_EXECUTIONS.has(step['execution'])) {
795
+ errors.push(withStepLine(stepName, `Step '${stepName}': invalid execution value '${String(step['execution'])}'; must be 'auto', 'agent', 'guard', or 'finalizer'`));
928
796
  }
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`));
797
+ // issue #517 (the drive-flip): ONE registry-driven walk mints every kind-prohibition —
798
+ // for each declared key × the step's kind, a `prohibited` cell WITHOUT an except arm
799
+ // mints exactly one refusal, message text per-cell data (renderRegistryProhibition).
800
+ // Sits at the sequence position of the EARLIEST check it replaced (the old finalizer
801
+ // prohibited-field loop), so minted refusals still precede the structural finalizer/guard
802
+ // requirements below. Multi-fire is dead by construction (one lookup, one refusal per
803
+ // key×kind), and a multi-bad-key step now errors in YAML declaration order.
804
+ //
805
+ // Except-bearing cells are SKIPPED — their value-conditional checks stay hand-written
806
+ // (today exactly trust×finalizer, below). Companion/value/sub-key rules are not minted at
807
+ // all (the clang line): toolsMissing, the tools agent+handler clause, the gate block,
808
+ // retry E1-E3, structured_output literal+eligibility, trace_schema compile, pos-int
809
+ // checks all stay hand-written further down.
810
+ //
811
+ // The kind gate is deliberate: on a step whose `execution` is missing or not one of the
812
+ // four kinds, the registry has no row to consult, so NO per-key kind refusal is minted —
813
+ // the invalid-execution/missing-required error above is the whole verdict. (Pre-#517 the
814
+ // per-key `!== '<kind>'` twins ALSO fired on malformed kinds; that was per-key advice
815
+ // keyed to a kind nobody declared. The workflow is refused either way — the refusal
816
+ // POPULATION is unchanged; disclosed in the changelog.)
817
+ if (VALID_EXECUTIONS.has(step['execution'])) {
818
+ const kind = step['execution'];
819
+ for (const key of Object.keys(step)) {
820
+ // Unknown keys are UNKNOWN_STEP_KEY's business (warned above), never a registry row.
821
+ const row = STEP_KEY_REGISTRY[key];
822
+ if (row === undefined || step[key] === undefined)
823
+ continue;
824
+ const cell = row[kind];
825
+ if (cell.c !== 'prohibited' || cell.except !== undefined)
826
+ continue;
827
+ errors.push(withKeyLine(stepName, key, `Step '${stepName}': ${renderRegistryProhibition(key, kind, cell)}`));
828
+ }
932
829
  }
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, {
940
- scope: 'step',
941
- code: 'UNKNOWN_VALIDATION_EXHAUSTION_KEY',
942
- 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)`));
830
+ // Finalizer step constraints (a workflow-level try/catch/finally). handler-only in v1.
831
+ if (step['execution'] === 'finalizer') {
832
+ // A finalizer must not gate reject any human-gate trust level. Value-conditional
833
+ // (`trust: 'auto'` is lawful), which is why this is the registry's except-bearing cell
834
+ // and stays hand-written rather than minted (#517).
835
+ if (step['trust'] !== undefined && step['trust'] !== 'auto') {
836
+ // issue #508: the reason forks on whether the declared value is actually a GATE
837
+ // literal — "a finalizer must not gate" is only true THEN. Any other value (an
838
+ // unrecognized trust, a service-trust literal, the retired human_notified) was never
839
+ // an attempt to gate at all, so that reason would be false for it — the #523 class,
840
+ // caught before shipping rather than after. `isGateTrust` is the pure-value question
841
+ // (no kind involved, since this branch already knows the kind and has already
842
+ // excluded 'auto'); the leading `'trust:` is kept exactly as before so the registry
843
+ // conformance runner's `namesKey` (`error.includes("'trust")`) still matches this arm
844
+ // — `namesKey` needs the quote BEFORE `trust`, not around the value, so switching the
845
+ // value's own rendering below does not touch it.
846
+ //
847
+ // issue #508 (final correction): the gate-literal arm stays a hand-written KIND
848
+ // prohibition (the key is the offense, not the value — #517's own boundary), but now
849
+ // shares `renderTrustValue` with every other arm — a previous ruling to "keep
850
+ // `String()` here to satisfy `namesKey`" was wrong (verified above) and there was
851
+ // never a real reason for two renderers, even though `isGateTrust` only ever admits
852
+ // the two known-string gate literals here in practice. The non-gate branch (an
853
+ // unrecognized trust, a service-trust literal, the retired human_notified) routes
854
+ // through the SAME composer every other refusal surface uses — no second hand-built
855
+ // arm-selector, no second value renderer. The conformance fixture (`buildFixture`)
856
+ // exercises only the gate-literal branch (`human_confirmed`), so the composer's three
857
+ // sub-arms are unreached by it — verified by grepping the fixture builder for this key.
858
+ const rawFinalizerTrust = step['trust'];
859
+ const finalizerMessage = isGateTrust(rawFinalizerTrust)
860
+ ? `Step '${stepName}': 'trust: ${renderTrustValue(rawFinalizerTrust)}' is not valid on execution: finalizer steps (a finalizer must not gate)`
861
+ : buildTrustRefusal({
862
+ kind: 'finalizer',
863
+ value: rawFinalizerTrust,
864
+ step: stepName,
865
+ surface: 'load',
866
+ });
867
+ errors.push(withStepLine(stepName, finalizerMessage));
952
868
  }
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)})`));
869
+ // v1 is handler-only.
870
+ if (step['handler'] === undefined) {
871
+ errors.push(withStepLine(stepName, `Step '${stepName}': execution: finalizer requires 'handler' (handler-only in v1)`));
957
872
  }
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)`));
873
+ // on_outcome is required, non-empty, every value in the FinalizerTrigger enum.
874
+ const rawOutcome = step['on_outcome'];
875
+ if (rawOutcome === undefined) {
876
+ errors.push(withStepLine(stepName, `Step '${stepName}': execution: finalizer requires 'on_outcome'`));
877
+ }
878
+ else {
879
+ const outcomes = Array.isArray(rawOutcome) ? rawOutcome : [rawOutcome];
880
+ if (outcomes.length === 0) {
881
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'on_outcome' must not be empty`));
967
882
  }
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}`));
883
+ for (const o of outcomes) {
884
+ if (typeof o !== 'string' || !VALID_FINALIZER_TRIGGERS.has(o)) {
885
+ errors.push(withStepLine(stepName, `Step '${stepName}': invalid on_outcome value '${String(o)}'; must be one of ${[...VALID_FINALIZER_TRIGGERS].join(', ')}`));
990
886
  }
991
887
  }
992
888
  }
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
- });
889
+ }
890
+ // issue #508 (L1) trust VALUE validation, auto/agent only. Before this check, an
891
+ // unrecognized or kind-inert `trust` (a typo, a service-trust literal, the retired
892
+ // `human_notified`) loaded clean, warned nothing, and ran with NO gate — the step's own
893
+ // declared human-approval control was silently disabled. Presence-keyed (`'trust' in
894
+ // step`, the same convention `trigger_rule` and `retry.backoff` already use below) so
895
+ // `trust:`/`trust: ~` (a null value, which loads clean today) is caught too — a blank
896
+ // declaration of a safety control is itself a false statement, not a no-op. `trust:`
897
+ // absent entirely is lawful (nothing was declared) and never reaches this block.
898
+ //
899
+ // Guard's OWN trust prohibition is minted by the #517 walk above (every value refused,
900
+ // no except arm); finalizer's is the hand-written except-cell just above (only 'auto' is
901
+ // lawful there). This block is what closes the remaining two kinds — the ones where a
902
+ // RECOGNIZED gate literal is meaningful, so an unrecognized one needs a VALUE verdict,
903
+ // not a kind verdict.
904
+ //
905
+ // issue #508 (final correction) — this whole value-refusal composition, for every kind and
906
+ // every surface, is now `buildTrustRefusal` (types/workflow-definition.ts, beside
907
+ // `classifyStepTrust`). Three prior rounds each hand-composed this text independently on
908
+ // this surface, execution-loop.ts's dispatch refusal, run-health.ts's finding, and the
909
+ // protocol generator's briefing — and every defect those rounds found (arm divergence, a
910
+ // String()-rendered array printing as its own first element, a grammar seam) fell out of
911
+ // that duplication. No site chooses an arm or renders a value on its own again; see the
912
+ // composer's own doc for the arm/mood/rendering contract in full.
913
+ if ((stepKind === 'auto' || stepKind === 'agent') &&
914
+ 'trust' in step &&
915
+ classifyStepTrust(stepKind, step['trust']) === 'refuse') {
916
+ errors.push(withKeyLine(stepName, 'trust', buildTrustRefusal({
917
+ kind: stepKind,
918
+ value: step['trust'],
919
+ step: stepName,
920
+ surface: 'load',
921
+ })));
922
+ }
923
+ // Guard step constraints (the guard kind-prohibitions, including `preconditions` — issue
924
+ // #369's own bespoke message, message_data-preserved — are minted by the #517 walk above).
925
+ if (step['execution'] === 'guard') {
926
+ if (step['abort_unless'] === undefined) {
927
+ errors.push(withStepLine(stepName, `Step '${stepName}': execution: guard requires 'abort_unless'`));
1005
928
  }
1006
929
  }
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`));
930
+ // llm_timeout_seconds must be a positive integer when present (the same convention as
931
+ // retry.total_timeout_seconds and gate.timeout_seconds). Deliberately kind-BLIND, which
932
+ // makes it the pinned DOUBLE-fire control for #517: on a wrong-kind step BOTH the minted
933
+ // prohibition and this shape error fire this check was never else-if-suppressed, unlike
934
+ // structured_output/validation_exhaustion/input_map's value checks below.
935
+ if (step['llm_timeout_seconds'] !== undefined &&
936
+ (!Number.isInteger(step['llm_timeout_seconds']) ||
937
+ step['llm_timeout_seconds'] <= 0)) {
938
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'llm_timeout_seconds' must be a positive integer`));
1047
939
  }
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, {
940
+ // WARN (do not reject): an idempotent auto step in a finalizer-bearing workflow gets
941
+ // `deadline: null` (issue #101), so the RECLAIM function is inert — `realm run reclaim --all`
942
+ // can never select it. The author should know it stays per-step-manual-reclaim-only.
943
+ //
944
+ // Issue #140 C5 (variant-aware reword): `idempotent` now has a SECOND function — gating
945
+ // `retry.on_timeout` — and that GATE function is live in every workflow, finalizer-bearing or
946
+ // not (shouldEnforceTimeout has no finalizer conjunct). A single unconditional message would
947
+ // either keep a falsehood (claiming idempotent is wholly inert when on_timeout is ALSO
948
+ // declared) or gratuitously mention a gate the author never declared (idempotent-alone case)
949
+ // — so the message is keyed on `step.retry?.on_timeout`, pinned by both-variant loader tests.
950
+ if (step['idempotent'] === true && step['execution'] === 'auto' && hasFinalizerStep) {
951
+ const stepRetry = typeof step['retry'] === 'object' && step['retry'] !== null
952
+ ? step['retry']
953
+ : undefined;
954
+ const onTimeoutDeclared = stepRetry?.['on_timeout'] === true;
955
+ warnings.push({
956
+ code: 'IDEMPOTENT_INERT_IN_FINALIZER',
957
+ severity: resolveSeverity('IDEMPOTENT_INERT_IN_FINALIZER'),
1053
958
  scope: 'step',
1054
- code: 'UNKNOWN_GATE_KEY',
1055
959
  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 precedentthe 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`));
1064
- }
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`));
1068
- }
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
- }
1100
- }
960
+ message: `Step '${stepName}': 'idempotent: true' cannot enable auto-reclaim in a finalizer-bearing ` +
961
+ `workflow (its claim carries no deadline, so 'realm run reclaim --all' can never select ` +
962
+ `it). Recover it with 'realm run reclaim <run-id> --step ${stepName} --force'.` +
963
+ (onTimeoutDeclared
964
+ ? ` Its 'retry.on_timeout' gate role is unaffected timeout retries remain active.`
965
+ : ''),
966
+ });
967
+ }
968
+ // issue #236 (L0 prevention layer): structured_output's only legal value is the literal
969
+ // 'strict'. On an opted-in step, Phase A REJECTS an ineligible verdict at load time — the
970
+ // API provably rejects some legal schemas and silently weakens others, so authoring never
971
+ // ships a schema the gate already knows is unsafe. Caveats are NOT rejected (informational
972
+ // only, surfaced by validate's nudge — Deliverable 7); this loader block only ever REJECTS.
973
+ // #517 re-gate: the kind half is minted by the registry walk above; the value checks
974
+ // below keep their old else-branch semantics via an explicit valid-kind conjunct — a
975
+ // wrong-kind step gets ONLY the minted refusal, never the value noise.
976
+ if (step['structured_output'] !== undefined && step['execution'] === 'agent') {
977
+ if (step['structured_output'] !== 'strict') {
978
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'structured_output' must be the literal string 'strict' (got ${JSON.stringify(step['structured_output'])})`));
1101
979
  }
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'.`,
980
+ else {
981
+ const verdict = assessStructuredOutputEligibility({
982
+ ...(step['output_schema'] !== undefined
983
+ ? { output_schema: step['output_schema'] }
984
+ : {}),
985
+ ...(step['input_schema'] !== undefined
986
+ ? { input_schema: step['input_schema'] }
987
+ : {}),
988
+ ...(step['tools'] !== undefined ? { tools: step['tools'] } : {}),
1112
989
  });
990
+ if (verdict.verdict === 'ineligible') {
991
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'structured_output: strict' is not eligible for this step's ` +
992
+ `schema — ${renderIneligibleMessage(verdict.reasons)}`));
993
+ }
1113
994
  }
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
- });
995
+ }
996
+ // issue #220 (PR-2): validation_exhaustion is only valid on execution: agent steps — the
997
+ // countable rejection set (VALIDATION_INPUT_SCHEMA/VALIDATION_OUTPUT_SCHEMA) is agent-only by
998
+ // construction (execution-loop.ts's countRejection). Full rule table: `mode` value validated
999
+ // (REFUSE on an unrecognized value — unvalidatable posture, fail-closed); `mode: 'default'`
1000
+ // requires `default_output` (REFUSE — nothing to substitute) which in turn requires the step's
1001
+ // own `output_schema` (REFUSE — B5, an unvalidatable default) against which `default_output` is
1002
+ // then AJV-proven AT LOAD TIME (REFUSE — B10, reusing the runtime validator so load-time and
1003
+ // runtime verdicts can never diverge); `default_output` present without `mode: 'default'` WARNS
1004
+ // as dead config (never rejects — it's simply inert); an unknown sub-key WARNS.
1005
+ // #517 re-gate: kind half minted above; else-semantics preserved by the explicit conjunct.
1006
+ if (step['validation_exhaustion'] !== undefined && step['execution'] === 'agent') {
1007
+ if (typeof step['validation_exhaustion'] !== 'object' ||
1008
+ step['validation_exhaustion'] === null) {
1009
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion' must be an object`));
1125
1010
  }
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'),
1011
+ else {
1012
+ const exhaustionBlock = step['validation_exhaustion'];
1013
+ // WARN (do not reject) on an unknown validation_exhaustion sub-key — the retry-block-style
1014
+ // pattern (issue #140's UNKNOWN_RETRY_KEY), its OWN code (issue #220 PR-2 mints
1015
+ // UNKNOWN_VALIDATION_EXHAUSTION_KEY, replacing PR-1's UNKNOWN_STEP_KEY noun-override —
1016
+ // closes the #170-flip incoherence against the structurally identical retry-key family).
1017
+ warnings.push(...findUnknownKeys(exhaustionBlock, KNOWN_VALIDATION_EXHAUSTION_KEYS, {
1134
1018
  scope: 'step',
1019
+ code: 'UNKNOWN_VALIDATION_EXHAUSTION_KEY',
1135
1020
  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
- });
1021
+ noun: 'validation_exhaustion',
1022
+ positionOf: (key) => sourceMap.posOf(['steps', stepName, 'validation_exhaustion', key]),
1023
+ }));
1024
+ if ('threshold' in exhaustionBlock &&
1025
+ (!Number.isInteger(exhaustionBlock['threshold']) ||
1026
+ exhaustionBlock['threshold'] < 1)) {
1027
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.threshold' must be a positive integer ` +
1028
+ `(1 is legal — it disables in-drive schema-repair, since the first rejection ` +
1029
+ `already meets it)`));
1030
+ }
1031
+ const modeValue = exhaustionBlock['mode'];
1032
+ if (modeValue !== undefined && modeValue !== 'fail' && modeValue !== 'default') {
1033
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.mode' must be 'fail' or 'default' ` +
1034
+ `(got: ${JSON.stringify(modeValue)})`));
1035
+ }
1036
+ const hasDefaultOutput = 'default_output' in exhaustionBlock;
1037
+ if (modeValue === 'default') {
1038
+ if (!hasDefaultOutput) {
1039
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.mode: default' requires ` +
1040
+ `'default_output' (nothing to substitute on exhaustion)`));
1041
+ }
1042
+ else if (step['output_schema'] === undefined) {
1043
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.default_output' requires the step to ` +
1044
+ `declare 'output_schema' (an undeclared schema makes the default unvalidatable)`));
1045
+ }
1046
+ else {
1047
+ // B10 — load-time AJV proof: REUSE the runtime validator so the load-time verdict can
1048
+ // never diverge from the runtime verdict for the exact same (default_output,
1049
+ // output_schema) pair. This is the loader's first load-time Ajv compile of an
1050
+ // output_schema (today output_schema is only compiled at runtime), so the catch below
1051
+ // legitimately sees TWO different populations: a VALIDATION_OUTPUT_SCHEMA
1052
+ // WorkflowError (default_output fails the schema) and a raw Ajv schema-compilation
1053
+ // Error (a structurally malformed output_schema) — both fail-closed to a load refusal,
1054
+ // but they carry their detail DIFFERENTLY: a WorkflowError has `.details.errors`; a raw
1055
+ // Error has NO `.details` at all (reading `.details.errors` on it throws a TypeError
1056
+ // that would escape the loader mid-walk — verified empirically). Discriminate.
1057
+ try {
1058
+ validateOutputSchema(exhaustionBlock['default_output'], step['output_schema'], stepName);
1059
+ }
1060
+ catch (err) {
1061
+ const detail = err instanceof WorkflowError
1062
+ ? JSON.stringify(err.details['errors'])
1063
+ : err instanceof Error
1064
+ ? err.message
1065
+ : String(err);
1066
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.default_output' does not validate ` +
1067
+ `against the step's own 'output_schema': ${detail}`));
1068
+ }
1069
+ }
1070
+ }
1071
+ else if (hasDefaultOutput) {
1072
+ // default_output present without mode: 'default' (mode: 'fail' or absent) — inert, not
1073
+ // an error: WARN as dead config rather than silently ignoring it.
1074
+ warnings.push({
1075
+ code: 'DEAD_VALIDATION_EXHAUSTION_CONFIG',
1076
+ severity: resolveSeverity('DEAD_VALIDATION_EXHAUSTION_CONFIG'),
1077
+ scope: 'step',
1078
+ step: stepName,
1079
+ message: `Step '${stepName}': 'validation_exhaustion.default_output' is ignored without ` +
1080
+ `'mode: default' — set 'validation_exhaustion.mode: default' to enable it, or ` +
1081
+ `remove 'default_output'.`,
1082
+ });
1083
+ }
1140
1084
  }
1141
1085
  }
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, {
1086
+ // WARN (do not reject): an agent step declaring BOTH input_schema and output_schema has its
1087
+ // submitted output validated against BOTH (execution-loop.ts validateInputSchema AND
1088
+ // validateOutputSchema) a divergence between the two degrades to a confusing recoverable
1089
+ // VALIDATION_*_SCHEMA error rather than a clean failure. Detection/warn only; no schema change.
1090
+ if (step['execution'] === 'agent' &&
1091
+ step['input_schema'] !== undefined &&
1092
+ step['output_schema'] !== undefined) {
1093
+ warnings.push({
1094
+ code: 'DUAL_SCHEMA_DECLARED',
1095
+ severity: resolveSeverity('DUAL_SCHEMA_DECLARED'),
1162
1096
  scope: 'step',
1163
- code: 'UNKNOWN_RETRY_KEY',
1164
1097
  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'.`));
1098
+ message: `Step '${stepName}': declares both input_schema and output_schema; the agent's submitted ` +
1099
+ `output is validated against both prefer one to avoid divergence.`,
1100
+ });
1101
+ }
1102
+ // trace_validation_mode must be 'warn' or 'enforce' when provided.
1103
+ if (step['trace_validation_mode'] !== undefined &&
1104
+ step['trace_validation_mode'] !== 'warn' &&
1105
+ step['trace_validation_mode'] !== 'enforce') {
1106
+ errors.push(withStepLine(stepName, `Step '${stepName}': invalid trace_validation_mode '${String(step['trace_validation_mode'])}'; must be 'warn' or 'enforce'`));
1107
+ }
1108
+ // issue #433: the effective STATIC gate choice source, hoisted per-step BEFORE the
1109
+ // `gate:`-block-only region below. Member (b) below must fire even when there is NO `gate:`
1110
+ // key at all (the executed g433b shape: a gate-trusted step with no gate block but an
1111
+ // empty `input_schema.properties.choice.enum`), and the entire #291 region beneath this one
1112
+ // is gated on `step['gate'] !== undefined` it cannot host a check that must fire without
1113
+ // one. Pure reads; the existing E2/membership cells (below) pin messages, not evaluation
1114
+ // order, so hoisting these three declarations ahead of them is safe.
1115
+ const gateObj = typeof step['gate'] === 'object' && step['gate'] !== null
1116
+ ? step['gate']
1117
+ : undefined;
1118
+ const declaredGateChoices = gateObj?.['choices'];
1119
+ const declaredChoiceEnum = step['input_schema']?.properties?.['choice']?.enum;
1120
+ // Renders the OFFENDING KEY's own line via its full nested path, falling back to the
1121
+ // step's line and then to no position the same two-rung univocal vocabulary
1122
+ // `withKeyLine` documents above (issue #420: `(line N)` for the key, `(step at line N)`
1123
+ // for the step; never conflated). `withKeyLine` itself is single-segment
1124
+ // (`['steps', stepName, key]`) and cannot express a nested path like
1125
+ // `['steps', stepName, 'gate', 'choices']`, so this is a local sibling rather than a call
1126
+ // to it no existing `withKeyLine` call site is touched.
1127
+ const withPathLine = (path, message) => {
1128
+ const keyLine = sourceMap.posOf(path)?.line;
1129
+ if (keyLine !== undefined)
1130
+ return `${message} (line ${keyLine})`;
1131
+ const stepLine = sourceMap.posOf(['steps', stepName])?.line;
1132
+ if (stepLine !== undefined)
1133
+ return `${message} (step at line ${stepLine})`;
1134
+ return message;
1135
+ };
1136
+ // Member (a) (issue #433): a DECLARED `gate.choices` list that is empty is never right, on
1137
+ // ANY step — gate-trusted or not (the #291 block's own posture just below: a `gate:` key
1138
+ // is validated "regardless of trust"; the #417 strict-on-known-key policy agrees). An empty
1139
+ // list on a gate-trusted step mints an unanswerable gate (every response is refused against
1140
+ // an empty expected set) with no disposal path short of an authored expiry — on an ungated
1141
+ // step it is dead weight either way, so the message is deliberately population-invariant
1142
+ // rather than false for the ungated population.
1143
+ if (Array.isArray(declaredGateChoices) && declaredGateChoices.length === 0) {
1144
+ errors.push(withPathLine(['steps', stepName, 'gate', 'choices'], `Step '${stepName}': 'gate.choices', when declared, must be non-empty — an empty ` +
1145
+ 'list is never right: on a gate-trusted step (trust: human_confirmed/human_reviewed) ' +
1146
+ 'it mints a gate NO response can ever resolve (every submission is refused against ' +
1147
+ 'an empty expected list, and the live run wedges with no disposal path: abandon ' +
1148
+ 'refuses a pending gate; purge and drain refuse a live run; only an authored ' +
1149
+ "'gate.timeout_seconds' + 'on_expiry' expiry could ever clear it). Declare at least " +
1150
+ "one choice, or remove the key to fall back to 'input_schema.properties.choice.enum' " +
1151
+ 'or the default pair (approve/reject).'));
1152
+ }
1153
+ // Member (b) (issue #433): for a GATE-TRUSTED step with no `gate.choices` list declared
1154
+ // (NULLISH — the mint's own `??` semantics; `gate: {choices:}` with a YAML-null value is
1155
+ // the third executed wedge shape, and presence-keying would let it escape this check), a
1156
+ // DECLARED-and-empty `input_schema.properties.choice.enum` is the effective choice source
1157
+ // and the same class of error, under its own key. `choices: null` with no `enum` at all
1158
+ // stays legal — the mint defaults to ['approve', 'reject'].
1159
+ if (isGateTrust(step['trust']) &&
1160
+ declaredGateChoices == null &&
1161
+ Array.isArray(declaredChoiceEnum) &&
1162
+ declaredChoiceEnum.length === 0) {
1163
+ errors.push(withPathLine(['steps', stepName, 'input_schema', 'properties', 'choice', 'enum'], `Step '${stepName}': 'input_schema.properties.choice.enum' is this gate's effective ` +
1164
+ "choice source (no 'gate.choices' list declared) and, when declared, must be " +
1165
+ 'non-empty — an empty list mints a gate NO response can ever resolve (every ' +
1166
+ 'submission is refused against an empty expected list, and the live run wedges ' +
1167
+ 'with no disposal path: abandon refuses a pending gate; purge and drain refuse a ' +
1168
+ "live run; only an authored 'gate.timeout_seconds' + 'on_expiry' expiry could ever " +
1169
+ "clear it). Declare at least one enum value, or remove 'enum' to get the default " +
1170
+ 'pair (approve/reject).'));
1171
+ }
1172
+ // issue #291 (authorable gate timeout — the FIRST validation the `gate:` block has ever had):
1173
+ // the E2 positive-integer checks on timeout_seconds/reminder_seconds/reminder_max, the
1174
+ // on_expiry enum, default_choice's required-iff + choice-set validation, and the dead-config
1175
+ // warn cells. The hard-error checks run regardless of `trust` (a shape/enum mistake is a
1176
+ // mistake whether or not this step can ever gate). The dead-config ADVISORIES fork on
1177
+ // `isGateTrust` (issue #524): the engine mints a gate only where trust requires human
1178
+ // confirmation (Step 5b, `execution-loop.ts`) — on any other step the WHOLE block is inert,
1179
+ // so a per-member remedy ("set a timeout") would be false: following it never makes the key
1180
+ // live, it only silences the one diagnostic that said so.
1181
+ if (step['gate'] !== undefined) {
1182
+ if (typeof step['gate'] !== 'object' || step['gate'] === null) {
1183
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'gate' must be an object`));
1207
1184
  }
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'),
1185
+ else {
1186
+ const gate = step['gate'];
1187
+ // WARN (do not reject) on an unknown gate-block key same non-breaking posture as
1188
+ // retry/validation_exhaustion (issues #140/#220).
1189
+ warnings.push(...findUnknownKeys(gate, KNOWN_GATE_KEYS, {
1215
1190
  scope: 'step',
1191
+ code: 'UNKNOWN_GATE_KEY',
1216
1192
  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
- });
1193
+ noun: 'gate',
1194
+ positionOf: (key) => sourceMap.posOf(['steps', stepName, 'gate', key]),
1195
+ }));
1196
+ // E2: timeout_seconds/reminder_seconds/reminder_max must each be a positive integer
1197
+ // (yaml-loader :1164-1174 precedent — the SAME convention as retry.total_timeout_seconds).
1198
+ if ('timeout_seconds' in gate &&
1199
+ (!Number.isInteger(gate['timeout_seconds']) || gate['timeout_seconds'] <= 0)) {
1200
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.timeout_seconds' must be a positive integer`));
1201
+ }
1202
+ if ('reminder_seconds' in gate &&
1203
+ (!Number.isInteger(gate['reminder_seconds']) ||
1204
+ gate['reminder_seconds'] <= 0)) {
1205
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.reminder_seconds' must be a positive integer`));
1206
+ }
1207
+ if ('reminder_max' in gate &&
1208
+ (!Number.isInteger(gate['reminder_max']) || gate['reminder_max'] <= 0)) {
1209
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.reminder_max' must be a positive integer`));
1210
+ }
1211
+ // on_expiry must be 'settle_default' or 'abort' when provided.
1212
+ const onExpiry = gate['on_expiry'];
1213
+ if (onExpiry !== undefined && onExpiry !== 'settle_default' && onExpiry !== 'abort') {
1214
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.on_expiry' must be 'settle_default' or 'abort' (got: ${JSON.stringify(onExpiry)})`));
1215
+ }
1216
+ // default_choice: REQUIRED iff on_expiry === 'settle_default' (E2-style hard error,
1217
+ // mirroring validation_exhaustion.mode:'default' requiring default_output); validated
1218
+ // against the step's own EFFECTIVE STATIC choice set — the EXACT same three-source
1219
+ // derivation the engine mints PendingGate.choices from (execution-loop.ts's gate-open
1220
+ // site: gate.choices ?? input_schema.properties.choice.enum ?? ['approve','reject']),
1221
+ // sourced from the issue #433 hoist above (`declaredGateChoices ?? declaredChoiceEnum`
1222
+ // — one chain, so this can never drift from the mint's) — so a load-time-legal
1223
+ // default_choice can NEVER fail at enactment time.
1224
+ const hasDefaultChoice = 'default_choice' in gate;
1225
+ // issue #524: the one place this block's dead-config ADVISORIES fork. The mint only
1226
+ // ever reads `gate.*` where `isGateTrust(trust)` holds (execution-loop.ts:3302,
1227
+ // `W_GATE_MINT_TRUST`) — computed once so the three sites below can never disagree.
1228
+ const gateTrusted = isGateTrust(step['trust']);
1229
+ if (onExpiry === 'settle_default') {
1230
+ if (!hasDefaultChoice) {
1231
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.on_expiry: settle_default' requires 'gate.default_choice' ` +
1232
+ `(nothing to resolve the gate with on expiry)`));
1233
+ }
1234
+ else {
1235
+ const choicesRaw = declaredGateChoices ?? declaredChoiceEnum;
1236
+ const effectiveChoices = Array.isArray(choicesRaw)
1237
+ ? choicesRaw
1238
+ : ['approve', 'reject'];
1239
+ if (!effectiveChoices.includes(gate['default_choice'])) {
1240
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.default_choice' (${JSON.stringify(gate['default_choice'])}) ` +
1241
+ `is not one of the step's effective choices: ${effectiveChoices.join(', ')}`));
1242
+ }
1243
+ }
1244
+ }
1245
+ else if (hasDefaultChoice && gateTrusted) {
1246
+ // default_choice with on_expiry:'abort' or with no on_expiry at all — inert, not an
1247
+ // error: WARN as dead config (the #220 DEAD_VALIDATION_EXHAUSTION_CONFIG precedent).
1248
+ // Gate-trusted only (issue #524) — off gate trust the block advisory below covers it.
1249
+ warnings.push({
1250
+ code: 'DEAD_GATE_CONFIG',
1251
+ severity: resolveSeverity('DEAD_GATE_CONFIG'),
1252
+ scope: 'step',
1253
+ step: stepName,
1254
+ message: `Step '${stepName}': 'gate.default_choice' is ignored without ` +
1255
+ `'gate.on_expiry: settle_default' — set it, or remove 'gate.default_choice'.`,
1256
+ });
1257
+ }
1258
+ if (gateTrusted) {
1259
+ // Dead config: on_expiry declared but no timeout_seconds — nothing will ever trigger
1260
+ // the enforce clock, so the declared disposition can never enact. Gate-trusted only
1261
+ // (issue #524): off gate trust the block advisory below covers it.
1262
+ if (onExpiry !== undefined && gate['timeout_seconds'] === undefined) {
1263
+ warnings.push({
1264
+ code: 'DEAD_GATE_CONFIG',
1265
+ severity: resolveSeverity('DEAD_GATE_CONFIG'),
1266
+ scope: 'step',
1267
+ step: stepName,
1268
+ message: `Step '${stepName}': 'gate.on_expiry' is ignored without 'gate.timeout_seconds' ` +
1269
+ `— set a timeout, or remove 'gate.on_expiry'.`,
1270
+ });
1271
+ }
1272
+ // Dead notification ([F-A2-5]): reminder_seconds >= timeout_seconds means the FIRST
1273
+ // reminder occurrence would never fire before the enforce clock expires. Gate-trusted
1274
+ // only (issue #524): off gate trust the block advisory below covers it.
1275
+ if (typeof gate['reminder_seconds'] === 'number' &&
1276
+ typeof gate['timeout_seconds'] === 'number' &&
1277
+ gate['reminder_seconds'] >= gate['timeout_seconds']) {
1278
+ warnings.push({
1279
+ code: 'DEAD_GATE_CONFIG',
1280
+ severity: resolveSeverity('DEAD_GATE_CONFIG'),
1281
+ scope: 'step',
1282
+ step: stepName,
1283
+ message: `Step '${stepName}': 'gate.reminder_seconds' (${String(gate['reminder_seconds'])}) ` +
1284
+ `>= 'gate.timeout_seconds' (${String(gate['timeout_seconds'])}) — the first ` +
1285
+ `reminder would never fire before the gate expires.`,
1286
+ });
1287
+ }
1288
+ }
1289
+ else {
1290
+ // issue #524 (the gate-remedy silence): without gate trust the mint never reads ANY
1291
+ // key in this block, so a per-member remedy ("set a timeout") is false — following it
1292
+ // would silence the diagnostic while the whole block stays exactly as dead. ONE
1293
+ // advisory naming the true cause, unconditional on which keys are set (the block is
1294
+ // equally inert whichever ones are). Position on the STRUCTURED channel only
1295
+ // (`withKeyLine`/`withPathLine` are the ERROR-string helpers, consumed only by
1296
+ // `errors.push` — no loader advisory carries a position today, and this one follows
1297
+ // that convention: `renderLoaderWarning` prints `⚠ ${message}` alone). The kind list
1298
+ // is DERIVED from the registry (`consumedKindsFor('trust')`, the #517
1299
+ // `consumed_home.kinds` pattern) so the remedy can never drift from the vocabulary
1300
+ // that actually gates it.
1301
+ const gatePos = sourceMap.posOf(['steps', stepName, 'gate']);
1302
+ const trustKinds = consumedKindsFor('trust');
1303
+ warnings.push({
1304
+ code: 'DEAD_GATE_CONFIG',
1305
+ severity: resolveSeverity('DEAD_GATE_CONFIG'),
1306
+ scope: 'step',
1307
+ step: stepName,
1308
+ key: 'gate',
1309
+ ...(gatePos !== undefined
1310
+ ? {
1311
+ line: gatePos.line,
1312
+ column: gatePos.column,
1313
+ endLine: gatePos.endLine,
1314
+ endColumn: gatePos.endColumn,
1315
+ }
1316
+ : {}),
1317
+ message: `Step '${stepName}': the 'gate:' block is inert — this step declares no gate ` +
1318
+ `trust ('trust: human_confirmed' or 'trust: human_reviewed'), so no gate is ever ` +
1319
+ `minted and none of its keys are read. Remove the block, or (on an ` +
1320
+ `${trustKinds.join(' or ')} step) declare that trust.`,
1321
+ });
1322
+ }
1221
1323
  }
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
- });
1324
+ }
1325
+ if ('uses_service' in step && typeof step['uses_service'] === 'string') {
1326
+ const services = doc['services'];
1327
+ if (typeof services !== 'object' ||
1328
+ services === null ||
1329
+ !(step['uses_service'] in services)) {
1330
+ errors.push(withStepLine(stepName, `Step '${stepName}': uses_service '${step['uses_service']}' is not defined in 'services'`));
1331
+ }
1332
+ }
1333
+ // Validate retry: backoff must be a recognised value when present.
1334
+ if (step['retry'] !== undefined) {
1335
+ if (typeof step['retry'] !== 'object' || step['retry'] === null) {
1336
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'retry' must be an object`));
1250
1337
  }
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'),
1338
+ else {
1339
+ const retry = step['retry'];
1340
+ // The one population gate both retry advisories (W5 + RETRY_INERT_NON_AUTO) share:
1341
+ // the registry's own retry cell for this step's kind. `inert` = admitted-but-unread
1342
+ // (agent/guard). On `consumed` (auto) neither advisory applies; on `prohibited`
1343
+ // (finalizer) the #517 refusal above is the whole story and an advisory beside it
1344
+ // would contradict it; on a malformed kind the invalid-execution error is the verdict.
1345
+ const retryCellIsInert = VALID_EXECUTIONS.has(step['execution']) &&
1346
+ STEP_KEY_REGISTRY.retry[step['execution']].c === 'inert';
1347
+ // WARN (do not reject) on an unknown retry-block key — same non-breaking posture as the
1348
+ // step/workflow-level checks (issue #140). Noun overridden to 'retry' (not 'step') since
1349
+ // this is a nested block, not the step itself.
1350
+ warnings.push(...findUnknownKeys(retry, KNOWN_RETRY_KEYS, {
1259
1351
  scope: 'step',
1352
+ code: 'UNKNOWN_RETRY_KEY',
1260
1353
  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) {
1354
+ noun: 'retry',
1355
+ positionOf: (key) => sourceMap.posOf(['steps', stepName, 'retry', key]),
1356
+ }));
1357
+ if ('backoff' in retry &&
1358
+ retry['backoff'] !== 'fixed' &&
1359
+ retry['backoff'] !== 'linear' &&
1360
+ retry['backoff'] !== 'exponential') {
1361
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.backoff' must be 'fixed', 'linear', or 'exponential'`));
1362
+ }
1363
+ if ('max_attempts' in retry &&
1364
+ (!Number.isInteger(retry['max_attempts']) || retry['max_attempts'] < 1)) {
1365
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.max_attempts' must be a positive integer`));
1366
+ }
1367
+ if ('base_delay_ms' in retry &&
1368
+ (typeof retry['base_delay_ms'] !== 'number' || retry['base_delay_ms'] < 0)) {
1369
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.base_delay_ms' must be a non-negative number`));
1370
+ }
1371
+ if ('max_delay_ms' in retry &&
1372
+ (typeof retry['max_delay_ms'] !== 'number' || retry['max_delay_ms'] < 0)) {
1373
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.max_delay_ms' must be a non-negative number`));
1374
+ }
1375
+ // --- issue #140: on_timeout / total_timeout_seconds --------------------------------
1376
+ // E3: on_timeout must be a boolean (kills the 'on_timeout: "true"' silent-inert case).
1377
+ if ('on_timeout' in retry && typeof retry['on_timeout'] !== 'boolean') {
1378
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.on_timeout' must be a boolean`));
1379
+ }
1380
+ // E2: total_timeout_seconds must be a positive integer — same convention as
1381
+ // timeout_seconds (0 is rejected here at load; a hand-built definition bypassing the
1382
+ // loader may still set 0 and have the engine's resolveCapMs honor it as a present cap).
1383
+ if ('total_timeout_seconds' in retry &&
1384
+ (!Number.isInteger(retry['total_timeout_seconds']) ||
1385
+ retry['total_timeout_seconds'] <= 0)) {
1386
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.total_timeout_seconds' must be a positive integer`));
1387
+ }
1388
+ // E1: on_timeout: true requires idempotent: true — declared, never inferred. Strict
1389
+ // `=== true` on both loci, provably matching the engine's own conjunct.
1390
+ if (retry['on_timeout'] === true && step['idempotent'] !== true) {
1391
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.on_timeout: true' requires 'idempotent: true' declared ` +
1392
+ `on the step — a timeout-retry can run concurrently with the still-in-flight ` +
1393
+ `original attempt, so the step must explicitly attest that any partial prior ` +
1394
+ `application is harmless to re-apply. Declare 'idempotent: true' or remove ` +
1395
+ `'on_timeout'.`));
1396
+ }
1397
+ // W5 (CAP-ONLY advisory — the on_timeout half of this is already an E1 hard error, so
1398
+ // it never reaches here as a warning): the total-time cap only bounds `execution: 'auto'`
1399
+ // dispatch — inert on any other step type that LEGALLY declares `retry:`. The gate is
1400
+ // the registry's own retry cell: the advisory fires only where retry is admitted-but-
1401
+ // inert (agent/guard), never beside the finalizer refusal it would contradict, and
1402
+ // never on a malformed kind (already refused by the invalid-execution error).
1403
+ if (retryCellIsInert && retry['total_timeout_seconds'] !== undefined) {
1281
1404
  warnings.push({
1282
- code: 'TOTAL_TIMEOUT_BELOW_ATTEMPT',
1283
- severity: resolveSeverity('TOTAL_TIMEOUT_BELOW_ATTEMPT'),
1405
+ code: 'TOTAL_TIMEOUT_NON_AUTO',
1406
+ severity: resolveSeverity('TOTAL_TIMEOUT_NON_AUTO'),
1284
1407
  scope: 'step',
1285
1408
  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.`,
1409
+ message: `Step '${stepName}': 'retry.total_timeout_seconds' is inert on execution: ` +
1410
+ `'${String(step['execution'])}' steps the cap only bounds 'execution: auto' ` +
1411
+ `dispatch; no other kind's dispatch ever consumes it.`,
1290
1412
  });
1291
1413
  }
1414
+ // issue #218 (extends the W5 family): the BARE-KEYS advisory — no explicit
1415
+ // total_timeout_seconds (that shape is W5's, above), but retry: is present on a step no
1416
+ // dispatching retry loop ever consumes it on. Complementary to W5's own `!== undefined`
1417
+ // conjunct on the SAME registry-derived inert gate, so for any admitted-but-inert retry
1418
+ // block exactly ONE of {W5, RETRY_INERT_NON_AUTO} ever fires — never both, never
1419
+ // neither. The gate EXCLUDES the prohibited kind (finalizer): errors accumulate rather
1420
+ // than halt, so the old `!== 'auto'` gate leaked this advisory beside the finalizer
1421
+ // refusal, where every clause of it was false ("never throws" — the drain throws
1422
+ // routinely; "may still consume" — no dispatcher can reach a finalizer's retry;
1423
+ // "not an invalid one" — the co-fired error says it IS invalid). Registry-derived:
1424
+ // it fires exactly where the retry cell is inert (agent/guard).
1425
+ if (retryCellIsInert && retry['total_timeout_seconds'] === undefined) {
1426
+ const isAgent = step['execution'] === 'agent';
1427
+ const message = isAgent
1428
+ ? `Step '${stepName}': 'retry' is inert on execution: 'agent' steps — the built-in ` +
1429
+ `dispatch path never throws for agent steps, so this block can never mint a second ` +
1430
+ `attempt here (for schema-repair budgets, use the CLI drive's '--schema-retries' ` +
1431
+ `flag instead). An embedder-supplied throwing dispatcher may still consume this ` +
1432
+ `config — a deliberate public-API capability, not an invalid one.`
1433
+ : `Step '${stepName}': 'retry' is inert on execution: '${String(step['execution'])}' ` +
1434
+ `steps — a guard's evaluation never traverses the dispatch path (its conditions ` +
1435
+ `are evaluated inline, with no dispatcher and no retry read), so this block can ` +
1436
+ `never mint a second attempt here.`;
1437
+ warnings.push({
1438
+ code: 'RETRY_INERT_NON_AUTO',
1439
+ severity: resolveSeverity('RETRY_INERT_NON_AUTO'),
1440
+ scope: 'step',
1441
+ step: stepName,
1442
+ message,
1443
+ });
1444
+ }
1445
+ // W1: on_timeout with an effective max_attempts of 1 (explicit OR absent, since the
1446
+ // loader admits an absent max_attempts and the engine then defaults it to 1) — there is
1447
+ // no second attempt for the opt-in to retry into.
1448
+ const effectiveMaxAttempts = typeof retry['max_attempts'] === 'number' ? retry['max_attempts'] : 1;
1449
+ if (retry['on_timeout'] === true && effectiveMaxAttempts === 1) {
1450
+ warnings.push({
1451
+ code: 'ON_TIMEOUT_SINGLE_ATTEMPT',
1452
+ severity: resolveSeverity('ON_TIMEOUT_SINGLE_ATTEMPT'),
1453
+ scope: 'step',
1454
+ step: stepName,
1455
+ message: `Step '${stepName}': 'retry.on_timeout: true' has no effect with an effective ` +
1456
+ `'max_attempts' of 1 — there is no second attempt to retry into.`,
1457
+ });
1458
+ }
1459
+ // W2 (issue #524 correction): the declared cap is at or below the per-attempt timeout —
1460
+ // (a) an EXPLICIT cap below an EXPLICIT timeout_seconds, or (b) on_timeout: true with a
1461
+ // cap at-or-below the effective per-attempt timeout. This does NOT mean "no retry can
1462
+ // ever occur": `willRetry`'s first disjunct (execution-loop.ts) has no cap conjunct, so
1463
+ // a retryable failure that returns faster than the (clipped) attempt bound still retries
1464
+ // while 'max_attempts' allows another attempt — only an attempt that runs OUT its full
1465
+ // bound exhausts the cap with nothing left for a retry. Both arms require an EXPLICIT
1466
+ // total_timeout_seconds — the AMENDED default cap (the worst-case schedule) is, by
1467
+ // construction, never below a single attempt for max_attempts ≥ 2, so this never fires
1468
+ // on the bare 3600s-default population.
1469
+ const explicitCapSeconds = typeof retry['total_timeout_seconds'] === 'number'
1470
+ ? retry['total_timeout_seconds']
1471
+ : undefined;
1472
+ if (explicitCapSeconds !== undefined) {
1473
+ const explicitTimeoutSeconds = typeof step['timeout_seconds'] === 'number' ? step['timeout_seconds'] : undefined;
1474
+ const effectivePerAttemptSeconds = explicitTimeoutSeconds ?? DEFAULT_EXECUTION_TIMEOUT_SECONDS;
1475
+ const belowExplicitAttempt = explicitTimeoutSeconds !== undefined && explicitCapSeconds < explicitTimeoutSeconds;
1476
+ const capTooTightForRetry = retry['on_timeout'] === true && explicitCapSeconds <= effectivePerAttemptSeconds;
1477
+ if (belowExplicitAttempt || capTooTightForRetry) {
1478
+ warnings.push({
1479
+ code: 'TOTAL_TIMEOUT_BELOW_ATTEMPT',
1480
+ severity: resolveSeverity('TOTAL_TIMEOUT_BELOW_ATTEMPT'),
1481
+ scope: 'step',
1482
+ step: stepName,
1483
+ message: `Step '${stepName}': 'retry.total_timeout_seconds: ${explicitCapSeconds}' is at ` +
1484
+ `or below its per-attempt timeout (${effectivePerAttemptSeconds}s` +
1485
+ `${explicitTimeoutSeconds === undefined ? ', the default' : ''}) — each attempt ` +
1486
+ `is bounded by what remains of the cap, so an attempt that runs to its bound ` +
1487
+ `exhausts the cap with no retry; a faster failure still retries while ` +
1488
+ `'max_attempts' allows another attempt and its backoff wait fits the remaining cap.`,
1489
+ });
1490
+ }
1491
+ }
1292
1492
  }
1293
1493
  }
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`));
1494
+ if ('service_method' in step &&
1495
+ !VALID_SERVICE_METHODS.has(step['service_method'])) {
1496
+ errors.push(withStepLine(stepName, `Step '${stepName}': invalid service_method '${String(step['service_method'])}'; must be 'fetch', 'create', 'update', or 'delete'`));
1302
1497
  }
1303
- else {
1498
+ // Validate input_map VALUES (auto only — the kind half is minted by the #517 walk; the
1499
+ // explicit conjunct preserves the old else-branch: a wrong-kind step gets only the minted
1500
+ // refusal, never the value noise).
1501
+ if (step['input_map'] !== undefined && step['execution'] === 'auto') {
1304
1502
  // issue #392: input_map's errors are minted deep inside a recursive walk that knows only
1305
1503
  // its path string, not the step's position. Collected here and suffixed on the way out,
1306
1504
  // 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
1505
+ // "(step at line 12)" on three of five errors would reasonably wonder what is different about
1308
1506
  // the other two, and nothing is.
1309
1507
  const inputMapErrors = [];
1310
1508
  validateInputMapNode(step['input_map'], `Step '${stepName}': input_map`, inputMapErrors, 0);
1311
1509
  errors.push(...inputMapErrors.map((e) => withStepLine(stepName, e)));
1312
1510
  }
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'`));
1326
- }
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}`));
1511
+ // Step config may hold any JSON value (scalars, arrays, nested objects). It is passed through
1512
+ // opaquely to handlers (context.config) and merged into adapter config for uses_service steps;
1513
+ // the adapter's config_schema (below) remains the real validator for uses_service config.
1514
+ // Validate step config against adapter config_schema (requires registry).
1515
+ if (step['config'] !== undefined && step['uses_service'] !== undefined) {
1516
+ const serviceName = step['uses_service'];
1517
+ const services = doc['services'];
1518
+ const service = services?.[serviceName];
1519
+ const adapterName = service?.['adapter'];
1520
+ const adapter = adapterName !== undefined ? registry?.getAdapter(adapterName) : undefined;
1521
+ if (adapter !== undefined && adapter.config_schema === undefined) {
1522
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'config' declared but adapter '${adapterName}' does not declare 'config_schema'`));
1523
+ }
1524
+ else if (adapter?.config_schema !== undefined) {
1525
+ const ajv = new Ajv();
1526
+ const valid = ajv.validate(adapter.config_schema, step['config']);
1527
+ if (!valid) {
1528
+ const errMessages = ajv.errors?.map((e) => e.message ?? '').join('; ') ?? 'unknown error';
1529
+ errors.push(withStepLine(stepName, `Step '${stepName}': config validation failed against adapter config_schema: ${errMessages}`));
1530
+ }
1333
1531
  }
1334
1532
  }
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`));
1533
+ // Validate uses_resources: each listed step ID must exist in the workflow.
1534
+ if (step['handler'] !== undefined && registry !== undefined) {
1535
+ const handlerName = step['handler'];
1536
+ const handler = registry.getHandler(handlerName);
1537
+ if (handler !== undefined && handler.uses_resources !== undefined) {
1538
+ for (const resourceStepId of handler.uses_resources) {
1539
+ if (!(resourceStepId in stepsRaw)) {
1540
+ errors.push(withStepLine(stepName, `Step '${stepName}': handler '${handlerName}' declares uses_resources '${resourceStepId}' ` +
1541
+ `but no step with that ID exists in this workflow`));
1542
+ }
1345
1543
  }
1346
1544
  }
1347
1545
  }
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(', ')}`));
1546
+ // Validate trigger_rule.
1547
+ if ('trigger_rule' in step) {
1548
+ if (!VALID_TRIGGER_RULES.has(step['trigger_rule'])) {
1549
+ errors.push(withStepLine(stepName, `Step '${stepName}': invalid trigger_rule '${String(step['trigger_rule'])}'; must be one of ${[...VALID_TRIGGER_RULES].join(', ')}`));
1550
+ }
1353
1551
  }
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`));
1552
+ // Validate depends_on: must be an array of existing step names.
1553
+ if ('depends_on' in step && step['depends_on'] !== undefined) {
1554
+ if (!Array.isArray(step['depends_on'])) {
1555
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'depends_on' must be an array`));
1556
+ }
1557
+ else {
1558
+ for (const dep of step['depends_on']) {
1559
+ if (typeof dep !== 'string') {
1560
+ errors.push(withStepLine(stepName, `Step '${stepName}': depends_on entries must be strings`));
1561
+ }
1562
+ else if (dep === stepName) {
1563
+ errors.push(withStepLine(stepName, `Step '${stepName}': a step cannot depend on itself`));
1564
+ }
1565
+ else if (!(dep in stepsRaw)) {
1566
+ errors.push(withStepLine(stepName, `Step '${stepName}': depends_on references unknown step '${dep}'`));
1567
+ }
1568
+ else if (stepsRaw[dep]['execution'] === 'finalizer') {
1569
+ // A domain step depending on a held-out finalizer would deadlock: the finalizer
1570
+ // never enters the eligible set, so this step never becomes eligible and the run
1571
+ // never seals.
1572
+ errors.push(withStepLine(stepName, `Step '${stepName}': depends_on references finalizer step '${dep}' — finalizers ` +
1573
+ `run at the terminal transition and are held out of the DAG; a step cannot depend on one.`));
1574
+ }
1575
+ }
1576
+ }
1359
1577
  }
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`));
1578
+ // Validate when: string | string[] of single-comparison/bare-path leaves (implicit AND).
1579
+ if ('when' in step && step['when'] !== undefined) {
1580
+ const rawWhen = step['when'];
1581
+ if (typeof rawWhen === 'string') {
1582
+ if (rawWhen.trim() === '') {
1583
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'when' must be a non-empty string`));
1364
1584
  }
1365
- else if (dep === stepName) {
1366
- errors.push(withStepLine(stepName, `Step '${stepName}': a step cannot depend on itself`));
1585
+ else {
1586
+ validateConditionLeaf('when', rawWhen, stepName, dependsOn, stepKind, errors, withStepLine);
1367
1587
  }
1368
- else if (!(dep in stepsRaw)) {
1369
- errors.push(withStepLine(stepName, `Step '${stepName}': depends_on references unknown step '${dep}'`));
1588
+ }
1589
+ else if (Array.isArray(rawWhen)) {
1590
+ if (rawWhen.length === 0) {
1591
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'when' array must not be empty`));
1370
1592
  }
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.`));
1593
+ else {
1594
+ for (const leaf of rawWhen) {
1595
+ if (typeof leaf !== 'string' || leaf.trim() === '') {
1596
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'when' array entries must be non-empty strings`));
1597
+ }
1598
+ else {
1599
+ validateConditionLeaf('when', leaf, stepName, dependsOn, stepKind, errors, withStepLine);
1600
+ }
1601
+ }
1377
1602
  }
1378
1603
  }
1604
+ else {
1605
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'when' must be a string or an array of strings`));
1606
+ }
1379
1607
  }
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`));
1608
+ // Validate abort_unless leaf shape (guard steps only; the LEGACY depends_on/run.params
1609
+ // reference check is when-only but issue #220 §4c's `$settlement` one-hop check fires here
1610
+ // too, via the hoisted `dependsOn`, SCOPED to `$settlement.`-prefixed paths only).
1611
+ if (step['abort_unless'] !== undefined && step['execution'] === 'guard') {
1612
+ const rawAbort = step['abort_unless'];
1613
+ if (typeof rawAbort === 'string') {
1614
+ if (rawAbort.trim() === '') {
1615
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' must be a non-empty string`));
1616
+ }
1617
+ else {
1618
+ validateConditionLeaf('abort_unless', rawAbort, stepName, dependsOn, stepKind, errors, withStepLine);
1619
+ }
1620
+ }
1621
+ else if (Array.isArray(rawAbort)) {
1622
+ if (rawAbort.length === 0) {
1623
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' array must not be empty`));
1624
+ }
1625
+ else {
1626
+ for (const leaf of rawAbort) {
1627
+ if (typeof leaf !== 'string' || leaf.trim() === '') {
1628
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' array entries must be non-empty strings`));
1629
+ }
1630
+ else {
1631
+ validateConditionLeaf('abort_unless', leaf, stepName, dependsOn, stepKind, errors, withStepLine);
1632
+ }
1633
+ }
1634
+ }
1387
1635
  }
1388
1636
  else {
1389
- validateConditionLeaf('when', rawWhen, stepName, dependsOn, errors, withStepLine);
1637
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' must be a string or an array of strings`));
1390
1638
  }
1391
1639
  }
1392
- else if (Array.isArray(rawWhen)) {
1393
- if (rawWhen.length === 0) {
1394
- errors.push(withStepLine(stepName, `Step '${stepName}': 'when' array must not be empty`));
1640
+ // Validate preconditions leaf shape (each must be a single comparison). Reference check is
1641
+ // `$settlement`-scoped only (issue #220 §4c) — a non-`$settlement` precondition has no
1642
+ // depends_on/run.params check (unchanged from before this PR).
1643
+ if (step['preconditions'] !== undefined) {
1644
+ const rawPre = step['preconditions'];
1645
+ if (!Array.isArray(rawPre)) {
1646
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'preconditions' must be an array of strings`));
1395
1647
  }
1396
1648
  else {
1397
- for (const leaf of rawWhen) {
1649
+ for (const leaf of rawPre) {
1398
1650
  if (typeof leaf !== 'string' || leaf.trim() === '') {
1399
- errors.push(withStepLine(stepName, `Step '${stepName}': 'when' array entries must be non-empty strings`));
1651
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'preconditions' entries must be non-empty strings`));
1400
1652
  }
1401
1653
  else {
1402
- validateConditionLeaf('when', leaf, stepName, dependsOn, errors, withStepLine);
1654
+ validateConditionLeaf('preconditions', leaf, stepName, dependsOn, stepKind, errors, withStepLine);
1403
1655
  }
1404
1656
  }
1405
1657
  }
1406
1658
  }
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`));
1659
+ // issue #362 — REJECT A PROVABLY-DEAD FAILURE CONDITION.
1660
+ //
1661
+ // `$settlement.<dep>.failed == true` under a trigger rule that structurally excludes a failed
1662
+ // `<dep>` can never be true. The trigger gate runs BEFORE the condition gate, and both
1663
+ // `all_success` and `none_failed` carry an explicit "no dep in failed_steps" conjunct — so if
1664
+ // the rule is satisfied, `<dep>` did not fail, and the condition is false by construction.
1665
+ //
1666
+ // The author's compensation step therefore never runs. It is not silent at runtime — the run
1667
+ // record says `trigger_rule_unsatisfiable` — but it names the RULE and the blocking dep, never
1668
+ // the condition the author wrote, so the diagnosis points away from the mistake. This is an
1669
+ // authoring-time error precisely because the fix is one word and no legitimate use of the
1670
+ // shape exists.
1671
+ dead_condition: {
1672
+ const rule = step['execution'] === 'guard' ? 'all_success' : (step['trigger_rule'] ?? 'all_success');
1673
+ // A guard may not declare `trigger_rule` at all (it is a prohibited field), so a guard that
1674
+ // declares one must NOT be able to suppress this check by doing so.
1675
+ const ruleDeclared = step['execution'] !== 'guard' && step['trigger_rule'] !== undefined;
1676
+ if (step['execution'] !== 'guard' && !VALID_TRIGGER_RULES.has(rule)) {
1677
+ break dead_condition; // an invalid rule already has its own error — adding noise helps nobody
1427
1678
  }
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`));
1679
+ if (rule !== 'all_success' && rule !== 'none_failed')
1680
+ break dead_condition;
1681
+ const asLeaves = (v) => Array.isArray(v)
1682
+ ? v.filter((x) => typeof x === 'string')
1683
+ : typeof v === 'string'
1684
+ ? [v]
1685
+ : [];
1686
+ const surfaces = [
1687
+ { surface: 'when', leaves: asLeaves(step['when']) },
1688
+ // `abort_unless` is only validated (and only meaningful) on guards; on anything else the
1689
+ // loader leaves it alone and the engine never reads it.
1690
+ {
1691
+ surface: 'abort_unless',
1692
+ leaves: step['execution'] === 'guard' ? asLeaves(step['abort_unless']) : [],
1693
+ },
1694
+ // `preconditions` is collected for EVERY step kind, but it is INERT on a guard: the sole
1695
+ // `checkPreconditions` call site is `executeStep` (execution-loop.ts:1380), and a guard
1696
+ // goes through `executeGuardStep`, which evaluates only `abort_unless`. That is why the
1697
+ // guard arm's consequence below is forked — collapsing it back into one shared string
1698
+ // would make the error claim a wedge that cannot happen.
1699
+ //
1700
+ // Post-#369 a guard declaring `preconditions` is REFUSED outright — since #517, by
1701
+ // the registry mint near the top of Step 3 — so this arm now only ever fires ALONGSIDE
1702
+ // that refusal: errors accumulate rather than short-circuit, and the mint runs first, so both messages reach the author
1703
+ // with the prohibition printed above this one. The arm is kept, not deleted — it is what
1704
+ // stops the dead-condition message from claiming a wedge that a guard cannot have, and a
1705
+ // definition reaching this code by any path other than a fresh YAML load (a
1706
+ // store-registered definition, an inline object) is never re-parsed and never sees the
1707
+ // prohibition at all.
1708
+ { surface: 'preconditions', leaves: asLeaves(step['preconditions']) },
1709
+ ];
1710
+ for (const { surface, leaves } of surfaces) {
1711
+ for (const leaf of leaves) {
1712
+ const split = splitComparison(leaf);
1713
+ // Two spellings are equally dead: the explicit `== true`, and the BARE PATH, which the
1714
+ // engine coerces with `Boolean()`. `preconditions` refuses bare paths anyway.
1715
+ const lhsPath = split.kind === 'comparison' && split.op === '==' && split.rhsRaw.trim() === 'true'
1716
+ ? split.lhsPath
1717
+ : split.kind === 'path'
1718
+ ? split.path
1719
+ : undefined;
1720
+ if (lhsPath === undefined)
1721
+ continue;
1722
+ // EXACTLY three segments. `$settlement.x.failed.deep` is also dead, but for a different
1723
+ // reason, so the trigger-rule remedy would be wrong advice there.
1724
+ const segments = lhsPath.trim().split('.');
1725
+ if (segments.length !== 3 ||
1726
+ segments[0] !== '$settlement' ||
1727
+ segments[2] !== 'failed') {
1728
+ continue;
1729
+ }
1730
+ const dep = segments[1];
1731
+ // Without the dep actually being a dependency, the one-hop error fires on its own AND
1732
+ // the trigger gate returns true unconditionally for a step with no deps — so the leaf
1733
+ // is not dead-by-trigger here and the remedy would be false advice.
1734
+ if (!dependsOn.includes(dep))
1735
+ continue;
1736
+ const ruleText = ruleDeclared ? `'${rule}'` : `the default '${rule}'`;
1737
+ const guardPrecondition = step['execution'] === 'guard' && surface === 'preconditions';
1738
+ const consequence = surface === 'when'
1739
+ ? `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`
1740
+ : surface === 'preconditions'
1741
+ ? guardPrecondition
1742
+ ? // NOT the wedge: an unevaluated condition cannot block anything.
1743
+ `the run behaves identically whether this condition is present or absent`
1744
+ : `the step never settles — the run WEDGES in a blocked envelope`
1745
+ : `the guard aborts the run on every execution`;
1746
+ if (step['execution'] === 'guard') {
1747
+ // Guards cannot declare a trigger rule, so the trigger-rule remedy is wrong advice
1748
+ // here. This is a v1 SCOPE narrowing, not an architectural statement — issue #366
1749
+ // carries the design question.
1750
+ //
1751
+ // The middle clause forks with the consequence: "by the time this is evaluated" is
1752
+ // itself false for `preconditions`, which a guard never evaluates at all.
1753
+ const cause = guardPrecondition
1754
+ ? `and on an execution: guard step it is never evaluated at all: a guard evaluates ` +
1755
+ `only 'abort_unless', so this condition is inert (${consequence})`
1756
+ : `a guard runs under ${ruleText} and 'trigger_rule' is not a valid field on ` +
1757
+ `execution: guard steps, so '${dep}' has always succeeded by the time this is ` +
1758
+ `evaluated (${consequence})`;
1759
+ errors.push(withStepLine(stepName, `Step '${stepName}': '${surface}' condition "${leaf}" can never be true — ${cause}. ` +
1760
+ `Guards run only when their dependencies succeeded; for work that must happen AFTER a ` +
1761
+ `failure, use an 'execution: finalizer' step (see issue #366 for widening guards).`));
1432
1762
  }
1433
1763
  else {
1434
- validateConditionLeaf('abort_unless', leaf, stepName, dependsOn, errors, withStepLine);
1764
+ const remedies = ['all_done', 'one_failed'];
1765
+ if (new Set(dependsOn).size === 1)
1766
+ remedies.push('all_failed');
1767
+ const tail = new Set(dependsOn).size > 1
1768
+ ? ` ('all_failed' fires only if EVERY dependency fails; 'one_success' only if at least one other dependency succeeds.)`
1769
+ : '';
1770
+ errors.push(withStepLine(stepName, `Step '${stepName}': '${surface}' condition "${leaf}" can never be true — under ` +
1771
+ `${ruleText} trigger rule, '${dep}' can never be in failed_steps when this step is ` +
1772
+ `evaluated (${consequence}). To run this step when '${dep}' fails, set trigger_rule to ` +
1773
+ `one of: ${remedies.join(', ')}.${tail}`));
1435
1774
  }
1436
1775
  }
1437
1776
  }
1438
1777
  }
1439
- else {
1440
- errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' must be a string or an array of strings`));
1778
+ // Validate tools × handler (the COMPOUND half of the old tools rule — #517 split it: the
1779
+ // non-agent kinds are minted from the registry above; this hand-written check keeps ONLY
1780
+ // the agent-with-handler arm, whose predicate is a companion conflict, not a kind rule).
1781
+ // Populations are disjoint by construction (this fires only on execution: 'agent'; the
1782
+ // mint only on non-agent kinds), so the old multi-fire cannot re-appear.
1783
+ if (step['tools'] !== undefined &&
1784
+ step['execution'] === 'agent' &&
1785
+ step['handler'] !== undefined) {
1786
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'tools' is only valid on execution: agent steps without 'handler' defined`));
1441
1787
  }
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`));
1788
+ // issue #413: `tool_timeout` requires `tools`. It bounds ONE tool call inside the agentic
1789
+ // loop (run-agent.ts), and a step with no tools never enters that loop — so the key sits
1790
+ // there bounding nothing while its author believes tool calls are capped.
1791
+ //
1792
+ // An EMPTY list counts as missing, and that is not pedantry: run-agent gates the tools path
1793
+ // on `tools.length > 0`, so `tools: []` is exactly as toolless at runtime as no key at all.
1794
+ // This one helper is also the shape check's complement further down, which is what makes
1795
+ // "exactly one error" true by construction rather than by coincidence.
1796
+ //
1797
+ // NOT extended to non-array `tools` spellings — that is #391, still open. Under the
1798
+ // `!toolsMissing` complement below, a non-array `tools` still lets the shape check fire, so
1799
+ // nothing is silently exempted here.
1800
+ const toolsMissing = step['tools'] === undefined ||
1801
+ (Array.isArray(step['tools']) && step['tools'].length === 0);
1802
+ if (step['tool_timeout'] !== undefined && toolsMissing) {
1803
+ errors.push(withKeyLine(stepName, 'tool_timeout', `Step '${stepName}': 'tool_timeout' requires 'tools' (a declared, non-empty list) — ` +
1804
+ 'without tool calls there is ' +
1805
+ 'nothing for it to bound, so the step would carry a bound with nothing to bind. ' +
1806
+ "In realm's own drive each tool call is capped at tool_timeout seconds (default " +
1807
+ '30); declare at least one tool or remove the key.'));
1450
1808
  }
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);
1809
+ // Validate tools: requires input_schema.
1810
+ if (step['tools'] !== undefined && step['input_schema'] === undefined) {
1811
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'tools' requires 'input_schema' to be defined — the agentic loop needs a schema for final output extraction`));
1812
+ }
1813
+ // Validate tools: entries must be in server_id:tool_name format.
1814
+ if (step['tools'] !== undefined && Array.isArray(step['tools'])) {
1815
+ for (const entry of step['tools']) {
1816
+ if (!/^[^:]+:[^:]+$/.test(entry)) {
1817
+ errors.push(withStepLine(stepName, `Step '${stepName}': tools entry '${entry}' must be in 'server_id:tool_name' format`));
1458
1818
  }
1459
1819
  }
1460
1820
  }
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
1821
+ // issue #338: the check below only runs when an `mcp_servers` block EXISTS, so the absent-block
1822
+ // variant loaded clean and every disclosure this loader has for tools lives inside that same
1823
+ // fork, so the corner produced no error, no warning, and a run where the declared tools were
1824
+ // simply never offered. ONE error per step, not one per entry: the entries are not individually
1825
+ // wrong, the workflow is.
1826
+ if (step['tools'] !== undefined &&
1827
+ Array.isArray(step['tools']) &&
1828
+ step['tools'].length > 0 &&
1829
+ !Array.isArray(doc['mcp_servers'])) {
1830
+ errors.push(withStepLine(stepName, `Step '${stepName}': declares tools but the workflow defines no mcp_servers no drive ` +
1831
+ `can ever offer these tools, so the declaration can never be satisfied. Define an ` +
1832
+ `mcp_servers block, or remove 'tools'.`));
1481
1833
  }
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}`));
1834
+ // Validate tools: server_id must reference a defined mcp_server.
1835
+ if (step['tools'] !== undefined &&
1836
+ Array.isArray(step['tools']) &&
1837
+ Array.isArray(doc['mcp_servers'])) {
1838
+ const serverIds = new Set(doc['mcp_servers'].map((s) => s.id));
1839
+ for (const entry of step['tools']) {
1840
+ const serverId = entry.split(':')[0] ?? '';
1841
+ if (!serverIds.has(serverId)) {
1842
+ errors.push(withStepLine(stepName, `Step '${stepName}': tools entry '${entry}' references unknown MCP server '${serverId}'`));
1575
1843
  }
1576
1844
  }
1577
1845
  }
1846
+ // Validate max_tool_calls: must be a positive integer.
1847
+ if (step['max_tool_calls'] !== undefined &&
1848
+ (!Number.isInteger(step['max_tool_calls']) || step['max_tool_calls'] <= 0)) {
1849
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'max_tool_calls' must be a positive integer`));
1850
+ }
1851
+ // Validate max_fan_out: must be a positive integer.
1852
+ if (step['max_fan_out'] !== undefined &&
1853
+ (!Number.isInteger(step['max_fan_out']) || step['max_fan_out'] <= 0)) {
1854
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'max_fan_out' must be a positive integer`));
1855
+ }
1856
+ // Validate tool_timeout: must be a positive integer. Skipped where the key is not valid at
1857
+ // all (issue #413's requires-tools check above already reported that) — the same convention
1858
+ // as `timeout_seconds` below: an author told BOTH that the key does not belong here and that
1859
+ // its value has the wrong shape is being pointed at the shape, which is not the problem.
1860
+ // The `!toolsMissing` complement is the SAME helper the prohibition keys on, so the two are
1861
+ // exhaustive and disjoint by construction: `tools: []` with a negative value reports once.
1862
+ if (step['tool_timeout'] !== undefined &&
1863
+ !toolsMissing &&
1864
+ (!Number.isInteger(step['tool_timeout']) || step['tool_timeout'] <= 0)) {
1865
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'tool_timeout' must be a positive integer`));
1866
+ }
1867
+ // Validate timeout_seconds: must be a positive integer (issue A3). Skipped on
1868
+ // execution: guard and agent — the #517 registry mint already flatly rejects
1869
+ // 'timeout_seconds' on both kinds; re-checking its shape here would double-report the
1870
+ // same root cause under a second, confusing message (an author told BOTH that the key is
1871
+ // invalid here and that its value has the wrong shape is being pointed at the shape,
1872
+ // which is not the problem).
1873
+ if (step['timeout_seconds'] !== undefined &&
1874
+ step['execution'] !== 'guard' &&
1875
+ step['execution'] !== 'agent' &&
1876
+ (!Number.isInteger(step['timeout_seconds']) || step['timeout_seconds'] <= 0)) {
1877
+ errors.push(withStepLine(stepName, `Step '${stepName}': 'timeout_seconds' must be a positive integer`));
1878
+ }
1578
1879
  }
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`));
1880
+ // Require at least one non-finalizer step: a workflow of only finalizers is meaningless
1881
+ // (nothing runs in the DAG, so it would seal immediately with no domain work).
1882
+ const stepEntries = Object.values(stepsRaw).filter((s) => typeof s === 'object' && s !== null && !Array.isArray(s));
1883
+ if (stepEntries.length > 0 && stepEntries.every((s) => s['execution'] === 'finalizer')) {
1884
+ errors.push(`Workflow has only finalizer steps — at least one non-finalizer step is required ` +
1885
+ `(finalizers run at the terminal transition of the DAG's domain steps).`);
1886
+ }
1887
+ // Reject depends_on cycles (issue #153): a transitive cycle among otherwise-valid edges is
1888
+ // loadable today (the per-step check above only validates one hop at a time), and at runtime
1889
+ // the cyclic steps are mutually ineligible forever the run silently seals `completed` with
1890
+ // the stranded steps in NO step set and zero evidence. Build the graph over VALID edges only
1891
+ // (dep exists, isn't self, isn't a finalizer) the self/unknown/finalizer-dep cases are
1892
+ // already reported by the per-step check above; a real cycle runs entirely through valid
1893
+ // edges, so excluding the already-errored ones here avoids double-reporting them.
1894
+ const dependencyEdges = new Map();
1895
+ for (const stepName of Object.keys(stepsRaw)) {
1896
+ dependencyEdges.set(stepName, []);
1897
+ }
1898
+ for (const [stepName, stepRaw] of Object.entries(stepsRaw)) {
1899
+ if (typeof stepRaw !== 'object' || stepRaw === null || Array.isArray(stepRaw))
1900
+ continue;
1901
+ const dependsOn = stepRaw['depends_on'];
1902
+ if (!Array.isArray(dependsOn))
1903
+ continue;
1904
+ for (const dep of dependsOn) {
1905
+ if (typeof dep === 'string' &&
1906
+ dep !== stepName &&
1907
+ dep in stepsRaw &&
1908
+ stepsRaw[dep]['execution'] !== 'finalizer') {
1909
+ dependencyEdges.get(stepName).push(dep);
1614
1910
  }
1615
1911
  }
1616
1912
  }
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'.`));
1913
+ for (const cycle of detectDependencyCycles(dependencyEdges)) {
1914
+ errors.push(`Workflow has a dependency cycle: ${cycle.join(' ')}`);
1629
1915
  }
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}'`));
1916
+ // Validate mcp_servers: ids must be unique (workflow-level check).
1917
+ if (Array.isArray(doc['mcp_servers'])) {
1918
+ const seen = new Set();
1919
+ for (const server of doc['mcp_servers']) {
1920
+ if (seen.has(server.id)) {
1921
+ errors.push(`mcp_servers: duplicate server id '${server.id}'`);
1639
1922
  }
1923
+ seen.add(server.id);
1640
1924
  }
1641
1925
  }
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);
1926
+ // Validate services: Ajv-strict entry schema (closed key set) + rate_limit fields.
1927
+ if (typeof doc['services'] === 'object' && doc['services'] !== null) {
1928
+ for (const [serviceName, serviceRaw] of Object.entries(doc['services'])) {
1929
+ if (typeof serviceRaw !== 'object' || serviceRaw === null)
1930
+ continue;
1931
+ const service = serviceRaw;
1932
+ // PERMANENT targeted rejection — must win over the generic unknown-key error.
1933
+ if ('auth' in service || 'token_from' in service) {
1934
+ errors.push(`Service '${serviceName}': 'auth.token_from' was removed in v0.14.0 — bind ` +
1935
+ `credentials in your deployment manifest (realm.yaml); see the migration note.`);
1936
+ }
1937
+ else {
1938
+ const serviceAjv = new Ajv({ strict: true, allErrors: true });
1939
+ if (!serviceAjv.validate(SERVICE_ENTRY_JSON_SCHEMA, service)) {
1940
+ for (const err of serviceAjv.errors ?? []) {
1941
+ const detail = err.keyword === 'additionalProperties'
1942
+ ? `unknown key '${String(err.params.additionalProperty)}'`
1943
+ : `${err.instancePath.replace(/^\//, '').replace(/\//g, '.') || 'entry'} ${err.message ?? 'invalid'}`;
1944
+ errors.push(`Service '${serviceName}': ${detail}`);
1945
+ }
1946
+ }
1947
+ }
1948
+ const rateLimit = service['rate_limit'];
1949
+ if (rateLimit === undefined)
1950
+ continue;
1951
+ if (typeof rateLimit !== 'object' || rateLimit === null) {
1952
+ errors.push(`Service '${serviceName}': 'rate_limit' must be an object`);
1953
+ continue;
1954
+ }
1955
+ const rl = rateLimit;
1956
+ if ('requests_per_second' in rl &&
1957
+ (!Number.isInteger(rl['requests_per_second']) ||
1958
+ rl['requests_per_second'] < 1)) {
1959
+ errors.push(`Service '${serviceName}': 'rate_limit.requests_per_second' must be a positive integer (≥ 1)`);
1960
+ }
1961
+ if ('burst' in rl) {
1962
+ if (!Number.isInteger(rl['burst']) || rl['burst'] < 1) {
1963
+ errors.push(`Service '${serviceName}': 'rate_limit.burst' must be a positive integer (≥ 1)`);
1964
+ }
1965
+ if (!('requests_per_second' in rl)) {
1966
+ errors.push(`Service '${serviceName}': 'rate_limit.burst' requires 'rate_limit.requests_per_second' to be set`);
1967
+ }
1968
+ }
1969
+ if ('fallback_retry_seconds' in rl &&
1970
+ (typeof rl['fallback_retry_seconds'] !== 'number' ||
1971
+ rl['fallback_retry_seconds'] <= 0)) {
1972
+ errors.push(`Service '${serviceName}': 'rate_limit.fallback_retry_seconds' must be a positive number (> 0)`);
1973
+ }
1974
+ if ('min_retry_seconds' in rl &&
1975
+ (typeof rl['min_retry_seconds'] !== 'number' || rl['min_retry_seconds'] <= 0)) {
1976
+ errors.push(`Service '${serviceName}': 'rate_limit.min_retry_seconds' must be a positive number (> 0)`);
1977
+ }
1978
+ if ('max_retry_seconds' in rl &&
1979
+ (!Number.isInteger(rl['max_retry_seconds']) || rl['max_retry_seconds'] < 1)) {
1980
+ errors.push(`Service '${serviceName}': 'rate_limit.max_retry_seconds' must be a positive integer (≥ 1)`);
1981
+ }
1707
1982
  }
1708
1983
  }
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}'`);
1984
+ // Step 3b: Trigger block validation (schema-driven — see trigger-schema.ts)
1985
+ const triggerRaw = doc['trigger'];
1986
+ if (triggerRaw !== undefined) {
1987
+ normalizeTriggerFilter(triggerRaw); // canonicalise shorthand BEFORE validation
1988
+ errors.push(...validateTriggerStructure(triggerRaw));
1989
+ }
1990
+ // Step 3c: workflow-level context blocks (issue #553). These four rules need nothing but the
1991
+ // text, so they belong to every surface — file, string, `validate --registered`, the public
1992
+ // `loadWorkflowFromString`. They lived in the file loader until #553 and were therefore
1993
+ // invisible to `validate` (which parsed extension-free workflows from string) and to
1994
+ // `--registered`; the public string loader silently accepted all four shapes. Pushed, never
1995
+ // thrown: the accumulator mints `Invalid workflow:` once and the #425 per-line grammar composes.
1996
+ const contextWrapperRaw = doc['context_wrapper'];
1997
+ if (contextWrapperRaw !== undefined) {
1998
+ const VALID_WRAPPER_FORMATS = new Set(['xml', 'brackets', 'none']);
1999
+ if (!VALID_WRAPPER_FORMATS.has(contextWrapperRaw)) {
2000
+ errors.push(withTopLevelLine(['context_wrapper'], `'context_wrapper' must be 'xml', 'brackets', or 'none' (found: '${String(contextWrapperRaw)}')`));
1719
2001
  }
1720
- seen.add(server.id);
1721
2002
  }
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}`);
1742
- }
2003
+ const workflowContextRaw = doc['workflow_context'];
2004
+ if (workflowContextRaw !== undefined) {
2005
+ for (const [name, entry] of Object.entries(workflowContextRaw)) {
2006
+ if (name.endsWith('.raw')) {
2007
+ errors.push(withTopLevelLine(['workflow_context', name], `workflow_context entry '${name}' must not end with '.raw'`));
1743
2008
  }
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)`);
2009
+ if (!/^[\w.]+$/.test(name)) {
2010
+ errors.push(withTopLevelLine(['workflow_context', name], `workflow_context entry '${name}' must match [\\w.]+ (underscores and dots only — no hyphens)`));
1760
2011
  }
1761
- if (!('requests_per_second' in rl)) {
1762
- errors.push(`Service '${serviceName}': 'rate_limit.burst' requires 'rate_limit.requests_per_second' to be set`);
2012
+ const rawEntry = entry;
2013
+ const rawSource = rawEntry?.['source'];
2014
+ if (rawSource === undefined || typeof rawSource['path'] !== 'string') {
2015
+ // The ENTRY's line: the missing key has no line, and a cite must never name an absent
2016
+ // key.
2017
+ errors.push(withTopLevelLine(['workflow_context', name], `workflow_context.${name}.source.path is required`));
1763
2018
  }
1764
2019
  }
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
- }
1778
2020
  }
2021
+ if (errors.length > 0) {
2022
+ throw new WorkflowError(`Invalid workflow: ${errors.join('; ')}`, {
2023
+ // issue #425: the pre-join strings — see the profile collector above.
2024
+ errors: [...errors],
2025
+ code: 'VALIDATION_WORKFLOW_SCHEMA',
2026
+ category: 'VALIDATION',
2027
+ agentAction: 'report_to_user',
2028
+ retryable: false,
2029
+ });
2030
+ }
2031
+ // Step 4: Stamp schema version and return typed result
2032
+ const definition = doc;
2033
+ definition.schema_version = CURRENT_WORKFLOW_SCHEMA_VERSION;
2034
+ return { definition, warnings };
1779
2035
  }
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
- });
2036
+ catch (err) {
2037
+ if (err instanceof WorkflowError)
2038
+ attachLoaderWarnings(err, warnings);
2039
+ throw err;
1793
2040
  }
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
2041
  }
1799
2042
  /** Returns true if any step in the raw steps map declares use_template. */
1800
2043
  function hasUseTemplateInSteps(steps) {