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,17 @@
1
+ """Stable compiler-contract entry points grouped by validation domain."""
2
+
3
+ from .compiler_resume_contracts import (
4
+ bind_await_external,
5
+ validate_await_external_definition,
6
+ validate_await_external_targets,
7
+ )
8
+ from .compiler_tool_contracts import validate_flow_tool_contracts
9
+ from .compiler_value_contracts import validate_entry_args_schema
10
+
11
+ __all__ = [
12
+ "bind_await_external",
13
+ "validate_await_external_definition",
14
+ "validate_await_external_targets",
15
+ "validate_entry_args_schema",
16
+ "validate_flow_tool_contracts",
17
+ ]
@@ -0,0 +1,594 @@
1
+ """External suspension, resume-token, and recovery compiler contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from typing import Any, Final, Optional, cast
9
+
10
+ from .compiler_schema_relations import (
11
+ CONTRACT_VALID,
12
+ combined_contract,
13
+ mutable_contract_json,
14
+ required_schema_properties,
15
+ schema_accepts_known_instance,
16
+ schema_is_proven_subset,
17
+ schema_path_status,
18
+ schema_property_contract,
19
+ schema_requires_path_in_every_alternative,
20
+ schema_type_atoms,
21
+ )
22
+ from .compiler_tool_contracts import (
23
+ registered_tool_definition,
24
+ validate_read_only_tool_effects,
25
+ validate_tool_inputs,
26
+ validate_tool_result_path,
27
+ )
28
+ from .compiler_value_contracts import FlowValueContract, declared_slot_value_contract
29
+ from .schema_contracts import schema_reference_target, validate_resume_token_schema
30
+ from .subflows import SubflowRegistry
31
+ from .tools import ToolRegistry
32
+
33
+ _RESUME_REFERENCE_PATTERN: Final[re.Pattern[str]] = re.compile(
34
+ r"^\$token(?:\.[A-Za-z][A-Za-z0-9_]*)+$"
35
+ )
36
+
37
+
38
+ def _uses_identification_v2(doc: dict[str, Any]) -> bool:
39
+ return any(use.get("ref") == "identification@2" for use in doc.get("uses", []))
40
+
41
+
42
+ def bind_await_external(doc: dict[str, Any]) -> Optional[dict[str, Any]]:
43
+ capability = cast(
44
+ Optional[dict[str, Any]],
45
+ (doc.get("capabilities") or {}).get("await_external"),
46
+ )
47
+ path_steps = [step for step in doc["path"] if step.get("await_external") is True]
48
+ if len(path_steps) > 1:
49
+ raise ValueError("flowspec/2 supports one capabilities.await_external binding")
50
+ if path_steps and capability is None:
51
+ raise ValueError("path await_external requires capabilities.await_external")
52
+ if capability is None:
53
+ return None
54
+
55
+ if path_steps:
56
+ path_step = path_steps[0]
57
+ unsupported_fields = {
58
+ "ask_when",
59
+ "skip_when",
60
+ "on_reject",
61
+ "correctable",
62
+ } & path_step.keys()
63
+ if unsupported_fields:
64
+ unsupported = ", ".join(sorted(unsupported_fields))
65
+ raise ValueError(f"path await_external does not support these fields: {unsupported}")
66
+ path_node_id = path_step.get("step")
67
+ capability_node_id = capability.get("step")
68
+ if path_node_id and capability_node_id and path_node_id != capability_node_id:
69
+ raise ValueError(
70
+ "path await_external step conflicts with capabilities.await_external.step"
71
+ )
72
+ bound_node_id = path_node_id or capability_node_id or "await_external"
73
+ path_step["step"] = bound_node_id
74
+ capability["step"] = bound_node_id
75
+ return capability
76
+
77
+ if not capability.get("step") and _uses_identification_v2(doc):
78
+ # Backward compatibility for documents authored before the explicit
79
+ # subflow binding existed.
80
+ capability["step"] = "authenticate_govbr"
81
+ if not capability.get("step"):
82
+ raise ValueError(
83
+ "capabilities.await_external must declare step or have a path await_external anchor"
84
+ )
85
+ return capability
86
+
87
+
88
+ def _validate_binding_map(
89
+ bindings: dict[str, Any],
90
+ *,
91
+ namespace: str,
92
+ location: str,
93
+ ) -> None:
94
+ if not isinstance(bindings, dict):
95
+ raise ValueError(f"{location} must be an object")
96
+ prefix = f"${namespace}."
97
+ for target, binding in bindings.items():
98
+ if not isinstance(target, str) or not target:
99
+ raise ValueError(f"{location} contains an empty destination")
100
+ if binding is not None and not isinstance(binding, (str, int, float, bool)):
101
+ raise ValueError(f"{location}.{target} must be a JSON scalar")
102
+ if isinstance(binding, str) and binding.startswith("$"):
103
+ reference_path = binding.removeprefix(prefix)
104
+ if (
105
+ not binding.startswith(prefix)
106
+ or not reference_path
107
+ or any(not segment for segment in reference_path.split("."))
108
+ ):
109
+ raise ValueError(f"{location}.{target} must use {prefix}path")
110
+
111
+
112
+ def _resume_reference_segments(reference: str, *, location: str) -> tuple[str, ...]:
113
+ if _RESUME_REFERENCE_PATTERN.fullmatch(reference) is None:
114
+ raise ValueError(
115
+ f"{location} must be an exact $token property path with identifier segments"
116
+ )
117
+ return tuple(reference.removeprefix("$token.").split("."))
118
+
119
+
120
+ def _schema_array_index_contract(
121
+ schema: Any,
122
+ array_index: int,
123
+ root_schema: Any,
124
+ reference_stack: tuple[str, ...] = (),
125
+ ) -> Any:
126
+ if schema is False:
127
+ return False
128
+ if schema is True or not isinstance(schema, Mapping):
129
+ return True
130
+
131
+ contract_fragments: list[Any] = []
132
+ prefix_items = schema.get("prefixItems")
133
+ if isinstance(prefix_items, (list, tuple)) and array_index < len(prefix_items):
134
+ contract_fragments.append(prefix_items[array_index])
135
+ elif "items" in schema:
136
+ contract_fragments.append(schema["items"])
137
+
138
+ if (reference := schema.get("$ref")) is not None:
139
+ reference_text = cast(str, reference)
140
+ contract_fragments.append(
141
+ True
142
+ if reference_text in reference_stack
143
+ else _schema_array_index_contract(
144
+ schema_reference_target(root_schema, reference_text),
145
+ array_index,
146
+ root_schema,
147
+ (*reference_stack, reference_text),
148
+ )
149
+ )
150
+ if isinstance((all_of := schema.get("allOf")), (list, tuple)):
151
+ contract_fragments.extend(
152
+ _schema_array_index_contract(branch, array_index, root_schema, reference_stack)
153
+ for branch in all_of
154
+ )
155
+ for union_keyword in ("anyOf", "oneOf"):
156
+ branches = schema.get(union_keyword)
157
+ if isinstance(branches, (list, tuple)):
158
+ contract_fragments.append(
159
+ combined_contract(
160
+ [
161
+ _schema_array_index_contract(
162
+ branch,
163
+ array_index,
164
+ root_schema,
165
+ reference_stack,
166
+ )
167
+ for branch in branches
168
+ ],
169
+ "anyOf",
170
+ )
171
+ )
172
+ return combined_contract(contract_fragments)
173
+
174
+
175
+ def _schema_path_value_contract(
176
+ schema: Any,
177
+ path_segments: tuple[str, ...],
178
+ root_schema: Any,
179
+ ) -> Any:
180
+ current_contract = schema
181
+ for path_segment in path_segments:
182
+ if path_segment.isdigit():
183
+ current_contract = _schema_array_index_contract(
184
+ current_contract,
185
+ int(path_segment),
186
+ root_schema,
187
+ )
188
+ else:
189
+ current_contract = schema_property_contract(
190
+ current_contract,
191
+ path_segment,
192
+ root_schema,
193
+ frozenset(required_schema_properties(current_contract, root_schema)),
194
+ )
195
+ return current_contract
196
+
197
+
198
+ def _resume_token_path_contract(
199
+ token_schema: dict[str, Any],
200
+ reference: str,
201
+ *,
202
+ location: str,
203
+ require_presence: bool,
204
+ ) -> FlowValueContract:
205
+ path_segments = _resume_reference_segments(reference, location=location)
206
+ path_status = schema_path_status(token_schema, path_segments)
207
+ if path_status != CONTRACT_VALID:
208
+ raise ValueError(f"{location} does not resolve exactly in resume.schema: {reference!r}")
209
+ if require_presence and not schema_requires_path_in_every_alternative(
210
+ token_schema,
211
+ path_segments,
212
+ token_schema,
213
+ ):
214
+ raise ValueError(f"{location} must be required by every resume.schema alternative")
215
+ path_contract = _schema_path_value_contract(token_schema, path_segments, token_schema)
216
+ return FlowValueContract(
217
+ schema=path_contract,
218
+ root_schema=token_schema,
219
+ origin=f"resume token path {reference!r}",
220
+ total=require_presence,
221
+ )
222
+
223
+
224
+ @dataclass(frozen=True)
225
+ class _AwaitStateTargetContract:
226
+ value_contract: FlowValueContract
227
+ partition: str
228
+
229
+
230
+ def _await_state_target_contract(
231
+ document: dict[str, Any],
232
+ subflows: SubflowRegistry,
233
+ state_key: str,
234
+ ) -> _AwaitStateTargetContract | None:
235
+ if declared_contract := declared_slot_value_contract(
236
+ document,
237
+ state_key,
238
+ include_absence=False,
239
+ ):
240
+ slot_definition = cast(dict[str, Any], document["slots"][state_key])
241
+ return _AwaitStateTargetContract(
242
+ value_contract=declared_contract,
243
+ partition=cast(str, slot_definition.get("persist", "data")),
244
+ )
245
+
246
+ for use_definition in document.get("uses", []):
247
+ subflow_reference = cast(str, use_definition["ref"])
248
+ if not subflows.has(subflow_reference):
249
+ continue
250
+ subflow_definition = subflows.definition(subflow_reference)
251
+ state_contract = subflow_definition.owned_state_keys.get(state_key)
252
+ if state_contract is None:
253
+ continue
254
+ state_schema = mutable_contract_json(state_contract.schema)
255
+ return _AwaitStateTargetContract(
256
+ value_contract=FlowValueContract(
257
+ schema=state_schema,
258
+ root_schema=state_schema,
259
+ origin=f"state key {state_key!r} owned by subflow {subflow_reference!r}",
260
+ total=True,
261
+ ),
262
+ partition=state_contract.partition,
263
+ )
264
+ return None
265
+
266
+
267
+ def _validate_source_contract_for_await_state(
268
+ document: dict[str, Any],
269
+ subflows: SubflowRegistry,
270
+ state_key: str,
271
+ source_contract: FlowValueContract,
272
+ *,
273
+ location: str,
274
+ ) -> None:
275
+ target_contract = _await_state_target_contract(document, subflows, state_key)
276
+ if target_contract is None:
277
+ return
278
+ if target_contract.partition == "internal" and state_key not in document.get("slots", {}):
279
+ raise ValueError(f"{location} cannot write subflow-owned internal state key {state_key!r}")
280
+ if schema_is_proven_subset(
281
+ source_contract.schema,
282
+ source_contract.root_schema,
283
+ target_contract.value_contract.schema,
284
+ target_contract.value_contract.root_schema,
285
+ ):
286
+ return
287
+ raise ValueError(
288
+ f"{location} {source_contract.origin} is not proven to satisfy "
289
+ f"{target_contract.value_contract.origin}"
290
+ )
291
+
292
+
293
+ def _validate_literal_for_await_state(
294
+ document: dict[str, Any],
295
+ subflows: SubflowRegistry,
296
+ state_key: str,
297
+ literal_value: Any,
298
+ *,
299
+ location: str,
300
+ ) -> None:
301
+ target_contract = _await_state_target_contract(document, subflows, state_key)
302
+ if target_contract is None:
303
+ return
304
+ if target_contract.partition == "internal" and state_key not in document.get("slots", {}):
305
+ raise ValueError(f"{location} cannot write subflow-owned internal state key {state_key!r}")
306
+ if not schema_accepts_known_instance(
307
+ target_contract.value_contract.schema,
308
+ target_contract.value_contract.root_schema,
309
+ literal_value,
310
+ ):
311
+ raise ValueError(
312
+ f"{location} literal does not satisfy {target_contract.value_contract.origin}"
313
+ )
314
+
315
+
316
+ def _validate_typed_resume_contract(
317
+ document: dict[str, Any],
318
+ capability: dict[str, Any],
319
+ tools: ToolRegistry,
320
+ subflows: SubflowRegistry,
321
+ ) -> None:
322
+ resume_contract = cast(dict[str, Any] | None, capability.get("resume"))
323
+ if resume_contract is None:
324
+ return
325
+ token_schema = cast(dict[str, Any], resume_contract["schema"])
326
+ validate_resume_token_schema(token_schema)
327
+ correlation_reference = cast(str, resume_contract["correlation"])
328
+ correlation_contract = _resume_token_path_contract(
329
+ token_schema,
330
+ correlation_reference,
331
+ location="$.capabilities.await_external.resume.correlation",
332
+ require_presence=True,
333
+ )
334
+ scalar_correlation_types = frozenset({"boolean", "integer", "non_integer_number", "string"})
335
+ correlation_types = schema_type_atoms(
336
+ correlation_contract.schema,
337
+ correlation_contract.root_schema,
338
+ )
339
+ if not correlation_types or not correlation_types <= scalar_correlation_types:
340
+ raise ValueError(
341
+ "$.capabilities.await_external.resume.correlation must resolve to a "
342
+ "non-null JSON scalar"
343
+ )
344
+
345
+ on_resume = cast(dict[str, Any], capability.get("on_resume") or {})
346
+ for state_key, binding in cast(dict[str, Any], on_resume.get("set") or {}).items():
347
+ binding_location = f"$.capabilities.await_external.on_resume.set[{state_key!r}]"
348
+ if isinstance(binding, str) and binding.startswith("$"):
349
+ source_contract = _resume_token_path_contract(
350
+ token_schema,
351
+ binding,
352
+ location=binding_location,
353
+ require_presence=False,
354
+ )
355
+ _validate_source_contract_for_await_state(
356
+ document,
357
+ subflows,
358
+ state_key,
359
+ source_contract,
360
+ location=binding_location,
361
+ )
362
+ else:
363
+ _validate_literal_for_await_state(
364
+ document,
365
+ subflows,
366
+ state_key,
367
+ binding,
368
+ location=binding_location,
369
+ )
370
+
371
+ enrichment = on_resume.get("enrich")
372
+ for transition_name, transition in {
373
+ "timeout": capability.get("timeout"),
374
+ **cast(dict[str, Any], capability.get("recovery") or {}),
375
+ }.items():
376
+ if not isinstance(transition, dict):
377
+ continue
378
+ for state_key, literal_value in cast(dict[str, Any], transition.get("set") or {}).items():
379
+ _validate_literal_for_await_state(
380
+ document,
381
+ subflows,
382
+ state_key,
383
+ literal_value,
384
+ location=(f"$.capabilities.await_external.{transition_name}.set[{state_key!r}]"),
385
+ )
386
+ if not isinstance(enrichment, dict):
387
+ return
388
+ tool_definition = tools.definition(cast(str, enrichment["tool"]))
389
+ enrichment_input = cast(dict[str, Any], enrichment.get("input") or {})
390
+ required_parameters = required_schema_properties(tool_definition.input_schema)
391
+ bound_parameters = frozenset(enrichment_input)
392
+ for parameter_name, binding in enrichment_input.items():
393
+ binding_location = (
394
+ f"$.capabilities.await_external.on_resume.enrich.input[{parameter_name!r}]"
395
+ )
396
+ parameter_contract = schema_property_contract(
397
+ tool_definition.input_schema,
398
+ parameter_name,
399
+ tool_definition.input_schema,
400
+ bound_parameters,
401
+ )
402
+ if isinstance(binding, str) and binding.startswith("$"):
403
+ source_contract = _resume_token_path_contract(
404
+ token_schema,
405
+ binding,
406
+ location=binding_location,
407
+ require_presence=parameter_name in required_parameters,
408
+ )
409
+ if not schema_is_proven_subset(
410
+ source_contract.schema,
411
+ source_contract.root_schema,
412
+ parameter_contract,
413
+ tool_definition.input_schema,
414
+ ):
415
+ raise ValueError(
416
+ f"{binding_location} {source_contract.origin} is not proven to satisfy "
417
+ f"tool parameter {parameter_name!r} in {tool_definition.identifier!r}"
418
+ )
419
+ elif not schema_accepts_known_instance(
420
+ parameter_contract,
421
+ tool_definition.input_schema,
422
+ binding,
423
+ ):
424
+ raise ValueError(
425
+ f"{binding_location} literal does not satisfy tool parameter "
426
+ f"{parameter_name!r} in {tool_definition.identifier!r}"
427
+ )
428
+
429
+ for state_key, binding in cast(dict[str, Any], enrichment.get("set") or {}).items():
430
+ binding_location = f"$.capabilities.await_external.on_resume.enrich.set[{state_key!r}]"
431
+ if isinstance(binding, str) and binding.startswith("$result."):
432
+ result_path = tuple(binding.removeprefix("$result.").split("."))
433
+ source_schema = _schema_path_value_contract(
434
+ tool_definition.output_schema,
435
+ result_path,
436
+ tool_definition.output_schema,
437
+ )
438
+ _validate_source_contract_for_await_state(
439
+ document,
440
+ subflows,
441
+ state_key,
442
+ FlowValueContract(
443
+ schema=source_schema,
444
+ root_schema=tool_definition.output_schema,
445
+ origin=f"tool result path {binding!r}",
446
+ total=False,
447
+ ),
448
+ location=binding_location,
449
+ )
450
+ else:
451
+ _validate_literal_for_await_state(
452
+ document,
453
+ subflows,
454
+ state_key,
455
+ binding,
456
+ location=binding_location,
457
+ )
458
+
459
+
460
+ def validate_await_external_definition(
461
+ document: dict[str, Any],
462
+ capability: Optional[dict[str, Any]],
463
+ tools: ToolRegistry,
464
+ subflows: SubflowRegistry,
465
+ path_step: Optional[dict[str, Any]],
466
+ ) -> None:
467
+ if capability is None:
468
+ return
469
+ if not isinstance(capability.get("step"), str) or not capability["step"]:
470
+ raise ValueError("capabilities.await_external.step must be a non-empty string")
471
+ if not isinstance(capability.get("resume_on"), str) or not capability["resume_on"]:
472
+ raise ValueError("capabilities.await_external.resume_on must be a non-empty string")
473
+ effective_interactive = (path_step or {}).get("interactive") or capability.get("interactive")
474
+ if effective_interactive:
475
+ if effective_interactive.get("kind") != "cta_url":
476
+ raise ValueError("await_external interactive.kind must be 'cta_url'")
477
+ if effective_interactive.get("field") != capability["resume_on"]:
478
+ raise ValueError("await_external interactive.field must match resume_on")
479
+ if effective_interactive.get("out_of_band", True) is not True:
480
+ raise ValueError("await_external interactive.out_of_band cannot be false")
481
+ next_step = effective_interactive.get("next_step")
482
+ if next_step is not None and next_step != capability["step"]:
483
+ raise ValueError("await_external interactive.next_step must match step")
484
+ on_resume = capability.get("on_resume") or {}
485
+ token_bindings = on_resume.get("set") or {}
486
+ _validate_binding_map(
487
+ token_bindings,
488
+ namespace="token",
489
+ location="capabilities.await_external.on_resume.set",
490
+ )
491
+
492
+ enrichment = on_resume.get("enrich")
493
+ if enrichment is None:
494
+ _validate_typed_resume_contract(document, capability, tools, subflows)
495
+ return
496
+ enrichment_definition: dict[str, Any] | None = None
497
+ if isinstance(enrichment, str):
498
+ if path_step is not None:
499
+ raise ValueError("path await_external requires object-form on_resume.enrich")
500
+ tool_name = enrichment
501
+ else:
502
+ if not isinstance(enrichment, dict):
503
+ raise ValueError("await_external enrichment must be a tool name or object")
504
+ enrichment_definition = enrichment
505
+ tool_name_value = enrichment.get("tool")
506
+ if not isinstance(tool_name_value, str) or not tool_name_value:
507
+ raise ValueError("await_external enrichment.tool must be a non-empty string")
508
+ tool_name = tool_name_value
509
+ _validate_binding_map(
510
+ enrichment.get("input") or {},
511
+ namespace="token",
512
+ location="capabilities.await_external.on_resume.enrich.input",
513
+ )
514
+ enrichment_bindings = enrichment.get("set") or {}
515
+ _validate_binding_map(
516
+ enrichment_bindings,
517
+ namespace="result",
518
+ location="capabilities.await_external.on_resume.enrich.set",
519
+ )
520
+ duplicate_writes = set(token_bindings) & set(enrichment_bindings)
521
+ if duplicate_writes:
522
+ duplicates = ", ".join(sorted(duplicate_writes))
523
+ raise ValueError(f"await_external mappings write the same keys twice: {duplicates}")
524
+ tool_definition = registered_tool_definition(
525
+ tools,
526
+ tool_name,
527
+ location="$.capabilities.await_external.on_resume.enrich.tool",
528
+ )
529
+ validate_read_only_tool_effects(
530
+ tool_definition,
531
+ location="$.capabilities.await_external.on_resume.enrich.tool",
532
+ )
533
+ if enrichment_definition is None:
534
+ _validate_typed_resume_contract(document, capability, tools, subflows)
535
+ return
536
+ enrichment_input = cast(dict[str, Any], enrichment_definition.get("input") or {})
537
+ validate_tool_inputs(
538
+ tool_definition,
539
+ [
540
+ (
541
+ parameter_name,
542
+ f"$.capabilities.await_external.on_resume.enrich.input[{parameter_name!r}]",
543
+ )
544
+ for parameter_name in enrichment_input
545
+ ],
546
+ call_location="$.capabilities.await_external.on_resume.enrich.input",
547
+ )
548
+ for state_key, result_binding in cast(
549
+ dict[str, Any], enrichment_definition.get("set") or {}
550
+ ).items():
551
+ if isinstance(result_binding, str) and result_binding.startswith("$result."):
552
+ validate_tool_result_path(
553
+ tool_definition,
554
+ result_binding,
555
+ location=(f"$.capabilities.await_external.on_resume.enrich.set[{state_key!r}]"),
556
+ allow_array_indices=True,
557
+ )
558
+ _validate_typed_resume_contract(document, capability, tools, subflows)
559
+
560
+
561
+ def validate_await_external_targets(
562
+ capability: Optional[dict[str, Any]],
563
+ node_ids: set[str],
564
+ await_external_node_ids: set[str],
565
+ ) -> None:
566
+ if capability is None:
567
+ return
568
+ bound_node_id = capability["step"]
569
+ if bound_node_id not in node_ids:
570
+ raise ValueError(
571
+ f"capabilities.await_external.step does not resolve to a node: {bound_node_id!r}"
572
+ )
573
+ if bound_node_id not in await_external_node_ids:
574
+ raise ValueError(
575
+ "capabilities.await_external.step resolves to a node that does not implement "
576
+ f"await_external: {bound_node_id!r}"
577
+ )
578
+ transitions = {
579
+ "timeout": capability.get("timeout"),
580
+ **(capability.get("recovery") or {}),
581
+ }
582
+ for event, transition in transitions.items():
583
+ if transition is None:
584
+ continue
585
+ target = transition["goto"]
586
+ if event == "resend" and target != bound_node_id:
587
+ raise ValueError(
588
+ "capabilities.await_external resend target must match its bound step: "
589
+ f"{bound_node_id!r}"
590
+ )
591
+ if target != "END" and target not in node_ids:
592
+ raise ValueError(
593
+ f"capabilities.await_external {event} target does not resolve to a node: {target!r}"
594
+ )