@sensigo/realm 0.41.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 (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 +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 +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 +7 -5
  26. package/dist/workflow/diagnostics.d.ts.map +1 -1
  27. package/dist/workflow/diagnostics.js +6 -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 +494 -340
  40. package/dist/workflow/yaml-loader.js.map +1 -1
  41. 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',
@@ -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', {
@@ -712,6 +755,11 @@ function parseWorkflowString(content, registry, opts) {
712
755
  // at all); the legacy when-only depends_on/run.params check (validateWhenReference) is
713
756
  // UNCHANGED — it still fires ONLY for `surface === 'when'`. This is a LIFT, not a new
714
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']
762
+ : undefined;
715
763
  const dependsOn = Array.isArray(step['depends_on'])
716
764
  ? step['depends_on'].filter((d) => typeof d === 'string')
717
765
  : [];
@@ -746,31 +794,77 @@ function parseWorkflowString(content, registry, opts) {
746
794
  if ('execution' in step && !VALID_EXECUTIONS.has(step['execution'])) {
747
795
  errors.push(withStepLine(stepName, `Step '${stepName}': invalid execution value '${String(step['execution'])}'; must be 'auto', 'agent', 'guard', or 'finalizer'`));
748
796
  }
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
+ }
829
+ }
749
830
  // Finalizer step constraints (a workflow-level try/catch/finally). handler-only in v1.
750
831
  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.
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).
772
835
  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)`));
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));
774
868
  }
775
869
  // v1 is handler-only.
776
870
  if (step['handler'] === undefined) {
@@ -793,141 +887,56 @@ function parseWorkflowString(content, registry, opts) {
793
887
  }
794
888
  }
795
889
  }
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.'));
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
+ })));
805
922
  }
806
- // Guard step constraints.
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).
807
925
  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
926
  if (step['abort_unless'] === undefined) {
828
927
  errors.push(withStepLine(stepName, `Step '${stepName}': execution: guard requires 'abort_unless'`));
829
928
  }
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
929
  }
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).
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.
893
935
  if (step['llm_timeout_seconds'] !== undefined &&
894
936
  (!Number.isInteger(step['llm_timeout_seconds']) ||
895
937
  step['llm_timeout_seconds'] <= 0)) {
896
938
  errors.push(withStepLine(stepName, `Step '${stepName}': 'llm_timeout_seconds' must be a positive integer`));
897
939
  }
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
940
  // WARN (do not reject): an idempotent auto step in a finalizer-bearing workflow gets
932
941
  // `deadline: null` (issue #101), so the RECLAIM function is inert — `realm run reclaim --all`
933
942
  // can never select it. The author should know it stays per-step-manual-reclaim-only.
@@ -956,21 +965,16 @@ function parseWorkflowString(content, registry, opts) {
956
965
  : ''),
957
966
  });
958
967
  }
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
968
+ // issue #236 (L0 prevention layer): structured_output's only legal value is the literal
965
969
  // 'strict'. On an opted-in step, Phase A REJECTS an ineligible verdict at load time — the
966
970
  // API provably rejects some legal schemas and silently weakens others, so authoring never
967
971
  // ships a schema the gate already knows is unsafe. Caveats are NOT rejected (informational
968
972
  // 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') {
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') {
974
978
  errors.push(withStepLine(stepName, `Step '${stepName}': 'structured_output' must be the literal string 'strict' (got ${JSON.stringify(step['structured_output'])})`));
975
979
  }
976
980
  else {
@@ -998,11 +1002,9 @@ function parseWorkflowString(content, registry, opts) {
998
1002
  // then AJV-proven AT LOAD TIME (REFUSE — B10, reusing the runtime validator so load-time and
999
1003
  // runtime verdicts can never diverge); `default_output` present without `mode: 'default'` WARNS
1000
1004
  // 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' ||
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' ||
1006
1008
  step['validation_exhaustion'] === null) {
1007
1009
  errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion' must be an object`));
1008
1010
  }
@@ -1097,26 +1099,85 @@ function parseWorkflowString(content, registry, opts) {
1097
1099
  `output is validated against both — prefer one to avoid divergence.`,
1098
1100
  });
1099
1101
  }
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
1102
  // trace_validation_mode must be 'warn' or 'enforce' when provided.
1109
1103
  if (step['trace_validation_mode'] !== undefined &&
1110
1104
  step['trace_validation_mode'] !== 'warn' &&
1111
1105
  step['trace_validation_mode'] !== 'enforce') {
1112
1106
  errors.push(withStepLine(stepName, `Step '${stepName}': invalid trace_validation_mode '${String(step['trace_validation_mode'])}'; must be 'warn' or 'enforce'`));
1113
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
+ }
1114
1172
  // issue #291 (authorable gate timeout — the FIRST validation the `gate:` block has ever had):
1115
1173
  // the E2 positive-integer checks on timeout_seconds/reminder_seconds/reminder_max, the
1116
1174
  // 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).
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.
1120
1181
  if (step['gate'] !== undefined) {
1121
1182
  if (typeof step['gate'] !== 'object' || step['gate'] === null) {
1122
1183
  errors.push(withStepLine(stepName, `Step '${stepName}': 'gate' must be an object`));
@@ -1156,17 +1217,22 @@ function parseWorkflowString(content, registry, opts) {
1156
1217
  // mirroring validation_exhaustion.mode:'default' requiring default_output); validated
1157
1218
  // against the step's own EFFECTIVE STATIC choice set — the EXACT same three-source
1158
1219
  // 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.
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.
1161
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']);
1162
1229
  if (onExpiry === 'settle_default') {
1163
1230
  if (!hasDefaultChoice) {
1164
1231
  errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.on_expiry: settle_default' requires 'gate.default_choice' ` +
1165
1232
  `(nothing to resolve the gate with on expiry)`));
1166
1233
  }
1167
1234
  else {
1168
- const choicesRaw = gate['choices'] ??
1169
- step['input_schema']?.properties?.['choice']?.enum;
1235
+ const choicesRaw = declaredGateChoices ?? declaredChoiceEnum;
1170
1236
  const effectiveChoices = Array.isArray(choicesRaw)
1171
1237
  ? choicesRaw
1172
1238
  : ['approve', 'reject'];
@@ -1176,9 +1242,10 @@ function parseWorkflowString(content, registry, opts) {
1176
1242
  }
1177
1243
  }
1178
1244
  }
1179
- else if (hasDefaultChoice) {
1245
+ else if (hasDefaultChoice && gateTrusted) {
1180
1246
  // default_choice with on_expiry:'abort' or with no on_expiry at all — inert, not an
1181
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.
1182
1249
  warnings.push({
1183
1250
  code: 'DEAD_GATE_CONFIG',
1184
1251
  severity: resolveSeverity('DEAD_GATE_CONFIG'),
@@ -1188,31 +1255,69 @@ function parseWorkflowString(content, registry, opts) {
1188
1255
  `'gate.on_expiry: settle_default' — set it, or remove 'gate.default_choice'.`,
1189
1256
  });
1190
1257
  }
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
- });
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
+ }
1202
1288
  }
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']) {
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');
1208
1303
  warnings.push({
1209
1304
  code: 'DEAD_GATE_CONFIG',
1210
1305
  severity: resolveSeverity('DEAD_GATE_CONFIG'),
1211
1306
  scope: 'step',
1212
1307
  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.`,
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.`,
1216
1321
  });
1217
1322
  }
1218
1323
  }
@@ -1232,6 +1337,13 @@ function parseWorkflowString(content, registry, opts) {
1232
1337
  }
1233
1338
  else {
1234
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';
1235
1347
  // WARN (do not reject) on an unknown retry-block key — same non-breaking posture as the
1236
1348
  // step/workflow-level checks (issue #140). Noun overridden to 'retry' (not 'step') since
1237
1349
  // this is a nested block, not the step itself.
@@ -1284,8 +1396,11 @@ function parseWorkflowString(content, registry, opts) {
1284
1396
  }
1285
1397
  // W5 (CAP-ONLY advisory — the on_timeout half of this is already an E1 hard error, so
1286
1398
  // 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) {
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) {
1289
1404
  warnings.push({
1290
1405
  code: 'TOTAL_TIMEOUT_NON_AUTO',
1291
1406
  severity: resolveSeverity('TOTAL_TIMEOUT_NON_AUTO'),
@@ -1293,19 +1408,21 @@ function parseWorkflowString(content, registry, opts) {
1293
1408
  step: stepName,
1294
1409
  message: `Step '${stepName}': 'retry.total_timeout_seconds' is inert on execution: ` +
1295
1410
  `'${String(step['execution'])}' steps — the cap only bounds 'execution: auto' ` +
1296
- `dispatch, which is the only dispatch ever wrapped in a timeout.`,
1411
+ `dispatch; no other kind's dispatch ever consumes it.`,
1297
1412
  });
1298
1413
  }
1299
1414
  // 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) {
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) {
1309
1426
  const isAgent = step['execution'] === 'agent';
1310
1427
  const message = isAgent
1311
1428
  ? `Step '${stepName}': 'retry' is inert on execution: 'agent' steps — the built-in ` +
@@ -1314,9 +1431,9 @@ function parseWorkflowString(content, registry, opts) {
1314
1431
  `flag instead). An embedder-supplied throwing dispatcher may still consume this ` +
1315
1432
  `config — a deliberate public-API capability, not an invalid one.`
1316
1433
  : `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.`;
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.`;
1320
1437
  warnings.push({
1321
1438
  code: 'RETRY_INERT_NON_AUTO',
1322
1439
  severity: resolveSeverity('RETRY_INERT_NON_AUTO'),
@@ -1339,13 +1456,16 @@ function parseWorkflowString(content, registry, opts) {
1339
1456
  `'max_attempts' of 1 — there is no second attempt to retry into.`,
1340
1457
  });
1341
1458
  }
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.
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.
1349
1469
  const explicitCapSeconds = typeof retry['total_timeout_seconds'] === 'number'
1350
1470
  ? retry['total_timeout_seconds']
1351
1471
  : undefined;
@@ -1361,9 +1481,11 @@ function parseWorkflowString(content, registry, opts) {
1361
1481
  scope: 'step',
1362
1482
  step: stepName,
1363
1483
  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.`,
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.`,
1367
1489
  });
1368
1490
  }
1369
1491
  }
@@ -1373,21 +1495,18 @@ function parseWorkflowString(content, registry, opts) {
1373
1495
  !VALID_SERVICE_METHODS.has(step['service_method'])) {
1374
1496
  errors.push(withStepLine(stepName, `Step '${stepName}': invalid service_method '${String(step['service_method'])}'; must be 'fetch', 'create', 'update', or 'delete'`));
1375
1497
  }
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
- }
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') {
1502
+ // issue #392: input_map's errors are minted deep inside a recursive walk that knows only
1503
+ // its path string, not the step's position. Collected here and suffixed on the way out,
1504
+ // so ONE step's error list never mixes positioned and bare messages a reader seeing
1505
+ // "(step at line 12)" on three of five errors would reasonably wonder what is different about
1506
+ // the other two, and nothing is.
1507
+ const inputMapErrors = [];
1508
+ validateInputMapNode(step['input_map'], `Step '${stepName}': input_map`, inputMapErrors, 0);
1509
+ errors.push(...inputMapErrors.map((e) => withStepLine(stepName, e)));
1391
1510
  }
1392
1511
  // Step config may hold any JSON value (scalars, arrays, nested objects). It is passed through
1393
1512
  // opaquely to handlers (context.config) and merged into adapter config for uses_service steps;
@@ -1464,7 +1583,7 @@ function parseWorkflowString(content, registry, opts) {
1464
1583
  errors.push(withStepLine(stepName, `Step '${stepName}': 'when' must be a non-empty string`));
1465
1584
  }
1466
1585
  else {
1467
- validateConditionLeaf('when', rawWhen, stepName, dependsOn, errors, withStepLine);
1586
+ validateConditionLeaf('when', rawWhen, stepName, dependsOn, stepKind, errors, withStepLine);
1468
1587
  }
1469
1588
  }
1470
1589
  else if (Array.isArray(rawWhen)) {
@@ -1477,7 +1596,7 @@ function parseWorkflowString(content, registry, opts) {
1477
1596
  errors.push(withStepLine(stepName, `Step '${stepName}': 'when' array entries must be non-empty strings`));
1478
1597
  }
1479
1598
  else {
1480
- validateConditionLeaf('when', leaf, stepName, dependsOn, errors, withStepLine);
1599
+ validateConditionLeaf('when', leaf, stepName, dependsOn, stepKind, errors, withStepLine);
1481
1600
  }
1482
1601
  }
1483
1602
  }
@@ -1496,7 +1615,7 @@ function parseWorkflowString(content, registry, opts) {
1496
1615
  errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' must be a non-empty string`));
1497
1616
  }
1498
1617
  else {
1499
- validateConditionLeaf('abort_unless', rawAbort, stepName, dependsOn, errors, withStepLine);
1618
+ validateConditionLeaf('abort_unless', rawAbort, stepName, dependsOn, stepKind, errors, withStepLine);
1500
1619
  }
1501
1620
  }
1502
1621
  else if (Array.isArray(rawAbort)) {
@@ -1509,7 +1628,7 @@ function parseWorkflowString(content, registry, opts) {
1509
1628
  errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' array entries must be non-empty strings`));
1510
1629
  }
1511
1630
  else {
1512
- validateConditionLeaf('abort_unless', leaf, stepName, dependsOn, errors, withStepLine);
1631
+ validateConditionLeaf('abort_unless', leaf, stepName, dependsOn, stepKind, errors, withStepLine);
1513
1632
  }
1514
1633
  }
1515
1634
  }
@@ -1532,7 +1651,7 @@ function parseWorkflowString(content, registry, opts) {
1532
1651
  errors.push(withStepLine(stepName, `Step '${stepName}': 'preconditions' entries must be non-empty strings`));
1533
1652
  }
1534
1653
  else {
1535
- validateConditionLeaf('preconditions', leaf, stepName, dependsOn, errors, withStepLine);
1654
+ validateConditionLeaf('preconditions', leaf, stepName, dependsOn, stepKind, errors, withStepLine);
1536
1655
  }
1537
1656
  }
1538
1657
  }
@@ -1578,9 +1697,9 @@ function parseWorkflowString(content, registry, opts) {
1578
1697
  // guard arm's consequence below is forked — collapsing it back into one shared string
1579
1698
  // would make the error claim a wedge that cannot happen.
1580
1699
  //
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
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
1584
1703
  // with the prohibition printed above this one. The arm is kept, not deleted — it is what
1585
1704
  // stops the dead-condition message from claiming a wedge that a guard cannot have, and a
1586
1705
  // definition reaching this code by any path other than a fresh YAML load (a
@@ -1656,9 +1775,14 @@ function parseWorkflowString(content, registry, opts) {
1656
1775
  }
1657
1776
  }
1658
1777
  }
1659
- // Validate tools: only valid on execution: agent steps without handler.
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.
1660
1783
  if (step['tools'] !== undefined &&
1661
- (step['execution'] !== 'agent' || step['handler'] !== undefined)) {
1784
+ step['execution'] === 'agent' &&
1785
+ step['handler'] !== undefined) {
1662
1786
  errors.push(withStepLine(stepName, `Step '${stepName}': 'tools' is only valid on execution: agent steps without 'handler' defined`));
1663
1787
  }
1664
1788
  // issue #413: `tool_timeout` requires `tools`. It bounds ONE tool call inside the agentic
@@ -1741,12 +1865,11 @@ function parseWorkflowString(content, registry, opts) {
1741
1865
  errors.push(withStepLine(stepName, `Step '${stepName}': 'tool_timeout' must be a positive integer`));
1742
1866
  }
1743
1867
  // 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.
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).
1750
1873
  if (step['timeout_seconds'] !== undefined &&
1751
1874
  step['execution'] !== 'guard' &&
1752
1875
  step['execution'] !== 'agent' &&
@@ -1864,6 +1987,37 @@ function parseWorkflowString(content, registry, opts) {
1864
1987
  normalizeTriggerFilter(triggerRaw); // canonicalise shorthand BEFORE validation
1865
1988
  errors.push(...validateTriggerStructure(triggerRaw));
1866
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)}')`));
2001
+ }
2002
+ }
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'`));
2008
+ }
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)`));
2011
+ }
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`));
2018
+ }
2019
+ }
2020
+ }
1867
2021
  if (errors.length > 0) {
1868
2022
  throw new WorkflowError(`Invalid workflow: ${errors.join('; ')}`, {
1869
2023
  // issue #425: the pre-join strings — see the profile collector above.