@sensigo/realm 0.41.0 → 0.43.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 (41) 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 +7 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +8 -3
  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 +1 -1
  23. package/dist/types/workflow-error.d.ts.map +1 -1
  24. package/dist/types/workflow-error.js.map +1 -1
  25. package/dist/workflow/diagnostics.d.ts +41 -5
  26. package/dist/workflow/diagnostics.d.ts.map +1 -1
  27. package/dist/workflow/diagnostics.js +34 -6
  28. package/dist/workflow/diagnostics.js.map +1 -1
  29. package/dist/workflow/registrar.d.ts +42 -0
  30. package/dist/workflow/registrar.d.ts.map +1 -1
  31. package/dist/workflow/registrar.js +57 -0
  32. package/dist/workflow/registrar.js.map +1 -1
  33. package/dist/workflow/step-key-registry.d.ts +1258 -0
  34. package/dist/workflow/step-key-registry.d.ts.map +1 -0
  35. package/dist/workflow/step-key-registry.js +1217 -0
  36. package/dist/workflow/step-key-registry.js.map +1 -0
  37. package/dist/workflow/yaml-loader.d.ts +16 -0
  38. package/dist/workflow/yaml-loader.d.ts.map +1 -1
  39. package/dist/workflow/yaml-loader.js +581 -345
  40. package/dist/workflow/yaml-loader.js.map +1 -1
  41. package/package.json +1 -1
@@ -4,9 +4,9 @@ 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
- import { findUnknownKeys, renderLoaderWarning, resolveSeverity, closestKey, } from './diagnostics.js';
9
+ import { findUnknownKeys, renderLoaderWarning, resolveSeverity, closestKey, isExtensionKey, EXTENSION_KEY_PREFIX, RESERVED_EXTENSION_PREFIX, } from './diagnostics.js';
10
10
  import { createSourcePositionCollector } from './source-positions.js';
11
11
  import { resolveTemplates } from './template-resolver.js';
12
12
  import { normalizeTriggerFilter, validateTriggerStructure } from './trigger-schema.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',
@@ -269,6 +316,58 @@ export function attachLoaderWarnings(err, warnings) {
269
316
  return;
270
317
  err.warnings = warnings;
271
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) {
333
+ const profilesDir = definition.profiles_dir !== undefined
334
+ ? resolve(workflowDir, definition.profiles_dir)
335
+ : join(workflowDir, 'profiles');
336
+ const resolvedProfiles = {};
337
+ const profileErrors = [];
338
+ for (const [stepName, step] of Object.entries(definition.steps)) {
339
+ if (step.agent_profile === undefined)
340
+ continue;
341
+ const profileName = step.agent_profile;
342
+ if (profileName in resolvedProfiles)
343
+ continue;
344
+ const profilePath = join(profilesDir, `${profileName}.md`);
345
+ let profileContent;
346
+ try {
347
+ profileContent = readFileSync(profilePath, 'utf8');
348
+ }
349
+ catch {
350
+ profileErrors.push(`Step '${stepName}': agent_profile '${profileName}' not found. Searched: ${profilePath}`);
351
+ continue;
352
+ }
353
+ const contentHash = createHash('sha256').update(profileContent).digest('hex');
354
+ resolvedProfiles[profileName] = { content: profileContent, content_hash: contentHash };
355
+ }
356
+ if (profileErrors.length > 0) {
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],
361
+ code: 'VALIDATION_WORKFLOW_SCHEMA',
362
+ category: 'VALIDATION',
363
+ agentAction: 'report_to_user',
364
+ retryable: false,
365
+ });
366
+ }
367
+ if (Object.keys(resolvedProfiles).length > 0) {
368
+ definition.resolved_profiles = resolvedProfiles;
369
+ }
370
+ }
272
371
  /**
273
372
  * Pure core of loadWorkflowFromFile (issue #169): parses + resolves everything a file-based load
274
373
  * needs, but never prints and never chooses between the two public presentations — it always
@@ -307,88 +406,20 @@ function loadWorkflowFromFileCore(filePath, registry) {
307
406
  // `parseWorkflowString` owns those throws and has already attached, and attach-once means its
308
407
  // richer set survives.
309
408
  try {
310
- // Resolve agent profiles — only possible when we have a file path.
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.
311
414
  const workflowDir = dirname(resolve(filePath));
312
- const profilesDir = definition.profiles_dir !== undefined
313
- ? resolve(workflowDir, definition.profiles_dir)
314
- : join(workflowDir, 'profiles');
315
- const resolvedProfiles = {};
316
- const profileErrors = [];
317
- for (const [stepName, step] of Object.entries(definition.steps)) {
318
- if (step.agent_profile === undefined)
319
- continue;
320
- const profileName = step.agent_profile;
321
- if (profileName in resolvedProfiles)
322
- continue;
323
- const profilePath = join(profilesDir, `${profileName}.md`);
324
- let profileContent;
325
- try {
326
- profileContent = readFileSync(profilePath, 'utf8');
327
- }
328
- catch {
329
- profileErrors.push(`Step '${stepName}': agent_profile '${profileName}' not found. Searched: ${profilePath}`);
330
- continue;
331
- }
332
- const contentHash = createHash('sha256').update(profileContent).digest('hex');
333
- resolvedProfiles[profileName] = { content: profileContent, content_hash: contentHash };
334
- }
335
- if (profileErrors.length > 0) {
336
- throw new WorkflowError(`Invalid workflow: ${profileErrors.join('; ')}`, {
337
- // issue #425: the pre-join strings, so a render can list them one per line. Two missing
338
- // profiles are two problems, not one long sentence.
339
- errors: [...profileErrors],
340
- code: 'VALIDATION_WORKFLOW_SCHEMA',
341
- category: 'VALIDATION',
342
- agentAction: 'report_to_user',
343
- retryable: false,
344
- });
345
- }
346
- if (Object.keys(resolvedProfiles).length > 0) {
347
- definition.resolved_profiles = resolvedProfiles;
348
- }
349
- // Validate context_wrapper if present.
350
- if (definition.context_wrapper !== undefined) {
351
- const VALID_WRAPPER_FORMATS = new Set(['xml', 'brackets', 'none']);
352
- if (!VALID_WRAPPER_FORMATS.has(definition.context_wrapper)) {
353
- throw new WorkflowError(`Invalid context_wrapper '${String(definition.context_wrapper)}'; must be 'xml', 'brackets', or 'none'`, {
354
- code: 'VALIDATION_WORKFLOW_SCHEMA',
355
- category: 'VALIDATION',
356
- agentAction: 'report_to_user',
357
- retryable: false,
358
- });
359
- }
360
- }
361
- // Validate and resolve workflow_context entry paths.
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.
362
420
  if (definition.workflow_context !== undefined) {
363
- for (const [name, entry] of Object.entries(definition.workflow_context)) {
364
- if (name.endsWith('.raw')) {
365
- throw new WorkflowError(`workflow_context entry names must not end with '.raw' (found: '${name}')`, {
366
- code: 'VALIDATION_WORKFLOW_SCHEMA',
367
- category: 'VALIDATION',
368
- agentAction: 'report_to_user',
369
- retryable: false,
370
- });
371
- }
372
- if (!/^[\w.]+$/.test(name)) {
373
- throw new WorkflowError(`workflow_context entry name '${name}' is invalid; names must match [\\w.]+ (underscores and dots only — no hyphens)`, {
374
- code: 'VALIDATION_WORKFLOW_SCHEMA',
375
- category: 'VALIDATION',
376
- agentAction: 'report_to_user',
377
- retryable: false,
378
- });
379
- }
380
- const rawEntry = entry;
381
- const rawSource = rawEntry['source'];
382
- if (rawSource === undefined || typeof rawSource['path'] !== 'string') {
383
- throw new WorkflowError(`workflow_context.${name}.source.path is required`, {
384
- code: 'VALIDATION_WORKFLOW_SCHEMA',
385
- category: 'VALIDATION',
386
- agentAction: 'report_to_user',
387
- retryable: false,
388
- });
389
- }
390
- // Resolve relative path to absolute.
391
- entry.source.path = resolve(workflowDir, rawSource['path']);
421
+ for (const entry of Object.values(definition.workflow_context)) {
422
+ entry.source.path = resolve(workflowDir, entry.source.path);
392
423
  }
393
424
  }
394
425
  // Auto-register schema.json if present and not explicitly declared.
@@ -404,6 +435,8 @@ function loadWorkflowFromFileCore(filePath, registry) {
404
435
  // the deployment-manifest anchor (`<trust_root>/realm.yaml`), needed by extension-free
405
436
  // workflows that consume manifest-constructed adapters by name. Core resolves/stores
406
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).
407
440
  definition.source_dir = workflowDir;
408
441
  definition.trust_root = findTrustRoot(workflowDir);
409
442
  if (definition.extensions !== undefined) {
@@ -623,6 +656,16 @@ function parseWorkflowString(content, registry, opts) {
623
656
  return `${message} (step at line ${stepLine})`;
624
657
  return message;
625
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
+ };
626
669
  // Step 2: Top-level validation
627
670
  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
628
671
  throw new WorkflowError('Invalid workflow: Workflow must be a non-null object', {
@@ -633,20 +676,72 @@ function parseWorkflowString(content, registry, opts) {
633
676
  });
634
677
  }
635
678
  const doc = raw;
636
- // WARN (do not reject) on a key that isn't authorable — checked against KNOWN_WORKFLOW_KEYS
637
- // ONLY (not RUNTIME_ONLY_WORKFLOW_KEYS), and BEFORE any loader-stamped field is added below.
679
+ // Mints a warning for a key that isn't authorable — checked against KNOWN_WORKFLOW_KEYS ONLY
680
+ // (not RUNTIME_ONLY_WORKFLOW_KEYS), and BEFORE any loader-stamped field is added below.
638
681
  // Deliberately excluding runtime-only keys from "known" here means hand-authoring one (e.g.
639
682
  // `schema_version:` or `model:` in YAML) warns too — those fields are stamped by the loader
640
683
  // and any authored value is silently overwritten/ignored, which is exactly the kind of mistake
641
- // this check exists to surface (issue #144). Non-breaking by design: siblings #170
642
- // (hard-reject) and #169 (structured warnings channel) are deliberately out of scope here.
684
+ // this check exists to surface (issue #144). Under the #170 flip UNKNOWN_WORKFLOW_KEY is
685
+ // 'error', so the authoring boundary (validate/register/watch) REFUSES the file over it while
686
+ // execution surfaces stay lenient.
687
+ //
688
+ // ONE exception (issue #559): a key in the author's extension namespace (`isExtensionKey`)
689
+ // mints NOTHING — it is the author's own field (a YAML anchor host, a tooling note), carried
690
+ // verbatim into the definition (the cast below adds no field pick) and never read by realm.
691
+ // The reserved sub-namespace does NOT get that pass: a `x-realm-` key falls through to the
692
+ // ordinary mint below and is retargeted (the `.map` after) to a message naming the reservation.
643
693
  {
644
694
  const workflowId = typeof doc['id'] === 'string' ? doc['id'] : '<unknown>';
645
695
  warnings.push(...findUnknownKeys(doc, KNOWN_WORKFLOW_KEYS, {
646
696
  scope: 'workflow',
647
697
  code: 'UNKNOWN_WORKFLOW_KEY',
648
698
  id: workflowId,
699
+ isExtension: isExtensionKey,
649
700
  positionOf: (key) => sourceMap.posOf([key]),
701
+ }).map((w) => {
702
+ // TWO targeted messages (issue #559): same code, same policy — sentences built by
703
+ // interpolating the exported consts, never a quoted literal, because the D5 never-read
704
+ // witness scans this very file for that exact quoting shape.
705
+ //
706
+ // Both arms are POSITIVE `if`s that return, never a guard-and-early-return — that shape
707
+ // is load-bearing: a guard followed by an unconditional `return` makes any arm placed
708
+ // after it unreachable (and fails to compile — TS narrows `w.key` to `undefined` past
709
+ // the point the guard already excluded it). Ending in a bare `return w;` is what lets a
710
+ // future third arm append cleanly.
711
+ //
712
+ // ORDER matters: the reserved-prefix check runs FIRST, matched case-insensitively (a
713
+ // key that lowercases into `x-realm-…` is realm's reserved sub-namespace regardless of
714
+ // how the author capitalized it — the alternative, checking case-only first, would
715
+ // catch `X-Realm-Foo` under "you spelled the namespace right, just wrong case" and hand
716
+ // it a remedy that runs the author straight into the RESERVED refusal on the very next
717
+ // try). Because the reserved check already consumes every case-variant of its own
718
+ // prefix, the case-only arm below only ever sees a key that is NOT in the reserved
719
+ // sub-namespace under any capitalization.
720
+ if (w.key === undefined)
721
+ return w;
722
+ const at = w.line === undefined ? '' : ` (line ${w.line})`;
723
+ if (w.key.toLowerCase().startsWith(RESERVED_EXTENSION_PREFIX)) {
724
+ return {
725
+ ...w,
726
+ message: `workflow '${workflowId}': unknown key '${w.key}'${at} — the ` +
727
+ `'${RESERVED_EXTENSION_PREFIX}' prefix is reserved for realm's own future ` +
728
+ `extension keys; any other '${EXTENSION_KEY_PREFIX}' name is yours to use (an ` +
729
+ `extension key is carried verbatim and never read).`,
730
+ };
731
+ }
732
+ // A CASE-ONLY miss (e.g. `X-Foo`): the likeliest authoring slip for a prefix
733
+ // convention — the capital-`X-` habit comes from HTTP headers. Never reached by an
734
+ // actual lowercase `x-` key (those were already skipped by `isExtension` before the
735
+ // mint), so this arm fires only for a key that MEANT the namespace and missed the case.
736
+ if (w.key.toLowerCase().startsWith(EXTENSION_KEY_PREFIX)) {
737
+ return {
738
+ ...w,
739
+ message: `workflow '${workflowId}': unknown key '${w.key}'${at} — the extension ` +
740
+ `namespace is lowercase: a key starting '${EXTENSION_KEY_PREFIX}' is the ` +
741
+ `author's, carried verbatim and never read; '${w.key}' is not one.`,
742
+ };
743
+ }
744
+ return w;
650
745
  }));
651
746
  }
652
747
  // Project extensions: hard error for string-based loading (fires before any other
@@ -712,17 +807,52 @@ function parseWorkflowString(content, registry, opts) {
712
807
  // at all); the legacy when-only depends_on/run.params check (validateWhenReference) is
713
808
  // UNCHANGED — it still fires ONLY for `surface === 'when'`. This is a LIFT, not a new
714
809
  // computation — byte-identical to the previous block-local `dependsOn` for `when`'s own use.
810
+ // The step's kind, once, for every kind-forked check below (undefined = malformed or
811
+ // missing execution — already refused by the invalid-execution/required error).
812
+ const stepKind = VALID_EXECUTIONS.has(step['execution'])
813
+ ? step['execution']
814
+ : undefined;
715
815
  const dependsOn = Array.isArray(step['depends_on'])
716
816
  ? step['depends_on'].filter((d) => typeof d === 'string')
717
817
  : [];
718
818
  // WARN (do not reject) on an unknown step key — runs after template resolution above, so a
719
819
  // template-expanded step's keys are checked too. Same non-breaking posture as the
720
820
  // workflow-level check (issue #144).
821
+ //
822
+ // NO extension namespace at step level (issue #559): step keys are the #417 consumption
823
+ // registry, a closed set where an inert key is a load error by ratified policy. So NO
824
+ // `isExtension` ctx is passed here, and EVERY key starting with the raw `x-` prefix —
825
+ // `x-realm-…` included, since the reservation is a top-level-only concept — still mints
826
+ // UNKNOWN_STEP_KEY, retargeted below to a message pointing the author at the top level.
721
827
  warnings.push(...findUnknownKeys(step, KNOWN_STEP_KEYS, {
722
828
  scope: 'step',
723
829
  code: 'UNKNOWN_STEP_KEY',
724
830
  step: stepName,
725
831
  positionOf: (key) => sourceMap.posOf(['steps', stepName, key]),
832
+ }).map((w) => {
833
+ // TARGETED message for an `x-` step key (issue #559): the namespace is top-level only,
834
+ // so the remedy is "move it", not "is this a typo" — did_you_mean is dropped because
835
+ // the targeted message is complete (a suggestion across the boundary would be noise).
836
+ //
837
+ // The tail forks on whether the key falls in the RESERVED sub-namespace (issue #582):
838
+ // following the plain "move it to the top" remedy for a reserved name (e.g.
839
+ // `x-realm-foo`, `x-Realm-foo`) lands the author on the RESERVATION refusal one level
840
+ // up — a two-round-trip. The reserved check is case-insensitive here for the same
841
+ // reason `isExtensionKey` is: the reservation holds in every capitalization.
842
+ if (w.key === undefined || !w.key.startsWith(EXTENSION_KEY_PREFIX))
843
+ return w;
844
+ const at = w.line === undefined ? '' : ` (line ${w.line})`;
845
+ const { did_you_mean: _dropped, ...rest } = w;
846
+ const reserved = w.key.toLowerCase().startsWith(RESERVED_EXTENSION_PREFIX);
847
+ return {
848
+ ...rest,
849
+ message: `step '${stepName}': unknown key '${w.key}'${at} — '${EXTENSION_KEY_PREFIX}' ` +
850
+ `extension keys are accepted only at the top level of a workflow file; step keys ` +
851
+ `are a closed set. Move it to the top of the file` +
852
+ (reserved
853
+ ? ` under a name outside the reserved '${RESERVED_EXTENSION_PREFIX}' prefix.`
854
+ : `.`),
855
+ };
726
856
  }));
727
857
  // issue #220 §4c PR ordering interlock: `$settlement` is reserved NOW (PR-1) even though the
728
858
  // namespace it names is not minted until a later PR — else the inter-PR gap could register a
@@ -746,31 +876,77 @@ function parseWorkflowString(content, registry, opts) {
746
876
  if ('execution' in step && !VALID_EXECUTIONS.has(step['execution'])) {
747
877
  errors.push(withStepLine(stepName, `Step '${stepName}': invalid execution value '${String(step['execution'])}'; must be 'auto', 'agent', 'guard', or 'finalizer'`));
748
878
  }
879
+ // issue #517 (the drive-flip): ONE registry-driven walk mints every kind-prohibition —
880
+ // for each declared key × the step's kind, a `prohibited` cell WITHOUT an except arm
881
+ // mints exactly one refusal, message text per-cell data (renderRegistryProhibition).
882
+ // Sits at the sequence position of the EARLIEST check it replaced (the old finalizer
883
+ // prohibited-field loop), so minted refusals still precede the structural finalizer/guard
884
+ // requirements below. Multi-fire is dead by construction (one lookup, one refusal per
885
+ // key×kind), and a multi-bad-key step now errors in YAML declaration order.
886
+ //
887
+ // Except-bearing cells are SKIPPED — their value-conditional checks stay hand-written
888
+ // (today exactly trust×finalizer, below). Companion/value/sub-key rules are not minted at
889
+ // all (the clang line): toolsMissing, the tools agent+handler clause, the gate block,
890
+ // retry E1-E3, structured_output literal+eligibility, trace_schema compile, pos-int
891
+ // checks all stay hand-written further down.
892
+ //
893
+ // The kind gate is deliberate: on a step whose `execution` is missing or not one of the
894
+ // four kinds, the registry has no row to consult, so NO per-key kind refusal is minted —
895
+ // the invalid-execution/missing-required error above is the whole verdict. (Pre-#517 the
896
+ // per-key `!== '<kind>'` twins ALSO fired on malformed kinds; that was per-key advice
897
+ // keyed to a kind nobody declared. The workflow is refused either way — the refusal
898
+ // POPULATION is unchanged; disclosed in the changelog.)
899
+ if (VALID_EXECUTIONS.has(step['execution'])) {
900
+ const kind = step['execution'];
901
+ for (const key of Object.keys(step)) {
902
+ // Unknown keys are UNKNOWN_STEP_KEY's business (warned above), never a registry row.
903
+ const row = STEP_KEY_REGISTRY[key];
904
+ if (row === undefined || step[key] === undefined)
905
+ continue;
906
+ const cell = row[kind];
907
+ if (cell.c !== 'prohibited' || cell.except !== undefined)
908
+ continue;
909
+ errors.push(withKeyLine(stepName, key, `Step '${stepName}': ${renderRegistryProhibition(key, kind, cell)}`));
910
+ }
911
+ }
749
912
  // Finalizer step constraints (a workflow-level try/catch/finally). handler-only in v1.
750
913
  if (step['execution'] === 'finalizer') {
751
- const prohibited = [
752
- 'depends_on',
753
- 'trigger_rule',
754
- 'abort_unless',
755
- 'abort_message',
756
- 'output_schema',
757
- 'agent_profile',
758
- 'tools',
759
- 'uses_service',
760
- 'service_method',
761
- 'operation',
762
- 'input_map',
763
- 'when',
764
- 'retry',
765
- ];
766
- for (const field of prohibited) {
767
- if (step[field] !== undefined) {
768
- errors.push(withStepLine(stepName, `Step '${stepName}': '${field}' is not valid on execution: finalizer steps`));
769
- }
770
- }
771
- // A finalizer must not gate — reject any human-gate trust level.
914
+ // A finalizer must not gate — reject any human-gate trust level. Value-conditional
915
+ // (`trust: 'auto'` is lawful), which is why this is the registry's except-bearing cell
916
+ // and stays hand-written rather than minted (#517).
772
917
  if (step['trust'] !== undefined && step['trust'] !== 'auto') {
773
- errors.push(withStepLine(stepName, `Step '${stepName}': 'trust: ${String(step['trust'])}' is not valid on execution: finalizer steps (a finalizer must not gate)`));
918
+ // issue #508: the reason forks on whether the declared value is actually a GATE
919
+ // literal — "a finalizer must not gate" is only true THEN. Any other value (an
920
+ // unrecognized trust, a service-trust literal, the retired human_notified) was never
921
+ // an attempt to gate at all, so that reason would be false for it — the #523 class,
922
+ // caught before shipping rather than after. `isGateTrust` is the pure-value question
923
+ // (no kind involved, since this branch already knows the kind and has already
924
+ // excluded 'auto'); the leading `'trust:` is kept exactly as before so the registry
925
+ // conformance runner's `namesKey` (`error.includes("'trust")`) still matches this arm
926
+ // — `namesKey` needs the quote BEFORE `trust`, not around the value, so switching the
927
+ // value's own rendering below does not touch it.
928
+ //
929
+ // issue #508 (final correction): the gate-literal arm stays a hand-written KIND
930
+ // prohibition (the key is the offense, not the value — #517's own boundary), but now
931
+ // shares `renderTrustValue` with every other arm — a previous ruling to "keep
932
+ // `String()` here to satisfy `namesKey`" was wrong (verified above) and there was
933
+ // never a real reason for two renderers, even though `isGateTrust` only ever admits
934
+ // the two known-string gate literals here in practice. The non-gate branch (an
935
+ // unrecognized trust, a service-trust literal, the retired human_notified) routes
936
+ // through the SAME composer every other refusal surface uses — no second hand-built
937
+ // arm-selector, no second value renderer. The conformance fixture (`buildFixture`)
938
+ // exercises only the gate-literal branch (`human_confirmed`), so the composer's three
939
+ // sub-arms are unreached by it — verified by grepping the fixture builder for this key.
940
+ const rawFinalizerTrust = step['trust'];
941
+ const finalizerMessage = isGateTrust(rawFinalizerTrust)
942
+ ? `Step '${stepName}': 'trust: ${renderTrustValue(rawFinalizerTrust)}' is not valid on execution: finalizer steps (a finalizer must not gate)`
943
+ : buildTrustRefusal({
944
+ kind: 'finalizer',
945
+ value: rawFinalizerTrust,
946
+ step: stepName,
947
+ surface: 'load',
948
+ });
949
+ errors.push(withStepLine(stepName, finalizerMessage));
774
950
  }
775
951
  // v1 is handler-only.
776
952
  if (step['handler'] === undefined) {
@@ -793,141 +969,56 @@ function parseWorkflowString(content, registry, opts) {
793
969
  }
794
970
  }
795
971
  }
796
- // on_outcome is only valid on execution: finalizer steps.
797
- if (step['on_outcome'] !== undefined && step['execution'] !== 'finalizer') {
798
- errors.push(
799
- // Consumer: settlement.ts:145 (`finalizerTriggers`) it is read only when selecting
800
- // which finalizers a run's outcome should fire.
801
- withKeyLine(stepName, 'on_outcome', `Step '${stepName}': 'on_outcome' is only valid on execution: finalizer stepsit ` +
802
- 'selects which finalizers run for a given outcome, and only finalizers are selected ' +
803
- 'that way, so here it would decide nothing. Move it to the finalizer that should ' +
804
- 'react to the outcome, or remove it.'));
972
+ // issue #508 (L1) trust VALUE validation, auto/agent only. Before this check, an
973
+ // unrecognized or kind-inert `trust` (a typo, a service-trust literal, the retired
974
+ // `human_notified`) loaded clean, warned nothing, and ran with NO gate — the step's own
975
+ // declared human-approval control was silently disabled. Presence-keyed (`'trust' in
976
+ // step`, the same convention `trigger_rule` and `retry.backoff` already use below) so
977
+ // `trust:`/`trust: ~` (a null value, which loads clean today) is caught too a blank
978
+ // declaration of a safety control is itself a false statement, not a no-op. `trust:`
979
+ // absent entirely is lawful (nothing was declared) and never reaches this block.
980
+ //
981
+ // Guard's OWN trust prohibition is minted by the #517 walk above (every value refused,
982
+ // no except arm); finalizer's is the hand-written except-cell just above (only 'auto' is
983
+ // lawful there). This block is what closes the remaining two kinds — the ones where a
984
+ // RECOGNIZED gate literal is meaningful, so an unrecognized one needs a VALUE verdict,
985
+ // not a kind verdict.
986
+ //
987
+ // issue #508 (final correction) — this whole value-refusal composition, for every kind and
988
+ // every surface, is now `buildTrustRefusal` (types/workflow-definition.ts, beside
989
+ // `classifyStepTrust`). Three prior rounds each hand-composed this text independently on
990
+ // this surface, execution-loop.ts's dispatch refusal, run-health.ts's finding, and the
991
+ // protocol generator's briefing — and every defect those rounds found (arm divergence, a
992
+ // String()-rendered array printing as its own first element, a grammar seam) fell out of
993
+ // that duplication. No site chooses an arm or renders a value on its own again; see the
994
+ // composer's own doc for the arm/mood/rendering contract in full.
995
+ if ((stepKind === 'auto' || stepKind === 'agent') &&
996
+ 'trust' in step &&
997
+ classifyStepTrust(stepKind, step['trust']) === 'refuse') {
998
+ errors.push(withKeyLine(stepName, 'trust', buildTrustRefusal({
999
+ kind: stepKind,
1000
+ value: step['trust'],
1001
+ step: stepName,
1002
+ surface: 'load',
1003
+ })));
805
1004
  }
806
- // Guard step constraints.
1005
+ // Guard step constraints (the guard kind-prohibitions, including `preconditions` — issue
1006
+ // #369's own bespoke message, message_data-preserved — are minted by the #517 walk above).
807
1007
  if (step['execution'] === 'guard') {
808
- const prohibited = [
809
- 'uses_service',
810
- 'handler',
811
- 'input_schema',
812
- 'output_schema',
813
- 'trust',
814
- 'agent_profile',
815
- 'trigger_rule',
816
- 'timeout_seconds',
817
- 'service_method',
818
- 'operation',
819
- 'input_map',
820
- 'tools',
821
- ];
822
- for (const field of prohibited) {
823
- if (step[field] !== undefined) {
824
- errors.push(withStepLine(stepName, `Step '${stepName}': '${field}' is not valid on execution: guard steps`));
825
- }
826
- }
827
1008
  if (step['abort_unless'] === undefined) {
828
1009
  errors.push(withStepLine(stepName, `Step '${stepName}': execution: guard requires 'abort_unless'`));
829
1010
  }
830
- // issue #369: `preconditions` gets its OWN error rather than joining `prohibited` above,
831
- // because the generic message ("'x' is not valid on execution: guard steps") would not say
832
- // the thing that matters — this field was ACCEPTED and INERT before this check existed, so
833
- // an author who wrote one has a workflow that looks guarded and never was. The generic
834
- // list's own message style is issue #366's territory; the other twelve are left alone.
835
- //
836
- // The claim "never evaluates it there" rests on `checkPreconditions` having exactly one
837
- // engine call site (execution-loop.ts:1380, inside `executeStep`), which `executeGuardStep`
838
- // never reaches. A test pins that count so a second call site reds this message.
839
- if (step['preconditions'] !== undefined) {
840
- errors.push(withKeyLine(stepName, 'preconditions', `Step '${stepName}': 'preconditions' is not valid on execution: guard steps — the ` +
841
- `engine never evaluates it there (a guard's execution evaluates only 'abort_unless'), ` +
842
- `so the run would LOOK guarded while the declared check never ran. Move the condition ` +
843
- `into 'abort_unless'. Whether guards gain a live condition surface is an open design ` +
844
- `question (issue #366) — if admitted later, existing workflows are unaffected.`));
845
- }
846
- }
847
- // abort_unless and abort_message are only valid on execution: guard steps.
848
- if (step['abort_unless'] !== undefined && step['execution'] !== 'guard') {
849
- errors.push(
850
- // Consumer: execution-loop.ts:4828 — the condition list a guard evaluates before the
851
- // run is allowed to continue.
852
- withKeyLine(stepName, 'abort_unless', `Step '${stepName}': 'abort_unless' is only valid on execution: guard steps — it is ` +
853
- 'the condition list a guard evaluates before letting the run continue, and only ' +
854
- 'guard steps are evaluated that way, so here it would gate nothing. Put the check ' +
855
- 'on a guard step, or remove it.'));
856
- }
857
- if (step['abort_message'] !== undefined && step['execution'] !== 'guard') {
858
- errors.push(
859
- // Consumer: execution-loop.ts:4943 — the text reported when a guard aborts the run.
860
- // The clause is about READERSHIP, not about who aborts: `handler_abort` and
861
- // `gate_expiry_abort` are seal arms too (types/run-record.ts:603-617), so "only a guard
862
- // aborts" would be false. What is true is that every reader of this key is a guard path.
863
- withKeyLine(stepName, 'abort_message', `Step '${stepName}': 'abort_message' is only valid on execution: guard steps — it is ` +
864
- 'the text reported when a guard aborts the run, and nothing but a guard reads it, ' +
865
- 'so here it would never be read. Move it to the guard that performs the abort, or ' +
866
- 'remove it.'));
867
1011
  }
868
- // agent_profile is only valid on agent steps.
869
- if ('agent_profile' in step && step['execution'] !== 'agent') {
870
- errors.push(
871
- // Consumer: run-agent.ts:584 resolved into the model prompt for the step.
872
- withKeyLine(stepName, 'agent_profile', `Step '${stepName}': 'agent_profile' is only valid on execution: agent steps — its ` +
873
- 'content is resolved into the model prompt, and only an agent step makes a model ' +
874
- 'request, so here it would reach no model. Move it to the agent step whose prompt ' +
875
- 'it should shape, or remove it.'));
876
- }
877
- // llm_timeout_seconds (issue #401) is only valid on agent steps — no other execution kind
878
- // makes a model request, so the key would be silently inert anywhere else. One `!== 'agent'`
879
- // check covers auto/guard/finalizer.
880
- if (step['llm_timeout_seconds'] !== undefined && step['execution'] !== 'agent') {
881
- errors.push(
882
- // Consumer: run-agent.ts:501-507 — the per-step clock resolution, which is the
883
- // per-attempt bound on the step's model request. The range names the resolution rather
884
- // than each read: :501 and :507 read the KEY, :503 reads the CLI flag it overrides.
885
- withKeyLine(stepName, 'llm_timeout_seconds', `Step '${stepName}': 'llm_timeout_seconds' is only valid on execution: agent steps — ` +
886
- 'it bounds one model request, and no other kind makes one, so here it would bound ' +
887
- 'nothing. Move it to the agent step whose request it should bound, or remove it. ' +
888
- "An auto step's dispatch is bounded by 'timeout_seconds', and a " +
889
- "finalizer's handler by its own 'timeout_seconds'."));
890
- }
891
- // ...and when present it must be a positive integer (the same convention as
892
- // retry.total_timeout_seconds and gate.timeout_seconds).
1012
+ // llm_timeout_seconds must be a positive integer when present (the same convention as
1013
+ // retry.total_timeout_seconds and gate.timeout_seconds). Deliberately kind-BLIND, which
1014
+ // makes it the pinned DOUBLE-fire control for #517: on a wrong-kind step BOTH the minted
1015
+ // prohibition and this shape error fire this check was never else-if-suppressed, unlike
1016
+ // structured_output/validation_exhaustion/input_map's value checks below.
893
1017
  if (step['llm_timeout_seconds'] !== undefined &&
894
1018
  (!Number.isInteger(step['llm_timeout_seconds']) ||
895
1019
  step['llm_timeout_seconds'] <= 0)) {
896
1020
  errors.push(withStepLine(stepName, `Step '${stepName}': 'llm_timeout_seconds' must be a positive integer`));
897
1021
  }
898
- // timeout_seconds is NOT valid on an agent step (issue #402). Nothing enforces it there:
899
- // `shouldEnforceTimeout` is `execution === 'auto'`, and agent dispatch is never wrapped in
900
- // `withTimeout` at all. The key is now inert as well as unenforced — issue #412 deleted the
901
- // `expected_timeout` display that used to render it into the NextAction, which is what made
902
- // it actively misleading rather than merely useless. The error stays: an author who writes a
903
- // bound should be told it does nothing, not left to find out. The message names both bounds
904
- // that DO exist, scoped to realm's own drive (an externally driven step gets neither), on
905
- // the RETRY_INERT_NON_AUTO precedent below.
906
- //
907
- // `=== 'agent'` EXACTLY, never `!== 'auto'`: finalizers consume this key twice — the drain
908
- // lease (execution-loop.ts:5226) and the handler's own bound (:5030) — and guards already
909
- // reject it in the prohibited-fields list above.
910
- if (step['timeout_seconds'] !== undefined && step['execution'] === 'agent') {
911
- errors.push(withKeyLine(stepName, 'timeout_seconds', `Step '${stepName}': 'timeout_seconds' is not valid on execution: agent steps — ` +
912
- 'the engine never enforces it there (agent dispatch is never wrapped in a timeout), ' +
913
- 'so the step would LOOK time-bounded while nothing enforced the bound. ' +
914
- "In realm's own drive the model request is bounded by 'llm_timeout_seconds' " +
915
- "(or --llm-timeout) and tool calls by 'tool_timeout'."));
916
- }
917
- // idempotent (issue #101 Phase 2) is only valid on execution: auto steps — the reliably
918
- // time-boundable, deadline-carrying class. It is inert (no concrete deadline is ever written)
919
- // on agent/guard/finalizer, so it is rejected there rather than silently ignored.
920
- if (step['idempotent'] !== undefined && step['execution'] !== 'auto') {
921
- errors.push(
922
- // Consumers: execution-loop.ts:2526 (the `willRetry` conjunct gating `retry.on_timeout`;
923
- // the :2115 advisory mirrors the rule for loader-bypassing definitions and, by its own
924
- // header, never gates) and reclaim.ts:73 (reclaim eligibility) — both act on auto
925
- // dispatch.
926
- withKeyLine(stepName, 'idempotent', `Step '${stepName}': 'idempotent' is only valid on execution: auto steps — it gates ` +
927
- "'retry.on_timeout' and reclaim eligibility, and both act on auto dispatch, so here " +
928
- 'it would gate nothing. Remove it, or move the work to an auto step if you need ' +
929
- 'either.'));
930
- }
931
1022
  // WARN (do not reject): an idempotent auto step in a finalizer-bearing workflow gets
932
1023
  // `deadline: null` (issue #101), so the RECLAIM function is inert — `realm run reclaim --all`
933
1024
  // can never select it. The author should know it stays per-step-manual-reclaim-only.
@@ -956,21 +1047,16 @@ function parseWorkflowString(content, registry, opts) {
956
1047
  : ''),
957
1048
  });
958
1049
  }
959
- // output_schema is only valid on execution: agent steps.
960
- if (step['output_schema'] !== undefined && step['execution'] !== 'agent') {
961
- errors.push(withStepLine(stepName, `Step '${stepName}': 'output_schema' is only valid on execution: agent steps`));
962
- }
963
- // issue #236 (L0 prevention layer): structured_output is only valid on execution: agent
964
- // steps (mirrors output_schema's rule above), and its only legal value is the literal
1050
+ // issue #236 (L0 prevention layer): structured_output's only legal value is the literal
965
1051
  // 'strict'. On an opted-in step, Phase A REJECTS an ineligible verdict at load time — the
966
1052
  // API provably rejects some legal schemas and silently weakens others, so authoring never
967
1053
  // ships a schema the gate already knows is unsafe. Caveats are NOT rejected (informational
968
1054
  // only, surfaced by validate's nudge — Deliverable 7); this loader block only ever REJECTS.
969
- if (step['structured_output'] !== undefined) {
970
- if (step['execution'] !== 'agent') {
971
- errors.push(withStepLine(stepName, `Step '${stepName}': 'structured_output' is only valid on execution: agent steps`));
972
- }
973
- else if (step['structured_output'] !== 'strict') {
1055
+ // #517 re-gate: the kind half is minted by the registry walk above; the value checks
1056
+ // below keep their old else-branch semantics via an explicit valid-kind conjunct — a
1057
+ // wrong-kind step gets ONLY the minted refusal, never the value noise.
1058
+ if (step['structured_output'] !== undefined && step['execution'] === 'agent') {
1059
+ if (step['structured_output'] !== 'strict') {
974
1060
  errors.push(withStepLine(stepName, `Step '${stepName}': 'structured_output' must be the literal string 'strict' (got ${JSON.stringify(step['structured_output'])})`));
975
1061
  }
976
1062
  else {
@@ -998,11 +1084,9 @@ function parseWorkflowString(content, registry, opts) {
998
1084
  // then AJV-proven AT LOAD TIME (REFUSE — B10, reusing the runtime validator so load-time and
999
1085
  // runtime verdicts can never diverge); `default_output` present without `mode: 'default'` WARNS
1000
1086
  // as dead config (never rejects — it's simply inert); an unknown sub-key WARNS.
1001
- if (step['validation_exhaustion'] !== undefined) {
1002
- if (step['execution'] !== 'agent') {
1003
- errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion' is only valid on execution: agent steps`));
1004
- }
1005
- else if (typeof step['validation_exhaustion'] !== 'object' ||
1087
+ // #517 re-gate: kind half minted above; else-semantics preserved by the explicit conjunct.
1088
+ if (step['validation_exhaustion'] !== undefined && step['execution'] === 'agent') {
1089
+ if (typeof step['validation_exhaustion'] !== 'object' ||
1006
1090
  step['validation_exhaustion'] === null) {
1007
1091
  errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion' must be an object`));
1008
1092
  }
@@ -1097,26 +1181,85 @@ function parseWorkflowString(content, registry, opts) {
1097
1181
  `output is validated against both — prefer one to avoid divergence.`,
1098
1182
  });
1099
1183
  }
1100
- // trace_schema is only valid on execution: agent steps.
1101
- if (step['trace_schema'] !== undefined && step['execution'] !== 'agent') {
1102
- errors.push(withStepLine(stepName, `Step '${stepName}': 'trace_schema' is only valid on execution: agent steps`));
1103
- }
1104
- // trace_validation_mode is only valid on execution: agent steps.
1105
- if (step['trace_validation_mode'] !== undefined && step['execution'] !== 'agent') {
1106
- errors.push(withStepLine(stepName, `Step '${stepName}': 'trace_validation_mode' is only valid on execution: agent steps`));
1107
- }
1108
1184
  // trace_validation_mode must be 'warn' or 'enforce' when provided.
1109
1185
  if (step['trace_validation_mode'] !== undefined &&
1110
1186
  step['trace_validation_mode'] !== 'warn' &&
1111
1187
  step['trace_validation_mode'] !== 'enforce') {
1112
1188
  errors.push(withStepLine(stepName, `Step '${stepName}': invalid trace_validation_mode '${String(step['trace_validation_mode'])}'; must be 'warn' or 'enforce'`));
1113
1189
  }
1190
+ // issue #433: the effective STATIC gate choice source, hoisted per-step BEFORE the
1191
+ // `gate:`-block-only region below. Member (b) below must fire even when there is NO `gate:`
1192
+ // key at all (the executed g433b shape: a gate-trusted step with no gate block but an
1193
+ // empty `input_schema.properties.choice.enum`), and the entire #291 region beneath this one
1194
+ // is gated on `step['gate'] !== undefined` — it cannot host a check that must fire without
1195
+ // one. Pure reads; the existing E2/membership cells (below) pin messages, not evaluation
1196
+ // order, so hoisting these three declarations ahead of them is safe.
1197
+ const gateObj = typeof step['gate'] === 'object' && step['gate'] !== null
1198
+ ? step['gate']
1199
+ : undefined;
1200
+ const declaredGateChoices = gateObj?.['choices'];
1201
+ const declaredChoiceEnum = step['input_schema']?.properties?.['choice']?.enum;
1202
+ // Renders the OFFENDING KEY's own line via its full nested path, falling back to the
1203
+ // step's line and then to no position — the same two-rung univocal vocabulary
1204
+ // `withKeyLine` documents above (issue #420: `(line N)` for the key, `(step at line N)`
1205
+ // for the step; never conflated). `withKeyLine` itself is single-segment
1206
+ // (`['steps', stepName, key]`) and cannot express a nested path like
1207
+ // `['steps', stepName, 'gate', 'choices']`, so this is a local sibling rather than a call
1208
+ // to it — no existing `withKeyLine` call site is touched.
1209
+ const withPathLine = (path, message) => {
1210
+ const keyLine = sourceMap.posOf(path)?.line;
1211
+ if (keyLine !== undefined)
1212
+ return `${message} (line ${keyLine})`;
1213
+ const stepLine = sourceMap.posOf(['steps', stepName])?.line;
1214
+ if (stepLine !== undefined)
1215
+ return `${message} (step at line ${stepLine})`;
1216
+ return message;
1217
+ };
1218
+ // Member (a) (issue #433): a DECLARED `gate.choices` list that is empty is never right, on
1219
+ // ANY step — gate-trusted or not (the #291 block's own posture just below: a `gate:` key
1220
+ // is validated "regardless of trust"; the #417 strict-on-known-key policy agrees). An empty
1221
+ // list on a gate-trusted step mints an unanswerable gate (every response is refused against
1222
+ // an empty expected set) with no disposal path short of an authored expiry — on an ungated
1223
+ // step it is dead weight either way, so the message is deliberately population-invariant
1224
+ // rather than false for the ungated population.
1225
+ if (Array.isArray(declaredGateChoices) && declaredGateChoices.length === 0) {
1226
+ errors.push(withPathLine(['steps', stepName, 'gate', 'choices'], `Step '${stepName}': 'gate.choices', when declared, must be non-empty — an empty ` +
1227
+ 'list is never right: on a gate-trusted step (trust: human_confirmed/human_reviewed) ' +
1228
+ 'it mints a gate NO response can ever resolve (every submission is refused against ' +
1229
+ 'an empty expected list, and the live run wedges with no disposal path: abandon ' +
1230
+ 'refuses a pending gate; purge and drain refuse a live run; only an authored ' +
1231
+ "'gate.timeout_seconds' + 'on_expiry' expiry could ever clear it). Declare at least " +
1232
+ "one choice, or remove the key to fall back to 'input_schema.properties.choice.enum' " +
1233
+ 'or the default pair (approve/reject).'));
1234
+ }
1235
+ // Member (b) (issue #433): for a GATE-TRUSTED step with no `gate.choices` list declared
1236
+ // (NULLISH — the mint's own `??` semantics; `gate: {choices:}` with a YAML-null value is
1237
+ // the third executed wedge shape, and presence-keying would let it escape this check), a
1238
+ // DECLARED-and-empty `input_schema.properties.choice.enum` is the effective choice source
1239
+ // and the same class of error, under its own key. `choices: null` with no `enum` at all
1240
+ // stays legal — the mint defaults to ['approve', 'reject'].
1241
+ if (isGateTrust(step['trust']) &&
1242
+ declaredGateChoices == null &&
1243
+ Array.isArray(declaredChoiceEnum) &&
1244
+ declaredChoiceEnum.length === 0) {
1245
+ errors.push(withPathLine(['steps', stepName, 'input_schema', 'properties', 'choice', 'enum'], `Step '${stepName}': 'input_schema.properties.choice.enum' is this gate's effective ` +
1246
+ "choice source (no 'gate.choices' list declared) and, when declared, must be " +
1247
+ 'non-empty — an empty list mints a gate NO response can ever resolve (every ' +
1248
+ 'submission is refused against an empty expected list, and the live run wedges ' +
1249
+ 'with no disposal path: abandon refuses a pending gate; purge and drain refuse a ' +
1250
+ "live run; only an authored 'gate.timeout_seconds' + 'on_expiry' expiry could ever " +
1251
+ "clear it). Declare at least one enum value, or remove 'enum' to get the default " +
1252
+ 'pair (approve/reject).'));
1253
+ }
1114
1254
  // issue #291 (authorable gate timeout — the FIRST validation the `gate:` block has ever had):
1115
1255
  // the E2 positive-integer checks on timeout_seconds/reminder_seconds/reminder_max, the
1116
1256
  // on_expiry enum, default_choice's required-iff + choice-set validation, and the dead-config
1117
- // warn cells. Runs regardless of `trust` (a `gate:` block with no gate trust is already inert
1118
- // no separate rejection needed; the existing render/mint paths never read it without a
1119
- // trust value).
1257
+ // warn cells. The hard-error checks run regardless of `trust` (a shape/enum mistake is a
1258
+ // mistake whether or not this step can ever gate). The dead-config ADVISORIES fork on
1259
+ // `isGateTrust` (issue #524): the engine mints a gate only where trust requires human
1260
+ // confirmation (Step 5b, `execution-loop.ts`) — on any other step the WHOLE block is inert,
1261
+ // so a per-member remedy ("set a timeout") would be false: following it never makes the key
1262
+ // live, it only silences the one diagnostic that said so.
1120
1263
  if (step['gate'] !== undefined) {
1121
1264
  if (typeof step['gate'] !== 'object' || step['gate'] === null) {
1122
1265
  errors.push(withStepLine(stepName, `Step '${stepName}': 'gate' must be an object`));
@@ -1156,17 +1299,22 @@ function parseWorkflowString(content, registry, opts) {
1156
1299
  // mirroring validation_exhaustion.mode:'default' requiring default_output); validated
1157
1300
  // against the step's own EFFECTIVE STATIC choice set — the EXACT same three-source
1158
1301
  // derivation the engine mints PendingGate.choices from (execution-loop.ts's gate-open
1159
- // site: gate.choices ?? input_schema.properties.choice.enum ?? ['approve','reject'])
1160
- // so a load-time-legal default_choice can NEVER fail at enactment time.
1302
+ // site: gate.choices ?? input_schema.properties.choice.enum ?? ['approve','reject']),
1303
+ // sourced from the issue #433 hoist above (`declaredGateChoices ?? declaredChoiceEnum`
1304
+ // — one chain, so this can never drift from the mint's) — so a load-time-legal
1305
+ // default_choice can NEVER fail at enactment time.
1161
1306
  const hasDefaultChoice = 'default_choice' in gate;
1307
+ // issue #524: the one place this block's dead-config ADVISORIES fork. The mint only
1308
+ // ever reads `gate.*` where `isGateTrust(trust)` holds (execution-loop.ts:3302,
1309
+ // `W_GATE_MINT_TRUST`) — computed once so the three sites below can never disagree.
1310
+ const gateTrusted = isGateTrust(step['trust']);
1162
1311
  if (onExpiry === 'settle_default') {
1163
1312
  if (!hasDefaultChoice) {
1164
1313
  errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.on_expiry: settle_default' requires 'gate.default_choice' ` +
1165
1314
  `(nothing to resolve the gate with on expiry)`));
1166
1315
  }
1167
1316
  else {
1168
- const choicesRaw = gate['choices'] ??
1169
- step['input_schema']?.properties?.['choice']?.enum;
1317
+ const choicesRaw = declaredGateChoices ?? declaredChoiceEnum;
1170
1318
  const effectiveChoices = Array.isArray(choicesRaw)
1171
1319
  ? choicesRaw
1172
1320
  : ['approve', 'reject'];
@@ -1176,9 +1324,10 @@ function parseWorkflowString(content, registry, opts) {
1176
1324
  }
1177
1325
  }
1178
1326
  }
1179
- else if (hasDefaultChoice) {
1327
+ else if (hasDefaultChoice && gateTrusted) {
1180
1328
  // default_choice with on_expiry:'abort' or with no on_expiry at all — inert, not an
1181
1329
  // error: WARN as dead config (the #220 DEAD_VALIDATION_EXHAUSTION_CONFIG precedent).
1330
+ // Gate-trusted only (issue #524) — off gate trust the block advisory below covers it.
1182
1331
  warnings.push({
1183
1332
  code: 'DEAD_GATE_CONFIG',
1184
1333
  severity: resolveSeverity('DEAD_GATE_CONFIG'),
@@ -1188,31 +1337,69 @@ function parseWorkflowString(content, registry, opts) {
1188
1337
  `'gate.on_expiry: settle_default' — set it, or remove 'gate.default_choice'.`,
1189
1338
  });
1190
1339
  }
1191
- // Dead config: on_expiry declared but no timeout_seconds — nothing will ever trigger the
1192
- // enforce clock, so the declared disposition can never enact.
1193
- if (onExpiry !== undefined && gate['timeout_seconds'] === undefined) {
1194
- warnings.push({
1195
- code: 'DEAD_GATE_CONFIG',
1196
- severity: resolveSeverity('DEAD_GATE_CONFIG'),
1197
- scope: 'step',
1198
- step: stepName,
1199
- message: `Step '${stepName}': 'gate.on_expiry' is ignored without 'gate.timeout_seconds' — ` +
1200
- `set a timeout, or remove 'gate.on_expiry'.`,
1201
- });
1340
+ if (gateTrusted) {
1341
+ // Dead config: on_expiry declared but no timeout_seconds — nothing will ever trigger
1342
+ // the enforce clock, so the declared disposition can never enact. Gate-trusted only
1343
+ // (issue #524): off gate trust the block advisory below covers it.
1344
+ if (onExpiry !== undefined && gate['timeout_seconds'] === undefined) {
1345
+ warnings.push({
1346
+ code: 'DEAD_GATE_CONFIG',
1347
+ severity: resolveSeverity('DEAD_GATE_CONFIG'),
1348
+ scope: 'step',
1349
+ step: stepName,
1350
+ message: `Step '${stepName}': 'gate.on_expiry' is ignored without 'gate.timeout_seconds' ` +
1351
+ `— set a timeout, or remove 'gate.on_expiry'.`,
1352
+ });
1353
+ }
1354
+ // Dead notification ([F-A2-5]): reminder_seconds >= timeout_seconds means the FIRST
1355
+ // reminder occurrence would never fire before the enforce clock expires. Gate-trusted
1356
+ // only (issue #524): off gate trust the block advisory below covers it.
1357
+ if (typeof gate['reminder_seconds'] === 'number' &&
1358
+ typeof gate['timeout_seconds'] === 'number' &&
1359
+ gate['reminder_seconds'] >= gate['timeout_seconds']) {
1360
+ warnings.push({
1361
+ code: 'DEAD_GATE_CONFIG',
1362
+ severity: resolveSeverity('DEAD_GATE_CONFIG'),
1363
+ scope: 'step',
1364
+ step: stepName,
1365
+ message: `Step '${stepName}': 'gate.reminder_seconds' (${String(gate['reminder_seconds'])}) ` +
1366
+ `>= 'gate.timeout_seconds' (${String(gate['timeout_seconds'])}) — the first ` +
1367
+ `reminder would never fire before the gate expires.`,
1368
+ });
1369
+ }
1202
1370
  }
1203
- // Dead notification ([F-A2-5]): reminder_seconds >= timeout_seconds means the FIRST
1204
- // reminder occurrence would never fire before the enforce clock expires.
1205
- if (typeof gate['reminder_seconds'] === 'number' &&
1206
- typeof gate['timeout_seconds'] === 'number' &&
1207
- gate['reminder_seconds'] >= gate['timeout_seconds']) {
1371
+ else {
1372
+ // issue #524 (the gate-remedy silence): without gate trust the mint never reads ANY
1373
+ // key in this block, so a per-member remedy ("set a timeout") is false — following it
1374
+ // would silence the diagnostic while the whole block stays exactly as dead. ONE
1375
+ // advisory naming the true cause, unconditional on which keys are set (the block is
1376
+ // equally inert whichever ones are). Position on the STRUCTURED channel only
1377
+ // (`withKeyLine`/`withPathLine` are the ERROR-string helpers, consumed only by
1378
+ // `errors.push` — no loader advisory carries a position today, and this one follows
1379
+ // that convention: `renderLoaderWarning` prints `⚠ ${message}` alone). The kind list
1380
+ // is DERIVED from the registry (`consumedKindsFor('trust')`, the #517
1381
+ // `consumed_home.kinds` pattern) so the remedy can never drift from the vocabulary
1382
+ // that actually gates it.
1383
+ const gatePos = sourceMap.posOf(['steps', stepName, 'gate']);
1384
+ const trustKinds = consumedKindsFor('trust');
1208
1385
  warnings.push({
1209
1386
  code: 'DEAD_GATE_CONFIG',
1210
1387
  severity: resolveSeverity('DEAD_GATE_CONFIG'),
1211
1388
  scope: 'step',
1212
1389
  step: stepName,
1213
- message: `Step '${stepName}': 'gate.reminder_seconds' (${String(gate['reminder_seconds'])}) ` +
1214
- `>= 'gate.timeout_seconds' (${String(gate['timeout_seconds'])}) the first reminder ` +
1215
- `would never fire before the gate expires.`,
1390
+ key: 'gate',
1391
+ ...(gatePos !== undefined
1392
+ ? {
1393
+ line: gatePos.line,
1394
+ column: gatePos.column,
1395
+ endLine: gatePos.endLine,
1396
+ endColumn: gatePos.endColumn,
1397
+ }
1398
+ : {}),
1399
+ message: `Step '${stepName}': the 'gate:' block is inert — this step declares no gate ` +
1400
+ `trust ('trust: human_confirmed' or 'trust: human_reviewed'), so no gate is ever ` +
1401
+ `minted and none of its keys are read. Remove the block, or (on an ` +
1402
+ `${trustKinds.join(' or ')} step) declare that trust.`,
1216
1403
  });
1217
1404
  }
1218
1405
  }
@@ -1232,6 +1419,13 @@ function parseWorkflowString(content, registry, opts) {
1232
1419
  }
1233
1420
  else {
1234
1421
  const retry = step['retry'];
1422
+ // The one population gate both retry advisories (W5 + RETRY_INERT_NON_AUTO) share:
1423
+ // the registry's own retry cell for this step's kind. `inert` = admitted-but-unread
1424
+ // (agent/guard). On `consumed` (auto) neither advisory applies; on `prohibited`
1425
+ // (finalizer) the #517 refusal above is the whole story and an advisory beside it
1426
+ // would contradict it; on a malformed kind the invalid-execution error is the verdict.
1427
+ const retryCellIsInert = VALID_EXECUTIONS.has(step['execution']) &&
1428
+ STEP_KEY_REGISTRY.retry[step['execution']].c === 'inert';
1235
1429
  // WARN (do not reject) on an unknown retry-block key — same non-breaking posture as the
1236
1430
  // step/workflow-level checks (issue #140). Noun overridden to 'retry' (not 'step') since
1237
1431
  // this is a nested block, not the step itself.
@@ -1284,8 +1478,11 @@ function parseWorkflowString(content, registry, opts) {
1284
1478
  }
1285
1479
  // W5 (CAP-ONLY advisory — the on_timeout half of this is already an E1 hard error, so
1286
1480
  // it never reaches here as a warning): the total-time cap only bounds `execution: 'auto'`
1287
- // dispatch — inert on any other step type that legally declares `retry:` today.
1288
- if (step['execution'] !== 'auto' && retry['total_timeout_seconds'] !== undefined) {
1481
+ // dispatch — inert on any other step type that LEGALLY declares `retry:`. The gate is
1482
+ // the registry's own retry cell: the advisory fires only where retry is admitted-but-
1483
+ // inert (agent/guard), never beside the finalizer refusal it would contradict, and
1484
+ // never on a malformed kind (already refused by the invalid-execution error).
1485
+ if (retryCellIsInert && retry['total_timeout_seconds'] !== undefined) {
1289
1486
  warnings.push({
1290
1487
  code: 'TOTAL_TIMEOUT_NON_AUTO',
1291
1488
  severity: resolveSeverity('TOTAL_TIMEOUT_NON_AUTO'),
@@ -1293,19 +1490,21 @@ function parseWorkflowString(content, registry, opts) {
1293
1490
  step: stepName,
1294
1491
  message: `Step '${stepName}': 'retry.total_timeout_seconds' is inert on execution: ` +
1295
1492
  `'${String(step['execution'])}' steps — the cap only bounds 'execution: auto' ` +
1296
- `dispatch, which is the only dispatch ever wrapped in a timeout.`,
1493
+ `dispatch; no other kind's dispatch ever consumes it.`,
1297
1494
  });
1298
1495
  }
1299
1496
  // issue #218 (extends the W5 family): the BARE-KEYS advisory — no explicit
1300
- // total_timeout_seconds (that shape is W5's, above), but retry: is present on a step the
1301
- // built-in dispatch path never wraps in a throwing retry loop at all. Complementary to
1302
- // W5's own `!== undefined` conjunct on the SAME `execution !== 'auto'` gate, so for any
1303
- // non-auto retry block that reaches this point (finalizer+retry and invalid-cap shapes
1304
- // already hard-errored above; on_timeout: true already hard-errored via E1 unless
1305
- // idempotent is also declared, which is itself rejected by the pre-existing
1306
- // idempotent-non-auto check) exactly ONE of {W5, RETRY_INERT_NON_AUTO} ever firesnever
1307
- // both, never neither.
1308
- if (step['execution'] !== 'auto' && retry['total_timeout_seconds'] === undefined) {
1497
+ // total_timeout_seconds (that shape is W5's, above), but retry: is present on a step no
1498
+ // dispatching retry loop ever consumes it on. Complementary to W5's own `!== undefined`
1499
+ // conjunct on the SAME registry-derived inert gate, so for any admitted-but-inert retry
1500
+ // block exactly ONE of {W5, RETRY_INERT_NON_AUTO} ever fires never both, never
1501
+ // neither. The gate EXCLUDES the prohibited kind (finalizer): errors accumulate rather
1502
+ // than halt, so the old `!== 'auto'` gate leaked this advisory beside the finalizer
1503
+ // refusal, where every clause of it was false ("never throws" the drain throws
1504
+ // routinely; "may still consume" — no dispatcher can reach a finalizer's retry;
1505
+ // "not an invalid one" the co-fired error says it IS invalid). Registry-derived:
1506
+ // it fires exactly where the retry cell is inert (agent/guard).
1507
+ if (retryCellIsInert && retry['total_timeout_seconds'] === undefined) {
1309
1508
  const isAgent = step['execution'] === 'agent';
1310
1509
  const message = isAgent
1311
1510
  ? `Step '${stepName}': 'retry' is inert on execution: 'agent' steps — the built-in ` +
@@ -1314,9 +1513,9 @@ function parseWorkflowString(content, registry, opts) {
1314
1513
  `flag instead). An embedder-supplied throwing dispatcher may still consume this ` +
1315
1514
  `config — a deliberate public-API capability, not an invalid one.`
1316
1515
  : `Step '${stepName}': 'retry' is inert on execution: '${String(step['execution'])}' ` +
1317
- `steps — the built-in dispatch path never throws for these steps, so this block can ` +
1318
- `never mint a second attempt here. An embedder-supplied throwing dispatcher may ` +
1319
- `still consume this config — a deliberate public-API capability, not an invalid one.`;
1516
+ `steps — a guard's evaluation never traverses the dispatch path (its conditions ` +
1517
+ `are evaluated inline, with no dispatcher and no retry read), so this block can ` +
1518
+ `never mint a second attempt here.`;
1320
1519
  warnings.push({
1321
1520
  code: 'RETRY_INERT_NON_AUTO',
1322
1521
  severity: resolveSeverity('RETRY_INERT_NON_AUTO'),
@@ -1339,13 +1538,16 @@ function parseWorkflowString(content, registry, opts) {
1339
1538
  `'max_attempts' of 1 — there is no second attempt to retry into.`,
1340
1539
  });
1341
1540
  }
1342
- // W2: the cap can never cover even a single full-length attempt — (a) an EXPLICIT cap
1343
- // below an EXPLICIT timeout_seconds, or (b) on_timeout: true with a cap at-or-below the
1344
- // effective per-attempt timeout (retry-defeating: the opt-in can never yield a viable
1345
- // second attempt). Both conditions require an EXPLICIT total_timeout_seconds the
1346
- // AMENDED default cap (the worst-case schedule) is, by construction, never below a
1347
- // single attempt for max_attempts 2, so this never fires on the bare 3600s-default
1348
- // population.
1541
+ // W2 (issue #524 correction): the declared cap is at or below the per-attempt timeout
1542
+ // (a) an EXPLICIT cap below an EXPLICIT timeout_seconds, or (b) on_timeout: true with a
1543
+ // cap at-or-below the effective per-attempt timeout. This does NOT mean "no retry can
1544
+ // ever occur": `willRetry`'s first disjunct (execution-loop.ts) has no cap conjunct, so
1545
+ // a retryable failure that returns faster than the (clipped) attempt bound still retries
1546
+ // while 'max_attempts' allows another attempt only an attempt that runs OUT its full
1547
+ // bound exhausts the cap with nothing left for a retry. Both arms require an EXPLICIT
1548
+ // total_timeout_seconds — the AMENDED default cap (the worst-case schedule) is, by
1549
+ // construction, never below a single attempt for max_attempts ≥ 2, so this never fires
1550
+ // on the bare 3600s-default population.
1349
1551
  const explicitCapSeconds = typeof retry['total_timeout_seconds'] === 'number'
1350
1552
  ? retry['total_timeout_seconds']
1351
1553
  : undefined;
@@ -1361,9 +1563,11 @@ function parseWorkflowString(content, registry, opts) {
1361
1563
  scope: 'step',
1362
1564
  step: stepName,
1363
1565
  message: `Step '${stepName}': 'retry.total_timeout_seconds: ${explicitCapSeconds}' is at ` +
1364
- `or below its own effective per-attempt timeout (${effectivePerAttemptSeconds}s) ` +
1365
- `— the cap can never cover a single full-length attempt, so a retry can never ` +
1366
- `occur before the cap fires.`,
1566
+ `or below its per-attempt timeout (${effectivePerAttemptSeconds}s` +
1567
+ `${explicitTimeoutSeconds === undefined ? ', the default' : ''}) each attempt ` +
1568
+ `is bounded by what remains of the cap, so an attempt that runs to its bound ` +
1569
+ `exhausts the cap with no retry; a faster failure still retries while ` +
1570
+ `'max_attempts' allows another attempt and its backoff wait fits the remaining cap.`,
1367
1571
  });
1368
1572
  }
1369
1573
  }
@@ -1373,21 +1577,18 @@ function parseWorkflowString(content, registry, opts) {
1373
1577
  !VALID_SERVICE_METHODS.has(step['service_method'])) {
1374
1578
  errors.push(withStepLine(stepName, `Step '${stepName}': invalid service_method '${String(step['service_method'])}'; must be 'fetch', 'create', 'update', or 'delete'`));
1375
1579
  }
1376
- // Validate input_map: only valid on execution: auto steps (both uses_service and handler).
1377
- if (step['input_map'] !== undefined) {
1378
- if (step['execution'] !== 'auto') {
1379
- errors.push(withStepLine(stepName, `Step '${stepName}': 'input_map' is only valid on execution: auto steps`));
1380
- }
1381
- else {
1382
- // issue #392: input_map's errors are minted deep inside a recursive walk that knows only
1383
- // its path string, not the step's position. Collected here and suffixed on the way out,
1384
- // so ONE step's error list never mixes positioned and bare messages — a reader seeing
1385
- // "(step at line 12)" on three of five errors would reasonably wonder what is different about
1386
- // the other two, and nothing is.
1387
- const inputMapErrors = [];
1388
- validateInputMapNode(step['input_map'], `Step '${stepName}': input_map`, inputMapErrors, 0);
1389
- errors.push(...inputMapErrors.map((e) => withStepLine(stepName, e)));
1390
- }
1580
+ // Validate input_map VALUES (auto only the kind half is minted by the #517 walk; the
1581
+ // explicit conjunct preserves the old else-branch: a wrong-kind step gets only the minted
1582
+ // refusal, never the value noise).
1583
+ if (step['input_map'] !== undefined && step['execution'] === 'auto') {
1584
+ // issue #392: input_map's errors are minted deep inside a recursive walk that knows only
1585
+ // its path string, not the step's position. Collected here and suffixed on the way out,
1586
+ // so ONE step's error list never mixes positioned and bare messages a reader seeing
1587
+ // "(step at line 12)" on three of five errors would reasonably wonder what is different about
1588
+ // the other two, and nothing is.
1589
+ const inputMapErrors = [];
1590
+ validateInputMapNode(step['input_map'], `Step '${stepName}': input_map`, inputMapErrors, 0);
1591
+ errors.push(...inputMapErrors.map((e) => withStepLine(stepName, e)));
1391
1592
  }
1392
1593
  // Step config may hold any JSON value (scalars, arrays, nested objects). It is passed through
1393
1594
  // opaquely to handlers (context.config) and merged into adapter config for uses_service steps;
@@ -1464,7 +1665,7 @@ function parseWorkflowString(content, registry, opts) {
1464
1665
  errors.push(withStepLine(stepName, `Step '${stepName}': 'when' must be a non-empty string`));
1465
1666
  }
1466
1667
  else {
1467
- validateConditionLeaf('when', rawWhen, stepName, dependsOn, errors, withStepLine);
1668
+ validateConditionLeaf('when', rawWhen, stepName, dependsOn, stepKind, errors, withStepLine);
1468
1669
  }
1469
1670
  }
1470
1671
  else if (Array.isArray(rawWhen)) {
@@ -1477,7 +1678,7 @@ function parseWorkflowString(content, registry, opts) {
1477
1678
  errors.push(withStepLine(stepName, `Step '${stepName}': 'when' array entries must be non-empty strings`));
1478
1679
  }
1479
1680
  else {
1480
- validateConditionLeaf('when', leaf, stepName, dependsOn, errors, withStepLine);
1681
+ validateConditionLeaf('when', leaf, stepName, dependsOn, stepKind, errors, withStepLine);
1481
1682
  }
1482
1683
  }
1483
1684
  }
@@ -1496,7 +1697,7 @@ function parseWorkflowString(content, registry, opts) {
1496
1697
  errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' must be a non-empty string`));
1497
1698
  }
1498
1699
  else {
1499
- validateConditionLeaf('abort_unless', rawAbort, stepName, dependsOn, errors, withStepLine);
1700
+ validateConditionLeaf('abort_unless', rawAbort, stepName, dependsOn, stepKind, errors, withStepLine);
1500
1701
  }
1501
1702
  }
1502
1703
  else if (Array.isArray(rawAbort)) {
@@ -1509,7 +1710,7 @@ function parseWorkflowString(content, registry, opts) {
1509
1710
  errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' array entries must be non-empty strings`));
1510
1711
  }
1511
1712
  else {
1512
- validateConditionLeaf('abort_unless', leaf, stepName, dependsOn, errors, withStepLine);
1713
+ validateConditionLeaf('abort_unless', leaf, stepName, dependsOn, stepKind, errors, withStepLine);
1513
1714
  }
1514
1715
  }
1515
1716
  }
@@ -1532,7 +1733,7 @@ function parseWorkflowString(content, registry, opts) {
1532
1733
  errors.push(withStepLine(stepName, `Step '${stepName}': 'preconditions' entries must be non-empty strings`));
1533
1734
  }
1534
1735
  else {
1535
- validateConditionLeaf('preconditions', leaf, stepName, dependsOn, errors, withStepLine);
1736
+ validateConditionLeaf('preconditions', leaf, stepName, dependsOn, stepKind, errors, withStepLine);
1536
1737
  }
1537
1738
  }
1538
1739
  }
@@ -1578,9 +1779,9 @@ function parseWorkflowString(content, registry, opts) {
1578
1779
  // guard arm's consequence below is forked — collapsing it back into one shared string
1579
1780
  // would make the error claim a wedge that cannot happen.
1580
1781
  //
1581
- // Post-#369 a guard declaring `preconditions` is REFUSED outright by the guard block
1582
- // above, so this arm now only ever fires ALONGSIDE that refusal: errors accumulate rather
1583
- // than short-circuit, and the guard block runs first, so both messages reach the author
1782
+ // Post-#369 a guard declaring `preconditions` is REFUSED outright since #517, by
1783
+ // the registry mint near the top of Step 3 — so this arm now only ever fires ALONGSIDE
1784
+ // that refusal: errors accumulate rather than short-circuit, and the mint runs first, so both messages reach the author
1584
1785
  // with the prohibition printed above this one. The arm is kept, not deleted — it is what
1585
1786
  // stops the dead-condition message from claiming a wedge that a guard cannot have, and a
1586
1787
  // definition reaching this code by any path other than a fresh YAML load (a
@@ -1656,9 +1857,14 @@ function parseWorkflowString(content, registry, opts) {
1656
1857
  }
1657
1858
  }
1658
1859
  }
1659
- // Validate tools: only valid on execution: agent steps without handler.
1860
+ // Validate tools × handler (the COMPOUND half of the old tools rule — #517 split it: the
1861
+ // non-agent kinds are minted from the registry above; this hand-written check keeps ONLY
1862
+ // the agent-with-handler arm, whose predicate is a companion conflict, not a kind rule).
1863
+ // Populations are disjoint by construction (this fires only on execution: 'agent'; the
1864
+ // mint only on non-agent kinds), so the old multi-fire cannot re-appear.
1660
1865
  if (step['tools'] !== undefined &&
1661
- (step['execution'] !== 'agent' || step['handler'] !== undefined)) {
1866
+ step['execution'] === 'agent' &&
1867
+ step['handler'] !== undefined) {
1662
1868
  errors.push(withStepLine(stepName, `Step '${stepName}': 'tools' is only valid on execution: agent steps without 'handler' defined`));
1663
1869
  }
1664
1870
  // issue #413: `tool_timeout` requires `tools`. It bounds ONE tool call inside the agentic
@@ -1741,12 +1947,11 @@ function parseWorkflowString(content, registry, opts) {
1741
1947
  errors.push(withStepLine(stepName, `Step '${stepName}': 'tool_timeout' must be a positive integer`));
1742
1948
  }
1743
1949
  // Validate timeout_seconds: must be a positive integer (issue A3). Skipped on
1744
- // execution: guard — the guard-prohibited-fields check above already flatly rejects
1745
- // 'timeout_seconds' there ('is not valid on execution: guard steps'); re-checking its
1746
- // shape here would double-report the same root cause under a second, confusing message.
1747
- // Same suppression for the agent prohibition (issue #402), for the same reason: an author
1748
- // told BOTH that the key is invalid here and that its value has the wrong shape is being
1749
- // pointed at the shape, which is not the problem.
1950
+ // execution: guard and agent — the #517 registry mint already flatly rejects
1951
+ // 'timeout_seconds' on both kinds; re-checking its shape here would double-report the
1952
+ // same root cause under a second, confusing message (an author told BOTH that the key is
1953
+ // invalid here and that its value has the wrong shape is being pointed at the shape,
1954
+ // which is not the problem).
1750
1955
  if (step['timeout_seconds'] !== undefined &&
1751
1956
  step['execution'] !== 'guard' &&
1752
1957
  step['execution'] !== 'agent' &&
@@ -1864,6 +2069,37 @@ function parseWorkflowString(content, registry, opts) {
1864
2069
  normalizeTriggerFilter(triggerRaw); // canonicalise shorthand BEFORE validation
1865
2070
  errors.push(...validateTriggerStructure(triggerRaw));
1866
2071
  }
2072
+ // Step 3c: workflow-level context blocks (issue #553). These four rules need nothing but the
2073
+ // text, so they belong to every surface — file, string, `validate --registered`, the public
2074
+ // `loadWorkflowFromString`. They lived in the file loader until #553 and were therefore
2075
+ // invisible to `validate` (which parsed extension-free workflows from string) and to
2076
+ // `--registered`; the public string loader silently accepted all four shapes. Pushed, never
2077
+ // thrown: the accumulator mints `Invalid workflow:` once and the #425 per-line grammar composes.
2078
+ const contextWrapperRaw = doc['context_wrapper'];
2079
+ if (contextWrapperRaw !== undefined) {
2080
+ const VALID_WRAPPER_FORMATS = new Set(['xml', 'brackets', 'none']);
2081
+ if (!VALID_WRAPPER_FORMATS.has(contextWrapperRaw)) {
2082
+ errors.push(withTopLevelLine(['context_wrapper'], `'context_wrapper' must be 'xml', 'brackets', or 'none' (found: '${String(contextWrapperRaw)}')`));
2083
+ }
2084
+ }
2085
+ const workflowContextRaw = doc['workflow_context'];
2086
+ if (workflowContextRaw !== undefined) {
2087
+ for (const [name, entry] of Object.entries(workflowContextRaw)) {
2088
+ if (name.endsWith('.raw')) {
2089
+ errors.push(withTopLevelLine(['workflow_context', name], `workflow_context entry '${name}' must not end with '.raw'`));
2090
+ }
2091
+ if (!/^[\w.]+$/.test(name)) {
2092
+ errors.push(withTopLevelLine(['workflow_context', name], `workflow_context entry '${name}' must match [\\w.]+ (underscores and dots only — no hyphens)`));
2093
+ }
2094
+ const rawEntry = entry;
2095
+ const rawSource = rawEntry?.['source'];
2096
+ if (rawSource === undefined || typeof rawSource['path'] !== 'string') {
2097
+ // The ENTRY's line: the missing key has no line, and a cite must never name an absent
2098
+ // key.
2099
+ errors.push(withTopLevelLine(['workflow_context', name], `workflow_context.${name}.source.path is required`));
2100
+ }
2101
+ }
2102
+ }
1867
2103
  if (errors.length > 0) {
1868
2104
  throw new WorkflowError(`Invalid workflow: ${errors.join('; ')}`, {
1869
2105
  // issue #425: the pre-join strings — see the profile collector above.