@vaudience/aigentflow-flow-validator 0.15.3

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.
package/PARITY.md ADDED
@@ -0,0 +1,1247 @@
1
+ # Parity with the AIgentFlow reference validator
2
+
3
+ This document maps every rule in this JavaScript validator back to the AIgentFlow
4
+ Go reference implementation, records the intentional divergences, and defines the
5
+ discipline for keeping the two in sync.
6
+
7
+ **Tracks AIgentFlow flow schema: `v2.802.0`** (`SPEC_VERSION` in [`src/spec/aigentflow-spec.json`](./src/spec/aigentflow-spec.json)), plus `server_owned_query_key` (package 0.15.1, below) from the AIgentFlow release after v2.790.0, and `credential_endpoint_unpaired` (package 0.15.2, below) from AIgentFlow v2.793.0 and the release after it, and `display_name_too_long` (package 0.15.3, below) from AIgentFlow v2.802.0.
8
+
9
+ > package 0.15.3 — **the optional top-level `display_name`** (AIgentFlow #187). The spec
10
+ > gains `display_name: scalar` on `Flow` and `specVersion` becomes `2.802.0`.
11
+ >
12
+ > | Rule (reference) | Code | Severity | Module |
13
+ > | --------------------------------------------------- | ----------------------- | -------- | ------------------------------ |
14
+ > | `validateBasicStructure`, display_name length bound | `display_name_too_long` | error | `validators/basicStructure.ts` |
15
+ >
16
+ > - A free-form label of at most 80 characters, counted in **Unicode code points** (Go's
17
+ > `utf8.RuneCountInString`), not UTF-16 units: 80 emoji pass here although they are 160
18
+ > UTF-16 units. Field `display_name`; absent or empty is fine.
19
+ > - A non-string scalar (`display_name: 123`) is read by its source text, as every other Go
20
+ > `string` field is; a mapping or list is not judged by this rule, as for `description`.
21
+
22
+ > package 0.15.2 — **an endpoint a server-supplied credential will never be sent to**, in
23
+ > step with the Go port (go-aigentflow-validator v0.6.2). The spec (new section
24
+ > `credentialEndpointPairing`; `specVersion` unchanged) and the ten new conformance
25
+ > fixtures are the Go port's byte for byte.
26
+ >
27
+ > | Rule (reference) | Code | Severity | Module |
28
+ > | ------------------------------------------------------------------------------------------------------------ | ------------------------------ | -------- | ------------------------------------------------------------------------------------------- |
29
+ > | `validateCredentialEndpointPairing` (DC-FORGE-231), `validateFamilyCredentialEndpointPairing` (DC-FORGE-233) | `credential_endpoint_unpaired` | warning | `validators/credentialEndpoint.ts`, `validators/goUrl.ts`, spec `credentialEndpointPairing` |
30
+ >
31
+ > - **Why.** AIgentFlow sends a credential the SERVER supplied (an org's or the
32
+ > platform's stored key, or a value of one of the deployment's environment variables)
33
+ > only to the endpoint that came with it: the default, the stored credential's own
34
+ > base URL, or a deployment-configured one. An endpoint the flow names is honoured only
35
+ > with a credential the flow supplies. The run-time refusal is the control; this
36
+ > warning names, at save, the shapes statically certain to meet it.
37
+ > - **ai://.** A step's (or loop sub-step's) `<provider>_base_url` with no key of the
38
+ > flow's own (the query's `api_key` / `<provider>_api_key`, or a literal
39
+ > `executor_config.<provider>.api_key`), and a literal
40
+ > `executor_config.<provider>.base_url` for an AI provider with no literal key beside
41
+ > it, unless at least one step uses the provider and every such step brings its own
42
+ > key. `ollama` and `vllm` are exempt.
43
+ > - **The executor families.** The reference's per-protocol table is spec data
44
+ > (`credentialEndpointPairing.families`), each row's server-variable set evaluated. A
45
+ > step warns when its secret (query first, then the family's `executor_config` block)
46
+ > is exactly `${NAME}` for one of the row's server variables on a row whose plugin
47
+ > expands references, and its endpoint (same order) is a literal: not templated, not a
48
+ > reference, and not of the same origin as the row's default. Implicit-credential
49
+ > families are not judged; `nexus://` and `aigentchat://` get the ai:// shape.
50
+ > - **The default-origin comparison needs Go's URL semantics**, so `validators/goUrl.ts`
51
+ > ports `net/url.Parse` (what it refuses, and the scheme and host it extracts),
52
+ > `strings.TrimSpace` (Go trims U+0085 and keeps U+FEFF) and `strings.ToLower` (simple
53
+ > case mapping: U+0130 is `i`). Its outputs are pinned against the Go port's.
54
+ > - **Measured** with the built CLI against the reference's own verdict on 465 files (the
55
+ > 403-file differential corpus of the Go port, plus 62 boundary probes): identical
56
+ > `(code, field)` pairs on the whole corpus (15 warnings, none on a bundled flow), and on
57
+ > every probe but four — two shared with the Go port (divergence 18) and two JS-only
58
+ > (below). Go `make parity-check` reports no drift.
59
+
60
+ > package 0.15.1 — **a step query may not declare a server-owned parameter**, in step
61
+ > with the Go port (go-aigentflow-validator v0.6.1). The spec (new section
62
+ > `serverOwnedQueryKeys`; `specVersion` unchanged) and the seven new conformance
63
+ > fixtures are the Go port's byte for byte.
64
+ >
65
+ > | Rule (reference) | Code | Severity | Module |
66
+ > | ----------------------------------------------------------------------- | ------------------------ | -------- | ----------------------------------------------------------------- |
67
+ > | `validateNoServerOwnedStepQueryKeys` / `IsServerOwnedParamKey` (CFX-05) | `server_owned_query_key` | error | `validators/serverOwnedQueryKeys.ts`, spec `serverOwnedQueryKeys` |
68
+ >
69
+ > - **Why.** The `aiv://` executor sends the running org's aigentverse credential, or
70
+ > a signed token naming the running person, to the base URL in its parameters, and
71
+ > the reference used to lay a step's `query:` over the credential resolver's output.
72
+ > A shared flow declaring `aiv_base_url: https://attacker.example` therefore chose
73
+ > where that credential went. The reference now discards such a value at run time
74
+ > and refuses it by name at save.
75
+ > - **Exactly as the reference.** The key set is data (`serverOwnedQueryKeys.keys`:
76
+ > `aiv_api_key`, `aiv_base_url`, `aiv_delegation`), so a new server-owned key is a
77
+ > spec change. Matching is exact and case-sensitive. The scanned surfaces are exactly
78
+ > the reference's two: a top-level step's `query:` (field `steps.<step>.query.<key>`)
79
+ > and a loop sub-step's `query:` (field `steps.<step>.loop.steps.<sub-step id>.query.<key>`,
80
+ > `stepId` the loop step). A parallel branch or a `for_each` body is a top-level step.
81
+ > Any value is refused, `null` and templates included; a key nested inside a query
82
+ > value, the name as a value, and a flow input parameter of that name are not.
83
+ > - **Measured** (in the Go port, with the reference at the rule's commit, over the 216
84
+ > bundled flows and every conformance fixture): identical `(code, field)` pairs, and
85
+ > no bundled flow trips the rule. Go `make parity-check` reports no drift. Three
86
+ > mutants here (the loop surface unscanned, the step surface unscanned, a folded
87
+ > case): all killed.
88
+
89
+ > v2.788.0, package 0.15.0 — **every save-door rule AIgentFlow added after v2.753.0,
90
+ > in step with the Go port (go-aigentflow-validator v0.6.0).** Since AIgentFlow
91
+ > v2.788.0 its save door and `/flows/validate` are one verdict, so "refuses what
92
+ > AIgentFlow's save refuses" is one target. The spec is the Go port's
93
+ > `spec/aigentflow-spec.json` byte for byte (`specVersion` 2.788.0; new sections
94
+ > `orchestratorToolAllow`, `exons`, `executorConfigEnvScopes`), and so are the 19
95
+ > new conformance fixtures and the one changed one.
96
+ >
97
+ > | Rule (reference) | Code | Severity | Module |
98
+ > | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ---------------------- | --------------------------------------------------- |
99
+ > | `end` is an ordinary step id to the reachability and cycle walks too (v2.760.0) | `unreachable_step` / `potential_infinite_loop` stop treating `end` as terminal | warning | spec `reachabilityTerminalMarkers` |
100
+ > | `ValidateExecutorConfigEnvScopes` (v2.597.0) | `executor_config_env_scope` | error | `validators/executorConfigEnv.ts` |
101
+ > | orchestrator `.exons` parse and spec presence (`validateOrchestrator`) | `orchestrator_exons_parse_failed` | error | `validators/exons.ts` |
102
+ > | orchestrator `.exons` `execution.provider` | `orchestrator_exons_no_provider` | error | `validators/exons.ts` |
103
+ > | orchestrator `requirements.resources` (v2.767.0) | `exons_resources_unhonoured` | error | `validators/exons.ts` |
104
+ > | a tool `orchestrator.tools` / `tools.allow` withholds (v2.760.0) | `orchestrator_tool_withheld` | warning | `validators/exons.ts`, spec `orchestratorToolAllow` |
105
+ > | inline `.exons` intake, on a step and on the orchestrator (v2.777.0) | `exons_attributes` | error, **engine only** | not ported (divergence #16) |
106
+ >
107
+ > - **`executor_config_env_scope`.** `api_key`, `base_url` and `organization` are Go
108
+ > `string` fields and are read by source text; an `extra` value is checked only when
109
+ > it is a YAML string. A reference is exactly `${NAME}` with a non-empty name, so
110
+ > `${A}${B}` is the single name `A}${B`. The scope table is the reference's own
111
+ > function evaluated for every key that has one (38 keys); any other key refuses every
112
+ > reference, and is looked up as an own property, so `constructor:` is an ordinary key.
113
+ > - **The frontmatter reader** follows the engine's extraction: an optional BOM, then
114
+ > spaces or tabs, then `---` and LF or CRLF; the frontmatter closes at the next line
115
+ > starting `---` followed by a line break or the end of the input, and is trimmed with
116
+ > Go's `TrimSpace` (which trims U+0085 and not U+FEFF). The retired `{~exons.config~}`
117
+ > block and an unclosed frontmatter are parse failures; no frontmatter, or an empty
118
+ > one, is `orchestrator_exons_parse_failed` too, because the reference refuses an
119
+ > orchestrator document without a spec. A frontmatter containing `{~` is not read at
120
+ > all (the engine executes it before decoding). The three fields are decoded as
121
+ > yaml.v3 decodes them non-strictly: an undeclared key is ignored, a declared one of
122
+ > the wrong shape fails the decode (and the reader then judges nothing), a number is
123
+ > its text, and a null list element is dropped.
124
+ > - **`orchestrator_tool_withheld`** is a warning, never an error: an unattended
125
+ > orchestrator is a legitimate design. `enable_signals` defaults to on (a YAML boolean
126
+ > only sets it), and a null `campaign:` is no campaign.
127
+ > - **Codes.** The reference refuses all of these at its strict parse with one generic
128
+ > code; each code here is the reference's own name for the rule where it has one, as
129
+ > in the Go port. The reference stops at the first refusal; this validator reports
130
+ > each. The verdict is the same.
131
+ >
132
+ > **Measured.** The Go port's `make parity-check` reports no drift: the spec and all 94
133
+ > fixtures are byte-identical, and the verdict and error codes agree on every fixture.
134
+ > Over the 235 flow files in AIgentFlow's repository the verdict, error codes and
135
+ > warning codes agree with the Go port's built-in reader on 235 of 235. A 39-shape
136
+ > orchestrator frontmatter matrix (the Go port's 15-shape engine-agreement matrix plus
137
+ > 24 decode shapes, each verdict measured on the Go port) is pinned in
138
+ > `test/v0150-save-door.test.ts`; 32 of the 39 get the engine's own verdict, and the
139
+ > other 7 are divergence #16, all looser. 16 mutants of the new rules (each guard
140
+ > removed or inverted, the scope table emptied, the signal exemption dropped, the
141
+ > templated-frontmatter skip removed, the `end` marker restored): all killed.
142
+
143
+ > v2.753.0, package 0.14.1 — **a number where the reference expects text is that
144
+ > text, in every Go `string` field.** No grammar change, so `specVersion` stays
145
+ > `2.753.0`. The reference decodes a step reference, an enum, a name and a
146
+ > duration into a Go `string`, and yaml.v3 fills a `string` from any scalar by
147
+ > its **source text**: `next: { default: 2 }` names the step `"2"`, `action: 1`
148
+ > is the invalid action `"1"`, `name: 123` is the name `"123"`. This validator
149
+ > read most of those fields with `isString`, so a number was either
150
+ > **invisible** (an existence or enum check skipped: looser) or **absent** (a
151
+ > present value reported missing: stricter). Ported from the Go port's v0.5.1,
152
+ > whose verdicts were measured on the reference's strict save parser.
153
+ >
154
+ > | Shape | Reference | 0.14.0 | 0.14.1 |
155
+ > | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------- | ------------------------------------------------ |
156
+ > | `next.default: 2`, a condition's `goto: 7`, `rendezvous: 9`, `error_strategy.goto_step: 9`, no such step | refused, step not found | valid (default, goto); `missing_required_field` / `goto_step_missing` | `step_not_found` |
157
+ > | the same with the step present, and `parallel.steps: [1]` | saves, reachable | `unreachable_step`; `step_not_found` / `goto_step_missing` | valid, reachable |
158
+ > | `default: 1e3` with a step `1000` | refused (`1e3` is not `1000`) | valid | `step_not_found` |
159
+ > | step keys `1e3:`, `0x1F:`, `True:` named by the same spelling | saves | `unreachable_step` (keys read `1000`, `31`, `true`) | valid |
160
+ > | `action: 1`, `on_fail: 1`, `for_each.resolution: 1`, `orchestrator.mode: 1` | refused, invalid value | valid | the rule's own code |
161
+ > | `name: 123`, `aigentflow_version: 2.0`, `start: 1`, `rubric: 5`, `for_each.items: 5`, `inject_as: 5`, a `query` param/property `type: 1` | saves | `missing_required_field` / the rule's "missing" code | valid (`unknown_data_type` warning for the type) |
162
+ > | loop sub-steps `id: 3` / `id: 4`, `next: { default: 4 }` | saves | `loop_step_id_required`, `invalid_type` | valid |
163
+ > | `executor: 42` | refused, unusable executor URL | `invalid_type` | `invalid_executor_url` |
164
+ > | `expression_functions: [{ function: 5 }]` | refused, not in the catalog | `invalid_expression_function` | `expression_function_unknown` |
165
+ >
166
+ > - **One reader.** Every Go `string` field is read through `Issues.stringAt` /
167
+ > `stringOf` / `nonEmptyStringOf` (`validators/util.ts`), which return a string
168
+ > as itself and a number or boolean by its source spelling. Only a mapping, a
169
+ > list or null is "not a string". The `Issues` collector now carries the
170
+ > pass's `scalarSources`, so the per-validator `sources` parameters are gone.
171
+ > Sites are left alone where the value is compared only against a literal no
172
+ > scalar spelling can equal (`orchestrator`, an `fn_` name, `{{`, an empty
173
+ > `output:` entry), and a step `query:` is a Go `any` in the reference, not a
174
+ > string field.
175
+ > - **The text comes from the parsed node, not the JS value.** The `yaml`
176
+ > package resolves `1.0` to 1, `0x1F` to 31, `True` to true and `-0` to -0
177
+ > before `toJS`. `parseFlow` reads `Scalar.source`, the scalar's own text,
178
+ > from the document node for each value (as `scalarSources` did for
179
+ > durations) and now for each **key** too: `retagScalarKeys` replaces a number
180
+ > or boolean key's value with its source text before conversion, so `1e3:` is
181
+ > the step `"1e3"`. Duplicate keys are compared by text, as yaml.v3 compares
182
+ > them: `1:` and `1.0:` are two steps, `"1":` and `1:` are one.
183
+ > `test/scalar-text.test.ts` pins the same 15 spellings the Go port pins
184
+ > against yaml.v3 (`2`, `-0`, `+1`, `017`, `0o17`, `0x1F`, `1_000`, `1e3`,
185
+ > `.5`, `0.10`, `.inf`, `true`, `True`, `FALSE`, `2024-01-01`) as a step key
186
+ > and as a value.
187
+ > - **Timestamps need nothing here.** The `yaml` package's default YAML 1.2
188
+ > core schema has no timestamp type, so `2024-01-01` is already the string
189
+ > `"2024-01-01"`, which is what the reference's `string` field receives.
190
+ > `1_000` and `017` differ between the two parsers as numbers, but both
191
+ > parsers keep the text, which is all a `string` field reads.
192
+ > - **Measured.** The Go port's ten `*-numeric-*` conformance fixtures are
193
+ > copied byte-for-byte, and `valid-open-key-sets.yaml` now forbids
194
+ > `unreachable_step`. On 0.14.0, 10 of those 11 cases fail; on 0.14.1 all 75
195
+ > fixtures pass, and the Go port's `make parity-check` (spec byte-identical,
196
+ > fixture set identical, 75 verdicts equal) is green. Over the reference's
197
+ > 216 bundled flows, not one finding changed, with or without
198
+ > `strictRegistries` (`unreachable_step` 25, `potential_infinite_loop` 1).
199
+
200
+ > v2.753.0 — **the three gaps that were looser than the reference in both ports
201
+ > are closed (package 0.14.0).** The reference's parser, validator, flow, step,
202
+ > orchestrator, campaign, input-schema and template-registry sources have a
203
+ > **zero** diff from `v2.738.0` to `v2.753.0`, so `specVersion` moves to
204
+ > `2.753.0` with no other rule change. Every verdict below was measured on the
205
+ > reference's save door (`NewStrictFlowParser().ParseFromYAMLBytes`, then
206
+ > `ValidateFlowWithDetails`) over a 133-shape probe matrix. Both ports now match
207
+ > the reference on all 133, and on all 65 conformance fixtures.
208
+ >
209
+ > 1. **`end` is not a sentinel at the save door.** `validateNextLogic` accepts
210
+ > only `null` and `orchestrator` without a step of that name, so
211
+ > `next: { default: end }` and a condition's `goto: end` are refused
212
+ > ("step_id (end) … not found") unless a step is called `end`. `End`, a
213
+ > rendezvous `end`, a parallel member `end`, an `error_strategy.goto_step: end`
214
+ > and a `quality_gate.goto_step: end` were already refused on both sides.
215
+ > `next: end` (a scalar) fails to decode. **Divergence #2 is closed**, and it
216
+ > was hiding a real problem: 15 fixtures this suite called valid routed to
217
+ > `end`, and the reference refused all 15 (14 on `end` itself, one first on
218
+ > an unknown key). They now say `'null'`, as do the 8 invalid fixtures that
219
+ > used it (three loop-body fixtures were refused by the reference on `end`
220
+ > before it reached the rule they test).
221
+ >
222
+ > The reference's reachability and cycle walks still skip `end`, so a flow
223
+ > that routes to a real step named `end` saves, and that step is reported
224
+ > `unreachable_step`. That is reproduced: the spec now carries two sets,
225
+ > `nextMarkers` (`null`, `orchestrator`: the save door) and
226
+ > `reachabilityTerminalMarkers` (`null`, `end`, `orchestrator`: the walks).
227
+ >
228
+ > 2. **Unknown keys are refused at every level (`unknown_yaml_key`), and a value
229
+ > of the wrong kind is `invalid_type`.** See [Unknown keys and value
230
+ > kinds](#unknown-keys-and-value-kinds). The key sets are derived from the
231
+ > reference's own types, not hand-listed. On the 216 bundled flows the new
232
+ > rule fires **zero** times. `valid-branching.yaml` carried a nested
233
+ > `constraints:` block the reference refuses; its two keys now sit at the
234
+ > flow root, where the reference reads them.
235
+ > 3. **A number in a Go `string` duration field is judged by its source text.**
236
+ > yaml.v3 fills such a field with the scalar's text, so `delay: 0.0` arrives
237
+ > as `"0.0"` (no unit, refused). `parseFlow` now records the source spelling
238
+ > of every number and boolean scalar (`scalarSources`), and `validateFlow`
239
+ > uses it. Measured for a mock delay: `0`, `+0`, `-0`, `100ms` and `"0"` save;
240
+ > `0.0`, `00`, `0x0`, `.0`, `-0.0`, `0.`, `0o0`, `0_0`, `1.5`, `100`, `1e3`,
241
+ > `true` and `.inf` are refused. The same mechanism closes four more gaps the
242
+ > probe found in the same family, each of which skipped numbers entirely:
243
+ > `for_each.throttle.delay`, `throttle.batch_delay` and `error_strategy.max_delay`
244
+ > (`100`, `1.5`, `0.0` were accepted, and are refused by the reference) and an
245
+ > orchestrator timer `interval: 0` (refused here as "no interval"; the
246
+ > reference saves it). **Divergence #13 is narrowed** to `validateFlowObject`,
247
+ > which never sees source text.
248
+ >
249
+ > Two parse changes keep the unknown-key rule from refusing flows the reference
250
+ > saves: `merge: true` (yaml.v3 honours `<<: *anchor`, and without it `<<` was a
251
+ > literal key), and a step id written as a number (`1:`) was already fine here.
252
+ >
253
+ > Cross-check over the reference's `example_flows/` (216 files): with
254
+ > `strictRegistries` the verdict matches on all 216, and `unreachable_step`
255
+ > 25 = 25 and `potential_infinite_loop` 1 = 1. In default mode the same 10 files
256
+ > as before differ (unknown template functions warn here, divergence #4).
257
+
258
+ > v2.738.0 — **five older save-door refusals, and one false positive removed
259
+ > (package 0.13.0).** No grammar change, so `specVersion` stays `2.738.0`: every
260
+ > rule below predates it and was simply never ported. The Go port
261
+ > (`go-aigentflow-validator`) carried them first. Each was checked against the
262
+ > reference's **strict save parser** (`NewStrictFlowParser().ParseFromYAMLBytes`,
263
+ > then `ValidateFlowWithDetails`) with a refused shape and an accepted shape, and
264
+ > each invalid conformance fixture was re-run with only the offending value
265
+ > corrected, to confirm it then saves.
266
+ >
267
+ > 1. **`reserved_step_id_orchestrator`** (v2.484.0, DC-COND-1). A top-level step
268
+ > may not be called `orchestrator`: it is the engine's step id for
269
+ > orchestrator signals and a reserved `next:` marker. Case-sensitive
270
+ > (`Orchestrator` saves), top-level steps only. `basicStructure.ts`.
271
+ > 2. **`tool_discovery_invalid`** (DC-FORGE-48). On the flow root, the
272
+ > orchestrator, and a step `query`, the value must be `eager`, `lazy` or
273
+ > `off`. Empty, null and templated values are skipped. The first two are Go
274
+ > `string` fields, which yaml.v3 fills from any scalar, so `tool_discovery: 5`
275
+ > or `true` is refused there. In a step `query` only a YAML string is judged.
276
+ > `saveDoor.ts`.
277
+ > 3. **`mock_delay_invalid`** (DC-FORGE-51). A `mock_scenarios.<scenario>.<step>.delay`
278
+ > must pass `time.ParseDuration`. `100` is refused and `100ms` saves. Every
279
+ > step key is checked, including one the flow does not define, because the
280
+ > reference walks the scenario map. `saveDoor.ts`. See divergence #13 for
281
+ > the one spelling family that cannot be told apart here.
282
+ > 4. **`output_param_empty`**. An `output:` entry may not be `''`. A YAML null
283
+ > entry is **not** refused: yaml.v3 drops null list entries while decoding,
284
+ > so the reference never sees it. `saveDoor.ts`.
285
+ > 5. **`campaign_no_child_flows`, `campaign_child_flow_no_id`**
286
+ > (`CampaignConfig.Validate`). `campaign.child_flows` needs at least one entry,
287
+ > and each entry needs a non-empty `flow_id` or `flow_name`. Null entries are
288
+ > dropped before the check, as in (4): `[~]` is empty, and a null beside a real
289
+ > entry is ignored. `flow_id: 123` names a flow (a string field). A
290
+ > non-list `child_flows` or a non-mapping entry is `invalid_type`.
291
+ > `orchestratorCampaign.ts`.
292
+ >
293
+ > The other `CampaignConfig.Validate` checks (`max_concurrent`, `max_depth`,
294
+ > `max_total_children` must each be `>= 1`) are **not ported, because no
295
+ > document can reach them**. `ApplyDefaults` runs first and replaces every
296
+ > value `<= 0` with a positive default. Measured: `max_concurrent: 0`,
297
+ > `-3`, `max_depth: 0.5` and `-1e30` all save.
298
+ >
299
+ > **False positive removed: `campaign.max_credits_per_child: 1.5` saves.**
300
+ > The field is a Go `int64`, and yaml.v3 decodes a float into an int field by
301
+ > truncating toward zero. Measured on the strict save parser: `1.5` is stored as
302
+ > `1`, `0.9` and `-0.5` as `0` (which then passes the `>= 0` check), and `1e3`
303
+ > as `1000`. All of them save. `-1`, `-1.5` and `-.inf` are refused by the
304
+ > `>= 0` check. A string (`"5"`), a boolean, `.nan`, `.inf`, and any float at or
305
+ > above 2^63 (`9.3e18`, `1e30`) fail to decode. Until now this validator refused
306
+ > every non-integer as `invalid_type`, which made it stricter than the door it
307
+ > predicts.
308
+ >
309
+ > **`parseGoDuration` is now a line-for-line port of `time.ParseDuration`**,
310
+ > using integer arithmetic with Go's overflow rules. The old version summed in
311
+ > floating point and was approximate near the int64 limit
312
+ > (`9223372036854775807ns` saves, `9223372036854775808ns` does not). This also
313
+ > affects every other duration rule that uses it (throttle, timer interval,
314
+ > `human_question_timeout`, step `max_duration`, error-strategy delays). None of
315
+ > their verdicts change outside that boundary.
316
+ >
317
+ > Cross-check over the reference's `example_flows/` (216 files; the reference
318
+ > run through its strict save parser, then `ValidateFlowWithDetails`):
319
+ > `unreachable_step` 25 = 25 and `potential_infinite_loop` 1 = 1, with identical
320
+ > `(file, field)` pairs. With `strictRegistries` the verdict matches on all 216
321
+ > files (206 valid on both sides). In default mode the same 10 files as before
322
+ > differ, for the reason given in the 0.12.0 note. 115 of the 216 files use
323
+ > `mock_scenarios`, `tool_discovery` or `child_flows`, and none of the new rules
324
+ > fires on any of them. The reference's parser, validator, flow, step, campaign,
325
+ > orchestrator, input-schema and template-registry sources are unchanged from
326
+ > `v2.738.0` to `v2.745.0`.
327
+
328
+ > v2.738.0 — **merge train (package 0.12.0).** The ten open parity PRs (#6–#12,
329
+ > #14–#16) landed together, and `specVersion` moved from `2.642.0` straight to
330
+ > `2.738.0`. Several of those PRs recorded a `specVersion` that deliberately
331
+ > understated, because each was cut from a `main` that lacked the others. That
332
+ > reconciliation is now done, and those notes are removed. The per-version notes
333
+ > below keep their original "no grammar change" wording as history.
334
+ >
335
+ > `2.738.0` is claimed because every grammar change between `v2.642.0` and
336
+ > `v2.738.0` that affects a static verdict was audited against the reference and
337
+ > is now ported, apart from the documented divergences. Three gaps turned up in
338
+ > review and are closed in the same train:
339
+ >
340
+ > 1. **`loop_sub_step_id_reserved`** (v2.648.0, warning). A loop sub-step whose
341
+ > id is `iterations`, `break`, `vars` or `duration_ms` collides with the loop
342
+ > result's own summary field, so `{{ .data.<loop>.<id> }}` reads the summary.
343
+ > Field `steps.<id>.loop.steps`, attributed to the parent step, as upstream.
344
+ > Like the rest of the loop-body walk, this validator checks every step, where
345
+ > the reference only walks steps reachable from `start`. It is a warning either
346
+ > way, so no verdict changes.
347
+ > 2. **`campaign.budget_max_per_child`** (v2.728.0, error). Retired for
348
+ > `max_credits_per_child`. The strict decoder refuses it and
349
+ > `retiredGrammarKeys` attaches advice, so it is reported as
350
+ > `unknown_yaml_key`, like the other retired keys in `retiredKeys.ts`.
351
+ > 3. **`campaign.max_credits_per_child`** (v2.728.0, error). It is a Go `int64`,
352
+ > so a non-integer fails to decode (`invalid_type`), and `CampaignConfig.Validate`
353
+ > refuses a negative value (`campaign_invalid_max_credits_per_child`). Absent,
354
+ > `null` and `0` mean "no per-child cap".
355
+ >
356
+ > One false positive was also fixed: `orchestrator.human_question_timeout: ""`
357
+ > (or `null`) was refused here, but the reference treats both as absent.
358
+ >
359
+ > The corpus cross-check then found two older defects, both fixed here:
360
+ >
361
+ > - **Seven orchestrator tool names were missing** from `orchestrator.tools`:
362
+ > `aif_memory_recall`, `aif_memory_reflect` and the five `aif_e2b_*` tools.
363
+ > The reference's `IsValidOrchestratorToolName` accepts all of them, so under
364
+ > `strictRegistries` this validator refused eight bundled flows the reference
365
+ > saves.
366
+ > - **Divergence #4 was not true for template functions.** By default the
367
+ > function lookup was skipped, so an unknown function produced no finding at
368
+ > all. It is now a `template_function_unknown` warning by default and an error
369
+ > under `strictRegistries`, as #4 says.
370
+ >
371
+ > Cross-check over the reference's `example_flows/` (216 YAML files, the reference
372
+ > run through `ValidateFlowWithDetails`): `unreachable_step` 25 = 25 and
373
+ > `potential_infinite_loop` 1 = 1, with identical `(file, field)` pairs. With
374
+ > `strictRegistries`, the valid/invalid verdict matches on all 216 files (206
375
+ > valid on both sides). In default mode the 10 files the reference refuses for an
376
+ > undefined template function are valid here and carry the warning instead.
377
+ >
378
+ > Still not ported, and older than this window: the rest of
379
+ > `CampaignConfig.Validate`. Ported in package 0.13.0 (note above), apart from
380
+ > the `>= 1` checks, which no document can reach.
381
+
382
+ > v2.648.0 — **the loop body is walked.** ⛔ The reference's `validateStepsInOrder`
383
+ > iterates `flow.steps`, and a `loop:` step's sub-steps live in a SECOND step table
384
+ > — so until v2.648.0 **no template and no operation shape inside a loop sub-step
385
+ > had been examined by either implementation.** This validator inherited the same
386
+ > blind spot and recorded it as a scope note, which is what that note was for.
387
+ >
388
+ > ⚠️ **Read it as a capability statement about the defects it PERMITS.** Turning
389
+ > the walk on in the reference produced ten syntax findings across five of its own
390
+ > shipped example flows, behind which sat five engine defects — including three
391
+ > template functions (`atoi`, `mod`, `int`) that five bundled flows used and the
392
+ > registry did not have. Those three are now in `templateFunctions`.
393
+ >
394
+ > 1. **Every loop-body finding is a WARNING, never an error.** ⛔ The reference
395
+ > consults this validator at its RUN door over flows stored long before the
396
+ > walk existed. ⚠️ The "the run already died anyway" licence that would make an
397
+ > Error safe requires the failing path to be UNCONDITIONAL, and inside a loop
398
+ > body four of the six evaluation sites SWALLOW a template failure and carry on
399
+ > with the raw string. The CODE survives the demotion; only the severity
400
+ > changes — dropping the finding would be worse than raising it.
401
+ > 2. **The dispatch set is a PARTITION, never a union.** `loop.set` / `loop.break`
402
+ > are dispatched by `executeLoopPostProcessing` and are legal ONLY on a loop
403
+ > sub-step; at the top level they remain undispatchable, and a merged set would
404
+ > silently accept a flow that fails at run time. `dispatchableOperationTypes`
405
+ > takes the scope as a parameter for exactly that reason, and both halves have
406
+ > their own fixture.
407
+ > 3. **Findings are attributed to the PARENT step id** — the id every other
408
+ > surface knows this work by — with the sub-step named by its index in the
409
+ > field path (`steps.<id>.loop.steps[i].…`), matching the reference.
410
+ >
411
+ > v2.647.0 — **a processing operation's SHAPE is now checked: the operation type,
412
+ > and the config keys its handler reads.** Both verdicts are **warnings** in the
413
+ > reference and here, which is the point of them: a flow carrying either mistake
414
+ > saves and runs. An operation type the engine has no dispatch case for fails at
415
+ > run time with "unsupported operation type"; a config key the handler never reads
416
+ > is simply ignored, so `transformation:` written where the handler reads
417
+ > `transformer:` produces a step that succeeds and does nothing. Ported into
418
+ > [`src/validators/processingOperations.ts`](./src/validators/processingOperations.ts),
419
+ > which also now owns the ONE decomposition of a processing operation —
420
+ > `templates.ts` shares it, so the `if:` guard is recognised in a single place.
421
+ >
422
+ > 1. **`unknown_processing_operation`.** The dispatchable set in a top-level
423
+ > step's `pre_processing:` / `post_processing:` is `data.set`,
424
+ > `conversation.append`, `output.set`, `binary.store`, `binary.get`,
425
+ > `binary.update`, `binary.delete`, `binary.transform`, `parse`. `loop.set` and
426
+ > `loop.break` are handled by a loop sub-step's post-processing before it
427
+ > delegates, so they are dispatchable **only there** — and are correctly
428
+ > reported as undispatchable at the top level. The two scopes are kept apart
429
+ > rather than merged for the same reason the key sets are (below): a name in
430
+ > the wrong half is the failure mode, and no merged set can see it.
431
+ >
432
+ > The operation type is the YAML **map key** (`- data.set: { … }`), not a
433
+ > field, so the finding addresses the operation itself
434
+ > (`steps.<id>.post_processing[0]`). Naming an `operation_type` field would
435
+ > send an author looking for something the grammar does not have. An unknown
436
+ > type also suppresses the key verdict beneath it: one verdict per defect.
437
+ >
438
+ > 2. **`unknown_processing_config_key`**, for the operations with a **closed** key
439
+ > set — `parse`, `binary.store`, `binary.get`, `binary.update`,
440
+ > `binary.delete`, `binary.transform`, `loop.break`. The sets are **per
441
+ > operation and are never unioned**: `asset_id` is read by
442
+ > `binary.get`/`binary.update`/`binary.delete` and is **not** read by
443
+ > `binary.transform`, which reads `source_asset_id`. A union over the
444
+ > operations accepts that key in the wrong half, which is precisely the
445
+ > mistake the rule exists to catch, so the spec models each operation
446
+ > separately and a conformance fixture pins the wrong-half case.
447
+ >
448
+ > `data.set`, `output.set`, `conversation.append` and `loop.set` have **open**
449
+ > key sets — every key is a name the author chose (a data key, an output key, a
450
+ > conversation id, a loop variable) — so "unknown key" is not a notion that
451
+ > applies to them and they can never produce this warning. That half is what
452
+ > makes the rule safe; asking them for a known key set would accuse every
453
+ > correct flow. The `if:` guard is a **sibling** of the operation key rather
454
+ > than a config key, except on `loop.break`, where `if` IS its config key.
455
+ >
456
+ > Scope, stated so it is not mistaken for more: **top-level config keys only**.
457
+ > Sub-keys of `metadata:` (forwarded to the asset store, which has its own
458
+ > vocabulary) and of `parameters:` (the transformer's, which varies per
459
+ > transformer) are out of scope in both implementations.
460
+ >
461
+ > Verified by running the reference validator over the new fixtures: identical
462
+ > codes and identical field paths, and the all-correct fixture produces neither
463
+ > code on either side.
464
+ >
465
+ > New conformance fixtures: `valid-processing-operation-unknown-type-warns.yaml`,
466
+ > `valid-processing-config-key-wrong-half-warns.yaml`,
467
+ > `valid-processing-config-keys-accepted.yaml`. The third needed a new
468
+ > `forbidWarningCodes` assertion in the conformance harness: a rule whose whole
469
+ > risk is a false positive needs a fixture that goes **red** when it fires, and
470
+ > `valid: true` does not say that, because a warning never changes the verdict.
471
+
472
+ > v2.652.0 (DC-FORGE-82; no grammar change, so `specVersion` stays `2.642.0`) —
473
+ > **one new static rule ported: `loop_substep_error_goto_ignored`.** A loop BODY is
474
+ > a second step table, and the walk that raises `unreachable_error_goto` never
475
+ > entered it — **on either side**; this validator had the same blind spot as the
476
+ > reference, for the same reason, which is why the finding ports rather than
477
+ > diverges.
478
+ >
479
+ > ⛔ **It is a DIFFERENT finding from `unreachable_error_goto`, and collapsing the
480
+ > two would be wrong.** That one is about a `goto_step` beside the wrong action,
481
+ > and its remedy is to write `action: "goto"`. Inside a loop body there is no
482
+ > action that helps: `executeLoopStep`'s failure switch has exactly two arms —
483
+ > `continue` (skip to the next sub-step) and a default that ABORTS the whole loop
484
+ > step — so `goto` lands in the default and the loop fails. **The strategy the
485
+ > author chose in order to avoid failing is the one that fails.** The message names
486
+ > the real remedy: put the `goto_step` on the LOOP step, whose own `error_strategy`
487
+ > is what the abort routes through.
488
+ >
489
+ > ⚠️ **Only the new warning is emitted over loop sub-steps — deliberately.** The
490
+ > reference's parser does not run its `error_strategy` checks (action enum, goto
491
+ > target existence, duration and backoff validation) over a loop body at all, so
492
+ > routing sub-steps through `validateOne` would make this oracle refuse shapes its
493
+ > door accepts — wrong in the more damaging direction.
494
+ >
495
+ > The fixture pair follows the `forbidWarningCodes` discipline established below.
496
+ > The counter-fixture puts a `goto_step` on the LOOP step beside `action: goto` —
497
+ > the exact shape the warning's own advice produces — so a rule that fired on any
498
+ > `goto_step` near a loop would make its own remedy warn. Both directions are
499
+ > mutation-verified: deleting the emission fails the positive fixture, dropping the
500
+ > `goto_step` presence guard fails the counter-fixture.
501
+ >
502
+ > The rest of v2.652.0 is out of scope: a composite step's `*StepError`, the
503
+ > one-hop `.step.error` carry into a for_each/loop handler, the loop sub-step
504
+ > strategy resolver and the unchecked `map[string]any` assertion are all engine
505
+ > runtime behaviour (divergence #3), not static grammar.
506
+
507
+ > v2.651.0 (DC-FORGE-81; no grammar change, so `specVersion` stays `2.642.0`) —
508
+ > **one new static rule ported: `unreachable_error_goto`.** The reference's engine
509
+ > switches on `error_strategy.action` and reads `goto_step` in the `goto` branch
510
+ > **only**; `retry`, `fail` and an absent action all fall through to failing the
511
+ > mission. Its parser has always checked that a `goto_step` names an existing step
512
+ > — but only once the action already was `goto`, so the one combination that
513
+ > silently does nothing was the one combination nothing asked about. ⛔ **Both of
514
+ > the reference's bundled example flows declared `action: "retry"` beside a
515
+ > `goto_step:` naming an error handler**, so neither handler was reachable, which
516
+ > is also why several broken `.step.error` reads inside those handlers had never
517
+ > been noticed — the steps containing them never ran.
518
+ >
519
+ > It is a **warning** on both sides, for the same reason: the reference consults
520
+ > this validator at its RUN door over flows that are already stored, and the
521
+ > declaration is INERT rather than fatal.
522
+ >
523
+ > ⭐ **The fixture pair is the point, and `forbidWarningCodes` is new here because
524
+ > of it.** This rule's entire risk is a FALSE POSITIVE, and neither `valid` nor
525
+ > `expectWarningCodes` can express that: a warning never changes the verdict, so an
526
+ > all-correct fixture stays green no matter how indiscriminately the rule fires.
527
+ > `valid-reachable-error-goto.yaml` forbids the code; without that assertion the
528
+ > suite passes with the rule's guard deleted. Both directions are mutation-verified.
529
+ >
530
+ > The rest of v2.649.0–v2.651.0 is out of scope: v2.649.0 is SPA styling, and
531
+ > v2.651.0's other five findings are runtime template-context behaviour
532
+ > (divergence #3), engine routing, or documentation surface.
533
+
534
+ > v2.672.0 (DC-FORGE-102, aigentflow#116; no grammar change, so `specVersion`
535
+ > stays `2.642.0`) — **three new static rules ported: a loop sub-step's `next:`
536
+ > targets are now checked.** A `loop:` sub-step may carry its own `next:` block,
537
+ > and its targets — `next.default` and every `next.conditions[].goto` — resolve
538
+ > ONLY against the sub-step ids of the SAME loop.
539
+ >
540
+ > ⛔ **Nothing checked them, on either side.** `validateNextLogic`,
541
+ > `validateOrchestratorNext` and this validator's connectivity pass all walk
542
+ > `flow.steps` only, and **a loop body is a SECOND step table** — the distinction
543
+ > this family of defects keeps being about. So `goto: pol` for a sub-step called
544
+ > `poll` saved clean, stored, and surfaced at run time as a WARN followed by a
545
+ > silent sequential advance: the branch the author wrote simply never happened,
546
+ > in the one construct whose entire purpose is branching.
547
+ >
548
+ > Three refusals, each with its own code, each a shape the runtime cannot act on:
549
+ >
550
+ > 1. **`loop_substep_next_target_not_found`** — the target names no sub-step of
551
+ > that loop. ⭐ **The interesting miss is a target that names a real TOP-LEVEL
552
+ > step**, which looks correct to every reader and to every previous check, and
553
+ > which the loop driver cannot see.
554
+ > 2. **`loop_substep_next_sentinel`** — the target is `null` or `orchestrator`.
555
+ > The reference's `resolveLoopSubStepNext` has no sentinel awareness at all, so
556
+ > `null` cannot end a mission from a loop body and `orchestrator` cannot yield;
557
+ > both take the same miss path as a typo. (This is also why the reference's
558
+ > `flowHasOrchestratorYieldEdge` skipping loop bodies is correct rather than a
559
+ > bug: the edge is unreachable, so the fix is to refuse WRITING it.)
560
+ > 3. **`loop_substep_next_parallel`** — the sub-step declares `next.parallel`. The
561
+ > loop driver reads conditions and default only, so the block never fans out
562
+ > and its rendezvous never runs.
563
+ >
564
+ > **Both jump directions stay legal** — the runtime sub-step index covers the
565
+ > whole loop, not only the sub-steps declared earlier — which is why the check
566
+ > runs as a SECOND pass over the already-collected id set, and why the valid
567
+ > fixture carries a forward jump AND a backward one. **An empty target is the
568
+ > documented "advance sequentially" and is left alone.**
569
+ >
570
+ > ⚠️ **`end` is reported as a missing target, not as a sentinel.** The reference
571
+ > names only `null` and `orchestrator` and lets everything else fall through to
572
+ > the existence check, and that is the honest answer inside a loop body, where
573
+ > nothing reads `end` either. (Since 0.14.0 the same is true of top-level next
574
+ > targets; divergence #2 is closed.)
575
+ >
576
+ > **Severity: all three are `error` here.** Upstream they are hard refusals at the
577
+ > SAVE door and downgraded to a warning on the stored-flow LOAD door, so a flow
578
+ > written before the rule keeps running exactly as degraded as it already was.
579
+ > This validator has no notion of doors and answers **"would this save"** — the
580
+ > same choice already made for the executor-URL shape and expression-function
581
+ > catalog rules. **No new severity was invented, and a refusal here does not imply
582
+ > a broken running flow.**
583
+ >
584
+ > ⭐ **`forbidErrorCodes` is new in the conformance harness.** `valid: true`
585
+ > already fails on any spurious error, so this is not the verdict-shaped hole
586
+ > `forbidWarningCodes` fills for a warning rule — it NAMES the rule under test, so
587
+ > a regression reads as "the loop-target rule fired on a legal backward jump"
588
+ > instead of as an anonymous count mismatch. All five mutants (drop the existence
589
+ > check, make an empty target illegal, drop the sentinel set, drop the parallel
590
+ > check, judge targets single-pass so a forward jump is refused) are killed by
591
+ > both a unit test and a conformance fixture.
592
+ >
593
+ > New conformance fixtures: `valid-loop-substep-next.yaml`,
594
+ > `invalid-loop-substep-next-unknown-target.yaml`,
595
+ > `invalid-loop-substep-next-sentinel.yaml`,
596
+ > `invalid-loop-substep-next-parallel.yaml`.
597
+
598
+ > DC-FORGE-145 (the release after v2.715.0; no grammar change, so `specVersion`
599
+ > stays `2.642.0`) — **one new static rule ported: `response_expectation_unread`.**
600
+ > The reference's engine reads a step's `response_expectation` only when
601
+ > `response_evaluation` is set; with no evaluation mode it returns the raw response
602
+ > untouched, so the expectation's `required`, `type` and `fallback` are never
603
+ > consulted. The reference measured **21 such steps in 9 of its own bundled flows**,
604
+ > including a requirement meant to fail an answer that had not searched.
605
+ > `async://` is exempt because its respond route validates the posted output
606
+ > against the expectation on its own. Loop sub-steps cannot declare an
607
+ > expectation, so — unlike `unreachable_error_goto` — there is no loop-body walk.
608
+ >
609
+ > It is a **warning** on both sides: the reference consults it at its RUN door over
610
+ > flows that are already stored, and the declaration is inert rather than fatal.
611
+ > The message text is the reference's `WARN_MSG_RESPONSE_EXPECTATION_UNREAD`
612
+ > verbatim, with Go's `%q` rendered as `JSON.stringify` (identical for printable
613
+ > ASCII, which is all a step id holds).
614
+ >
615
+ > Parity was measured, not assumed: over the reference's bundled corpus **before**
616
+ > its fix, this validator and the reference emit the **identical** 21
617
+ > `(flow, step)` findings; after it, both emit zero. Three counter-fixtures
618
+ > (`raw-text`, `async://`, absent/empty expectation) carry `forbidWarningCodes`, and
619
+ > each of the rule's four guards is mutation-verified against them.
620
+
621
+ > DC-FORGE-147 (no grammar change, so `specVersion` stays `2.642.0`) — **one new
622
+ > static rule ported, a warning: `step_max_duration_ignored`.** The reference now
623
+ > bounds each executor invocation of a step by its `max_duration`. Two
624
+ > declarations fall outside that, each warned on at `steps.<id>.max_duration`:
625
+ >
626
+ > - a value that is not `none`/`never`/`infinite` and does not parse as a Go
627
+ > `time.ParseDuration` string (`2d`, `5 minutes`, a `{{ template }}`, a bare
628
+ > `90`) — the engine runs that step with **no** time limit rather than fail a
629
+ > stored flow at its run door;
630
+ > - a parseable value on a `loop:` step, which makes no executor call of its own.
631
+ >
632
+ > Only top-level steps are walked: loop sub-steps have no `max_duration` field in
633
+ > the reference. `0s` and negative values parse (the engine treats them as no
634
+ > bound) and do not warn. Both messages are the reference's
635
+ > `WARN_MSG_STEP_MAX_DURATION_UNPARSEABLE` / `_LOOP` verbatim, with `%q` rendered
636
+ > as `JSON.stringify`. The reference sets no `StepID` on this warning, so neither
637
+ > does this port.
638
+ >
639
+ > The shared `parseGoDuration` helper was brought into line with Go on two edges
640
+ > it got wrong: `1.s` (digits on one side of the point suffice) now parses, and a
641
+ > value overflowing int64 nanoseconds (`2562048h`) no longer does. The unit test
642
+ > pins 28 strings against `go run` output. This also tightens `invalid_duration`
643
+ > on throttle `delay`/`batch_delay` and error-strategy delays, toward Go.
644
+ >
645
+ > Divergences, none of which changes a verdict: the reference's field is a Go
646
+ > `string`, so YAML decodes any scalar into it; this port reads a string or a
647
+ > finite number (as its decimal text) and ignores booleans/mappings. A YAML
648
+ > number's original spelling (`0x10`) is lost at parse here, so its text may
649
+ > differ in the message. The overflow boundary is float-approximate to within a
650
+ > few ns of 2^63.
651
+
652
+ > AIF DC-FORGE-150 (v2.721.0) — **three grammar keys deleted because nothing read them: the top-level
653
+ > `budget:`, the top-level `max_retries:`, and a `max_retries:` directly on a
654
+ > top-level step.** Measured inert on the reference's real engine before the
655
+ > deletion: a sub-cent `budget` completed a step that cost 2.0, and step
656
+ > `max_retries: 0` still made two attempts. The reference's save door parses with
657
+ > `KnownFields(true)`, so a flow declaring any of them is now **refused at save**,
658
+ > with migration advice from its `retiredGrammarKeys` table (parser.go); stored
659
+ > flows still load leniently and run.
660
+ >
661
+ > Ported as an **error** with the code this validator already uses for the other
662
+ > key the reference refuses specifically, `unknown_yaml_key` (the
663
+ > `next.conditions[].goto_step` case below) — new module
664
+ > [`src/validators/retiredKeys.ts`](./src/validators/retiredKeys.ts). Fields:
665
+ > `budget`, `max_retries`, `steps.<id>.max_retries`. The check is on key
666
+ > **presence**, not value: the strict decoder refuses the key whatever it holds, so
667
+ > `budget: 0`, `max_retries: 0` and `budget: null` are all refused. The message
668
+ > names the version, says AIgentFlow refuses to save it, and the suggestion names
669
+ > the replacement — `billing: { max_credits: N }` with its reach (it caps the
670
+ > credit reservation and refuses further agentic turns: agentic `ai://`,
671
+ > `exons://`, the orchestrator; plain chat steps and `flow://` sub-flows are not
672
+ > checked against it), or `error_strategy: { action: "retry", max_retries: N }`
673
+ > (N counts attempts, including the first).
674
+ >
675
+ > **Loop sub-steps are included** (`steps.<id>.loop.steps[<i>].max_retries`). The
676
+ > reference's loop sub-step type never had a `max_retries` field, so it was always
677
+ > refused there as an unknown key, independently of this deletion — and because
678
+ > the advice table matches the key NAME in the decoder error, not the type, the
679
+ > reference now attaches this same advice to it. Reporting it is therefore parity
680
+ > with "would this save", not an extension; it is the one location where this
681
+ > validator reports a generic unknown key, and it does so only because the
682
+ > reference names it.
683
+ >
684
+ > The false positive to guard is the working key one level down:
685
+ > `error_strategy.max_retries` (flow and step), `quality_gate.max_retries`,
686
+ > `billing.max_credits`, and the surviving `currency` / `max_duration`. The
687
+ > counter-fixture `valid-limit-keys-one-level-down.yaml` carries all of them;
688
+ > because this is an ERROR rule, `valid: true` (zero errors) is the proof and no
689
+ > `forbid*Codes` harness extension is needed. Four mutants were run and each was
690
+ > killed: presence→truthiness (skips `0`/`null`; 5 tests fail), the step rule also
691
+ > reading `error_strategy.max_retries` (2 fail), the budget rule also firing on a
692
+ > `billing:` block (2 fail), and not walking loop sub-steps (1 fails).
693
+ >
694
+ > `budget` and the flow-level `max_retries` are also removed from the exported
695
+ > `Flow` type (they were never on `StepDefinition`). `ErrorStrategyDefinition.
696
+ max_retries` and `QualityGateDefinition.max_retries` are unchanged. Removing
697
+ > public type fields is why the package version is a minor bump.
698
+ >
699
+ > **Supersedes the DC-FORGE-146 warnings** (`flow_budget_unenforced`,
700
+ > `max_retries_unread`) proposed in an open PR for the same three keys: the keys no
701
+ > longer exist, so there is nothing left to warn about.
702
+ >
703
+ > Divergence, none of which changes a verdict on a flow the reference would save:
704
+ > the reference refuses a `budget` or `max_retries` key at ANY depth where the
705
+ > containing type has no such field (the generic unknown-key rule, ported in
706
+ > 0.14.0 — see [Unknown keys and value kinds](#unknown-keys-and-value-kinds)).
707
+ > The four locations above keep their specific advice.
708
+
709
+ > v2.646.0 (no grammar change, so `specVersion` stays `2.642.0`) — **the reference
710
+ > finally validates `pre_processing:` / `post_processing:` templates at all.** Its
711
+ > `validateProcessingOperation` took `operation any` and asserted `map[string]any`
712
+ > while both call sites passed `*ProcessingOperationDefinition`, so it returned
713
+ > silently for the life of the repository. ⭐ **This validator has checked those
714
+ > blocks for syntax the whole time, which means the port was STRICTER than the
715
+ > reference it ports** — the opposite of the direction a mirror is usually wrong in,
716
+ > and invisible to both sides.
717
+ >
718
+ > What changed here: nothing about _what_ is reported, only _where_. The reference
719
+ > unmarshals a processing operation's single key into `OperationType` and its body
720
+ > into an inline `Config`, so it addresses findings as
721
+ > `steps.<id>.post_processing[0].<configKey>` and `steps.<id>.post_processing[0].if`.
722
+ > This validator walked the raw record and inserted the operation name as a path
723
+ > segment (`…post_processing[0].data.set.<configKey>`). The walker now matches the
724
+ > reference, pinned by a test that fails on the old address.
725
+ >
726
+ > The reference also gained `template_missing_field` coverage inside those blocks,
727
+ > grounded on the namespaces the processing handler actually builds — which is
728
+ > **narrower** than a step's own context (no `.loop`, no `.binary`, four of five
729
+ > `.step` sub-namespaces absent, and `.step.response` only after the step, holding
730
+ > the _evaluated_ response). That whole class stays out of scope here under
731
+ > **divergence #3**, unchanged.
732
+
733
+ > v2.642.0 — **`expression_functions:` stopped being inert.** The block had always
734
+ > been validated for SHAPE (exactly one key, `package` XOR `function`, non-empty
735
+ > value) and honoured in no other way: nothing read it, so a flow could declare
736
+ > functions and get none. It is now a real opt-in over a **fixed catalog of 20
737
+ > functions compiled into the AIgentFlow binary**. Three rules ported, all into
738
+ > [`src/validators/expressionFunctions.ts`](./src/validators/expressionFunctions.ts);
739
+ > the pre-existing structural rules are unchanged and still fire first.
740
+ >
741
+ > 1. **`package:` is refused** (`expression_function_package_unsupported`).
742
+ > Nothing is loaded at run time, ever — there is no safe way to load a package
743
+ > at run time, and flow YAML is reachable by any authenticated caller, so the
744
+ > catalog _is_ the security envelope. The message points at `function:` and
745
+ > lists the catalog.
746
+ > 2. **A `function:` outside the catalog is refused** (`expression_function_unknown`).
747
+ > The catalog is an enumerable, compile-time-linked set, which is why this is
748
+ > an error and not the lag-prone allow-list warning of divergence #4 — a name
749
+ > this validator does not know is a name AIgentFlow does not have.
750
+ > 3. **A template action calling an `fn_` name must name a catalog entry
751
+ > (`expression_function_unknown_use`) and be declared by the flow
752
+ > (`expression_function_undeclared_use`).** This is what makes the block
753
+ > load-bearing rather than decorative. Only the text between `{{` and `}}` is
754
+ > scanned, so a description mentioning `fn_slugify` in prose is not a call, and
755
+ > the action pattern is newline-tolerant so a multi-line action in a block
756
+ > scalar is matched whole. Every catalog name carries the `fn_` prefix
757
+ > precisely so a catalog entry can never shadow a standard function such as
758
+ > `index` or `default`.
759
+ >
760
+ > The 20 catalog names are also added to `templateFunctions`: the reference
761
+ > registers the whole catalog on its standard template registry unconditionally,
762
+ > so `{{ fn_round … }}` is a _known_ function there, and omitting them would make
763
+ > `{ strictRegistries: true }` report a second, wrong verdict on a correct flow.
764
+ >
765
+ > **Severity choice — the save door vs the run door.** Upstream, rules 1 and 2 are
766
+ > hard refusals when a flow is SAVED but only warnings when a stored flow is
767
+ > LOADED, so flows written before the rules existed keep running. This validator
768
+ > has no notion of doors and no severity meaning "refused at save, tolerated at
769
+ > run"; it answers **"would this save"**, which is the same choice already made
770
+ > for the executor-URL shape rule (also retroactive-tolerant upstream, also an
771
+ > error here). So all three rules are `error`, and no new severity was invented.
772
+ > **Consequence to know:** a flow this validator refuses may still be running in
773
+ > production — the refusal means it can no longer be saved unchanged, not that it
774
+ > is broken. Rule 3 is save-door-only upstream for a different reason (cost: the
775
+ > scan re-serialises the flow, and the load path runs on every mission start),
776
+ > which lands in the same place.
777
+ >
778
+ > **One deliberate scope difference (divergence #10).** The Go rule re-serialises
779
+ > the `Flow` struct, so it sees only fields that struct declares; this validator
780
+ > walks the parsed document, so it also sees keys the struct does not carry. On a
781
+ > flow AIgentFlow would accept the two are identical, because AIgentFlow's create
782
+ > path parses with `KnownFields(true)` and refuses anything else outright.
783
+ >
784
+ > **Correction to rule 3, made in the reference and ported here: a FIELD is not a
785
+ > CALL.** The first cut matched `\bfn_[A-Za-z0-9_]+`, and `\b` matches happily
786
+ > between the `.` and the `f` — so `{{ .data.fn_total }}`,
787
+ > `{{ .step.response.fn_score }}`, `{{ index .data "fn_result" }}` and
788
+ > `{{ index .data "step.fn_result" }}` were all read as calls. A flow with a state
789
+ > field, a step, or a data key whose name begins with `fn_` was then **refused
790
+ > with a message about a function it never called** — a false refusal on a valid
791
+ > flow, which is worse than a missed detection. Two guards, both required, each
792
+ > covered by its own test: quoted spans (`"…"` and backticks) are **stripped from
793
+ > the action before the scan**, because a quoted string inside an action names a
794
+ > data key rather than an identifier; and the character before `fn_` must be
795
+ > neither a dot nor a word character. The Go side captures and discards that
796
+ > character (`(^|[^.\w])(fn_…)`) because RE2 has no lookbehind; this validator uses
797
+ > the negative lookbehind `(?<![.\w])`, which is the cleaner equivalent — no
798
+ > capture group, and it cannot consume the delimiter between two adjacent matches
799
+ > — and is available on the Node >= 20 this package requires. A real call still
800
+ > matches after `{{`, `(`, `|` or whitespace.
801
+ >
802
+ > New conformance fixtures: `invalid-expression-function-package.yaml`,
803
+ > `invalid-expression-function-unknown-name.yaml`,
804
+ > `invalid-expression-function-undeclared-use.yaml`,
805
+ > `valid-expression-functions.yaml`,
806
+ > `valid-expression-function-prose-mention.yaml`,
807
+ > `valid-expression-function-field-lookalikes.yaml`.
808
+
809
+ > v2.640.0 — `unique_items` removed from the spec surface and from
810
+ > `PropertyDefinition`. AIgentFlow deleted the grammar field: it was declared
811
+ > twice in the Go domain with **zero** readers anywhere, and the served OpenAPI
812
+ > spec advertised it under a camelCase name the YAML parser would have rejected.
813
+ > Nothing honoured it in either direction. `billing.budget_exceeded_policy`,
814
+ > deleted in the same upstream cycle, was never mirrored here.
815
+
816
+ > v2.608.0 (AIF DC-FORGE-38 — a parity sweep covering v2.485.0 → v2.607.0).
817
+ > The audit of that 122-release window found only four rule-changing commits, and
818
+ > the sweep closed every one that is in scope, plus a defect in this validator
819
+ > that predates the window and mattered more than any of them.
820
+ >
821
+ > 1. **`next.conditions[].goto` — this validator read the wrong key.** The YAML
822
+ > key is `goto`; `ConditionDefinition.GotoStep` carries `yaml:"goto"`
823
+ > (aigentflow.domain.step.go:134). It is the ONE place in the grammar spelled
824
+ > that way — the flow-level and step-level `error_strategy` and `quality_gate`
825
+ > all use `goto_step` — and this validator read `goto_step` everywhere,
826
+ > including here. Two silent consequences: a conditional branch naming a
827
+ > step that does not exist was **never reported** (`step_not_found` on a
828
+ > condition could not fire at all), and every conditionally-reached step was
829
+ > accused of being `unreachable_step`. Measured against AIgentFlow's own
830
+ > validator over its 203 bundled flows, this validator reported **100**
831
+ > unreachable steps where the reference reported 24, and **missed** the one
832
+ > `potential_infinite_loop` the reference finds. Both numbers now match
833
+ > exactly. The old conformance fixtures used `goto_step`, so the whole suite
834
+ > was green over the defect. A condition carrying `goto_step` is now an
835
+ > explicit error (`unknown_yaml_key`), because AIgentFlow's create/update/
836
+ > validate path parses with `KnownFields(true)` and refuses such a flow
837
+ > outright — verified directly against the Go parser.
838
+ > 2. **Executor URL shape (AIF DC-FORGE-30, v2.598.0; DC-FORGE-38, v2.608.0).**
839
+ > `FlowParser.ValidateFlow` now applies the ONE parser (`URL_PATTERN_REGEX`,
840
+ > `ParseExecutorURLString`) to every step executor, and from v2.608.0 to
841
+ > `loop.steps[i].executor` as well. `openai:///gpt-4`, `ai://openai` and
842
+ > `http://api.example.com/v1` are refused at SAVE; this validator accepted all
843
+ > three. The regex is vendored verbatim as `executorUrlPattern`. **Templated
844
+ > URLs are skipped** in both implementations — the engine renders
845
+ > `step.executor` as a Go template before dispatch, and omitting that exception
846
+ > rejected eight working bundled flows when the Go rule was first written.
847
+ > Divergence #1 is untouched: the new rule is about SHAPE, and an unknown
848
+ > scheme is still a warning.
849
+ > 3. **Reachability follows five edge kinds (AIF DC-FORGE-30 §8, v2.598.0 +
850
+ > v2.598.1).** `findReachableSteps` delegates to the simulator's
851
+ > `collectNextTargets` — `next.default`, `next.conditions[].goto`,
852
+ > `next.parallel.steps[]`, `next.parallel.rendezvous`, step-level
853
+ > `error_strategy.goto_step` — plus the FLOW-level `error_strategy.goto_step`
854
+ > seeded into the queue. **The cycle detector was deliberately NOT widened**
855
+ > (`checkForCycles` still walks two edges), so this validator now uses two
856
+ > separate target functions; widening `hasCycle` too would invent a divergence
857
+ > and emit a spurious `potential_infinite_loop` on any rendezvous or error
858
+ > redirect back to an earlier step.
859
+ > 4. **`executorSchemes` is AIgentFlow's registered set exactly** (43 protocols,
860
+ > enumerated from `NewExecutorSchemaRegistry`). `web://` was missing, so a
861
+ > correct step warned; nine schemes AIgentFlow does not register (`openai`,
862
+ > `anthropic`, `perplexity`, `vertexai`, `ollama`, `vllm`, `aigentchat`,
863
+ > `external`, `https` — legacy names banner-marked non-functional in AIF
864
+ > v2.596.0) were listed, so this validator stayed silent where AIgentFlow
865
+ > fails at dispatch.
866
+ >
867
+ > **Audited and deliberately NOT ported** (recorded so the next sweep does not
868
+ > re-derive them):
869
+ >
870
+ > - `ValidateExecutorConfigEnvScopes` (AIF v2.597.0) — **ported in 0.15.0**
871
+ > (`executor_config_env_scope`, see the 0.15.0 note at the top). The four scope tables
872
+ > are vendored as one evaluated table, `executorConfigEnvScopes.scopes` in the spec.
873
+ > - Unknown-key rejection (**ported in 0.14.0**, see
874
+ > [Unknown keys and value kinds](#unknown-keys-and-value-kinds)). AIgentFlow's create path has always parsed with
875
+ > `KnownFields(true)`; AIF v2.604.0 made `POST /flows/validate` and the Studio
876
+ > assistant strict too, so "would this save" now answers the same everywhere.
877
+ > This validator inspects no unknown keys, so a step-level `output:` block
878
+ > (the real grammar is `post_processing: - output.set:`) still validates clean.
879
+ > Porting it means a full field inventory of `Flow`/`StepDefinition` and
880
+ > interacts with divergence #8. **A decision to make, not a delta.** The one
881
+ > case that mattered in practice — `goto_step` inside a condition — is now
882
+ > reported specifically, as are the keys AIgentFlow v2.721.0 deleted from the
883
+ > grammar (`budget`, `max_retries` — see DC-FORGE-150 above).
884
+ > - Everything else in the window is runtime field resolution (divergence #3),
885
+ > credentials/compliance (#5, #6) or documentation surface: AIF 2.526.0's
886
+ > `DeploymentQueryOption` rename, 2.580.0/2.582.0/2.585.0's
887
+ > `template_missing_field` grounding, 2.591.0–2.594.0's step-query stripping,
888
+ > 2.603.0's billed-header redaction, 2.604.0's `BuildOutputContractWarnings`.
889
+ > `input_schema.go` and `template.registry.go` have a **zero** diff across the
890
+ > whole window, and no new YAML-tagged flow or step field was added.
891
+
892
+ > v2.485.0 (DC-COND-2 CONDUCTOR): added the `campaign.on_children_complete`
893
+ > field (names the step the engine deterministically routes into once every
894
+ > spawned child is terminal). One static check ported:
895
+ > `campaign_handoff_step_unknown` (the referenced step must exist in `steps`) —
896
+ > consistent with the existing `next`-reference validation. The field's _runtime_
897
+ > effect (the deterministic all-children-terminal hand-off, idle-tick gating,
898
+ > event-authoritative campaign state) is engine behaviour and out of scope for
899
+ > static validation. New conformance fixtures: `valid-campaign-handoff.yaml`,
900
+ > `invalid-campaign-handoff-unknown-step.yaml`.
901
+ >
902
+ > v2.484.0 (DC-COND-1 CONDUCTOR): added the orchestrator `mode:` field
903
+ > (`monitor` | `owner`, default `monitor`). Two static checks ported:
904
+ > `orchestrator_mode_invalid` (unknown mode value) and
905
+ > `orchestrator_owner_needs_yield` (`owner` mode requires at least one step with
906
+ > `next: orchestrator` — mirrored via `flowHasOrchestratorYieldEdge`). The
907
+ > mode's _runtime_ effect (terminal-vs-yield lifecycle, deterministic backstops)
908
+ > is engine behaviour and out of scope for static validation. New conformance
909
+ > fixtures: `valid-orchestrator-monitor.yaml`, `invalid-orchestrator-owner-no-yield.yaml`.
910
+ >
911
+ > v2.735.0 (DC-FORGE-162, aigentflow#149): added Sprig's float names `addf`, `subf`,
912
+ > `mulf`, `divf` and `float64` to `templateFunctions`. `ternary` also accepts Sprig's
913
+ > condition-last order at runtime; that changes no name, so the allow-list is unaffected.
914
+ > Allow-list only.
915
+ >
916
+ > v2.478.0: added the `htmlDocument` template function (sanitises LLM chat output
917
+ > destined for public hosting — slices `<!doctype>`…`</html>`, dropping markdown
918
+ > fences + conversational preamble/postamble). Allow-list only; runtime behaviour
919
+ > is out of scope for static validation.
920
+
921
+ The Go reference has two layers, both ported here:
922
+
923
+ - `Validator.ValidateFlowWithDetails` — `aigentflow/aigentflow.validation.go` (the structured-result validator; our primary model).
924
+ - `FlowParser.ValidateFlow` — `aigentflow/aigentflow.parser.go` (parse-time structural checks).
925
+
926
+ Comparison contract: **error `code` + `valid` verdict**, not message wording. The conformance suite (`test/conformance/`) asserts verdicts structurally.
927
+
928
+ ---
929
+
930
+ ## Rule map
931
+
932
+ | Area | Go source | JS module | Tested by |
933
+ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------- |
934
+ | Required fields, start-step existence, per-step executor, reserved `.` in IDs, reserved id `orchestrator` | `validateBasicStructure`, `ValidateFlow` head | `validators/basicStructure.ts` | `validate.test.ts`, `conformance.test.ts` |
935
+ | Executor URI shape (the ONE parser) + scheme | `ValidateFlow` executor-URL rule (parser.go), `ParseExecutorURLString` | `validators/executors.ts` | `validate.test.ts` |
936
+ | Query/property/array-item schema + array constraints | `validateQueryParameters`, `validateProperties`, `validateArrayItems`, `validateArrayConstraints` | `validators/querySchema.ts` | `validate.test.ts` |
937
+ | Response-expectation types + array items + `required` | `ValidateFlow` (response block), `validateSemantics` | `validators/responseExpectation.ts` | `validate.test.ts` |
938
+ | Response expectation nothing reads (`response_expectation_unread`, warning) | `validateResponseExpectationIsRead` (validation.go) | `validators/responseExpectation.ts` | `validate.test.ts`, `conformance.test.ts` |
939
+ | Error strategy (action, goto, max_delay, backoff, retry_on) | `validateErrorStrategy` | `validators/errorStrategy.ts` | `validate.test.ts` |
940
+ | Retired `budget:` / `max_retries:` (flow, step, loop sub-step) and `campaign.budget_max_per_child` → `unknown_yaml_key` | `retiredGrammarKeys` + `KnownFields(true)` (parser.go) | `validators/retiredKeys.ts` | `validate.test.ts`, `conformance.test.ts` |
941
+ | `next` references (save-door sentinels `null`/`orchestrator`), reachability and cycles (`end` is an ordinary step) | `validateNextLogic` (parser.go), `findReachableSteps`, `checkForCycles` | `validators/connectivity.ts` | `validate.test.ts`, `conformance.test.ts` |
942
+ | Unknown keys at every level (`unknown_yaml_key`) and value kinds (`invalid_type`) | `KnownFields(true)` over the reference's types (parser.go); `knownKeys` in the spec | `validators/unknownKeys.ts` | `validate.test.ts`, `conformance.test.ts` |
943
+ | `next.parallel` + orchestrator-next requirement | `validateNextLogic`, `validateOrchestratorNext` | `validators/nextLogic.ts` | `validate.test.ts` |
944
+ | Expression functions (XOR package/function) | `validateExpressionFunctions` | `validators/expressionFunctions.ts` | `validate.test.ts` |
945
+ | Expression-function catalog (`package:` refused, unknown `function:` refused) | `validateExpressionFunctionCatalog` (parser.go) | `validators/expressionFunctions.ts` | `validate.test.ts`, `conformance.test.ts` |
946
+ | Expression-function USE (`{{ fn_* }}` must be in the catalog AND declared) | `validateExpressionFunctionUsage` (parser.go) | `validators/expressionFunctions.ts` | `validate.test.ts`, `conformance.test.ts` |
947
+ | Loop / for_each / throttle | `validateLoop`, `validateForEach`, `validateThrottle` | `validators/loopForEachThrottle.ts` | `validate.test.ts` |
948
+ | Loop sub-step id that shadows a loop-result summary field (`loop_sub_step_id_reserved`, warning) | `validateLoopSubStepIDCollision` (validation.loopbody.go) | `validators/loopForEachThrottle.ts` | `validate.test.ts`, `conformance.test.ts` |
949
+ | Loop sub-step `next:` targets (same-loop only, sentinels, no parallel) | `validateLoopSubStepNext` (parser.go) | `validators/loopForEachThrottle.ts` | `validate.test.ts`, `conformance.test.ts` |
950
+ | Orchestrator structure + campaign requires orchestrator | `validateOrchestrator`, `validateAndNormalizeCampaign` | `validators/orchestratorCampaign.ts` | `validate.test.ts` |
951
+ | Campaign `max_credits_per_child` (decoded as int64, >= 0), `child_flows` | `CampaignConfig.Validate` (domain.campaign.go) | `validators/orchestratorCampaign.ts` | `validate.test.ts`, `conformance.test.ts` |
952
+ | `tool_discovery` vocabulary, mock scenario `delay`, empty `output:` entry | `validateToolDiscoveryVocabulary`, `validateMockScenarioDelays`, `validateOutputParameters` (parser.go) | `validators/saveDoor.ts` | `validate.test.ts`, `conformance.test.ts` |
953
+ | `executor_config` `${NAME}` references outside the key's scope (`executor_config_env_scope`) | `ValidateExecutorConfigEnvScopes` (parser.go) | `validators/executorConfigEnv.ts` | `v0150-save-door.test.ts`, `conformance.test.ts` |
954
+ | A step or loop sub-step `query:` declaring a server-owned key (`server_owned_query_key`) | `validateNoServerOwnedStepQueryKeys` (validation.go), `IsServerOwnedParamKey` | `validators/serverOwnedQueryKeys.ts` | `v0151-save-door.test.ts`, `conformance.test.ts` |
955
+ | An endpoint a server-supplied credential will never be sent to (`credential_endpoint_unpaired`, warning) | `validateCredentialEndpointPairing`, `validateFamilyCredentialEndpointPairing` (validation.go) | `validators/credentialEndpoint.ts`, `validators/goUrl.ts` | `v0152-credential-endpoint.test.ts`, `conformance.test.ts` |
956
+ | Orchestrator `.exons` frontmatter: spec, provider, `requirements.resources`, `tools.allow` withholds | `validateOrchestrator` (parser.go), `validateOrchestratorToolAllowWithholds` | `validators/exons.ts` | `v0150-save-door.test.ts`, `conformance.test.ts` |
957
+ | Credential bindings (`stored/...`, inject_as, exclusivity) | `validateStepCredentialBindings` | `validators/credentialBindings.ts` | `validate.test.ts` |
958
+ | `input_schema` definition + ordering lint | `ValidateInputSchemaDefinition`, `LintInputSchemaFieldOrdering` | `validators/inputSchema.ts` | `validate.test.ts` |
959
+ | Step `output_schema` definition (reuses the input-schema subset) | `ValidateInputSchemaDefinition` (on `step.OutputSchema`, parser.go) | `validators/outputSchema.ts` (+ shared `inputSchema.ts`) | `validate.test.ts` |
960
+ | `quality_gate:` block (rubric/threshold/on_fail/goto) | `FlowParser.validateQualityGate` (parser.go) | `validators/qualityGate.ts` | `validate.test.ts` |
961
+ | Processing-operation type + per-operation config keys | `validateProcessingOperationShape` | `validators/processingOperations.ts` | `validate.test.ts`, `conformance.test.ts` |
962
+ | Step `max_duration` the engine cannot apply (`step_max_duration_ignored`, warning) | `validateStepMaxDurationIsApplied` (validation.go), `parseStepMaxDuration` (flowengine.steptimeout.go) | `validators/stepMaxDuration.ts` | `validate.test.ts`, `conformance.test.ts` |
963
+ | Go-template syntax | `validateTemplateExpression` (Parse step) | `template/gotmpl-syntax.ts` + `validators/templates.ts` | `gotmpl-syntax.test.ts`, `validate.test.ts` |
964
+
965
+ ---
966
+
967
+ ## Intentional divergences
968
+
969
+ These are deliberate, documented differences from the Go static pass. They keep the
970
+ validator useful and low-false-positive while staying offline.
971
+
972
+ 1. **Executor scheme is a warning, not an error.** The Go static validator does not
973
+ reject unknown schemes (the live executor registry decides at runtime, and schemes
974
+ are added frequently). We flag a **malformed** URI (`invalid_executor_url`) as an
975
+ error — that is always genuinely broken — but an unrecognised scheme is a
976
+ `unknown_executor_scheme` **warning**. This means a brand-new AIgentFlow scheme never
977
+ produces a false failure here.
978
+
979
+ 2. **Closed in 0.14.0 and 0.15.0: `end` was always a terminal marker here.** The reference
980
+ was internally inconsistent: its structured validator exempted `end`, and its save door
981
+ (`validateNextLogic`) did not. This port used to follow the structured validator, so
982
+ it accepted `next: { default: end }`, which the reference refuses to save. 0.14.0
983
+ followed each: the existence check uses the save door's set (`null`, `orchestrator`),
984
+ and the reachability and cycle walks kept `end` as terminal, so a real step named `end`
985
+ that only `end` routed to was `unreachable_step` on both sides.
986
+
987
+ **Since 0.15.0 (reference v2.760.0) the second half is gone too:** no reference walk
988
+ treats `end` as terminal, so a real step named `end` is reachable and a cycle through
989
+ it is a cycle (`warn-next-end-cycle.yaml`). Spec `reachabilityTerminalMarkers` is now
990
+ `["null", "orchestrator"]`, the same values as `nextMarkers`; the two keys stay
991
+ separate so a future split is a one-value change.
992
+
993
+ 3. **Runtime template field-resolution is not reproduced.** Go additionally _executes_
994
+ each template against a mock context to emit `template_missing_field` /
995
+ `condition_not_boolean` **warnings**. That requires simulating the runtime state graph;
996
+ we validate template **syntax** only. (Candidate for a future best-effort pass.)
997
+
998
+ 4. **Unknown orchestrator tools / template functions are warnings by default.** The
999
+ vendored allow-lists (`orchestrator.tools`, `templateFunctions`) can lag the live
1000
+ AIgentFlow registries. Pass `{ strictRegistries: true }` to make them errors.
1001
+
1002
+ 5. **Narrowed in 0.15.0: the orchestrator exons document is read, its body is not
1003
+ parsed.** The frontmatter is read the way the engine extracts and decodes it (see the
1004
+ 0.15.0 note at the top), so the spec's presence, `execution.provider`,
1005
+ `requirements.resources` and `tools.allow` are judged as the reference judges them.
1006
+ Whether the document as a whole parses, and whether its tags render, needs the
1007
+ engine: divergence #16.
1008
+
1009
+ 6. **Compliance / credentials / publish gates are out of scope** — they require a live
1010
+ server, the provider catalogue, and org context. See the README.
1011
+
1012
+ 7. **Regex engine.** `input_schema` `pattern` compilation uses the JS regex engine, not
1013
+ Go RE2. A pattern valid in one engine but not the other is a (rare) known divergence.
1014
+
1015
+ 8. **Shape errors.** Because YAML decodes into an untyped object (vs. Go's typed
1016
+ unmarshal), this validator emits `invalid_type` errors where Go would have failed at
1017
+ decode time. This is strictly additive.
1018
+
1019
+ 9. **`unresolvable_data_path` is not reproduced (DC-CP-7).** The Go engine, when a step
1020
+ declares an `output_schema`, treats that step's output as a typed contract and errors
1021
+ (`unresolvable_data_path`) on any `.data.<step>.<field>` template reference elsewhere in
1022
+ the flow that names a field the producing step's `output_schema` does not declare
1023
+ (opt-in: steps _without_ an `output_schema` are left lenient). Emitting it here would
1024
+ require statically resolving `.data.<step>.<field>` references out of every template and
1025
+ matching them to the producing step — i.e. the very runtime template field-resolution
1026
+ this validator deliberately does not perform (see divergence #3). We therefore validate
1027
+ the `output_schema` **definition** (structure/field types, via the shared input-schema
1028
+ validator) but do **not** cross-check template references against it. Candidate for the
1029
+ same future best-effort resolution pass as divergence #3.
1030
+
1031
+ The `wait://` and `eval://` schemes (DC-CP-5 / DC-CP-8) are now in the known-scheme set,
1032
+ so they no longer warn (`unknown_executor_scheme`); malformed `wait://` / `eval://` URIs
1033
+ still error via the shared shape check. `quality_gate.on_fail=human` exists in the Go
1034
+ enum but is validation-REJECTED there (pending the human-task inbox), so this validator
1035
+ rejects it too (`quality_gate_on_fail_unsupported`) — this is parity, not a divergence.
1036
+
1037
+ 10. **The `fn_*` usage scan walks the document, not the typed struct (v2.642.0).** The
1038
+ Go rule re-serialises the `Flow` struct before scanning for template actions, so it
1039
+ sees only fields the struct declares. This validator has no typed unmarshal, so it
1040
+ walks every string leaf of the parsed document — including keys the Go struct does
1041
+ not carry. The two agree on every flow AIgentFlow would accept, because its create
1042
+ path parses with `KnownFields(true)`; they differ only on a document that AIgentFlow
1043
+ rejects for an unrelated reason, and since 0.14.0 this validator refuses that document
1044
+ too (`unknown_yaml_key`). Same family as divergence #8, and strictly additive.
1045
+
1046
+ Severity: the catalog and usage rules are refused at AIgentFlow's SAVE door and only
1047
+ warned at its load/run door, so a flow stored before the rules existed keeps running.
1048
+ This validator answers "would this save" and therefore reports all of them as
1049
+ `error` — the choice already made for the executor-URL shape rule. That is parity
1050
+ with the save door, not a divergence, but it means a refusal here does **not** imply
1051
+ a broken running flow.
1052
+
1053
+ 11. **A malformed processing-operation entry produces no shape verdict (v2.647.0).**
1054
+ The reference's custom unmarshaller refuses an entry that is not a map, that
1055
+ carries more than one non-`if` key, or whose config is not a mapping — those
1056
+ flows never reach the rules above, because they fail to parse at all. This
1057
+ validator has no typed unmarshal, so such an entry simply yields no
1058
+ operation to have an opinion about and both rules stay silent; the
1059
+ structural pass keeps whatever it already said. Same family as divergence
1060
+ #8, in the lenient direction.
1061
+
1062
+ Scope note, resolved in v2.648.0: both implementations now walk the loop body
1063
+ as well, with `loop.set` / `loop.break` dispatchable there and undispatchable
1064
+ at the top level.
1065
+
1066
+ 12. **`orchestrator.human_question_timeout` — the refusal is ported, the WARNING is not.**
1067
+ AIF v2.695.0 (DC-FORGE-125, aigentflow#124) adds an opt-in per-question HITL deadline. The
1068
+ reference refuses an unparseable **or non-positive** duration, and this validator does the same
1069
+ — note the positivity half: `-5m` is a well-formed Go duration, so a bare `isValidGoDuration`
1070
+ check would accept what the door refuses, and an oracle **looser** than its door is wrong in the
1071
+ more damaging direction.
1072
+
1073
+ The reference ALSO logs a server-side warning (a log line, not a validation warning) when the declared timeout exceeds `ORCH_MAX_MISSION_DURATION` (the
1074
+ orchestrator's 60-minute runaway guard), because a deadline the mission clock outlives can never
1075
+ fire. That is **not** ported: the threshold is a deployment-side constant this package cannot
1076
+ observe, and hard-coding it here would put a second copy of an AIgentFlow number in another
1077
+ repository — the drift this file exists to prevent. A flow that declares an over-long timeout is
1078
+ therefore `valid` on both sides; only the reference's server log mentions it.
1079
+
1080
+ ⚠️ Absent means **no deadline**, in both — and an empty string or YAML `null` counts as absent, because
1081
+ the reference's field is a Go string and its check runs only on a non-empty value. This validator must never infer a default: the
1082
+ reference treats a default here as a policy nobody chose, and inventing one would make the
1083
+ oracle refuse or accept on a premise the door does not hold.
1084
+
1085
+ 13. **Narrowed in 0.14.0 and 0.14.1: a number in a Go `string` field is judged by
1086
+ its source text only when there is source text.** yaml.v3 fills the reference's
1087
+ `string` field with the scalar's **source text**. `validateFlow` now has it
1088
+ (`parseFlow` records the spelling of every number and boolean scalar, and gives
1089
+ every number or boolean mapping key its spelling), so `delay: 0.0`, `00` and
1090
+ `0x0` are refused as the reference refuses them, and `default: 1e3` names the
1091
+ step `"1e3"`. Two cases still see only the parsed value: `validateFlowObject`,
1092
+ whose caller parsed the document, and a value reached through a YAML alias.
1093
+ There a number is rendered the way JavaScript renders it (`0.0` is `"0"`, `1e3`
1094
+ is `"1000"`, `0x1F` is `"31"`), which is also how JavaScript renders a numeric
1095
+ object key, so an ordinary `2` still names the step `2`. For durations this is
1096
+ the lenient direction: every other number is refused either way, because no
1097
+ number's text carries a unit.
1098
+
1099
+ Since 0.14.1 the mechanism covers every Go `string` field the validators read:
1100
+ step references (`next.default`, condition `goto`, `parallel.steps` and
1101
+ `rendezvous`, `error_strategy` / `quality_gate` `goto_step`, `start`,
1102
+ `campaign.on_children_complete`, loop sub-step ids and `next` targets), enums
1103
+ (`action`, `retry_on`, `on_fail`, `resolution`, `mode`, trigger `type`, data
1104
+ types, `tools`), names and required text (`name`, `aigentflow_version`,
1105
+ `executor`, `rubric`, `items`, `while`, `exons`, credential `source` /
1106
+ `inject_as`, input-schema `name` / `type` / `pattern` / `visible_when.field`,
1107
+ `expression_functions` values, `response_evaluation`, `flow_id` /
1108
+ `flow_name`), and the durations (`delay`, `batch_delay`, `max_delay`,
1109
+ `retry_delay`, timer `interval`, `human_question_timeout`, `max_duration`,
1110
+ `tool_discovery`).
1111
+
1112
+ _Numbers 14 and 15 are the Go port's own (its #14 collects reference checks outside both
1113
+ ports' scope, such as plugin pre-validation; its #15 is #13 here). From 16 on, both ports
1114
+ number their shared divergences alike._
1115
+
1116
+ 16. **`.exons` documents: the engine's judgement needs the engine (0.15.0; Go port #16).**
1117
+ The reference judges an inline `.exons` document with go-exons: whether it parses
1118
+ (grammar, frontmatter decode and the spec's own validation), and whether every tag
1119
+ can render. This validator has no `.exons` template engine and must not grow one,
1120
+ because a second implementation of that grammar is a second opinion about it. The
1121
+ frontmatter reader makes `orchestrator_exons_parse_failed` (no spec, an unclosed
1122
+ frontmatter, the retired config block), `orchestrator_exons_no_provider`,
1123
+ `exons_resources_unhonoured` and `orchestrator_tool_withheld` the reference's. What
1124
+ it cannot see:
1125
+ - `exons_attributes`: a tag the engine's resolver refuses at execute
1126
+ (`{~exons.message~}` without `role=`, `{~exons.include~}` without `template=`),
1127
+ on a step's inline `query.exons` and on the orchestrator. The step rule is not
1128
+ ported at all: with no engine it has nothing to judge.
1129
+ - a document that does not parse for any other reason, including a spec the engine's
1130
+ own validation refuses (a missing `description`), and a frontmatter whose declared
1131
+ fields have the wrong shape (the reader then judges nothing);
1132
+ - a frontmatter containing a tag, which the engine executes before decoding.
1133
+
1134
+ **Direction: looser, never stricter.** The reader refuses only where the engine
1135
+ refuses as well; the Go port pins that with a 15-shape matrix against go-exons, and
1136
+ this repository pins the same 15 plus 24 decode shapes against the Go port's verdicts.
1137
+ The engine-only fixtures (`invalid-exons-step-attributes.yaml`,
1138
+ `invalid-orchestrator-exons-attributes.yaml`,
1139
+ `invalid-orchestrator-exons-spec-invalid.yaml`) state the reference's verdict and are
1140
+ marked `exonsEngineOnly`: this suite asserts the looser one (valid, none of the
1141
+ engine's codes). The Go port offers its engine as an optional companion module; there
1142
+ is no JavaScript equivalent.
1143
+
1144
+ 17. **`input_schema_file_after_parametric` is spelt `INPUT_SCHEMA_FILE_AFTER_PARAMETRIC`
1145
+ by the reference (Go port #17).** The reference's save door merges its `input_schema`
1146
+ ordering lint into the verdict with its own upper-case code, at field `doc`. Both
1147
+ ports have always reported it as `input_schema_file_after_parametric` at the offending
1148
+ field (`input_schema.fields[N]`). A warning only; renaming a published code would break
1149
+ every consumer that branches on it, so it stays.
1150
+
1151
+ 18. **`credential_endpoint_unpaired`: what the static rule cannot see (Go port #19).** The
1152
+ reference's save-time rule is itself static and is ported exactly. What it predicts is
1153
+ a RUN-TIME refusal that reads what no document carries: the values of the server's
1154
+ environment variables, the credential resolver's output (which stored key, with which
1155
+ base URL), the service configuration an executor fills, and whether a client attaches
1156
+ the deployment's service credential. Neither side reads them statically.
1157
+ - **Default endpoints of rows the rule never reads are not carried** (`getmd://`, which
1158
+ is never judged, and `nexus://`, whose shape consults no default). Verdict-neutral.
1159
+ - **A `credentials:` mapping with a non-string key (looser, shared).** The reference
1160
+ decodes it into `map[any]any`, which its nexus own-key check does not accept, so the
1161
+ endpoint warns there; both ports stringify mapping keys and see a key of the step's own.
1162
+ - **JS only, looser: an endpoint shaped like a YAML timestamp is not read as a string.**
1163
+ yaml.v3 decodes a PLAIN `2024-01-01` into `time.Time`, which the reference's
1164
+ `.(string)` rejects; the `yaml` package gives a string and the source's quoting is not
1165
+ kept. Where reading a value as a string could ADD a warning (an endpoint), a
1166
+ timestamp-shaped string is skipped, so a QUOTED one warns in the reference and not here.
1167
+ - **JS only, looser: a `!!binary` value** is a string to yaml.v3 and bytes here.
1168
+ - **Not ported, verdict-neutral:** Go validates a bracketed IPv6 host with
1169
+ `netip.ParseAddr`; a bracketed host is never a default's host either way.
1170
+
1171
+ ---
1172
+
1173
+ ## Unknown keys and value kinds
1174
+
1175
+ The reference's save door decodes a flow with yaml.v3 `KnownFields(true)`. That refuses
1176
+ a key the containing Go struct does not declare, **at every depth**, and a value whose
1177
+ KIND does not decode into the field (a scalar where a struct, map or list is required,
1178
+ or a mapping or list where a scalar is). `validators/unknownKeys.ts` reproduces both.
1179
+
1180
+ **The key sets are derived, not hand-listed.** `knownKeys` in the spec is generated by
1181
+ reflection over the reference's `Flow` type, applying yaml.v3's own field rules:
1182
+
1183
+ - a field's key is its `yaml:` tag name, or the lowercased field name when untagged;
1184
+ `yaml:"-"` and unexported fields are skipped;
1185
+ - an `,inline` struct's keys merge into the parent (this is why `currency` and
1186
+ `max_duration` are flow-root keys); an `,inline` map would make the type open (none
1187
+ does today);
1188
+ - a pointer is followed; a slice becomes `list<…>`; a map becomes `map<…>` with
1189
+ author-chosen keys; an interface is `any`;
1190
+ - a type with its own `UnmarshalYAML` is `any`, because yaml.v3 does not propagate
1191
+ `KnownFields` into `Node.Decode`. The one such type is a pre/post-processing entry,
1192
+ whose shape stays out of scope (divergence #11).
1193
+
1194
+ **How it was checked.** For each of the 35 struct types, a document placing an unknown
1195
+ key at that type's position must be refused as an unknown key; for each of its keys, a
1196
+ value of each wrong kind must fail to decode and a null must decode; for each `any`
1197
+ position, a mapping, a list and a scalar must all decode. 771 checks, run against the
1198
+ reference's strict parser, all as expected, and a mutated inventory (a key removed, a
1199
+ kind changed) fails them. On the 216 bundled flows the rule fires zero times.
1200
+
1201
+ **What is judged.** Keys and kinds only. Whether a scalar fits its field (a number in an
1202
+ integer field, `yes` in a boolean) is left to the rules that already own those fields
1203
+ (divergence #8). A YAML null is the zero value of any type and is never reported. A key
1204
+ the reference deleted from the grammar is named as retired wherever it appears; at the
1205
+ locations `retiredKeys.ts` covers, that rule's specific advice is reported instead, and a
1206
+ location is never reported twice.
1207
+
1208
+ **Regenerating it.** On a grammar change, re-run the derivation above against the new
1209
+ reference types (a short Go program: reflect over `Flow`, apply the rules above, print
1210
+ each struct type's key → shape map, sorted) and replace `knownKeys` in the spec. Then
1211
+ re-run the check above and the bundled-corpus comparison. The derivation program is
1212
+ not shipped here, because it links the reference implementation.
1213
+
1214
+ ---
1215
+
1216
+ ## Migration discipline — keep this in sync with AIgentFlow
1217
+
1218
+ When the AIgentFlow flow grammar or validation rules change, this validator **must** be
1219
+ updated. The trigger conditions and the checklist:
1220
+
1221
+ **Triggers** (any flow-YAML-affecting change in the `aigentflow` repo):
1222
+
1223
+ - a new executor scheme;
1224
+ - a new top-level flow field or step field;
1225
+ - a new/changed enum (data types, error actions, next markers, resolutions, input-schema
1226
+ field types, orchestrator triggers/tools);
1227
+ - a new or changed validation rule in `aigentflow.validation.go`, `aigentflow.parser.go`,
1228
+ or `aigentflow.input_schema.go`;
1229
+ - a new template function in `aigentflow.template.registry.go`.
1230
+
1231
+ **Checklist:**
1232
+
1233
+ 1. Update [`src/spec/aigentflow-spec.json`](./src/spec/aigentflow-spec.json) — the enum
1234
+ values and bump `specVersion` to the new AIgentFlow version. A new or renamed field
1235
+ in any flow type also means regenerating `knownKeys` (see
1236
+ [Unknown keys and value kinds](#unknown-keys-and-value-kinds)); otherwise the new key
1237
+ is refused as unknown.
1238
+ 2. Port the rule into the matching `validators/*.ts` module (or add a new module).
1239
+ 3. Add a row to the [rule map](#rule-map) above and, if it diverges, an entry under
1240
+ [Intentional divergences](#intentional-divergences).
1241
+ 4. Add a conformance fixture under `test/conformance/fixtures/` plus an entry in
1242
+ `test/conformance/conformance.test.ts`, and a focused unit test.
1243
+ 5. `npm run typecheck && npm run lint && npm test && npm run build` — all green.
1244
+ 6. Bump the package version and update `SPEC_VERSION` in the README.
1245
+
1246
+ > The `aigentflow` repository's `CLAUDE.md` carries a reciprocal note pointing back here,
1247
+ > so a flow-YAML change in either repo surfaces the obligation to update the other.