flowspec2 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. flowspec2/__init__.py +105 -0
  2. flowspec2/authoring/__init__.py +275 -0
  3. flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
  4. flowspec2/authoring/authoring-evidence.schema.json +455 -0
  5. flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
  6. flowspec2/authoring/benchmark.py +1409 -0
  7. flowspec2/authoring/corpus/await_correction.case.json +220 -0
  8. flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
  9. flowspec2/authoring/corpus/gated_derive.case.json +100 -0
  10. flowspec2/authoring/corpus/linear.case.json +55 -0
  11. flowspec2/authoring/corpus/manifest.json +31 -0
  12. flowspec2/authoring/corpus/subflow.case.json +66 -0
  13. flowspec2/authoring/corpus/terminal.case.json +94 -0
  14. flowspec2/authoring/corpus.py +307 -0
  15. flowspec2/authoring/ctk.py +836 -0
  16. flowspec2/authoring/detached_signature.py +120 -0
  17. flowspec2/authoring/evidence.py +311 -0
  18. flowspec2/authoring/evidence_signature.py +187 -0
  19. flowspec2/authoring/evidence_verification.py +229 -0
  20. flowspec2/authoring/gemini.py +215 -0
  21. flowspec2/authoring/operational-corpus.json +61 -0
  22. flowspec2/authoring/operational.py +956 -0
  23. flowspec2/authoring/operational_providers.py +167 -0
  24. flowspec2/authoring/presentation_review.py +1013 -0
  25. flowspec2/authoring/presentation_review_signature.py +305 -0
  26. flowspec2/authoring/projection.py +299 -0
  27. flowspec2/authoring/provider_prompt.py +83 -0
  28. flowspec2/backends/__init__.py +82 -0
  29. flowspec2/backends/http.py +189 -0
  30. flowspec2/checker.py +238 -0
  31. flowspec2/cli.py +789 -0
  32. flowspec2/cli_parser.py +345 -0
  33. flowspec2/clock.py +23 -0
  34. flowspec2/compat/__init__.py +47 -0
  35. flowspec2/compat/models.py +82 -0
  36. flowspec2/compat/open_workflow.py +616 -0
  37. flowspec2/compat/rasa.py +19 -0
  38. flowspec2/compat/rasa_export.py +876 -0
  39. flowspec2/compat/rasa_import.py +992 -0
  40. flowspec2/compat/rasa_shared.py +270 -0
  41. flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
  42. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
  43. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
  44. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
  45. flowspec2/compat/tool_profiles.py +149 -0
  46. flowspec2/compat/yaml.py +147 -0
  47. flowspec2/compiler.py +957 -0
  48. flowspec2/compiler_contracts.py +17 -0
  49. flowspec2/compiler_resume_contracts.py +594 -0
  50. flowspec2/compiler_schema_relations.py +1830 -0
  51. flowspec2/compiler_tool_contracts.py +245 -0
  52. flowspec2/compiler_value_contracts.py +447 -0
  53. flowspec2/derive.py +32 -0
  54. flowspec2/diagnostics.py +94 -0
  55. flowspec2/domains.py +489 -0
  56. flowspec2/experimental/__init__.py +57 -0
  57. flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
  58. flowspec2/experimental/v3-preview-loss-policy.json +161 -0
  59. flowspec2/experimental/v3_lowering.py +928 -0
  60. flowspec2/experimental/v3_preview.py +2460 -0
  61. flowspec2/flowspec-2.schema.json +635 -0
  62. flowspec2/interactive.py +300 -0
  63. flowspec2/ir.py +1459 -0
  64. flowspec2/json_codec.py +120 -0
  65. flowspec2/llm.py +366 -0
  66. flowspec2/models.py +121 -0
  67. flowspec2/nodes.py +1537 -0
  68. flowspec2/observability.py +151 -0
  69. flowspec2/predicates.py +89 -0
  70. flowspec2/profiles.py +118 -0
  71. flowspec2/py.typed +0 -0
  72. flowspec2/runtime.py +1059 -0
  73. flowspec2/schema.py +54 -0
  74. flowspec2/schema_contracts.py +203 -0
  75. flowspec2/semantic_derive_contracts.py +530 -0
  76. flowspec2/semantic_path_contracts.py +528 -0
  77. flowspec2/semantic_predicate_contracts.py +355 -0
  78. flowspec2/semantic_schema_contracts.py +137 -0
  79. flowspec2/semantic_source_contracts.py +21 -0
  80. flowspec2/semantic_state_contracts.py +450 -0
  81. flowspec2/semantic_support.py +40 -0
  82. flowspec2/semantics.py +410 -0
  83. flowspec2/state_migration.py +1034 -0
  84. flowspec2/subflows/__init__.py +1117 -0
  85. flowspec2/subflows/address.py +328 -0
  86. flowspec2/subflows/identification.py +1031 -0
  87. flowspec2/tools.py +1154 -0
  88. flowspec2-1.0.0.dist-info/METADATA +482 -0
  89. flowspec2-1.0.0.dist-info/RECORD +92 -0
  90. flowspec2-1.0.0.dist-info/WHEEL +4 -0
  91. flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
  92. flowspec2-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,530 @@
1
+ """Derived-value semantic contracts and execution-order checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from itertools import product
7
+ from typing import Any, cast
8
+
9
+ from .derive import decode_derive_key, encode_derive_component, encode_derive_key
10
+ from .diagnostics import DiagnosticLocation, FlowDiagnostic
11
+ from .domains import make_slot_model
12
+ from .semantic_path_contracts import step_identifier
13
+ from .semantic_schema_contracts import slot_model_accepts
14
+ from .semantic_support import json_pointer, semantic_diagnostic
15
+
16
+
17
+ def derive_contracts(
18
+ document: dict[str, Any],
19
+ identifiers: dict[str, str],
20
+ *,
21
+ external_slots: frozenset[str] | None,
22
+ external_steps: frozenset[str],
23
+ ) -> list[FlowDiagnostic]:
24
+ diagnostics: list[FlowDiagnostic] = []
25
+ declared_slots = set(document.get("slots", {}))
26
+ native_collected_slots = {
27
+ cast(str, path_step.get("slot", path_step.get("confirm")))
28
+ for path_step in document["path"]
29
+ if "slot" in path_step or "confirm" in path_step
30
+ }
31
+ slots = set(declared_slots)
32
+ if external_slots is not None:
33
+ slots.update(external_slots)
34
+ definitions: dict[str, int] = {}
35
+ for derive_index, derive_definition in enumerate(document.get("derive", [])):
36
+ target = derive_definition["writes"]
37
+ if (first_index := definitions.get(target)) is not None:
38
+ diagnostics.append(
39
+ semantic_diagnostic(
40
+ "FLOWSPEC_SEMANTIC_DUPLICATE_DERIVE_TARGET",
41
+ json_pointer("derive", derive_index, "writes"),
42
+ f"derive target {target!r} has more than one definition",
43
+ related=(
44
+ DiagnosticLocation(path=json_pointer("derive", first_index, "writes")),
45
+ ),
46
+ )
47
+ )
48
+ else:
49
+ definitions[target] = derive_index
50
+ if target in native_collected_slots:
51
+ diagnostics.append(
52
+ semantic_diagnostic(
53
+ "FLOWSPEC_SEMANTIC_DERIVE_OVERWRITES_DECLARED_SLOT",
54
+ json_pointer("derive", derive_index, "writes"),
55
+ f"derive target {target!r} collides with a collected slot",
56
+ suggested_fix="Use a distinct derived state key.",
57
+ )
58
+ )
59
+ if external_slots is not None and target in external_slots:
60
+ diagnostics.append(
61
+ semantic_diagnostic(
62
+ "FLOWSPEC_SEMANTIC_DERIVE_OVERWRITES_PROFILE_SLOT",
63
+ json_pointer("derive", derive_index, "writes"),
64
+ f"derive target {target!r} is owned by a profile subflow",
65
+ suggested_fix="Use a distinct derived state key.",
66
+ )
67
+ )
68
+
69
+ derive_targets = set(definitions)
70
+ explicit_derive_nodes = {
71
+ cast(str, path_step["derive"]): cast(
72
+ str,
73
+ path_step.get("step", f"derive_{path_step['derive']}"),
74
+ )
75
+ for path_step in document["path"]
76
+ if "derive" in path_step
77
+ }
78
+ derive_node_identifiers = {
79
+ target: explicit_derive_nodes.get(target, f"derive_{target}") for target in derive_targets
80
+ }
81
+ slot_definitions = cast(dict[str, dict[str, Any]], document.get("slots", {}))
82
+ domain_definitions = cast(dict[str, dict[str, Any]], document.get("domains", {}))
83
+ gatedstep_identifiers = set(
84
+ cast(dict[str, Any], cast(dict[str, Any], document.get("overrides", {})).get("gates", {}))
85
+ )
86
+
87
+ def source_can_be_absent(source_slot: str, slot_definition: dict[str, Any]) -> bool:
88
+ source_path_step: dict[str, Any] = next(
89
+ (
90
+ path_step
91
+ for path_step in document["path"]
92
+ if path_step.get("slot", path_step.get("confirm")) == source_slot
93
+ ),
94
+ {},
95
+ )
96
+ return bool(
97
+ not slot_definition.get("required", True)
98
+ or slot_definition.get("nullable", False)
99
+ or slot_definition.get("on_exhaust") == "skip"
100
+ or "ask_when" in source_path_step
101
+ or "skip_when" in source_path_step
102
+ or source_path_step.get("step") in gatedstep_identifiers
103
+ )
104
+
105
+ def closed_source_values(source_slot: str) -> tuple[Any, ...] | None:
106
+ slot_definition = slot_definitions.get(source_slot)
107
+ if slot_definition is None:
108
+ return None
109
+ domain_definition = domain_definitions.get(cast(str, slot_definition["domain"]))
110
+ if domain_definition is None:
111
+ return None
112
+ domain_kind = domain_definition.get("type", "categorical")
113
+ source_values: list[Any]
114
+ if domain_kind == "categorical":
115
+ source_values = list(domain_definition.get("values", []))
116
+ elif domain_kind == "bool":
117
+ source_values = [True, False]
118
+ else:
119
+ return None
120
+ if source_can_be_absent(source_slot, slot_definition) and None not in source_values:
121
+ source_values.append(None)
122
+ return tuple(source_values)
123
+
124
+ def closed_source_tokens(source_slot: str) -> tuple[str, ...] | None:
125
+ source_values = closed_source_values(source_slot)
126
+ return (
127
+ tuple(encode_derive_component(source_value) for source_value in source_values)
128
+ if source_values is not None
129
+ else None
130
+ )
131
+
132
+ def open_source_is_compatible_with_target(
133
+ source_slot: str,
134
+ target_slot: str,
135
+ ) -> bool | None:
136
+ source_slot_definition = slot_definitions.get(source_slot)
137
+ target_slot_definition = slot_definitions.get(target_slot)
138
+ if source_slot_definition is None or target_slot_definition is None:
139
+ return None
140
+ if source_can_be_absent(source_slot, source_slot_definition) and not bool(
141
+ target_slot_definition.get("nullable", False)
142
+ ):
143
+ return False
144
+ source_domain = domain_definitions.get(cast(str, source_slot_definition["domain"]))
145
+ target_domain = domain_definitions.get(cast(str, target_slot_definition["domain"]))
146
+ if source_domain is None or target_domain is None:
147
+ return None
148
+ source_kind = cast(str, source_domain.get("type", "categorical"))
149
+ target_kind = cast(str, target_domain.get("type", "categorical"))
150
+ if source_kind in {"integer", "number"}:
151
+ if target_kind not in {"integer", "number"}:
152
+ return False
153
+ if source_kind == "number" and target_kind == "integer":
154
+ return False
155
+ source_minimum = source_domain.get("minimum")
156
+ target_minimum = target_domain.get("minimum")
157
+ source_maximum = source_domain.get("maximum")
158
+ target_maximum = target_domain.get("maximum")
159
+ if target_minimum is not None and (
160
+ source_minimum is None or source_minimum < target_minimum
161
+ ):
162
+ return False
163
+ if target_maximum is not None and (
164
+ source_maximum is None or source_maximum > target_maximum
165
+ ):
166
+ return False
167
+ return True
168
+ string_kinds = {"brazilian_tax_id", "email", "name", "free_text"}
169
+ if source_kind not in string_kinds or target_kind not in string_kinds:
170
+ return False if source_kind != target_kind else None
171
+ if target_kind == "free_text":
172
+ if target_domain.get("optional"):
173
+ return True
174
+ return source_kind != "free_text" or not source_domain.get("optional")
175
+ if source_kind == target_kind:
176
+ return True
177
+ if source_kind == "brazilian_tax_id" and target_kind == "name":
178
+ return True
179
+ return False
180
+
181
+ for derive_index, derive_definition in enumerate(document.get("derive", [])):
182
+ derive_target = cast(str, derive_definition["writes"])
183
+ if derive_target not in explicit_derive_nodes and "after" not in derive_definition:
184
+ diagnostics.append(
185
+ semantic_diagnostic(
186
+ "FLOWSPEC_SEMANTIC_UNPLACED_DERIVE",
187
+ json_pointer("derive", derive_index),
188
+ "derive must be placed in path or declare an after anchor",
189
+ suggested_fix="Add a derive path step or an explicit after step id.",
190
+ )
191
+ )
192
+ for source_index, source_slot in enumerate(derive_definition["from"]):
193
+ if (
194
+ external_slots is not None
195
+ and source_slot not in slots
196
+ and source_slot not in derive_targets
197
+ ):
198
+ diagnostics.append(
199
+ semantic_diagnostic(
200
+ "FLOWSPEC_SEMANTIC_UNKNOWN_DERIVE_SOURCE",
201
+ json_pointer("derive", derive_index, "from", source_index),
202
+ f"derive source {source_slot!r} is not declared or exposed",
203
+ )
204
+ )
205
+ if len(derive_definition["from"]) > 1:
206
+ slot_definition = slot_definitions.get(source_slot)
207
+ domain_definition = (
208
+ domain_definitions.get(slot_definition["domain"])
209
+ if slot_definition is not None
210
+ else None
211
+ )
212
+ domain_kind = (
213
+ domain_definition.get("type", "categorical")
214
+ if domain_definition is not None
215
+ else None
216
+ )
217
+ categorical_values = (
218
+ domain_definition.get("values", [])
219
+ if domain_kind == "categorical" and domain_definition is not None
220
+ else []
221
+ )
222
+ if domain_kind not in {"categorical", "bool"} or any(
223
+ isinstance(domain_value, str) and "|" in domain_value
224
+ for domain_value in categorical_values
225
+ ):
226
+ diagnostics.append(
227
+ semantic_diagnostic(
228
+ "FLOWSPEC_SEMANTIC_NON_INJECTIVE_DERIVE_KEY",
229
+ json_pointer("derive", derive_index, "from", source_index),
230
+ "multi-source lookup keys require closed categorical or bool "
231
+ "sources whose values do not contain '|'",
232
+ suggested_fix=(
233
+ "Use closed sources or compose single-source derives so the "
234
+ "lookup key is unambiguous."
235
+ ),
236
+ )
237
+ )
238
+ if (
239
+ (anchor := derive_definition.get("after")) is not None
240
+ and anchor not in identifiers
241
+ and anchor not in external_steps
242
+ and anchor not in derive_node_identifiers.values()
243
+ ):
244
+ diagnostics.append(
245
+ semantic_diagnostic(
246
+ "FLOWSPEC_SEMANTIC_UNKNOWN_DERIVE_ANCHOR",
247
+ json_pointer("derive", derive_index, "after"),
248
+ f"derive anchor {anchor!r} is not a path or profile step",
249
+ suggested_fix="Use a stable executable step id.",
250
+ )
251
+ )
252
+ if isinstance(
253
+ default_value := derive_definition.get("default"), str
254
+ ) and default_value.startswith("$from["):
255
+ default_match = re.fullmatch(r"\$from\[(\d+)\]", default_value)
256
+ if default_match is None or int(default_match.group(1)) >= len(
257
+ derive_definition["from"]
258
+ ):
259
+ diagnostics.append(
260
+ semantic_diagnostic(
261
+ "FLOWSPEC_SEMANTIC_INVALID_DERIVE_DEFAULT_REFERENCE",
262
+ json_pointer("derive", derive_index, "default"),
263
+ f"derive default {default_value!r} does not reference an existing source",
264
+ )
265
+ )
266
+ expected_arity = len(derive_definition["from"])
267
+ for lookup_key in derive_definition["lookup"]:
268
+ lookup_components = decode_derive_key(lookup_key, expected_arity)
269
+ if len(lookup_components) != expected_arity:
270
+ diagnostics.append(
271
+ semantic_diagnostic(
272
+ "FLOWSPEC_SEMANTIC_DERIVE_LOOKUP_ARITY",
273
+ json_pointer("derive", derive_index, "lookup", lookup_key),
274
+ f"lookup key has a different arity than the {expected_arity} derive sources",
275
+ )
276
+ )
277
+ continue
278
+ for source_index, (source_slot, lookup_component) in enumerate(
279
+ zip(derive_definition["from"], lookup_components, strict=True)
280
+ ):
281
+ accepted_tokens = closed_source_tokens(source_slot)
282
+ if accepted_tokens is not None and lookup_component not in accepted_tokens:
283
+ diagnostics.append(
284
+ semantic_diagnostic(
285
+ "FLOWSPEC_SEMANTIC_DERIVE_LOOKUP_VALUE_OUT_OF_DOMAIN",
286
+ json_pointer("derive", derive_index, "lookup", lookup_key),
287
+ f"lookup component {lookup_component!r} is not reachable from "
288
+ f"source {source_slot!r}",
289
+ related=(
290
+ DiagnosticLocation(
291
+ path=json_pointer("derive", derive_index, "from", source_index),
292
+ message="Source slot for this lookup component.",
293
+ ),
294
+ ),
295
+ )
296
+ )
297
+
298
+ target_slot_definition = slot_definitions.get(derive_target)
299
+ if target_slot_definition is not None:
300
+ target_domain_name = cast(str, target_slot_definition["domain"])
301
+ if target_domain_name in domain_definitions:
302
+ target_model = make_slot_model(
303
+ derive_target,
304
+ target_domain_name,
305
+ domain_definitions,
306
+ nullable=bool(target_slot_definition.get("nullable", False)),
307
+ )
308
+
309
+ authored_outputs = [
310
+ *(
311
+ (
312
+ json_pointer("derive", derive_index, "lookup", lookup_key),
313
+ lookup_value,
314
+ )
315
+ for lookup_key, lookup_value in derive_definition["lookup"].items()
316
+ ),
317
+ *(
318
+ [
319
+ (
320
+ json_pointer("derive", derive_index, "default"),
321
+ derive_definition["default"],
322
+ )
323
+ ]
324
+ if "default" in derive_definition
325
+ and not str(derive_definition["default"]).startswith("$from[")
326
+ else []
327
+ ),
328
+ ]
329
+ for output_path, output_value in authored_outputs:
330
+ try:
331
+ target_model.model_validate({derive_target: output_value})
332
+ except Exception:
333
+ diagnostics.append(
334
+ semantic_diagnostic(
335
+ "FLOWSPEC_SEMANTIC_DERIVE_OUTPUT_OUT_OF_DOMAIN",
336
+ output_path,
337
+ f"derive output {output_value!r} is not accepted by target "
338
+ f"slot {derive_target!r}",
339
+ )
340
+ )
341
+
342
+ if isinstance(default_value, str) and (
343
+ default_match := re.fullmatch(r"\$from\[(\d+)\]", default_value)
344
+ ):
345
+ source_index = int(default_match.group(1))
346
+ if source_index < len(derive_definition["from"]):
347
+ source_slot = cast(str, derive_definition["from"][source_index])
348
+ source_value_sets = [
349
+ closed_source_values(candidate_source)
350
+ for candidate_source in derive_definition["from"]
351
+ ]
352
+ fallback_values: tuple[Any, ...] | None = None
353
+ if all(source_values is not None for source_values in source_value_sets):
354
+ fallback_values = tuple(
355
+ source_values[source_index]
356
+ for source_values in product(
357
+ *cast(list[tuple[Any, ...]], source_value_sets)
358
+ )
359
+ if encode_derive_key(source_values)
360
+ not in derive_definition["lookup"]
361
+ )
362
+ fallback_is_compatible = (
363
+ all(
364
+ slot_model_accepts(
365
+ target_model,
366
+ derive_target,
367
+ fallback_value,
368
+ )
369
+ for fallback_value in fallback_values
370
+ )
371
+ if fallback_values is not None
372
+ else open_source_is_compatible_with_target(
373
+ source_slot,
374
+ derive_target,
375
+ )
376
+ )
377
+ if fallback_is_compatible is False:
378
+ diagnostics.append(
379
+ semantic_diagnostic(
380
+ "FLOWSPEC_SEMANTIC_DERIVE_DEFAULT_SOURCE_INCOMPATIBLE",
381
+ json_pointer("derive", derive_index, "default"),
382
+ f"derive fallback source {source_slot!r} is not accepted by "
383
+ f"target slot {derive_target!r}",
384
+ )
385
+ )
386
+
387
+ if target_slot_definition.get("required", True) and "default" not in derive_definition:
388
+ source_token_sets = [
389
+ closed_source_tokens(source_slot) for source_slot in derive_definition["from"]
390
+ ]
391
+ lookup_keys = set(derive_definition["lookup"])
392
+ is_total = all(token_set is not None for token_set in source_token_sets) and all(
393
+ encode_derive_key(source_tokens) in lookup_keys
394
+ for source_tokens in product(*cast(list[tuple[str, ...]], source_token_sets))
395
+ )
396
+ if not is_total:
397
+ diagnostics.append(
398
+ semantic_diagnostic(
399
+ "FLOWSPEC_SEMANTIC_REQUIRED_DERIVE_NOT_TOTAL",
400
+ json_pointer("derive", derive_index),
401
+ f"derive for required slot {derive_target!r} does not cover every "
402
+ "reachable source value and has no default",
403
+ suggested_fix="Add a domain-valid default or complete the lookup.",
404
+ )
405
+ )
406
+
407
+ dependency_graph = {
408
+ target: [
409
+ source
410
+ for source in document["derive"][derive_index]["from"]
411
+ if source in derive_targets
412
+ ]
413
+ for target, derive_index in definitions.items()
414
+ }
415
+ active_targets: list[str] = []
416
+ completed_targets: set[str] = set()
417
+ reported_cycles: set[frozenset[str]] = set()
418
+
419
+ def visit(target: str) -> None:
420
+ if target in completed_targets:
421
+ return
422
+ if target in active_targets:
423
+ cycle = active_targets[active_targets.index(target) :] + [target]
424
+ cycle_members = frozenset(cycle)
425
+ if cycle_members not in reported_cycles:
426
+ reported_cycles.add(cycle_members)
427
+ diagnostics.append(
428
+ semantic_diagnostic(
429
+ "FLOWSPEC_SEMANTIC_DERIVE_DEPENDENCY_CYCLE",
430
+ json_pointer("derive", definitions[target], "from"),
431
+ f"derive dependency cycle: {' -> '.join(cycle)}",
432
+ )
433
+ )
434
+ return
435
+ active_targets.append(target)
436
+ for source in dependency_graph[target]:
437
+ visit(source)
438
+ active_targets.pop()
439
+ completed_targets.add(target)
440
+
441
+ for target in sorted(derive_targets):
442
+ visit(target)
443
+
444
+ terminal_identifier = cast(
445
+ str,
446
+ cast(dict[str, Any], document.get("terminal", {})).get("step", "terminal"),
447
+ )
448
+ ordered_node_identifiers = ["__init__"]
449
+ native_slot_nodes: dict[str, str] = {}
450
+ for path_step in document["path"]:
451
+ node_identifier = step_identifier(path_step, terminal_identifier)
452
+ if node_identifier is not None:
453
+ ordered_node_identifiers.append(node_identifier)
454
+ if "slot" in path_step or "confirm" in path_step:
455
+ native_slot_nodes[cast(str, path_step.get("slot", path_step.get("confirm")))] = cast(
456
+ str, node_identifier
457
+ )
458
+
459
+ last_inserted_for_anchor: dict[str, str] = {}
460
+ unresolved_placement_targets: set[str] = set()
461
+ for derive_index, derive_definition in enumerate(document.get("derive", [])):
462
+ target = cast(str, derive_definition["writes"])
463
+ if target in explicit_derive_nodes:
464
+ continue
465
+ derive_node_identifier = derive_node_identifiers[target]
466
+ anchor = derive_definition.get("after")
467
+ if anchor is None:
468
+ terminal_position = (
469
+ ordered_node_identifiers.index(terminal_identifier)
470
+ if terminal_identifier in ordered_node_identifiers
471
+ else len(ordered_node_identifiers)
472
+ )
473
+ ordered_node_identifiers.insert(terminal_position, derive_node_identifier)
474
+ continue
475
+ effective_anchor = last_inserted_for_anchor.get(anchor, anchor)
476
+ if effective_anchor not in ordered_node_identifiers:
477
+ if anchor not in external_steps:
478
+ diagnostics.append(
479
+ semantic_diagnostic(
480
+ "FLOWSPEC_SEMANTIC_DERIVE_ANCHOR_ORDER",
481
+ json_pointer("derive", derive_index, "after"),
482
+ f"derive anchor {anchor!r} is not executable before this declaration",
483
+ suggested_fix="Declare an anchored producer before derives that target it.",
484
+ )
485
+ )
486
+ unresolved_placement_targets.add(target)
487
+ continue
488
+ anchor_index = ordered_node_identifiers.index(effective_anchor)
489
+ ordered_node_identifiers.insert(anchor_index + 1, derive_node_identifier)
490
+ last_inserted_for_anchor[cast(str, anchor)] = derive_node_identifier
491
+
492
+ node_positions = {
493
+ node_identifier: node_index
494
+ for node_index, node_identifier in enumerate(ordered_node_identifiers)
495
+ }
496
+ for derive_index, derive_definition in enumerate(document.get("derive", [])):
497
+ target = cast(str, derive_definition["writes"])
498
+ if target in unresolved_placement_targets:
499
+ continue
500
+ target_node = derive_node_identifiers[target]
501
+ target_position = node_positions.get(target_node)
502
+ if target_position is None:
503
+ continue
504
+ for source_index, source_slot in enumerate(derive_definition["from"]):
505
+ producer_node = derive_node_identifiers.get(source_slot) or native_slot_nodes.get(
506
+ source_slot
507
+ )
508
+ if producer_node is None:
509
+ continue
510
+ producer_position = node_positions.get(producer_node)
511
+ if producer_position is None or producer_position >= target_position:
512
+ diagnostics.append(
513
+ semantic_diagnostic(
514
+ "FLOWSPEC_SEMANTIC_DERIVE_EXECUTION_ORDER",
515
+ json_pointer("derive", derive_index, "from", source_index),
516
+ f"derive source {source_slot!r} is produced after {target!r} would execute",
517
+ suggested_fix="Move the derive after every source producer.",
518
+ )
519
+ )
520
+
521
+ for path_index, path_step in enumerate(document["path"]):
522
+ if "derive" in path_step and path_step["derive"] not in definitions:
523
+ diagnostics.append(
524
+ semantic_diagnostic(
525
+ "FLOWSPEC_SEMANTIC_UNKNOWN_DERIVE_TARGET",
526
+ json_pointer("path", path_index, "derive"),
527
+ f"derive step {path_step['derive']!r} has no derive definition",
528
+ )
529
+ )
530
+ return diagnostics