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
flowspec2/ir.py ADDED
@@ -0,0 +1,1459 @@
1
+ """Canonical normalization and a deterministic intermediate representation.
2
+
3
+ Flow documents remain the only authoring surface. This module turns a valid
4
+ source document into an explicit, immutable compiler input: schema defaults and
5
+ node identifiers are materialized, references and state access are indexed, and
6
+ stable digests make cache/provenance decisions independent of JSON key order.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import copy
12
+ import hashlib
13
+ import json
14
+ import re
15
+ from collections.abc import Mapping
16
+ from dataclasses import asdict, dataclass
17
+ from typing import Any, Final, Literal, cast
18
+
19
+ from jsonschema import Draft202012Validator
20
+
21
+ from .profiles import FlowProfile, capability_is_requested, reference_profile
22
+ from .schema import schema, validate_flow
23
+ from .subflows import SubflowDefinition
24
+
25
+ FLOW_IR_FORMAT: Final[str] = "flowspec2/ir@1"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class FlowReference:
30
+ """A statically discoverable reference from one source location."""
31
+
32
+ source: str
33
+ namespace: Literal[
34
+ "domain",
35
+ "slot",
36
+ "step",
37
+ "subflow",
38
+ "tool",
39
+ "config",
40
+ "data",
41
+ "internal",
42
+ "payload",
43
+ "address",
44
+ "token",
45
+ ]
46
+ target: str
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class FlowSlot:
51
+ """The normalized persistence and validation contract for one slot."""
52
+
53
+ name: str
54
+ domain: str
55
+ partition: Literal["data", "internal"]
56
+ required: bool
57
+ nullable: bool
58
+ requires: tuple[str, ...]
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class FlowNode:
63
+ """A source-level node with explicit state access."""
64
+
65
+ identifier: str
66
+ kind: Literal[
67
+ "init",
68
+ "collect",
69
+ "confirm",
70
+ "derive",
71
+ "terminal",
72
+ "subflow",
73
+ "await_external",
74
+ ]
75
+ source: str
76
+ reads: tuple[str, ...]
77
+ writes: tuple[str, ...]
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class FlowTransition:
82
+ """A deterministic source-level transition in the normalized rail."""
83
+
84
+ source: str
85
+ target: str
86
+ condition: Literal[
87
+ "next",
88
+ "confirm",
89
+ "reject",
90
+ "correction",
91
+ "resume",
92
+ "abort",
93
+ "resend",
94
+ "switch",
95
+ "timeout",
96
+ "pause",
97
+ "end",
98
+ ]
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class FlowIR:
103
+ """Immutable, serializable representation consumed after source checking."""
104
+
105
+ ir_format: str
106
+ format: str
107
+ flow: str
108
+ version: str
109
+ profile: str
110
+ profile_digest: str
111
+ dependency_digest: str
112
+ source_digest: str
113
+ digest: str
114
+ canonical_json: str
115
+ state_schema_json: str
116
+ slots: tuple[FlowSlot, ...]
117
+ nodes: tuple[FlowNode, ...]
118
+ transitions: tuple[FlowTransition, ...]
119
+ references: tuple[FlowReference, ...]
120
+ required_capabilities: tuple[str, ...]
121
+
122
+ def to_document(self) -> dict[str, Any]:
123
+ """Return a fresh mutable document for a compiler invocation."""
124
+
125
+ return cast(dict[str, Any], json.loads(self.canonical_json))
126
+
127
+ def state_schema(self) -> dict[str, Any]:
128
+ """Return the generated JSON Schema for the three state partitions."""
129
+
130
+ return cast(dict[str, Any], json.loads(self.state_schema_json))
131
+
132
+ def to_dict(self) -> dict[str, Any]:
133
+ """Return the complete deterministic JSON-compatible IR projection."""
134
+
135
+ projection = {
136
+ "ir_format": self.ir_format,
137
+ "format": self.format,
138
+ "flow": self.flow,
139
+ "version": self.version,
140
+ "profile": self.profile,
141
+ "profile_digest": self.profile_digest,
142
+ "dependency_digest": self.dependency_digest,
143
+ "source_digest": self.source_digest,
144
+ "digest": self.digest,
145
+ "document": self.to_document(),
146
+ "state_schema": self.state_schema(),
147
+ "slots": [asdict(slot) for slot in self.slots],
148
+ "nodes": [asdict(node) for node in self.nodes],
149
+ "transitions": [asdict(transition) for transition in self.transitions],
150
+ "references": [asdict(reference) for reference in self.references],
151
+ "required_capabilities": list(self.required_capabilities),
152
+ }
153
+ return cast(dict[str, Any], json.loads(_canonical_json(projection)))
154
+
155
+
156
+ def _canonical_json(document: Any) -> str:
157
+ return json.dumps(
158
+ document,
159
+ ensure_ascii=False,
160
+ allow_nan=False,
161
+ separators=(",", ":"),
162
+ sort_keys=True,
163
+ )
164
+
165
+
166
+ def _mutable_json(value: Any) -> Any:
167
+ if isinstance(value, Mapping):
168
+ return {key: _mutable_json(nested_value) for key, nested_value in value.items()}
169
+ if isinstance(value, tuple):
170
+ return [_mutable_json(nested_value) for nested_value in value]
171
+ if isinstance(value, list):
172
+ return [_mutable_json(nested_value) for nested_value in value]
173
+ return value
174
+
175
+
176
+ def _digest(document: Any) -> str:
177
+ return hashlib.sha256(_canonical_json(document).encode()).hexdigest()
178
+
179
+
180
+ def _resolved_dependency_contract(
181
+ document: dict[str, Any],
182
+ profile: FlowProfile | str,
183
+ ) -> dict[str, Any]:
184
+ referenced_tools: set[str] = set()
185
+ if (entry := document.get("entry")) is not None:
186
+ referenced_tools.add(cast(str, entry["tool"]))
187
+ if (terminal := document.get("terminal")) is not None:
188
+ referenced_tools.add(cast(str, terminal["tool"]))
189
+ await_definition = cast(
190
+ dict[str, Any],
191
+ cast(dict[str, Any], document.get("capabilities", {})).get("await_external", {}),
192
+ )
193
+ enrichment = cast(dict[str, Any], await_definition.get("on_resume", {})).get("enrich")
194
+ if isinstance(enrichment, str):
195
+ referenced_tools.add(enrichment)
196
+ elif isinstance(enrichment, dict):
197
+ referenced_tools.add(cast(str, enrichment["tool"]))
198
+
199
+ referenced_subflows = {
200
+ cast(str, use_definition["ref"]) for use_definition in document.get("uses", [])
201
+ }
202
+ requested_capabilities = {
203
+ capability_name
204
+ for capability_name, capability_value in document.get("capabilities", {}).items()
205
+ if capability_is_requested(capability_value)
206
+ }
207
+ if document.get("auto_flow") is not None:
208
+ requested_capabilities.add("auto_flow")
209
+ used_domain_types = {
210
+ cast(str, domain_definition.get("type", "categorical"))
211
+ for domain_definition in cast(
212
+ dict[str, dict[str, Any]], document.get("domains", {})
213
+ ).values()
214
+ }
215
+
216
+ if isinstance(profile, str):
217
+ return {
218
+ "identifier": profile,
219
+ "resolved": False,
220
+ "tools": sorted(referenced_tools),
221
+ "subflows": sorted(referenced_subflows),
222
+ "capabilities": sorted(requested_capabilities),
223
+ "domain_types": sorted(used_domain_types),
224
+ }
225
+
226
+ for subflow_reference in referenced_subflows:
227
+ if profile.subflows.has(subflow_reference):
228
+ subflow_definition = profile.subflows.definition(subflow_reference)
229
+ requested_capabilities.update(subflow_definition.capabilities)
230
+ referenced_tools.update(subflow_definition.required_tools)
231
+ return {
232
+ "identifier": profile.identifier,
233
+ "allow_legacy_contracts": profile.allow_legacy_contracts,
234
+ "tools": {
235
+ tool_name: (
236
+ profile.tools.definition(tool_name).as_dict()
237
+ if profile.tools.has(tool_name)
238
+ else None
239
+ )
240
+ for tool_name in sorted(referenced_tools)
241
+ },
242
+ "subflows": {
243
+ subflow_reference: (
244
+ profile.subflows.definition(subflow_reference).as_dict()
245
+ if profile.subflows.has(subflow_reference)
246
+ else None
247
+ )
248
+ for subflow_reference in sorted(referenced_subflows)
249
+ },
250
+ "capabilities": {
251
+ capability_name: capability_name in profile.capabilities
252
+ for capability_name in sorted(requested_capabilities)
253
+ },
254
+ "domain_types": {
255
+ domain_type: domain_type in profile.domain_types
256
+ for domain_type in sorted(used_domain_types)
257
+ },
258
+ }
259
+
260
+
261
+ def _resolve_local_reference(root_schema: dict[str, Any], reference: str) -> dict[str, Any]:
262
+ if not reference.startswith("#/"):
263
+ raise ValueError(f"only local schema references are supported, got {reference!r}")
264
+ resolved: Any = root_schema
265
+ for encoded_segment in reference[2:].split("/"):
266
+ segment = encoded_segment.replace("~1", "/").replace("~0", "~")
267
+ resolved = resolved[segment]
268
+ return cast(dict[str, Any], resolved)
269
+
270
+
271
+ def _matching_branch(
272
+ instance: Any,
273
+ branches: list[dict[str, Any]],
274
+ validator: Draft202012Validator,
275
+ ) -> dict[str, Any] | None:
276
+ return next(
277
+ (branch for branch in branches if validator.evolve(schema=branch).is_valid(instance)),
278
+ None,
279
+ )
280
+
281
+
282
+ def _materialize_existing_defaults(
283
+ instance: Any,
284
+ schema_node: dict[str, Any],
285
+ *,
286
+ root_schema: dict[str, Any],
287
+ validator: Draft202012Validator,
288
+ ) -> Any:
289
+ """Copy an instance while applying defaults only inside existing containers."""
290
+
291
+ normalized = copy.deepcopy(instance)
292
+ reference = schema_node.get("$ref")
293
+ if isinstance(reference, str):
294
+ normalized = _materialize_existing_defaults(
295
+ normalized,
296
+ _resolve_local_reference(root_schema, reference),
297
+ root_schema=root_schema,
298
+ validator=validator,
299
+ )
300
+ schema_node = {key: value for key, value in schema_node.items() if key != "$ref"}
301
+
302
+ all_of = schema_node.get("allOf")
303
+ if isinstance(all_of, list):
304
+ for branch in all_of:
305
+ normalized = _materialize_existing_defaults(
306
+ normalized,
307
+ cast(dict[str, Any], branch),
308
+ root_schema=root_schema,
309
+ validator=validator,
310
+ )
311
+
312
+ for union_keyword in ("oneOf", "anyOf"):
313
+ raw_branches = schema_node.get(union_keyword)
314
+ if isinstance(raw_branches, list):
315
+ branches = [cast(dict[str, Any], branch) for branch in raw_branches]
316
+ if (branch := _matching_branch(normalized, branches, validator)) is not None:
317
+ normalized = _materialize_existing_defaults(
318
+ normalized,
319
+ branch,
320
+ root_schema=root_schema,
321
+ validator=validator,
322
+ )
323
+ break
324
+
325
+ conditional = schema_node.get("if")
326
+ if isinstance(conditional, dict):
327
+ branch_key = "then" if validator.evolve(schema=conditional).is_valid(normalized) else "else"
328
+ if isinstance(branch := schema_node.get(branch_key), dict):
329
+ normalized = _materialize_existing_defaults(
330
+ normalized,
331
+ branch,
332
+ root_schema=root_schema,
333
+ validator=validator,
334
+ )
335
+
336
+ if isinstance(normalized, dict):
337
+ properties = cast(dict[str, dict[str, Any]], schema_node.get("properties", {}))
338
+ for property_name, property_schema in properties.items():
339
+ if property_name in normalized:
340
+ normalized[property_name] = _materialize_existing_defaults(
341
+ normalized[property_name],
342
+ property_schema,
343
+ root_schema=root_schema,
344
+ validator=validator,
345
+ )
346
+ elif "default" in property_schema:
347
+ normalized[property_name] = copy.deepcopy(property_schema["default"])
348
+
349
+ additional_schema = schema_node.get("additionalProperties")
350
+ if isinstance(additional_schema, dict):
351
+ for property_name in normalized.keys() - properties.keys():
352
+ normalized[property_name] = _materialize_existing_defaults(
353
+ normalized[property_name],
354
+ additional_schema,
355
+ root_schema=root_schema,
356
+ validator=validator,
357
+ )
358
+ elif isinstance(normalized, list) and isinstance(schema_node.get("items"), dict):
359
+ item_schema = cast(dict[str, Any], schema_node["items"])
360
+ normalized = [
361
+ _materialize_existing_defaults(
362
+ item,
363
+ item_schema,
364
+ root_schema=root_schema,
365
+ validator=validator,
366
+ )
367
+ for item in normalized
368
+ ]
369
+
370
+ return normalized
371
+
372
+
373
+ def _materialize_container(
374
+ container: dict[str, Any],
375
+ key: str,
376
+ schema_node: dict[str, Any],
377
+ *,
378
+ root_schema: dict[str, Any],
379
+ validator: Draft202012Validator,
380
+ ) -> None:
381
+ current = container.setdefault(key, {})
382
+ container[key] = _materialize_existing_defaults(
383
+ current,
384
+ schema_node,
385
+ root_schema=root_schema,
386
+ validator=validator,
387
+ )
388
+
389
+
390
+ def _materialize_effective_containers(
391
+ document: dict[str, Any],
392
+ root_schema: dict[str, Any],
393
+ validator: Draft202012Validator,
394
+ ) -> dict[str, Any]:
395
+ top_properties = cast(dict[str, dict[str, Any]], root_schema["properties"])
396
+ effective_containers = ["config"]
397
+ if "service" in document:
398
+ effective_containers.append("service")
399
+ for key in effective_containers:
400
+ _materialize_container(
401
+ document,
402
+ key,
403
+ top_properties[key],
404
+ root_schema=root_schema,
405
+ validator=validator,
406
+ )
407
+
408
+ domain_schema = cast(dict[str, Any], top_properties["domains"]["additionalProperties"])
409
+ domain_branches = cast(list[dict[str, Any]], domain_schema["oneOf"])
410
+ for domain_definition in document.get("domains", {}).values():
411
+ domain_branch = _matching_branch(domain_definition, domain_branches, validator)
412
+ if domain_branch is None:
413
+ raise ValueError("normalized domain does not match a closed domain-kind contract")
414
+ domain_properties = cast(dict[str, dict[str, Any]], domain_branch["properties"])
415
+ normalize_schema = domain_properties.get("normalize")
416
+ if normalize_schema is None:
417
+ continue
418
+ _materialize_container(
419
+ domain_definition,
420
+ "normalize",
421
+ normalize_schema,
422
+ root_schema=root_schema,
423
+ validator=validator,
424
+ )
425
+
426
+ if (terminal := document.get("terminal")) is not None:
427
+ terminal_schema = _resolve_local_reference(root_schema, "#/$defs/terminalDef")
428
+ terminal_properties = cast(dict[str, dict[str, Any]], terminal_schema["properties"])
429
+ _materialize_container(
430
+ terminal,
431
+ "outcomes",
432
+ terminal_properties["outcomes"],
433
+ root_schema=root_schema,
434
+ validator=validator,
435
+ )
436
+ outcomes = cast(dict[str, Any], terminal["outcomes"])
437
+ outcome_properties = cast(
438
+ dict[str, dict[str, Any]], terminal_properties["outcomes"]["properties"]
439
+ )
440
+ for outcome in ("success", "retryable", "fatal"):
441
+ _materialize_container(
442
+ outcomes,
443
+ outcome,
444
+ outcome_properties[outcome],
445
+ root_schema=root_schema,
446
+ validator=validator,
447
+ )
448
+ _materialize_container(
449
+ terminal,
450
+ "empty_payload",
451
+ terminal_properties["empty_payload"],
452
+ root_schema=root_schema,
453
+ validator=validator,
454
+ )
455
+
456
+ return document
457
+
458
+
459
+ def _materialize_node_identifiers(document: dict[str, Any]) -> None:
460
+ await_definition = cast(
461
+ dict[str, Any] | None,
462
+ cast(dict[str, Any], document.get("capabilities", {})).get("await_external"),
463
+ )
464
+ for path_step in document["path"]:
465
+ if "slot" in path_step:
466
+ path_step.setdefault("step", path_step["slot"])
467
+ elif "confirm" in path_step:
468
+ path_step.setdefault("step", path_step["confirm"])
469
+ elif "derive" in path_step:
470
+ path_step.setdefault("step", f"derive_{path_step['derive']}")
471
+ elif "await_external" in path_step:
472
+ resolved_identifier = path_step.get("step")
473
+ if resolved_identifier is None and await_definition is not None:
474
+ resolved_identifier = await_definition.get("step")
475
+ resolved_identifier = resolved_identifier or "await_external"
476
+ path_step["step"] = resolved_identifier
477
+ if await_definition is not None:
478
+ await_definition.setdefault("step", resolved_identifier)
479
+
480
+
481
+ def normalize_flow(document: dict[str, Any], *, validate: bool = True) -> dict[str, Any]:
482
+ """Return a schema-valid canonical copy with effective defaults and node ids."""
483
+
484
+ if validate:
485
+ validate_flow(document)
486
+ root_schema = schema()
487
+ validator = Draft202012Validator(root_schema)
488
+ normalized = cast(
489
+ dict[str, Any],
490
+ _materialize_existing_defaults(
491
+ document,
492
+ root_schema,
493
+ root_schema=root_schema,
494
+ validator=validator,
495
+ ),
496
+ )
497
+ normalized = _materialize_effective_containers(normalized, root_schema, validator)
498
+ _materialize_node_identifiers(normalized)
499
+ if validate:
500
+ validate_flow(normalized)
501
+ return cast(dict[str, Any], json.loads(_canonical_json(normalized)))
502
+
503
+
504
+ def _escape_pointer_segment(segment: str) -> str:
505
+ return segment.replace("~", "~0").replace("/", "~1")
506
+
507
+
508
+ def _predicate_references(predicate: Any) -> tuple[str, ...]:
509
+ if not isinstance(predicate, dict) or len(predicate) != 1:
510
+ return ()
511
+ operator, operand = next(iter(predicate.items()))
512
+ candidates: list[Any]
513
+ if operator in {"eq", "ne"} and isinstance(operand, list):
514
+ candidates = operand
515
+ elif operator == "in" and isinstance(operand, list):
516
+ candidates = operand[:1]
517
+ elif operator == "is_present":
518
+ candidates = [operand]
519
+ elif operator in {"and", "or"} and isinstance(operand, list):
520
+ return tuple(
521
+ dict.fromkeys(
522
+ reference
523
+ for nested_predicate in operand
524
+ for reference in _predicate_references(nested_predicate)
525
+ )
526
+ )
527
+ elif operator == "not":
528
+ return _predicate_references(operand)
529
+ else:
530
+ candidates = []
531
+ return tuple(
532
+ candidate
533
+ for candidate in candidates
534
+ if isinstance(candidate, str)
535
+ and candidate.split(".", 1)[0] in {"slots", "internal", "payload", "config", "address"}
536
+ and "." in candidate
537
+ )
538
+
539
+
540
+ def _state_reference(slot_name: str, slots: Mapping[str, Any]) -> str:
541
+ slot_definition = cast(dict[str, Any], slots.get(slot_name, {}))
542
+ partition = slot_definition.get("persist", "data")
543
+ return f"{partition}.{slot_name}"
544
+
545
+
546
+ def _canonical_state_reference(reference: str, slots: dict[str, Any]) -> str:
547
+ namespace, state_key = reference.split(".", 1)
548
+ if namespace == "slots":
549
+ return _state_reference(state_key, slots)
550
+ if namespace == "address":
551
+ return f"data.address.{state_key}"
552
+ return reference
553
+
554
+
555
+ def _step_kind(path_step: dict[str, Any]) -> str:
556
+ return next(
557
+ kind
558
+ for kind in ("slot", "confirm", "derive", "terminal", "use", "await_external")
559
+ if kind in path_step
560
+ )
561
+
562
+
563
+ def _path_predicate_reads(
564
+ path_step: dict[str, Any],
565
+ document: dict[str, Any],
566
+ ) -> tuple[str, ...]:
567
+ gates = cast(
568
+ dict[str, Any], cast(dict[str, Any], document.get("overrides", {})).get("gates", {})
569
+ )
570
+ step_identifier = cast(str, path_step.get("step", ""))
571
+ interactive = cast(dict[str, Any], path_step.get("interactive", {}))
572
+ option_predicates = tuple(
573
+ conditional_option.get("gate") for conditional_option in interactive.get("options_when", [])
574
+ )
575
+ confirmation = cast(dict[str, Any], document.get("confirm", {}))
576
+ confirmation_interactive = (
577
+ cast(dict[str, Any], confirmation.get("interactive", {}))
578
+ if path_step.get("correctable") is True
579
+ else {}
580
+ )
581
+ confirmation_option_predicates = tuple(
582
+ conditional_option.get("gate")
583
+ for conditional_option in confirmation_interactive.get("options_when", [])
584
+ )
585
+ predicates = (
586
+ path_step.get("ask_when"),
587
+ path_step.get("skip_when"),
588
+ gates.get(step_identifier),
589
+ *option_predicates,
590
+ *confirmation_option_predicates,
591
+ )
592
+ slots = cast(dict[str, Any], document.get("slots", {}))
593
+ return tuple(
594
+ dict.fromkeys(
595
+ _canonical_state_reference(reference, slots)
596
+ for predicate in predicates
597
+ for reference in _predicate_references(predicate)
598
+ )
599
+ )
600
+
601
+
602
+ def _state_writes_for_mapping(
603
+ mapping: Mapping[str, Any],
604
+ slots: Mapping[str, Any],
605
+ ) -> tuple[str, ...]:
606
+ return tuple(_state_reference(state_key, slots) for state_key in mapping)
607
+
608
+
609
+ def _await_state_access(document: dict[str, Any]) -> tuple[tuple[str, ...], tuple[str, ...]]:
610
+ await_definition = cast(
611
+ dict[str, Any],
612
+ cast(dict[str, Any], document.get("capabilities", {})).get("await_external", {}),
613
+ )
614
+ if not await_definition:
615
+ return (), ()
616
+ slots = cast(dict[str, Any], document.get("slots", {}))
617
+ resume_on = cast(str, await_definition.get("resume_on", ""))
618
+ reads = tuple(
619
+ reference
620
+ for reference in (f"payload.{resume_on}" if resume_on else "", "payload._external_event")
621
+ if reference
622
+ )
623
+ on_resume = cast(dict[str, Any], await_definition.get("on_resume", {}))
624
+ writes = list(_state_writes_for_mapping(cast(dict[str, Any], on_resume.get("set", {})), slots))
625
+ enrichment = on_resume.get("enrich")
626
+ if isinstance(enrichment, dict):
627
+ writes.extend(
628
+ _state_writes_for_mapping(
629
+ cast(dict[str, Any], enrichment.get("set", {})),
630
+ slots,
631
+ )
632
+ )
633
+ for transition in (
634
+ await_definition.get("timeout"),
635
+ *cast(dict[str, Any], await_definition.get("recovery", {})).values(),
636
+ ):
637
+ if isinstance(transition, dict):
638
+ writes.extend(
639
+ _state_writes_for_mapping(
640
+ cast(dict[str, Any], transition.get("set", {})),
641
+ slots,
642
+ )
643
+ )
644
+ if await_definition.get("max_resends") is not None:
645
+ node_id = cast(str, await_definition["step"])
646
+ resend_count_reference = f"internal._await_external_resend_count:{node_id}"
647
+ reads = (*reads, resend_count_reference)
648
+ writes.append(resend_count_reference)
649
+ return tuple(dict.fromkeys(reads)), tuple(dict.fromkeys(writes))
650
+
651
+
652
+ def _node_for_step(
653
+ path_step: dict[str, Any],
654
+ path_index: int,
655
+ document: dict[str, Any],
656
+ subflow_definitions: Mapping[str, SubflowDefinition],
657
+ ) -> FlowNode:
658
+ step_kind = _step_kind(path_step)
659
+ source = f"/path/{path_index}"
660
+ slots = cast(dict[str, Any], document.get("slots", {}))
661
+ predicate_reads = _path_predicate_reads(path_step, document)
662
+ if step_kind == "slot":
663
+ slot_name = cast(str, path_step["slot"])
664
+ slot_definition = cast(dict[str, Any], slots.get(slot_name, {}))
665
+ dependency_reads = tuple(
666
+ _state_reference(required_slot, slots)
667
+ for required_slot in slot_definition.get("requires", [])
668
+ )
669
+ return FlowNode(
670
+ cast(str, path_step["step"]),
671
+ "collect",
672
+ source,
673
+ tuple(dict.fromkeys((*predicate_reads, *dependency_reads))),
674
+ (_state_reference(slot_name, slots),),
675
+ )
676
+ if step_kind == "confirm":
677
+ slot_name = cast(str, path_step["confirm"])
678
+ return FlowNode(
679
+ cast(str, path_step["step"]),
680
+ "confirm",
681
+ source,
682
+ predicate_reads,
683
+ (_state_reference(slot_name, slots),),
684
+ )
685
+ if step_kind == "derive":
686
+ writes = cast(str, path_step["derive"])
687
+ derive_definition = next(
688
+ (
689
+ cast(dict[str, Any], candidate)
690
+ for candidate in document.get("derive", [])
691
+ if candidate.get("writes") == writes
692
+ ),
693
+ {},
694
+ )
695
+ return FlowNode(
696
+ cast(str, path_step["step"]),
697
+ "derive",
698
+ source,
699
+ tuple(
700
+ _state_reference(source_slot, slots)
701
+ for source_slot in derive_definition.get("from", [])
702
+ ),
703
+ (_state_reference(writes, slots),),
704
+ )
705
+ if step_kind == "terminal":
706
+ terminal = cast(dict[str, Any], document.get("terminal", {}))
707
+ success_writes = cast(
708
+ dict[str, Any],
709
+ cast(dict[str, Any], terminal.get("outcomes", {})).get("success", {}).get("set", {}),
710
+ )
711
+ return FlowNode(
712
+ cast(str, terminal.get("step", "terminal")),
713
+ "terminal",
714
+ source,
715
+ tuple(
716
+ _state_reference(binding["slot"], slots) for binding in terminal.get("input", [])
717
+ ),
718
+ tuple(
719
+ dict.fromkeys(
720
+ _state_reference(state_key, slots)
721
+ for state_key in (*terminal.get("outputs", {}), *success_writes)
722
+ )
723
+ ),
724
+ )
725
+ if step_kind == "use":
726
+ reference = cast(str, path_step["use"])
727
+ definition = subflow_definitions.get(reference)
728
+ owned_state_access = (
729
+ tuple(
730
+ f"{state_contract.partition}.{state_key}"
731
+ for state_key, state_contract in sorted(definition.owned_state_keys.items())
732
+ )
733
+ if definition is not None
734
+ else ()
735
+ )
736
+ await_reads: tuple[str, ...] = ()
737
+ await_writes: tuple[str, ...] = ()
738
+ await_definition = cast(
739
+ dict[str, Any],
740
+ cast(dict[str, Any], document.get("capabilities", {})).get("await_external", {}),
741
+ )
742
+ if definition is not None and await_definition.get("step") in definition.node_identifiers:
743
+ await_reads, await_writes = _await_state_access(document)
744
+ return FlowNode(
745
+ f"use:{path_index}:{reference}",
746
+ "subflow",
747
+ source,
748
+ tuple(dict.fromkeys((*owned_state_access, *await_reads))),
749
+ tuple(dict.fromkeys((*owned_state_access, *await_writes))),
750
+ )
751
+ await_reads, await_writes = _await_state_access(document)
752
+ return FlowNode(
753
+ cast(str, path_step["step"]),
754
+ "await_external",
755
+ source,
756
+ await_reads,
757
+ await_writes,
758
+ )
759
+
760
+
761
+ def _unplaced_derive_nodes(document: dict[str, Any]) -> tuple[FlowNode, ...]:
762
+ placed = {path_step["derive"] for path_step in document["path"] if "derive" in path_step}
763
+ slots = cast(dict[str, Any], document.get("slots", {}))
764
+ return tuple(
765
+ FlowNode(
766
+ f"derive_{derive_definition['writes']}",
767
+ "derive",
768
+ f"/derive/{derive_index}",
769
+ tuple(_state_reference(source, slots) for source in derive_definition["from"]),
770
+ (_state_reference(derive_definition["writes"], slots),),
771
+ )
772
+ for derive_index, derive_definition in enumerate(document.get("derive", []))
773
+ if derive_definition["writes"] not in placed
774
+ )
775
+
776
+
777
+ def _ordered_nodes(
778
+ document: dict[str, Any],
779
+ subflow_definitions: Mapping[str, SubflowDefinition],
780
+ ) -> tuple[FlowNode, ...]:
781
+ path_nodes = [
782
+ _node_for_step(path_step, path_index, document, subflow_definitions)
783
+ for path_index, path_step in enumerate(document["path"])
784
+ ]
785
+ last_inserted_for_anchor: dict[str, str] = {}
786
+ for derive_node in _unplaced_derive_nodes(document):
787
+ derive_definition = document["derive"][int(derive_node.source.rsplit("/", 1)[1])]
788
+ anchor = derive_definition.get("after")
789
+ if anchor is None:
790
+ raise ValueError(
791
+ f"{derive_node.source} must be placed in path or declare an after anchor"
792
+ )
793
+ effective_anchor = last_inserted_for_anchor.get(anchor, anchor)
794
+ anchor_index = next(
795
+ (
796
+ node_index
797
+ for node_index, candidate in enumerate(path_nodes)
798
+ if candidate.identifier == effective_anchor
799
+ ),
800
+ None,
801
+ )
802
+ if anchor_index is None:
803
+ raise ValueError(
804
+ f"{derive_node.source}/after does not resolve to a normalized node: {anchor!r}"
805
+ )
806
+ path_nodes.insert(anchor_index + 1, derive_node)
807
+ last_inserted_for_anchor[anchor] = derive_node.identifier
808
+ entry = cast(dict[str, Any], document.get("entry", {}))
809
+ init_writes = ["data.service"] if document.get("service") else []
810
+ if entry.get("writes"):
811
+ init_writes.append(f"data.{entry['writes']}")
812
+ init_writes.append("internal._entry_done")
813
+ return (
814
+ FlowNode("__init__", "init", "", (), tuple(dict.fromkeys(init_writes))),
815
+ *path_nodes,
816
+ )
817
+
818
+
819
+ def _transitions(
820
+ nodes: tuple[FlowNode, ...], document: dict[str, Any]
821
+ ) -> tuple[FlowTransition, ...]:
822
+ transitions: list[FlowTransition] = []
823
+ path_slot_nodes = {
824
+ path_step.get("slot") or path_step.get("confirm"): path_step.get("step")
825
+ for path_step in document["path"]
826
+ if "slot" in path_step or "confirm" in path_step
827
+ }
828
+ confirmation = cast(dict[str, Any], document.get("confirm", {}))
829
+ await_definition = cast(
830
+ dict[str, Any],
831
+ cast(dict[str, Any], document.get("capabilities", {})).get("await_external", {}),
832
+ )
833
+ for node_index, node in enumerate(nodes):
834
+ next_identifier = (
835
+ nodes[node_index + 1].identifier if node_index + 1 < len(nodes) else "$end"
836
+ )
837
+ if node.kind == "terminal":
838
+ transitions.append(FlowTransition(node.identifier, "$end", "end"))
839
+ continue
840
+ if node.kind == "await_external":
841
+ transitions.extend(
842
+ (
843
+ FlowTransition(node.identifier, next_identifier, "resume"),
844
+ FlowTransition(node.identifier, "$end", "pause"),
845
+ )
846
+ )
847
+ configured_transitions = {
848
+ "timeout": await_definition.get("timeout"),
849
+ **cast(dict[str, Any], await_definition.get("recovery", {})),
850
+ }
851
+ for event_name, transition in configured_transitions.items():
852
+ if not isinstance(transition, dict):
853
+ continue
854
+ transition_target = cast(str, transition["goto"])
855
+ transitions.append(
856
+ FlowTransition(
857
+ node.identifier,
858
+ "$end" if transition_target == "END" else transition_target,
859
+ cast(Any, event_name),
860
+ )
861
+ )
862
+ continue
863
+ if node.source and node.kind == "confirm":
864
+ path_index = int(node.source.rsplit("/", 1)[1])
865
+ path_step = cast(dict[str, Any], document["path"][path_index])
866
+ if path_step.get("correctable") is True and confirmation:
867
+ confirmation_target = cast(
868
+ str,
869
+ confirmation.get("on_confirm", next_identifier),
870
+ )
871
+ transitions.append(FlowTransition(node.identifier, confirmation_target, "confirm"))
872
+ for correctable_slot in confirmation.get("correctable", []):
873
+ target = path_slot_nodes.get(correctable_slot)
874
+ transitions.append(
875
+ FlowTransition(
876
+ node.identifier,
877
+ cast(str, target or f"$slot:{correctable_slot}"),
878
+ "correction",
879
+ )
880
+ )
881
+ transitions.append(FlowTransition(node.identifier, "$end", "pause"))
882
+ continue
883
+ transitions.append(FlowTransition(node.identifier, next_identifier, "confirm"))
884
+ transitions.append(FlowTransition(node.identifier, "$end", "pause"))
885
+ if "on_reject" in path_step:
886
+ transitions.append(FlowTransition(node.identifier, "$end", "reject"))
887
+ continue
888
+ transitions.append(FlowTransition(node.identifier, next_identifier, "next"))
889
+ if node.kind == "collect":
890
+ transitions.append(FlowTransition(node.identifier, "$end", "pause"))
891
+ return tuple(transitions)
892
+
893
+
894
+ def _validate_reachability(
895
+ nodes: tuple[FlowNode, ...],
896
+ transitions: tuple[FlowTransition, ...],
897
+ ) -> None:
898
+ if not nodes:
899
+ raise ValueError("normalized flow must contain an init node")
900
+ node_identifiers = {node.identifier for node in nodes}
901
+ outgoing: dict[str, set[str]] = {}
902
+ for transition in transitions:
903
+ if transition.target in node_identifiers:
904
+ outgoing.setdefault(transition.source, set()).add(transition.target)
905
+ reachable = {nodes[0].identifier}
906
+ pending = [nodes[0].identifier]
907
+ while pending:
908
+ source = pending.pop()
909
+ for target in outgoing.get(source, set()):
910
+ if target not in reachable:
911
+ reachable.add(target)
912
+ pending.append(target)
913
+ if unreachable := sorted(node_identifiers - reachable):
914
+ raise ValueError(f"normalized flow contains unreachable nodes: {', '.join(unreachable)}")
915
+
916
+
917
+ def _references(document: dict[str, Any]) -> tuple[FlowReference, ...]:
918
+ references: list[FlowReference] = []
919
+
920
+ def append_predicate_references(source: str, predicate: Any) -> None:
921
+ for predicate_reference in _predicate_references(predicate):
922
+ predicate_namespace, predicate_target = predicate_reference.split(".", 1)
923
+ normalized_namespace = "data" if predicate_namespace == "slots" else predicate_namespace
924
+ references.append(
925
+ FlowReference(
926
+ source,
927
+ cast(Any, normalized_namespace),
928
+ predicate_target,
929
+ )
930
+ )
931
+
932
+ for slot_name, slot_definition in document.get("slots", {}).items():
933
+ escaped_slot = _escape_pointer_segment(slot_name)
934
+ references.append(
935
+ FlowReference(f"/slots/{escaped_slot}/domain", "domain", slot_definition["domain"])
936
+ )
937
+ references.extend(
938
+ FlowReference(
939
+ f"/slots/{escaped_slot}/requires/{requirement_index}",
940
+ "slot",
941
+ required_slot,
942
+ )
943
+ for requirement_index, required_slot in enumerate(slot_definition.get("requires", []))
944
+ )
945
+ for path_index, path_step in enumerate(document["path"]):
946
+ step_kind = _step_kind(path_step)
947
+ if step_kind in {"slot", "confirm"}:
948
+ references.append(
949
+ FlowReference(
950
+ f"/path/{path_index}/{step_kind}",
951
+ "slot",
952
+ cast(str, path_step[step_kind]),
953
+ )
954
+ )
955
+ elif step_kind == "derive":
956
+ references.append(
957
+ FlowReference(
958
+ f"/path/{path_index}/derive",
959
+ "slot",
960
+ cast(str, path_step["derive"]),
961
+ )
962
+ )
963
+ elif step_kind == "use":
964
+ references.append(
965
+ FlowReference(
966
+ f"/path/{path_index}/use",
967
+ "subflow",
968
+ cast(str, path_step["use"]),
969
+ )
970
+ )
971
+ interactive = cast(dict[str, Any], path_step.get("interactive", {}))
972
+ if (domain_name := interactive.get("from_domain")) is not None:
973
+ references.append(
974
+ FlowReference(
975
+ f"/path/{path_index}/interactive/from_domain",
976
+ "domain",
977
+ cast(str, domain_name),
978
+ )
979
+ )
980
+ for option_index, conditional_option in enumerate(interactive.get("options_when", [])):
981
+ append_predicate_references(
982
+ f"/path/{path_index}/interactive/options_when/{option_index}/gate",
983
+ conditional_option.get("gate"),
984
+ )
985
+ for predicate_key in ("ask_when", "skip_when"):
986
+ append_predicate_references(
987
+ f"/path/{path_index}/{predicate_key}",
988
+ path_step.get(predicate_key),
989
+ )
990
+ references.extend(
991
+ FlowReference(f"/uses/{use_index}/ref", "subflow", use_definition["ref"])
992
+ for use_index, use_definition in enumerate(document.get("uses", []))
993
+ )
994
+ for derive_index, derive_definition in enumerate(document.get("derive", [])):
995
+ references.extend(
996
+ FlowReference(
997
+ f"/derive/{derive_index}/from/{source_index}",
998
+ "slot",
999
+ source_slot,
1000
+ )
1001
+ for source_index, source_slot in enumerate(derive_definition["from"])
1002
+ )
1003
+ if (anchor := derive_definition.get("after")) is not None:
1004
+ references.append(FlowReference(f"/derive/{derive_index}/after", "step", anchor))
1005
+ if (entry := document.get("entry")) is not None:
1006
+ references.append(FlowReference("/entry/tool", "tool", entry["tool"]))
1007
+ if (terminal := document.get("terminal")) is not None:
1008
+ references.append(FlowReference("/terminal/tool", "tool", terminal["tool"]))
1009
+ references.extend(
1010
+ FlowReference(
1011
+ f"/terminal/input/{binding_index}/slot",
1012
+ "slot",
1013
+ binding["slot"],
1014
+ )
1015
+ for binding_index, binding in enumerate(terminal.get("input", []))
1016
+ )
1017
+ if (confirmation := document.get("confirm")) is not None:
1018
+ references.append(FlowReference("/confirm/slot", "slot", confirmation["slot"]))
1019
+ references.extend(
1020
+ FlowReference(
1021
+ f"/confirm/correctable/{correctable_index}",
1022
+ "slot",
1023
+ correctable_slot,
1024
+ )
1025
+ for correctable_index, correctable_slot in enumerate(
1026
+ confirmation.get("correctable", [])
1027
+ )
1028
+ )
1029
+ if (confirmation_target := confirmation.get("on_confirm")) is not None:
1030
+ references.append(FlowReference("/confirm/on_confirm", "step", confirmation_target))
1031
+ confirmation_interactive = cast(dict[str, Any], confirmation.get("interactive", {}))
1032
+ if (confirmation_domain := confirmation_interactive.get("from_domain")) is not None:
1033
+ references.append(
1034
+ FlowReference(
1035
+ "/confirm/interactive/from_domain",
1036
+ "domain",
1037
+ cast(str, confirmation_domain),
1038
+ )
1039
+ )
1040
+ for option_index, conditional_option in enumerate(
1041
+ confirmation_interactive.get("options_when", [])
1042
+ ):
1043
+ append_predicate_references(
1044
+ f"/confirm/interactive/options_when/{option_index}/gate",
1045
+ conditional_option.get("gate"),
1046
+ )
1047
+ gates = cast(
1048
+ dict[str, Any], cast(dict[str, Any], document.get("overrides", {})).get("gates", {})
1049
+ )
1050
+ for gate_step, gate_predicate in gates.items():
1051
+ escaped_step = _escape_pointer_segment(gate_step)
1052
+ references.append(FlowReference(f"/overrides/gates/{escaped_step}", "step", gate_step))
1053
+ append_predicate_references(
1054
+ f"/overrides/gates/{escaped_step}",
1055
+ gate_predicate,
1056
+ )
1057
+ if (auto_flow := document.get("auto_flow")) is not None:
1058
+ if (resume_target := auto_flow.get("resume_at")) is not None:
1059
+ references.append(FlowReference("/auto_flow/resume_at", "step", resume_target))
1060
+ references.extend(
1061
+ FlowReference(
1062
+ f"/auto_flow/prefill_from/{prefill_index}",
1063
+ "slot",
1064
+ prefill_slot,
1065
+ )
1066
+ for prefill_index, prefill_slot in enumerate(auto_flow.get("prefill_from", []))
1067
+ )
1068
+ append_predicate_references("/auto_flow/send_when", auto_flow.get("send_when"))
1069
+ await_definition = cast(
1070
+ dict[str, Any],
1071
+ cast(dict[str, Any], document.get("capabilities", {})).get("await_external", {}),
1072
+ )
1073
+ if await_definition and (await_step := await_definition.get("step")) is not None:
1074
+ references.append(
1075
+ FlowReference(
1076
+ "/capabilities/await_external/step",
1077
+ "step",
1078
+ cast(str, await_step),
1079
+ )
1080
+ )
1081
+ resume_contract = cast(dict[str, Any], await_definition.get("resume", {}))
1082
+ if correlation_reference := resume_contract.get("correlation"):
1083
+ references.append(
1084
+ FlowReference(
1085
+ "/capabilities/await_external/resume/correlation",
1086
+ "token",
1087
+ cast(str, correlation_reference),
1088
+ )
1089
+ )
1090
+ on_resume = cast(dict[str, Any], await_definition.get("on_resume", {}))
1091
+ for state_key, token_binding in cast(dict[str, Any], on_resume.get("set", {})).items():
1092
+ if isinstance(token_binding, str) and token_binding.startswith("$token."):
1093
+ references.append(
1094
+ FlowReference(
1095
+ "/capabilities/await_external/on_resume/set/"
1096
+ + _escape_pointer_segment(state_key),
1097
+ "token",
1098
+ token_binding,
1099
+ )
1100
+ )
1101
+ enrichment = on_resume.get("enrich")
1102
+ if isinstance(enrichment, str):
1103
+ references.append(
1104
+ FlowReference(
1105
+ "/capabilities/await_external/on_resume/enrich",
1106
+ "tool",
1107
+ enrichment,
1108
+ )
1109
+ )
1110
+ elif isinstance(enrichment, dict):
1111
+ references.append(
1112
+ FlowReference(
1113
+ "/capabilities/await_external/on_resume/enrich/tool",
1114
+ "tool",
1115
+ enrichment["tool"],
1116
+ )
1117
+ )
1118
+ for parameter_name, token_binding in cast(
1119
+ dict[str, Any], enrichment.get("input", {})
1120
+ ).items():
1121
+ if isinstance(token_binding, str) and token_binding.startswith("$token."):
1122
+ references.append(
1123
+ FlowReference(
1124
+ "/capabilities/await_external/on_resume/enrich/input/"
1125
+ + _escape_pointer_segment(parameter_name),
1126
+ "token",
1127
+ token_binding,
1128
+ )
1129
+ )
1130
+ configured_transitions = {
1131
+ "timeout": await_definition.get("timeout"),
1132
+ **cast(dict[str, Any], await_definition.get("recovery", {})),
1133
+ }
1134
+ for event_name, transition in configured_transitions.items():
1135
+ if isinstance(transition, dict) and transition.get("goto") != "END":
1136
+ references.append(
1137
+ FlowReference(
1138
+ f"/capabilities/await_external/{event_name}/goto",
1139
+ "step",
1140
+ transition["goto"],
1141
+ )
1142
+ )
1143
+ return tuple(references)
1144
+
1145
+
1146
+ def _domain_state_schema(domain_definition: dict[str, Any], nullable: bool) -> dict[str, Any]:
1147
+ domain_type = domain_definition["type"]
1148
+ if domain_type == "categorical":
1149
+ allowed_values: list[Any] = [
1150
+ value for value in domain_definition.get("values", []) if value is not None or nullable
1151
+ ]
1152
+ if nullable and None not in allowed_values:
1153
+ allowed_values.append(None)
1154
+ return {"enum": allowed_values}
1155
+ if domain_type == "bool":
1156
+ return {"type": ["boolean", "null"] if nullable else "boolean"}
1157
+ if domain_type in {"integer", "number"}:
1158
+ numeric_schema: dict[str, Any] = {
1159
+ "type": [domain_type, "null"] if nullable else domain_type
1160
+ }
1161
+ for constraint in ("minimum", "maximum"):
1162
+ if constraint in domain_definition:
1163
+ numeric_schema[constraint] = domain_definition[constraint]
1164
+ return numeric_schema
1165
+ state_type: str | list[str] = ["string", "null"] if nullable else "string"
1166
+ state_schema: dict[str, Any] = {"type": state_type}
1167
+ if domain_type == "brazilian_tax_id":
1168
+ state_schema["pattern"] = r"^[0-9]{11}$"
1169
+ elif domain_type == "email":
1170
+ state_schema["format"] = "email"
1171
+ state_schema["pattern"] = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
1172
+ elif domain_type == "name":
1173
+ state_schema["minLength"] = 2
1174
+ elif domain_type == "free_text" and not domain_definition.get("optional"):
1175
+ state_schema["minLength"] = 1
1176
+ return state_schema
1177
+
1178
+
1179
+ def _embedded_subflow_state_schema(
1180
+ definition: SubflowDefinition,
1181
+ state_key: str,
1182
+ ) -> dict[str, Any]:
1183
+ """Embed a subflow state contract as its own JSON Schema resource."""
1184
+
1185
+ state_schema = cast(
1186
+ dict[str, Any],
1187
+ _mutable_json(definition.owned_state_keys[state_key].schema),
1188
+ )
1189
+ state_schema.setdefault(
1190
+ "$id",
1191
+ f"urn:flowspec2:subflow-state:{definition.ref}:{state_key}",
1192
+ )
1193
+ return state_schema
1194
+
1195
+
1196
+ def _state_schema(
1197
+ document: dict[str, Any],
1198
+ subflow_definitions: Mapping[str, SubflowDefinition],
1199
+ ) -> dict[str, Any]:
1200
+ partitions: dict[str, dict[str, Any]] = {
1201
+ partition: {"type": "object", "properties": {}, "additionalProperties": True}
1202
+ for partition in ("data", "internal", "payload")
1203
+ }
1204
+ domains = cast(dict[str, dict[str, Any]], document.get("domains", {}))
1205
+ for slot_name, slot_definition in document.get("slots", {}).items():
1206
+ partition = cast(str, slot_definition["persist"])
1207
+ domain_definition = domains.get(slot_definition["domain"])
1208
+ if domain_definition is None:
1209
+ continue
1210
+ partitions[partition]["properties"][slot_name] = _domain_state_schema(
1211
+ domain_definition,
1212
+ cast(bool, slot_definition["nullable"]),
1213
+ )
1214
+
1215
+ data_properties = cast(dict[str, Any], partitions["data"]["properties"])
1216
+ internal_properties = cast(dict[str, Any], partitions["internal"]["properties"])
1217
+ for use_definition in document.get("uses", []):
1218
+ definition = subflow_definitions.get(use_definition["ref"])
1219
+ if definition is None:
1220
+ continue
1221
+ for state_key, state_contract in definition.owned_state_keys.items():
1222
+ partition_properties = cast(
1223
+ dict[str, Any], partitions[state_contract.partition]["properties"]
1224
+ )
1225
+ partition_properties.setdefault(
1226
+ state_key,
1227
+ _embedded_subflow_state_schema(definition, state_key),
1228
+ )
1229
+ if service_definition := document.get("service"):
1230
+ data_properties.setdefault("service", {"const": copy.deepcopy(service_definition)})
1231
+ if (entry := document.get("entry")) is not None:
1232
+ if entry.get("writes"):
1233
+ data_properties.setdefault(entry["writes"], {})
1234
+ internal_properties.setdefault("_entry_done", {"type": "boolean"})
1235
+ for derive_definition in document.get("derive", []):
1236
+ writes = cast(str, derive_definition["writes"])
1237
+ if writes in document.get("slots", {}):
1238
+ continue
1239
+ possible_values = list(derive_definition.get("lookup", {}).values())
1240
+ literal_default = derive_definition.get("default")
1241
+ source_default_schema: dict[str, Any] | None = None
1242
+ if isinstance(literal_default, str) and (
1243
+ default_match := re.fullmatch(r"\$from\[(\d+)\]", literal_default)
1244
+ ):
1245
+ source_index = int(default_match.group(1))
1246
+ derive_sources = cast(list[str], derive_definition["from"])
1247
+ if source_index < len(derive_sources):
1248
+ source_name = derive_sources[source_index]
1249
+ source_slot = cast(
1250
+ dict[str, Any] | None,
1251
+ cast(dict[str, Any], document.get("slots", {})).get(source_name),
1252
+ )
1253
+ if source_slot is not None:
1254
+ source_partition = cast(str, source_slot["persist"])
1255
+ source_default_schema = copy.deepcopy(
1256
+ cast(dict[str, Any], partitions[source_partition]["properties"]).get(
1257
+ source_name,
1258
+ {},
1259
+ )
1260
+ )
1261
+ else:
1262
+ source_default_schema = copy.deepcopy(data_properties.get(source_name, {}))
1263
+ if literal_default is not None and not (
1264
+ isinstance(literal_default, str) and literal_default.startswith("$from[")
1265
+ ):
1266
+ possible_values.append(literal_default)
1267
+ unique_values = list(dict.fromkeys(_canonical_json(value) for value in possible_values))
1268
+ enumerated_schema: dict[str, Any] | None = (
1269
+ {"enum": [json.loads(serialized_value) for serialized_value in unique_values]}
1270
+ if unique_values
1271
+ else None
1272
+ )
1273
+ if source_default_schema == {}:
1274
+ derived_state_schema: dict[str, Any] = {}
1275
+ elif source_default_schema is not None and enumerated_schema is not None:
1276
+ if set(source_default_schema) == {"enum"}:
1277
+ combined_values = [
1278
+ *cast(list[Any], enumerated_schema["enum"]),
1279
+ *cast(list[Any], source_default_schema["enum"]),
1280
+ ]
1281
+ combined_serialized = dict.fromkeys(
1282
+ _canonical_json(value) for value in combined_values
1283
+ )
1284
+ derived_state_schema = {
1285
+ "enum": [json.loads(value) for value in combined_serialized]
1286
+ }
1287
+ else:
1288
+ derived_state_schema = {"anyOf": [enumerated_schema, source_default_schema]}
1289
+ elif source_default_schema is not None:
1290
+ derived_state_schema = source_default_schema
1291
+ else:
1292
+ derived_state_schema = enumerated_schema or {}
1293
+ data_properties.setdefault(
1294
+ writes,
1295
+ derived_state_schema,
1296
+ )
1297
+ if (terminal := document.get("terminal")) is not None:
1298
+ for output_key in terminal.get("outputs", {}):
1299
+ data_properties.setdefault(output_key, {})
1300
+ for state_key, state_value in (
1301
+ cast(dict[str, Any], terminal.get("outcomes", {})).get("success", {}).get("set", {})
1302
+ ).items():
1303
+ data_properties.setdefault(state_key, {"const": state_value})
1304
+ await_definition = cast(
1305
+ dict[str, Any],
1306
+ cast(dict[str, Any], document.get("capabilities", {})).get("await_external", {}),
1307
+ )
1308
+ if await_definition:
1309
+ slots = cast(dict[str, Any], document.get("slots", {}))
1310
+
1311
+ if await_definition.get("max_resends") is not None:
1312
+ await_node_id = cast(str, await_definition["step"])
1313
+ internal_properties.setdefault(
1314
+ f"_await_external_resend_count:{await_node_id}",
1315
+ {"type": "integer", "minimum": 0},
1316
+ )
1317
+
1318
+ def set_await_state_schema(state_key: str, state_value_schema: dict[str, Any]) -> None:
1319
+ slot_definition = cast(dict[str, Any] | None, slots.get(state_key))
1320
+ partition_name = (
1321
+ cast(str, slot_definition["persist"]) if slot_definition is not None else "data"
1322
+ )
1323
+ partition_properties = cast(dict[str, Any], partitions[partition_name]["properties"])
1324
+ partition_properties.setdefault(
1325
+ state_key,
1326
+ state_value_schema if slot_definition is not None else {},
1327
+ )
1328
+
1329
+ on_resume = cast(dict[str, Any], await_definition.get("on_resume", {}))
1330
+ for state_key, binding in cast(dict[str, Any], on_resume.get("set", {})).items():
1331
+ set_await_state_schema(
1332
+ state_key,
1333
+ {} if isinstance(binding, str) and binding.startswith("$") else {"const": binding},
1334
+ )
1335
+ enrichment = on_resume.get("enrich")
1336
+ if isinstance(enrichment, dict):
1337
+ for state_key in cast(dict[str, Any], enrichment.get("set", {})):
1338
+ set_await_state_schema(state_key, {})
1339
+ for transition in (
1340
+ await_definition.get("timeout"),
1341
+ *cast(dict[str, Any], await_definition.get("recovery", {})).values(),
1342
+ ):
1343
+ if not isinstance(transition, dict):
1344
+ continue
1345
+ for state_key, state_value in cast(dict[str, Any], transition.get("set", {})).items():
1346
+ set_await_state_schema(state_key, {"const": state_value})
1347
+ data_properties.setdefault("_reset_on_next_call", {"type": "boolean"})
1348
+ internal_properties.setdefault("_flowspec2_flow_finished", {"type": "boolean"})
1349
+ return {
1350
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
1351
+ "type": "object",
1352
+ "additionalProperties": False,
1353
+ "required": ["data", "internal", "payload"],
1354
+ "properties": partitions,
1355
+ }
1356
+
1357
+
1358
+ def build_flow_ir(
1359
+ document: dict[str, Any],
1360
+ *,
1361
+ profile: FlowProfile | str | None = None,
1362
+ validate: bool = True,
1363
+ ) -> FlowIR:
1364
+ """Normalize a source document and index its deterministic contracts."""
1365
+
1366
+ effective_profile: FlowProfile | str = reference_profile() if profile is None else profile
1367
+ profile_identifier = (
1368
+ effective_profile.identifier
1369
+ if isinstance(effective_profile, FlowProfile)
1370
+ else effective_profile
1371
+ )
1372
+ if not profile_identifier.strip():
1373
+ raise ValueError("profile identifier must be non-empty")
1374
+ subflow_definitions: Mapping[str, SubflowDefinition] = (
1375
+ effective_profile.subflows.definitions if isinstance(effective_profile, FlowProfile) else {}
1376
+ )
1377
+ normalized = normalize_flow(document, validate=validate)
1378
+ profile_contract = (
1379
+ effective_profile.as_dict()
1380
+ if isinstance(effective_profile, FlowProfile)
1381
+ else {"identifier": profile_identifier, "resolved": False}
1382
+ )
1383
+ profile_digest = (
1384
+ effective_profile.digest
1385
+ if isinstance(effective_profile, FlowProfile)
1386
+ else _digest(profile_contract)
1387
+ )
1388
+ dependency_digest = _digest(_resolved_dependency_contract(normalized, effective_profile))
1389
+ slots = tuple(
1390
+ FlowSlot(
1391
+ name=slot_name,
1392
+ domain=slot_definition["domain"],
1393
+ partition=slot_definition["persist"],
1394
+ required=slot_definition["required"],
1395
+ nullable=slot_definition["nullable"],
1396
+ requires=tuple(slot_definition.get("requires", [])),
1397
+ )
1398
+ for slot_name, slot_definition in sorted(normalized.get("slots", {}).items())
1399
+ )
1400
+ capabilities = {
1401
+ *(
1402
+ f"capability:{name}"
1403
+ for name, capability_value in normalized.get("capabilities", {}).items()
1404
+ if capability_is_requested(capability_value)
1405
+ ),
1406
+ *(f"subflow:{use['ref']}" for use in normalized.get("uses", [])),
1407
+ }
1408
+ if normalized.get("auto_flow") is not None:
1409
+ capabilities.add("capability:auto_flow")
1410
+ for use_definition in normalized.get("uses", []):
1411
+ subflow_definition = subflow_definitions.get(use_definition["ref"])
1412
+ if subflow_definition is not None:
1413
+ capabilities.update(
1414
+ f"capability:{capability}" for capability in subflow_definition.capabilities
1415
+ )
1416
+ capabilities.update(
1417
+ f"tool:{tool_name}" for tool_name in subflow_definition.required_tools
1418
+ )
1419
+ if (entry := normalized.get("entry")) is not None:
1420
+ capabilities.add(f"tool:{entry['tool']}")
1421
+ if (terminal := normalized.get("terminal")) is not None:
1422
+ capabilities.add(f"tool:{terminal['tool']}")
1423
+ await_definition = cast(
1424
+ dict[str, Any],
1425
+ cast(dict[str, Any], normalized.get("capabilities", {})).get("await_external", {}),
1426
+ )
1427
+ enrichment = cast(dict[str, Any], await_definition.get("on_resume", {})).get("enrich")
1428
+ if isinstance(enrichment, str):
1429
+ capabilities.add(f"tool:{enrichment}")
1430
+ elif isinstance(enrichment, dict):
1431
+ capabilities.add(f"tool:{enrichment['tool']}")
1432
+ canonical_json = _canonical_json(normalized)
1433
+ nodes = _ordered_nodes(normalized, subflow_definitions)
1434
+ transitions = _transitions(nodes, normalized)
1435
+ _validate_reachability(nodes, transitions)
1436
+ return FlowIR(
1437
+ ir_format=FLOW_IR_FORMAT,
1438
+ format=normalized["schema"],
1439
+ flow=normalized["flow"],
1440
+ version=normalized["version"],
1441
+ profile=profile_identifier,
1442
+ profile_digest=profile_digest,
1443
+ dependency_digest=dependency_digest,
1444
+ source_digest=_digest(document),
1445
+ digest=_digest(
1446
+ {
1447
+ "ir_format": FLOW_IR_FORMAT,
1448
+ "dependency_digest": dependency_digest,
1449
+ "document": normalized,
1450
+ }
1451
+ ),
1452
+ canonical_json=canonical_json,
1453
+ state_schema_json=_canonical_json(_state_schema(normalized, subflow_definitions)),
1454
+ slots=slots,
1455
+ nodes=nodes,
1456
+ transitions=transitions,
1457
+ references=_references(normalized),
1458
+ required_capabilities=tuple(sorted(capabilities)),
1459
+ )