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,2460 @@
1
+ """Experimental flowspec/3-draft source preview and v2 migration helpers.
2
+
3
+ The preview is deliberately isolated from runtime compilation. Its only purpose
4
+ is to make the proposed authoring surface executable as a schema and measurable
5
+ against existing flowspec/2 documents before any stable format decision.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ import unicodedata
13
+ from dataclasses import dataclass, field
14
+ from enum import StrEnum
15
+ from pathlib import Path
16
+ from typing import Any, Iterable, Mapping, cast
17
+
18
+ import jsonschema
19
+
20
+ from ..diagnostics import DiagnosticLocation, FlowDiagnostic
21
+ from ..domains import categorical_number_bindings, parse_affirmation
22
+ from ..schema import validate_flow
23
+ from ..semantics import semantic_diagnostics
24
+
25
+ V2_SCHEMA_IDENTIFIER = "flowspec/2"
26
+ V3_PREVIEW_SCHEMA_IDENTIFIER = "flowspec/3-draft"
27
+ V3_PREVIEW_LOSS_POLICY_CONTRACT = "flowspec2/v3-preview-loss-policy@1"
28
+
29
+ _SCHEMA_PATH = Path(__file__).with_name("flowspec-3-draft.schema.json")
30
+ _LOSS_POLICY_PATH = Path(__file__).with_name("v3-preview-loss-policy.json")
31
+ _PREDICATE_REFERENCE_NAMESPACES = {
32
+ "internal": "$internal",
33
+ "payload": "$payload",
34
+ "config": "$config",
35
+ "address": "$address",
36
+ }
37
+ _SLOT_CONTRACT_KEYS = (
38
+ "persist",
39
+ "required",
40
+ "nullable",
41
+ "prefill_sources",
42
+ "max_attempts",
43
+ "on_exhaust",
44
+ "default",
45
+ "fill_only_when_asked",
46
+ )
47
+ _TOP_LEVEL_NATIVE_KEYS = {
48
+ "schema",
49
+ "flow",
50
+ "version",
51
+ "service",
52
+ "route",
53
+ "config",
54
+ "domains",
55
+ "slots",
56
+ "path",
57
+ "uses",
58
+ "derive",
59
+ "confirm",
60
+ "terminal",
61
+ "overrides",
62
+ "entry",
63
+ "auto_flow",
64
+ "capabilities",
65
+ }
66
+
67
+ _schema_cache: dict[str, Any] | None = None
68
+ _loss_policy_cache: dict[V3PreviewLossCategory, _V3PreviewLossRule] | None = None
69
+
70
+
71
+ class V3PreviewDocumentMode(StrEnum):
72
+ """Validation boundary for authored sources and v2 migration artifacts."""
73
+
74
+ AUTHORING = "authoring"
75
+ V2_MIGRATION = "v2_migration"
76
+
77
+
78
+ class V3PreviewLossCategory(StrEnum):
79
+ """Closed reasons why the v2 migrator cannot emit native preview syntax."""
80
+
81
+ AGENT_CAPABILITY = "agent_capability"
82
+ AUTOMATIC_FLOW_CONTRACT = "automatic_flow_contract"
83
+ AWAIT_RESUME_CONTRACT = "await_resume_contract"
84
+ AWAIT_TIMEOUT_DURATION = "await_timeout_duration"
85
+ DUPLICATE_DOMAIN_ROW = "duplicate_domain_row"
86
+ DUPLICATE_TERMINAL_INPUT_PARAMETER = "duplicate_terminal_input_parameter"
87
+ ENTRY_CONTRACT = "entry_contract"
88
+ IMPLICIT_AWAIT_CAPABILITY = "implicit_await_capability"
89
+ INVALID_BOOLEAN_DOMAIN_FIELD = "invalid_boolean_domain_field"
90
+ INVALID_NON_CHOICE_DOMAIN_FIELD = "invalid_non_choice_domain_field"
91
+ INVALID_NON_CHOICE_SYNONYMS = "invalid_non_choice_synonyms"
92
+ INVALID_SYNONYM_TARGET = "invalid_synonym_target"
93
+ LEGACY_AWAIT_ENRICHMENT = "legacy_await_enrichment"
94
+ LEGACY_INTERACTIVE_GATE = "legacy_interactive_gate"
95
+ MISMATCHED_INTERACTIVE_DOMAIN = "mismatched_interactive_domain"
96
+ ORPHAN_DOMAIN_ROW = "orphan_domain_row"
97
+ SHADOWED_AWAIT_PRESENTATION = "shadowed_await_presentation"
98
+ UNKNOWN_DERIVE_ANCHOR = "unknown_derive_anchor"
99
+ UNKNOWN_TOP_LEVEL_FRAGMENT = "unknown_top_level_fragment"
100
+ UNUSED_CONFIRMATION_CONTRACT = "unused_confirmation_contract"
101
+ UNUSED_DOMAIN_DECLARATION = "unused_domain_declaration"
102
+ UNUSED_OVERRIDE_GATE = "unused_override_gate"
103
+ UNUSED_SLOT_DECLARATION = "unused_slot_declaration"
104
+ UNUSED_SUBFLOW_DECLARATION = "unused_subflow_declaration"
105
+ UNUSED_TERMINAL_CONTRACT = "unused_terminal_contract"
106
+
107
+
108
+ class V3PreviewLossDisposition(StrEnum):
109
+ """Normative status assigned to a non-native source fragment."""
110
+
111
+ COMPATIBILITY_ONLY = "compatibility_only"
112
+ EXCLUDED = "excluded"
113
+
114
+
115
+ class V3PreviewLossHandling(StrEnum):
116
+ """Required lowerer behavior for a loss-accounting category."""
117
+
118
+ REHYDRATE = "rehydrate"
119
+ REJECT = "reject"
120
+
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class _V3PreviewLossRule:
124
+ disposition: V3PreviewLossDisposition
125
+ handling: V3PreviewLossHandling
126
+ description: str
127
+ source_path_pattern: str
128
+
129
+
130
+ class V3PreviewMigrationError(ValueError):
131
+ """A source construct cannot be represented as a valid preview document."""
132
+
133
+
134
+ class V3PreviewValidationError(ValueError):
135
+ """A structurally valid preview violates one or more semantic contracts."""
136
+
137
+ def __init__(self, diagnostics: tuple[FlowDiagnostic, ...]) -> None:
138
+ self.diagnostics = diagnostics
139
+ summary = "; ".join(
140
+ f"{diagnostic.code} at {diagnostic.path or '/'}: {diagnostic.message}"
141
+ for diagnostic in diagnostics
142
+ )
143
+ super().__init__(summary)
144
+
145
+
146
+ @dataclass(frozen=True, slots=True)
147
+ class CompactByteComparison:
148
+ """UTF-8 byte sizes of canonical compact JSON for a migration pair."""
149
+
150
+ source_bytes: int
151
+ preview_bytes: int
152
+
153
+ @property
154
+ def delta_bytes(self) -> int:
155
+ """Return preview bytes minus source bytes without interpreting the sign."""
156
+ return self.preview_bytes - self.source_bytes
157
+
158
+ def as_dict(self) -> dict[str, int]:
159
+ """Return a serialization-friendly measurement report."""
160
+ return {
161
+ "source_bytes": self.source_bytes,
162
+ "preview_bytes": self.preview_bytes,
163
+ "delta_bytes": self.delta_bytes,
164
+ }
165
+
166
+
167
+ @dataclass(frozen=True, slots=True)
168
+ class V3PreviewLossEntry:
169
+ """Immutable classification of one non-native v2 source fragment."""
170
+
171
+ source_path: str
172
+ category: V3PreviewLossCategory
173
+ disposition: V3PreviewLossDisposition
174
+ _source_fragment_json: str = field(repr=False)
175
+
176
+ @property
177
+ def source_fragment(self) -> Any:
178
+ """Return a fresh JSON value so callers cannot mutate the entry."""
179
+ return json.loads(self._source_fragment_json)
180
+
181
+ @property
182
+ def handling(self) -> V3PreviewLossHandling:
183
+ """Return the policy-mandated lowerer behavior."""
184
+ return _loss_policy_rules()[self.category].handling
185
+
186
+ def as_dict(self) -> dict[str, Any]:
187
+ """Return the canonical document representation of this entry."""
188
+ return {
189
+ "source_path": self.source_path,
190
+ "category": self.category.value,
191
+ "disposition": self.disposition.value,
192
+ "source_fragment": self.source_fragment,
193
+ }
194
+
195
+
196
+ @dataclass(frozen=True, slots=True)
197
+ class V3PreviewMigrationReport:
198
+ """Immutable migration artifact with explicit non-native source locations."""
199
+
200
+ _preview_json: str = field(repr=False)
201
+ loss_entries: tuple[V3PreviewLossEntry, ...]
202
+ compact_bytes: CompactByteComparison
203
+
204
+ @property
205
+ def preview_document(self) -> dict[str, Any]:
206
+ """Return a fresh preview document so callers cannot mutate the report."""
207
+ return cast(dict[str, Any], json.loads(self._preview_json))
208
+
209
+
210
+ @dataclass(slots=True)
211
+ class _MigrationContext:
212
+ source_document: dict[str, Any]
213
+ domains: dict[str, dict[str, Any]]
214
+ slots: dict[str, dict[str, Any]]
215
+ use_declarations: list[dict[str, Any]]
216
+ derive_definitions: list[dict[str, Any]]
217
+ confirmation_definition: dict[str, Any] | None
218
+ terminal_definition: dict[str, Any] | None
219
+ override_gates: dict[str, dict[str, Any]]
220
+ await_capability: dict[str, Any] | None
221
+ used_domain_names: set[str] = field(default_factory=set)
222
+ placed_slot_names: set[str] = field(default_factory=set)
223
+ consumed_use_indexes: set[int] = field(default_factory=set)
224
+ consumed_derive_indexes: set[int] = field(default_factory=set)
225
+ consumed_override_gate_names: set[str] = field(default_factory=set)
226
+ confirmation_consumed: bool = False
227
+ terminal_consumed: bool = False
228
+ await_capability_consumed: bool = False
229
+ loss_entries_by_path: dict[str, V3PreviewLossEntry] = field(default_factory=dict)
230
+
231
+
232
+ def preview_loss_policy() -> dict[str, Any]:
233
+ """Return a private copy of the authoritative preview loss policy."""
234
+ policy_document = cast(
235
+ dict[str, Any], json.loads(_LOSS_POLICY_PATH.read_text(encoding="utf-8"))
236
+ )
237
+ _validate_loss_policy_document(policy_document)
238
+ return cast(dict[str, Any], _canonical_clone(policy_document))
239
+
240
+
241
+ def _loss_policy_rules() -> dict[V3PreviewLossCategory, _V3PreviewLossRule]:
242
+ global _loss_policy_cache
243
+ cached_rules = _loss_policy_cache
244
+ if cached_rules is not None:
245
+ return cached_rules
246
+ policy_document = preview_loss_policy()
247
+ category_documents = cast(dict[str, dict[str, str]], policy_document["categories"])
248
+ cached_rules = {
249
+ V3PreviewLossCategory(category_name): _V3PreviewLossRule(
250
+ disposition=V3PreviewLossDisposition(category_document["disposition"]),
251
+ handling=V3PreviewLossHandling(category_document["handling"]),
252
+ description=category_document["description"],
253
+ source_path_pattern=category_document["source_path_pattern"],
254
+ )
255
+ for category_name, category_document in category_documents.items()
256
+ }
257
+ _loss_policy_cache = cached_rules
258
+ return cached_rules
259
+
260
+
261
+ def _validate_loss_policy_document(policy_document: dict[str, Any]) -> None:
262
+ if set(policy_document) != {"_sections", "contract", "categories"}:
263
+ raise RuntimeError("preview loss policy has an invalid top-level shape")
264
+ section_index = policy_document.get("_sections")
265
+ if not isinstance(section_index, dict) or set(section_index) != {"loss-policy"}:
266
+ raise RuntimeError("preview loss policy has an invalid section index")
267
+ if policy_document.get("contract") != V3_PREVIEW_LOSS_POLICY_CONTRACT:
268
+ raise RuntimeError("preview loss policy has an unsupported contract")
269
+ category_documents = policy_document.get("categories")
270
+ if not isinstance(category_documents, dict):
271
+ raise RuntimeError("preview loss policy categories must be an object")
272
+ expected_categories = {category.value for category in V3PreviewLossCategory}
273
+ if set(category_documents) != expected_categories:
274
+ raise RuntimeError("preview loss policy categories do not match the closed category enum")
275
+ for category_name, category_document in category_documents.items():
276
+ if not isinstance(category_document, dict) or set(category_document) != {
277
+ "description",
278
+ "disposition",
279
+ "handling",
280
+ "source_path_pattern",
281
+ }:
282
+ raise RuntimeError(f"preview loss policy category {category_name!r} is malformed")
283
+ try:
284
+ disposition = V3PreviewLossDisposition(category_document["disposition"])
285
+ handling = V3PreviewLossHandling(category_document["handling"])
286
+ except (TypeError, ValueError) as error:
287
+ raise RuntimeError(
288
+ f"preview loss policy category {category_name!r} has an invalid decision"
289
+ ) from error
290
+ expected_handling = (
291
+ V3PreviewLossHandling.REHYDRATE
292
+ if disposition is V3PreviewLossDisposition.COMPATIBILITY_ONLY
293
+ else V3PreviewLossHandling.REJECT
294
+ )
295
+ if handling is not expected_handling:
296
+ raise RuntimeError(
297
+ f"preview loss policy category {category_name!r} has an inconsistent decision"
298
+ )
299
+ if (
300
+ not isinstance(category_document["description"], str)
301
+ or not category_document["description"].strip()
302
+ ):
303
+ raise RuntimeError(
304
+ f"preview loss policy category {category_name!r} needs a description"
305
+ )
306
+ source_path_pattern = category_document["source_path_pattern"]
307
+ if not isinstance(source_path_pattern, str):
308
+ raise RuntimeError(
309
+ f"preview loss policy category {category_name!r} needs a source path pattern"
310
+ )
311
+ try:
312
+ re.compile(source_path_pattern)
313
+ except re.error as error:
314
+ raise RuntimeError(
315
+ f"preview loss policy category {category_name!r} has an invalid source path pattern"
316
+ ) from error
317
+
318
+
319
+ def preview_schema() -> dict[str, Any]:
320
+ """Return a private copy of the closed experimental Draft 2020-12 schema."""
321
+ global _schema_cache
322
+ cached_schema = _schema_cache
323
+ if cached_schema is None:
324
+ cached_schema = cast(dict[str, Any], json.loads(_SCHEMA_PATH.read_text(encoding="utf-8")))
325
+ jsonschema.Draft202012Validator.check_schema(cached_schema)
326
+ _schema_cache = cached_schema
327
+ return cast(dict[str, Any], _canonical_clone(cached_schema))
328
+
329
+
330
+ def validate_v3_preview(
331
+ preview_document: Mapping[str, Any],
332
+ *,
333
+ mode: V3PreviewDocumentMode = V3PreviewDocumentMode.AUTHORING,
334
+ ) -> None:
335
+ """Raise when a document violates structural or semantic preview contracts."""
336
+ mode = V3PreviewDocumentMode(mode)
337
+ jsonschema.Draft202012Validator(preview_schema()).validate(preview_document)
338
+ if diagnostics := _semantic_v3_diagnostics(cast(dict[str, Any], preview_document), mode):
339
+ raise V3PreviewValidationError(diagnostics)
340
+
341
+
342
+ def check_v3_preview(
343
+ preview_document: object,
344
+ *,
345
+ mode: V3PreviewDocumentMode = V3PreviewDocumentMode.AUTHORING,
346
+ ) -> tuple[FlowDiagnostic, ...]:
347
+ """Return all deterministic structural or semantic preview diagnostics."""
348
+ mode = V3PreviewDocumentMode(mode)
349
+ validator = jsonschema.Draft202012Validator(preview_schema())
350
+ structural_findings = tuple(
351
+ sorted(
352
+ (
353
+ _schema_diagnostic(validation_error)
354
+ for validation_error in validator.iter_errors(cast(Any, preview_document))
355
+ ),
356
+ key=lambda diagnostic: (diagnostic.path, diagnostic.code, diagnostic.message),
357
+ )
358
+ )
359
+ if structural_findings:
360
+ return structural_findings
361
+ return _semantic_v3_diagnostics(cast(dict[str, Any], preview_document), mode)
362
+
363
+
364
+ def _schema_diagnostic(validation_error: jsonschema.ValidationError) -> FlowDiagnostic:
365
+ validator_name = re.sub(
366
+ r"[^A-Za-z0-9]+", "_", str(validation_error.validator or "violation")
367
+ ).strip("_")
368
+ return FlowDiagnostic(
369
+ code=f"FLOWSPEC3_SCHEMA_{validator_name.upper() or 'VIOLATION'}",
370
+ severity="error",
371
+ path=_validation_error_pointer(validation_error),
372
+ message=validation_error.message,
373
+ related_locations=_validation_context_locations(validation_error),
374
+ )
375
+
376
+
377
+ def _validation_error_pointer(validation_error: jsonschema.ValidationError) -> str:
378
+ path_segments = list(validation_error.absolute_path)
379
+ if missing_property := _required_property(validation_error):
380
+ path_segments.append(missing_property)
381
+ return _pointer_from_segments(path_segments)
382
+
383
+
384
+ def _required_property(validation_error: jsonschema.ValidationError) -> str | None:
385
+ if validation_error.validator != "required":
386
+ return None
387
+ required_properties = validation_error.validator_value
388
+ instance = validation_error.instance
389
+ if not isinstance(required_properties, list) or not isinstance(instance, Mapping):
390
+ return None
391
+ missing_properties = [
392
+ property_name
393
+ for property_name in required_properties
394
+ if isinstance(property_name, str) and property_name not in instance
395
+ ]
396
+ return missing_properties[0] if len(missing_properties) == 1 else None
397
+
398
+
399
+ def _validation_context_locations(
400
+ validation_error: jsonschema.ValidationError,
401
+ ) -> tuple[DiagnosticLocation, ...]:
402
+ locations = {
403
+ DiagnosticLocation(
404
+ path=_validation_error_pointer(context_error),
405
+ message=context_error.message,
406
+ )
407
+ for context_error in validation_error.context
408
+ }
409
+ return tuple(sorted(locations, key=lambda location: (location.path, location.message or "")))
410
+
411
+
412
+ def _pointer_from_segments(path_segments: Iterable[str | int]) -> str:
413
+ encoded_segments = (
414
+ str(path_segment).replace("~", "~0").replace("/", "~1") for path_segment in path_segments
415
+ )
416
+ return "".join(f"/{path_segment}" for path_segment in encoded_segments)
417
+
418
+
419
+ def _v3_diagnostic(code: str, path: str, message: str) -> FlowDiagnostic:
420
+ return FlowDiagnostic(code=code, severity="error", path=path, message=message)
421
+
422
+
423
+ def _loss_accounting_diagnostics(
424
+ preview_document: dict[str, Any], mode: V3PreviewDocumentMode
425
+ ) -> list[FlowDiagnostic]:
426
+ passthrough = cast(dict[str, Any] | None, preview_document.get("v2_passthrough"))
427
+ if passthrough is None:
428
+ return []
429
+ if mode == V3PreviewDocumentMode.AUTHORING:
430
+ return [
431
+ _v3_diagnostic(
432
+ "FLOWSPEC3_AUTHORED_LOSS_ACCOUNTING",
433
+ "/v2_passthrough",
434
+ "v2 loss accounting is allowed only on v2 migration artifacts",
435
+ )
436
+ ]
437
+
438
+ diagnostics: list[FlowDiagnostic] = []
439
+ loss_entries = cast(list[dict[str, Any]], passthrough["entries"])
440
+ source_paths = [cast(str, loss_entry["source_path"]) for loss_entry in loss_entries]
441
+ if source_paths != sorted(source_paths):
442
+ diagnostics.append(
443
+ _v3_diagnostic(
444
+ "FLOWSPEC3_UNORDERED_LOSS_ENTRIES",
445
+ "/v2_passthrough/entries",
446
+ "loss entries must be ordered by source_path",
447
+ )
448
+ )
449
+ first_indexes_by_path: dict[str, int] = {}
450
+ policy_rules = _loss_policy_rules()
451
+ for loss_index, loss_entry in enumerate(loss_entries):
452
+ loss_path = f"/v2_passthrough/entries/{loss_index}"
453
+ source_path = cast(str, loss_entry["source_path"])
454
+ if (first_index := first_indexes_by_path.get(source_path)) is not None:
455
+ diagnostics.append(
456
+ _v3_diagnostic(
457
+ "FLOWSPEC3_DUPLICATE_LOSS_SOURCE_PATH",
458
+ f"{loss_path}/source_path",
459
+ f"source path {source_path!r} is already accounted at "
460
+ f"/v2_passthrough/entries/{first_index}",
461
+ )
462
+ )
463
+ else:
464
+ first_indexes_by_path[source_path] = loss_index
465
+ try:
466
+ category = V3PreviewLossCategory(cast(str, loss_entry["category"]))
467
+ except ValueError:
468
+ diagnostics.append(
469
+ _v3_diagnostic(
470
+ "FLOWSPEC3_UNKNOWN_LOSS_CATEGORY",
471
+ f"{loss_path}/category",
472
+ f"unknown loss category {loss_entry['category']!r}",
473
+ )
474
+ )
475
+ continue
476
+ expected_disposition = policy_rules[category].disposition
477
+ if loss_entry["disposition"] != expected_disposition.value:
478
+ diagnostics.append(
479
+ _v3_diagnostic(
480
+ "FLOWSPEC3_LOSS_DISPOSITION_MISMATCH",
481
+ f"{loss_path}/disposition",
482
+ f"category {category.value!r} requires disposition "
483
+ f"{expected_disposition.value!r}",
484
+ )
485
+ )
486
+ if re.fullmatch(policy_rules[category].source_path_pattern, source_path) is None:
487
+ diagnostics.append(
488
+ _v3_diagnostic(
489
+ "FLOWSPEC3_LOSS_SOURCE_PATH_MISMATCH",
490
+ f"{loss_path}/source_path",
491
+ f"source path {source_path!r} is not allowed for category {category.value!r}",
492
+ )
493
+ )
494
+ return diagnostics
495
+
496
+
497
+ def _semantic_v3_diagnostics(
498
+ preview_document: dict[str, Any], mode: V3PreviewDocumentMode
499
+ ) -> tuple[FlowDiagnostic, ...]:
500
+ diagnostics = _loss_accounting_diagnostics(preview_document, mode)
501
+ steps = cast(list[dict[str, Any]], preview_document["steps"])
502
+ identifiers: dict[str, tuple[int, str]] = {}
503
+ slot_positions: dict[str, int] = {}
504
+ derive_positions: dict[str, int] = {}
505
+ use_positions: dict[str, int] = {}
506
+ has_profile_owned_slots = any("use" in preview_step for preview_step in steps)
507
+
508
+ for step_index, preview_step in enumerate(steps):
509
+ step_path = _json_pointer("steps", step_index)
510
+ if subflow_reference := preview_step.get("use"):
511
+ subflow_reference = cast(str, subflow_reference)
512
+ previous_use_position = use_positions.get(subflow_reference)
513
+ if previous_use_position is not None:
514
+ diagnostics.append(
515
+ _v3_diagnostic(
516
+ "FLOWSPEC3_DUPLICATE_SUBFLOW_USE",
517
+ f"{step_path}/use",
518
+ f"subflow {subflow_reference!r} is already used at "
519
+ f"/steps/{previous_use_position}",
520
+ )
521
+ )
522
+ else:
523
+ use_positions[subflow_reference] = step_index
524
+ step_identifier = _preview_step_identifier(preview_step)
525
+ if step_identifier is not None:
526
+ identifier_path = f"{step_path}/id" if "id" in preview_step else step_path
527
+ if previous_identifier := identifiers.get(step_identifier):
528
+ diagnostics.append(
529
+ _v3_diagnostic(
530
+ "FLOWSPEC3_DUPLICATE_STEP_ID",
531
+ identifier_path,
532
+ f"step identifier {step_identifier!r} is already declared at "
533
+ f"{previous_identifier[1]}",
534
+ )
535
+ )
536
+ else:
537
+ identifiers[step_identifier] = (step_index, identifier_path)
538
+
539
+ for slot_kind in ("collect", "confirm"):
540
+ if slot_name := preview_step.get(slot_kind):
541
+ slot_name = cast(str, slot_name)
542
+ previous_position = slot_positions.get(slot_name)
543
+ if previous_position is not None:
544
+ diagnostics.append(
545
+ _v3_diagnostic(
546
+ "FLOWSPEC3_DUPLICATE_SLOT_WRITER",
547
+ f"{step_path}/{slot_kind}",
548
+ f"slot {slot_name!r} is already written by /steps/{previous_position}",
549
+ )
550
+ )
551
+ else:
552
+ slot_positions[slot_name] = step_index
553
+
554
+ if derive_name := preview_step.get("derive"):
555
+ derive_name = cast(str, derive_name)
556
+ if derive_name in derive_positions:
557
+ diagnostics.append(
558
+ _v3_diagnostic(
559
+ "FLOWSPEC3_DUPLICATE_DERIVE_WRITER",
560
+ f"{step_path}/derive",
561
+ f"derive target {derive_name!r} is already written",
562
+ )
563
+ )
564
+ elif derive_name in slot_positions:
565
+ diagnostics.append(
566
+ _v3_diagnostic(
567
+ "FLOWSPEC3_STATE_WRITER_COLLISION",
568
+ f"{step_path}/derive",
569
+ f"derive target {derive_name!r} collides with a slot writer",
570
+ )
571
+ )
572
+ else:
573
+ derive_positions[derive_name] = step_index
574
+
575
+ for slot_name, slot_position in slot_positions.items():
576
+ if slot_name in derive_positions:
577
+ diagnostics.append(
578
+ _v3_diagnostic(
579
+ "FLOWSPEC3_STATE_WRITER_COLLISION",
580
+ f"/steps/{slot_position}",
581
+ f"slot {slot_name!r} collides with a derive target",
582
+ )
583
+ )
584
+
585
+ _check_state_writer_collisions(preview_document, steps, diagnostics)
586
+ _check_step_cardinality(steps, diagnostics)
587
+ for step_index, preview_step in enumerate(steps):
588
+ _check_step_semantics(
589
+ preview_step,
590
+ step_index,
591
+ identifiers,
592
+ slot_positions,
593
+ derive_positions,
594
+ has_profile_owned_slots,
595
+ diagnostics,
596
+ )
597
+
598
+ return tuple(
599
+ sorted(
600
+ set(diagnostics),
601
+ key=lambda diagnostic: (diagnostic.path, diagnostic.code, diagnostic.message),
602
+ )
603
+ )
604
+
605
+
606
+ def _check_state_writer_collisions(
607
+ preview_document: dict[str, Any],
608
+ steps: list[dict[str, Any]],
609
+ diagnostics: list[FlowDiagnostic],
610
+ ) -> None:
611
+ first_writer_by_key: dict[str, str] = {}
612
+
613
+ if "service" in preview_document:
614
+ _register_state_writer(
615
+ "service",
616
+ "/service",
617
+ first_writer_by_key,
618
+ diagnostics,
619
+ )
620
+ for step_index, preview_step in enumerate(steps):
621
+ step_path = _json_pointer("steps", step_index)
622
+ for writer_kind in ("collect", "confirm", "derive"):
623
+ if state_key := preview_step.get(writer_kind):
624
+ _register_state_writer(
625
+ cast(str, state_key),
626
+ f"{step_path}/{writer_kind}",
627
+ first_writer_by_key,
628
+ diagnostics,
629
+ )
630
+ if "await" in preview_step:
631
+ on_resume = cast(dict[str, Any], preview_step.get("on_resume", {}))
632
+ for state_key in cast(dict[str, Any], on_resume.get("set", {})):
633
+ _register_state_writer(
634
+ state_key,
635
+ f"{step_path}/on_resume/set/{_pointer_segment(state_key)}",
636
+ first_writer_by_key,
637
+ diagnostics,
638
+ )
639
+ enrichment = cast(dict[str, Any], on_resume.get("enrich", {}))
640
+ for state_key in cast(dict[str, Any], enrichment.get("set", {})):
641
+ _register_state_writer(
642
+ state_key,
643
+ f"{step_path}/on_resume/enrich/set/{_pointer_segment(state_key)}",
644
+ first_writer_by_key,
645
+ diagnostics,
646
+ )
647
+ if "submit" in preview_step:
648
+ for state_key in cast(dict[str, Any], preview_step.get("outputs", {})):
649
+ _register_state_writer(
650
+ state_key,
651
+ f"{step_path}/outputs/{_pointer_segment(state_key)}",
652
+ first_writer_by_key,
653
+ diagnostics,
654
+ )
655
+ success_set = cast(dict[str, Any], preview_step["outcomes"]["success"].get("set", {}))
656
+ for state_key in success_set:
657
+ _register_state_writer(
658
+ state_key,
659
+ f"{step_path}/outcomes/success/set/{_pointer_segment(state_key)}",
660
+ first_writer_by_key,
661
+ diagnostics,
662
+ )
663
+
664
+
665
+ def _register_state_writer(
666
+ state_key: str,
667
+ writer_path: str,
668
+ first_writer_by_key: dict[str, str],
669
+ diagnostics: list[FlowDiagnostic],
670
+ ) -> None:
671
+ previous_writer = first_writer_by_key.get(state_key)
672
+ if previous_writer is None:
673
+ first_writer_by_key[state_key] = writer_path
674
+ return
675
+ diagnostics.append(
676
+ _v3_diagnostic(
677
+ "FLOWSPEC3_STATE_WRITER_COLLISION",
678
+ writer_path,
679
+ f"state key {state_key!r} is already written at {previous_writer}",
680
+ )
681
+ )
682
+
683
+
684
+ def _check_step_cardinality(steps: list[dict[str, Any]], diagnostics: list[FlowDiagnostic]) -> None:
685
+ cardinality_contracts = (
686
+ ("submit", "FLOWSPEC3_MULTIPLE_SUBMIT_STEPS"),
687
+ ("await", "FLOWSPEC3_MULTIPLE_AWAIT_STEPS"),
688
+ ("correction_hub", "FLOWSPEC3_MULTIPLE_CORRECTION_HUBS"),
689
+ )
690
+ for step_key, diagnostic_code in cardinality_contracts:
691
+ matching_indexes = [
692
+ step_index for step_index, preview_step in enumerate(steps) if step_key in preview_step
693
+ ]
694
+ for duplicate_index in matching_indexes[1:]:
695
+ diagnostics.append(
696
+ _v3_diagnostic(
697
+ diagnostic_code,
698
+ _json_pointer("steps", duplicate_index, step_key),
699
+ f"{step_key!r} is a singular flow-level construct",
700
+ )
701
+ )
702
+
703
+
704
+ def _check_step_semantics(
705
+ preview_step: dict[str, Any],
706
+ step_index: int,
707
+ identifiers: dict[str, tuple[int, str]],
708
+ slot_positions: dict[str, int],
709
+ derive_positions: dict[str, int],
710
+ has_profile_owned_slots: bool,
711
+ diagnostics: list[FlowDiagnostic],
712
+ ) -> None:
713
+ step_path = _json_pointer("steps", step_index)
714
+ domain = cast(dict[str, Any] | None, preview_step.get("domain"))
715
+ if domain is not None:
716
+ _check_domain_semantics(domain, preview_step, step_path, diagnostics)
717
+
718
+ for required_index, required_reference in enumerate(preview_step.get("requires", [])):
719
+ _check_source_reference(
720
+ cast(dict[str, str], required_reference),
721
+ f"{step_path}/requires/{required_index}",
722
+ step_index,
723
+ slot_positions,
724
+ derive_positions,
725
+ has_profile_owned_slots,
726
+ diagnostics,
727
+ slots_only=True,
728
+ )
729
+
730
+ if "derive" in preview_step:
731
+ for source_index, source_reference in enumerate(preview_step["from"]):
732
+ _check_source_reference(
733
+ cast(dict[str, str], source_reference),
734
+ f"{step_path}/from/{source_index}",
735
+ step_index,
736
+ slot_positions,
737
+ derive_positions,
738
+ has_profile_owned_slots,
739
+ diagnostics,
740
+ )
741
+ if isinstance(preview_step.get("default"), dict):
742
+ default_reference = cast(dict[str, str], preview_step["default"])
743
+ if set(default_reference) & {"$slot", "$derive"}:
744
+ _check_source_reference(
745
+ default_reference,
746
+ f"{step_path}/default",
747
+ step_index,
748
+ slot_positions,
749
+ derive_positions,
750
+ has_profile_owned_slots,
751
+ diagnostics,
752
+ )
753
+
754
+ if "submit" in preview_step:
755
+ for parameter_name, source_reference in preview_step.get("input", {}).items():
756
+ _check_source_reference(
757
+ cast(dict[str, str], source_reference),
758
+ f"{step_path}/input/{_pointer_segment(parameter_name)}",
759
+ step_index,
760
+ slot_positions,
761
+ derive_positions,
762
+ has_profile_owned_slots,
763
+ diagnostics,
764
+ )
765
+ output_keys = set(cast(dict[str, Any], preview_step.get("outputs", {})))
766
+ success_keys = set(cast(dict[str, Any], preview_step["outcomes"]["success"].get("set", {})))
767
+ for colliding_key in sorted(output_keys & success_keys):
768
+ diagnostics.append(
769
+ _v3_diagnostic(
770
+ "FLOWSPEC3_SUBMIT_WRITE_COLLISION",
771
+ f"{step_path}/outcomes/success/set/{_pointer_segment(colliding_key)}",
772
+ f"state key {colliding_key!r} is also written by submit outputs",
773
+ )
774
+ )
775
+
776
+ if "confirm" in preview_step:
777
+ _check_confirmation_semantics(
778
+ preview_step,
779
+ step_index,
780
+ identifiers,
781
+ slot_positions,
782
+ derive_positions,
783
+ has_profile_owned_slots,
784
+ diagnostics,
785
+ )
786
+
787
+ if "await" in preview_step:
788
+ _check_await_semantics(preview_step, step_index, identifiers, diagnostics)
789
+
790
+ ui = cast(dict[str, Any] | None, preview_step.get("ui"))
791
+ if ui is not None:
792
+ _check_ui_semantics(
793
+ ui,
794
+ domain,
795
+ step_index,
796
+ slot_positions,
797
+ derive_positions,
798
+ has_profile_owned_slots,
799
+ diagnostics,
800
+ )
801
+
802
+ for predicate_key in ("ask_when", "skip_when", "gate"):
803
+ if predicate := preview_step.get(predicate_key):
804
+ _check_predicate_references(
805
+ cast(dict[str, Any], predicate),
806
+ f"{step_path}/{predicate_key}",
807
+ step_index,
808
+ slot_positions,
809
+ derive_positions,
810
+ has_profile_owned_slots,
811
+ diagnostics,
812
+ )
813
+
814
+
815
+ def _check_domain_semantics(
816
+ domain: dict[str, Any],
817
+ preview_step: dict[str, Any],
818
+ step_path: str,
819
+ diagnostics: list[FlowDiagnostic],
820
+ ) -> None:
821
+ domain_type = cast(str, domain["type"])
822
+ minimum = domain.get("minimum")
823
+ maximum = domain.get("maximum")
824
+ if minimum is not None and maximum is not None and minimum > maximum:
825
+ diagnostics.append(
826
+ _v3_diagnostic(
827
+ "FLOWSPEC3_INVALID_NUMERIC_RANGE",
828
+ f"{step_path}/domain/minimum",
829
+ "minimum must not exceed maximum",
830
+ )
831
+ )
832
+
833
+ if domain_type in {"categorical", "bool"}:
834
+ _check_domain_options(domain, step_path, diagnostics)
835
+
836
+ if "default" in preview_step and not _domain_accepts_value(
837
+ domain, preview_step["default"], nullable=preview_step.get("nullable") is True
838
+ ):
839
+ diagnostics.append(
840
+ _v3_diagnostic(
841
+ "FLOWSPEC3_INVALID_DEFAULT",
842
+ f"{step_path}/default",
843
+ "default is outside the inline domain or nullability contract",
844
+ )
845
+ )
846
+
847
+
848
+ def _check_domain_options(
849
+ domain: dict[str, Any], step_path: str, diagnostics: list[FlowDiagnostic]
850
+ ) -> None:
851
+ option_owner_by_identity: dict[str, int] = {}
852
+ normalized_owner: dict[str, tuple[int | None, str]] = {}
853
+ accent_fold = cast(dict[str, Any], domain.get("normalize", {})).get("accent_fold") is True
854
+ for option_index, option in enumerate(cast(list[dict[str, Any]], domain["options"])):
855
+ option_value = option["value"]
856
+ option_identity = _json_identity(option_value)
857
+ option_path = f"{step_path}/domain/options/{option_index}"
858
+ if not cast(str, option["label"]).strip():
859
+ diagnostics.append(
860
+ _v3_diagnostic(
861
+ "FLOWSPEC3_EMPTY_OPTION_LABEL",
862
+ f"{option_path}/label",
863
+ "option label must contain non-whitespace text",
864
+ )
865
+ )
866
+ if option_identity in option_owner_by_identity:
867
+ diagnostics.append(
868
+ _v3_diagnostic(
869
+ "FLOWSPEC3_DUPLICATE_OPTION_VALUE",
870
+ f"{option_path}/value",
871
+ f"option value duplicates /domain/options/{option_owner_by_identity[option_identity]}",
872
+ )
873
+ )
874
+ else:
875
+ option_owner_by_identity[option_identity] = option_index
876
+
877
+ candidate_inputs = [str(option_value), *cast(list[str], option.get("aliases", []))]
878
+ for candidate_index, candidate_input in enumerate(candidate_inputs):
879
+ candidate_path = (
880
+ f"{option_path}/value"
881
+ if candidate_index == 0
882
+ else f"{option_path}/aliases/{candidate_index - 1}"
883
+ )
884
+ _register_normalized_input(
885
+ candidate_input,
886
+ option_index,
887
+ candidate_path,
888
+ accent_fold,
889
+ normalized_owner,
890
+ diagnostics,
891
+ )
892
+
893
+ for null_alias_index, null_alias in enumerate(domain.get("null_aliases", [])):
894
+ _register_normalized_input(
895
+ cast(str, null_alias),
896
+ None,
897
+ f"{step_path}/domain/null_aliases/{null_alias_index}",
898
+ accent_fold,
899
+ normalized_owner,
900
+ diagnostics,
901
+ )
902
+
903
+ normalization = cast(dict[str, Any], domain.get("normalize", {}))
904
+ if domain["type"] == "categorical" and normalization.get("number_words") is True:
905
+ option_values = [
906
+ option["value"] for option in cast(list[dict[str, Any]], domain["options"])
907
+ ]
908
+ for number_input, target_value in categorical_number_bindings(option_values).items():
909
+ option_index = next(
910
+ index
911
+ for index, option_value in enumerate(option_values)
912
+ if _same_json_scalar(option_value, target_value)
913
+ )
914
+ _register_normalized_input(
915
+ number_input,
916
+ option_index,
917
+ f"{step_path}/domain/normalize/number_words",
918
+ accent_fold,
919
+ normalized_owner,
920
+ diagnostics,
921
+ )
922
+
923
+ if domain["type"] == "bool" and normalization.get("affirmation") is True:
924
+ for option_index, option in enumerate(cast(list[dict[str, Any]], domain["options"])):
925
+ for alias_index, option_alias in enumerate(option.get("aliases", [])):
926
+ parsed_alias = parse_affirmation(
927
+ option_alias,
928
+ emoji_veto=normalization.get("emoji_veto") is True,
929
+ )
930
+ if parsed_alias is not None and parsed_alias is not option["value"]:
931
+ diagnostics.append(
932
+ _v3_diagnostic(
933
+ "FLOWSPEC3_BOOLEAN_ALIAS_CONFLICT",
934
+ f"{step_path}/domain/options/{option_index}/aliases/{alias_index}",
935
+ "alias conflicts with the enabled affirmation normalization",
936
+ )
937
+ )
938
+
939
+
940
+ def _register_normalized_input(
941
+ candidate_input: str,
942
+ option_index: int | None,
943
+ candidate_path: str,
944
+ accent_fold: bool,
945
+ normalized_owner: dict[str, tuple[int | None, str]],
946
+ diagnostics: list[FlowDiagnostic],
947
+ ) -> None:
948
+ normalized_input = _normalize_domain_input(candidate_input, accent_fold=accent_fold)
949
+ if not normalized_input:
950
+ diagnostics.append(
951
+ _v3_diagnostic(
952
+ "FLOWSPEC3_EMPTY_OPTION_INPUT",
953
+ candidate_path,
954
+ "option values and aliases must contain non-whitespace input",
955
+ )
956
+ )
957
+ return
958
+ if previous_owner := normalized_owner.get(normalized_input):
959
+ if previous_owner[0] == option_index:
960
+ if not previous_owner[1].endswith("/value"):
961
+ diagnostics.append(
962
+ _v3_diagnostic(
963
+ "FLOWSPEC3_DUPLICATE_OPTION_INPUT",
964
+ candidate_path,
965
+ f"normalized input duplicates {previous_owner[1]}",
966
+ )
967
+ )
968
+ return
969
+ diagnostics.append(
970
+ _v3_diagnostic(
971
+ "FLOWSPEC3_AMBIGUOUS_OPTION_INPUT",
972
+ candidate_path,
973
+ f"normalized input collides with {previous_owner[1]}",
974
+ )
975
+ )
976
+ return
977
+ normalized_owner[normalized_input] = (option_index, candidate_path)
978
+
979
+
980
+ def _normalize_domain_input(candidate_input: str, *, accent_fold: bool) -> str:
981
+ normalized_input = " ".join(candidate_input.split()).casefold()
982
+ if accent_fold:
983
+ normalized_input = "".join(
984
+ character
985
+ for character in unicodedata.normalize("NFKD", normalized_input)
986
+ if not unicodedata.combining(character)
987
+ )
988
+ return normalized_input
989
+
990
+
991
+ def _domain_accepts_value(domain: dict[str, Any], candidate: Any, *, nullable: bool) -> bool:
992
+ if candidate is None:
993
+ return nullable
994
+ domain_type = domain["type"]
995
+ if domain_type == "categorical":
996
+ return any(
997
+ _same_json_scalar(candidate, option["value"])
998
+ for option in cast(list[dict[str, Any]], domain["options"])
999
+ )
1000
+ if domain_type == "bool":
1001
+ return isinstance(candidate, bool)
1002
+ if domain_type == "integer":
1003
+ valid_type = isinstance(candidate, int) and not isinstance(candidate, bool)
1004
+ elif domain_type == "number":
1005
+ valid_type = isinstance(candidate, (int, float)) and not isinstance(candidate, bool)
1006
+ else:
1007
+ valid_type = isinstance(candidate, str)
1008
+ if not valid_type:
1009
+ return False
1010
+ if isinstance(candidate, (int, float)) and not isinstance(candidate, bool):
1011
+ minimum = domain.get("minimum")
1012
+ maximum = domain.get("maximum")
1013
+ return (minimum is None or candidate >= minimum) and (
1014
+ maximum is None or candidate <= maximum
1015
+ )
1016
+ return True
1017
+
1018
+
1019
+ def _check_source_reference(
1020
+ source_reference: dict[str, str],
1021
+ reference_path: str,
1022
+ consumer_index: int,
1023
+ slot_positions: dict[str, int],
1024
+ derive_positions: dict[str, int],
1025
+ has_profile_owned_slots: bool,
1026
+ diagnostics: list[FlowDiagnostic],
1027
+ *,
1028
+ slots_only: bool = False,
1029
+ ) -> None:
1030
+ reference_kind, source_name = next(iter(source_reference.items()))
1031
+ if reference_kind == "$slot":
1032
+ producer_index = slot_positions.get(source_name)
1033
+ if producer_index is None:
1034
+ if not has_profile_owned_slots:
1035
+ diagnostics.append(
1036
+ _v3_diagnostic(
1037
+ "FLOWSPEC3_UNKNOWN_SLOT_REFERENCE",
1038
+ reference_path,
1039
+ f"slot {source_name!r} has no local or profile-owned producer",
1040
+ )
1041
+ )
1042
+ return
1043
+ elif reference_kind == "$derive" and not slots_only:
1044
+ producer_index = derive_positions.get(source_name)
1045
+ if producer_index is None:
1046
+ diagnostics.append(
1047
+ _v3_diagnostic(
1048
+ "FLOWSPEC3_UNKNOWN_DERIVE_REFERENCE",
1049
+ reference_path,
1050
+ f"derive target {source_name!r} is not declared",
1051
+ )
1052
+ )
1053
+ return
1054
+ else:
1055
+ diagnostics.append(
1056
+ _v3_diagnostic(
1057
+ "FLOWSPEC3_INVALID_SOURCE_REFERENCE",
1058
+ reference_path,
1059
+ "this contract requires a slot reference",
1060
+ )
1061
+ )
1062
+ return
1063
+ if producer_index >= consumer_index:
1064
+ diagnostics.append(
1065
+ _v3_diagnostic(
1066
+ "FLOWSPEC3_SOURCE_NOT_AVAILABLE",
1067
+ reference_path,
1068
+ f"source {source_name!r} must be produced by an earlier step",
1069
+ )
1070
+ )
1071
+
1072
+
1073
+ def _check_confirmation_semantics(
1074
+ preview_step: dict[str, Any],
1075
+ step_index: int,
1076
+ identifiers: dict[str, tuple[int, str]],
1077
+ slot_positions: dict[str, int],
1078
+ derive_positions: dict[str, int],
1079
+ has_profile_owned_slots: bool,
1080
+ diagnostics: list[FlowDiagnostic],
1081
+ ) -> None:
1082
+ step_path = _json_pointer("steps", step_index)
1083
+ if target_reference := preview_step.get("on_confirm"):
1084
+ _check_step_reference(
1085
+ cast(dict[str, str], target_reference),
1086
+ f"{step_path}/on_confirm",
1087
+ identifiers,
1088
+ diagnostics,
1089
+ after_index=step_index,
1090
+ )
1091
+ for correctable_index, correctable_reference in enumerate(preview_step.get("correctable", [])):
1092
+ _check_source_reference(
1093
+ cast(dict[str, str], correctable_reference),
1094
+ f"{step_path}/correctable/{correctable_index}",
1095
+ step_index,
1096
+ slot_positions,
1097
+ derive_positions,
1098
+ has_profile_owned_slots,
1099
+ diagnostics,
1100
+ slots_only=True,
1101
+ )
1102
+
1103
+
1104
+ def _check_await_semantics(
1105
+ preview_step: dict[str, Any],
1106
+ step_index: int,
1107
+ identifiers: dict[str, tuple[int, str]],
1108
+ diagnostics: list[FlowDiagnostic],
1109
+ ) -> None:
1110
+ step_path = _json_pointer("steps", step_index)
1111
+ if ui := preview_step.get("ui"):
1112
+ await_ui = cast(dict[str, Any], ui)
1113
+ if await_ui["field"] != preview_step["resume_on"]:
1114
+ diagnostics.append(
1115
+ _v3_diagnostic(
1116
+ "FLOWSPEC3_AWAIT_FIELD_MISMATCH",
1117
+ f"{step_path}/ui/field",
1118
+ "CTA field must equal the await resume_on payload key",
1119
+ )
1120
+ )
1121
+ if await_ui["next_step"]["$step"] != preview_step["id"]:
1122
+ diagnostics.append(
1123
+ _v3_diagnostic(
1124
+ "FLOWSPEC3_AWAIT_NEXT_STEP_MISMATCH",
1125
+ f"{step_path}/ui/next_step",
1126
+ "CTA next_step must target its enclosing await step",
1127
+ )
1128
+ )
1129
+
1130
+ transition_locations: list[tuple[str, dict[str, Any]]] = []
1131
+ if timeout_transition := preview_step.get("timeout"):
1132
+ transition_locations.append((f"{step_path}/timeout", timeout_transition))
1133
+ transition_locations.extend(
1134
+ (f"{step_path}/recovery/{event_name}", transition)
1135
+ for event_name, transition in preview_step.get("recovery", {}).items()
1136
+ )
1137
+ for transition_path, transition in transition_locations:
1138
+ if isinstance(transition["goto"], dict):
1139
+ _check_step_reference(
1140
+ cast(dict[str, str], transition["goto"]),
1141
+ f"{transition_path}/goto",
1142
+ identifiers,
1143
+ diagnostics,
1144
+ )
1145
+ resend_transition = cast(
1146
+ dict[str, Any] | None,
1147
+ cast(dict[str, Any], preview_step.get("recovery", {})).get("resend"),
1148
+ )
1149
+ if resend_transition is not None and resend_transition["goto"] != {"$step": preview_step["id"]}:
1150
+ diagnostics.append(
1151
+ _v3_diagnostic(
1152
+ "FLOWSPEC3_AWAIT_RESEND_TARGET_MISMATCH",
1153
+ f"{step_path}/recovery/resend/goto",
1154
+ "resend must target its enclosing await step",
1155
+ )
1156
+ )
1157
+
1158
+ on_resume = cast(dict[str, Any], preview_step.get("on_resume", {}))
1159
+ direct_writes = set(cast(dict[str, Any], on_resume.get("set", {})))
1160
+ enrichment = cast(dict[str, Any], on_resume.get("enrich", {}))
1161
+ enrichment_writes = set(cast(dict[str, Any], enrichment.get("set", {})))
1162
+ for colliding_key in sorted(direct_writes & enrichment_writes):
1163
+ diagnostics.append(
1164
+ _v3_diagnostic(
1165
+ "FLOWSPEC3_AWAIT_WRITE_COLLISION",
1166
+ f"{step_path}/on_resume/enrich/set/{_pointer_segment(colliding_key)}",
1167
+ f"state key {colliding_key!r} is also written by on_resume.set",
1168
+ )
1169
+ )
1170
+
1171
+
1172
+ def _check_ui_semantics(
1173
+ ui: dict[str, Any],
1174
+ domain: dict[str, Any] | None,
1175
+ step_index: int,
1176
+ slot_positions: dict[str, int],
1177
+ derive_positions: dict[str, int],
1178
+ has_profile_owned_slots: bool,
1179
+ diagnostics: list[FlowDiagnostic],
1180
+ ) -> None:
1181
+ step_path = _json_pointer("steps", step_index)
1182
+ if ui["kind"] in {"buttons", "list"} and (
1183
+ domain is None or domain["type"] not in {"categorical", "bool"}
1184
+ ):
1185
+ diagnostics.append(
1186
+ _v3_diagnostic(
1187
+ "FLOWSPEC3_CHOICE_UI_REQUIRES_OPTIONS",
1188
+ f"{step_path}/ui/kind",
1189
+ "buttons and list UI require a categorical or boolean inline domain",
1190
+ )
1191
+ )
1192
+ if (
1193
+ ui["kind"] in {"buttons", "list"}
1194
+ and domain is not None
1195
+ and domain["type"] in {"categorical", "bool"}
1196
+ ):
1197
+ allowed_values = [option["value"] for option in domain.get("options", [])]
1198
+ if not allowed_values:
1199
+ diagnostics.append(
1200
+ _v3_diagnostic(
1201
+ "FLOWSPEC3_CHOICE_UI_REQUIRES_OPTIONS",
1202
+ f"{step_path}/ui/kind",
1203
+ "buttons and list UI require renderable domain options",
1204
+ )
1205
+ )
1206
+ seen_values: list[Any] = []
1207
+ for condition_index, conditional_option in enumerate(ui.get("options_when", [])):
1208
+ condition_value = conditional_option["value"]
1209
+ condition_path = f"{step_path}/ui/options_when/{condition_index}/value"
1210
+ if any(_same_json_scalar(condition_value, seen_value) for seen_value in seen_values):
1211
+ diagnostics.append(
1212
+ _v3_diagnostic(
1213
+ "FLOWSPEC3_DUPLICATE_CONDITIONAL_OPTION",
1214
+ condition_path,
1215
+ "options_when repeats an option value",
1216
+ )
1217
+ )
1218
+ seen_values.append(condition_value)
1219
+ if not any(
1220
+ _same_json_scalar(condition_value, allowed_value)
1221
+ for allowed_value in allowed_values
1222
+ ):
1223
+ diagnostics.append(
1224
+ _v3_diagnostic(
1225
+ "FLOWSPEC3_UNKNOWN_CONDITIONAL_OPTION",
1226
+ condition_path,
1227
+ "options_when value is not rendered by the inline domain",
1228
+ )
1229
+ )
1230
+ _check_predicate_references(
1231
+ cast(dict[str, Any], conditional_option["gate"]),
1232
+ f"{step_path}/ui/options_when/{condition_index}/gate",
1233
+ step_index,
1234
+ slot_positions,
1235
+ derive_positions,
1236
+ has_profile_owned_slots,
1237
+ diagnostics,
1238
+ )
1239
+ if ui["kind"] == "flow":
1240
+ for prefill_index, prefill_reference in enumerate(ui.get("prefill_from", [])):
1241
+ _check_source_reference(
1242
+ cast(dict[str, str], prefill_reference),
1243
+ f"{step_path}/ui/prefill_from/{prefill_index}",
1244
+ step_index,
1245
+ slot_positions,
1246
+ derive_positions,
1247
+ has_profile_owned_slots,
1248
+ diagnostics,
1249
+ slots_only=True,
1250
+ )
1251
+
1252
+
1253
+ def _check_predicate_references(
1254
+ predicate: dict[str, Any],
1255
+ predicate_path: str,
1256
+ consumer_index: int,
1257
+ slot_positions: dict[str, int],
1258
+ derive_positions: dict[str, int],
1259
+ has_profile_owned_slots: bool,
1260
+ diagnostics: list[FlowDiagnostic],
1261
+ ) -> None:
1262
+ operator, operand = next(iter(predicate.items()))
1263
+ if operator in {"and", "or"}:
1264
+ for child_index, child_predicate in enumerate(operand):
1265
+ _check_predicate_references(
1266
+ cast(dict[str, Any], child_predicate),
1267
+ f"{predicate_path}/{operator}/{child_index}",
1268
+ consumer_index,
1269
+ slot_positions,
1270
+ derive_positions,
1271
+ has_profile_owned_slots,
1272
+ diagnostics,
1273
+ )
1274
+ return
1275
+ if operator == "not":
1276
+ _check_predicate_references(
1277
+ cast(dict[str, Any], operand),
1278
+ f"{predicate_path}/not",
1279
+ consumer_index,
1280
+ slot_positions,
1281
+ derive_positions,
1282
+ has_profile_owned_slots,
1283
+ diagnostics,
1284
+ )
1285
+ return
1286
+ operands = [operand] if operator == "is_present" else cast(list[Any], operand)
1287
+ for operand_index, candidate_operand in enumerate(operands):
1288
+ if isinstance(candidate_operand, dict) and set(candidate_operand) & {"$slot", "$derive"}:
1289
+ _check_source_reference(
1290
+ cast(dict[str, str], candidate_operand),
1291
+ f"{predicate_path}/{operator}/{operand_index}",
1292
+ consumer_index,
1293
+ slot_positions,
1294
+ derive_positions,
1295
+ has_profile_owned_slots,
1296
+ diagnostics,
1297
+ )
1298
+
1299
+
1300
+ def _check_step_reference(
1301
+ step_reference: dict[str, str],
1302
+ reference_path: str,
1303
+ identifiers: dict[str, tuple[int, str]],
1304
+ diagnostics: list[FlowDiagnostic],
1305
+ *,
1306
+ after_index: int | None = None,
1307
+ ) -> None:
1308
+ target_identifier = step_reference["$step"]
1309
+ target = identifiers.get(target_identifier)
1310
+ if target is None:
1311
+ diagnostics.append(
1312
+ _v3_diagnostic(
1313
+ "FLOWSPEC3_UNKNOWN_STEP_REFERENCE",
1314
+ reference_path,
1315
+ f"step {target_identifier!r} is not declared",
1316
+ )
1317
+ )
1318
+ elif after_index is not None and target[0] <= after_index:
1319
+ diagnostics.append(
1320
+ _v3_diagnostic(
1321
+ "FLOWSPEC3_NON_FORWARD_CONFIRM_TARGET",
1322
+ reference_path,
1323
+ "confirmation target must be a later step",
1324
+ )
1325
+ )
1326
+
1327
+
1328
+ def _pointer_segment(path_segment: str) -> str:
1329
+ return path_segment.replace("~", "~0").replace("/", "~1")
1330
+
1331
+
1332
+ def compact_json_bytes(flow_document: Mapping[str, Any]) -> int:
1333
+ """Measure canonical compact JSON in UTF-8 bytes."""
1334
+ return len(_canonical_json(flow_document).encode("utf-8"))
1335
+
1336
+
1337
+ def migrate_v2_to_v3_preview(source_document: Mapping[str, Any]) -> dict[str, Any]:
1338
+ """Purely migrate a valid flowspec/2 source into the non-stable preview."""
1339
+ return migrate_v2_to_v3_preview_report(source_document).preview_document
1340
+
1341
+
1342
+ def migrate_v2_to_v3_preview_report(
1343
+ source_document: Mapping[str, Any],
1344
+ ) -> V3PreviewMigrationReport:
1345
+ """Migrate and report classified source losses plus neutral byte measurements."""
1346
+ private_source = _canonical_clone(source_document)
1347
+ if not isinstance(private_source, dict):
1348
+ raise V3PreviewMigrationError("flowspec/2 source must be a JSON object")
1349
+ if private_source.get("schema") != V2_SCHEMA_IDENTIFIER:
1350
+ raise V3PreviewMigrationError(
1351
+ f"source schema must be {V2_SCHEMA_IDENTIFIER!r}, got {private_source.get('schema')!r}"
1352
+ )
1353
+ _reject_ephemeral_slot_persistence(private_source)
1354
+ validation_source = _without_legacy_interactive_gates(private_source)
1355
+ validate_flow(validation_source)
1356
+ if source_diagnostics := semantic_diagnostics(validation_source):
1357
+ first_diagnostic = source_diagnostics[0]
1358
+ raise V3PreviewMigrationError(
1359
+ "flowspec/2 source is not semantically linked: "
1360
+ f"{first_diagnostic.code} at {first_diagnostic.path or '/'}: "
1361
+ f"{first_diagnostic.message}"
1362
+ )
1363
+
1364
+ migration_context = _new_migration_context(private_source)
1365
+ preview_document = _migrate_document(migration_context)
1366
+ validate_v3_preview(preview_document, mode=V3PreviewDocumentMode.V2_MIGRATION)
1367
+ canonical_preview = _canonical_json(preview_document)
1368
+ compact_bytes = CompactByteComparison(
1369
+ source_bytes=compact_json_bytes(private_source),
1370
+ preview_bytes=len(canonical_preview.encode("utf-8")),
1371
+ )
1372
+ return V3PreviewMigrationReport(
1373
+ _preview_json=canonical_preview,
1374
+ loss_entries=tuple(
1375
+ sorted(
1376
+ migration_context.loss_entries_by_path.values(),
1377
+ key=lambda loss_entry: loss_entry.source_path,
1378
+ )
1379
+ ),
1380
+ compact_bytes=compact_bytes,
1381
+ )
1382
+
1383
+
1384
+ def _reject_ephemeral_slot_persistence(source_document: dict[str, Any]) -> None:
1385
+ slots = source_document.get("slots")
1386
+ if not isinstance(slots, dict):
1387
+ return
1388
+ for slot_name, slot_definition in slots.items():
1389
+ if isinstance(slot_definition, dict) and slot_definition.get("persist") == "payload":
1390
+ raise V3PreviewMigrationError(
1391
+ f"{_json_pointer('slots', slot_name, 'persist')} uses ephemeral payload "
1392
+ "persistence, which cannot survive a conversational pause"
1393
+ )
1394
+
1395
+
1396
+ def _without_legacy_interactive_gates(
1397
+ source_document: dict[str, Any],
1398
+ ) -> dict[str, Any]:
1399
+ cleaned_document = cast(dict[str, Any], _canonical_clone(source_document))
1400
+ interactive_definitions: list[Any] = [
1401
+ path_step.get("interactive")
1402
+ for path_step in cast(list[dict[str, Any]], cleaned_document.get("path", []))
1403
+ ]
1404
+ interactive_definitions.extend(
1405
+ [
1406
+ cast(dict[str, Any], cleaned_document.get("confirm", {})).get("interactive"),
1407
+ cast(dict[str, Any], cleaned_document.get("auto_flow", {})).get("interactive"),
1408
+ cast(
1409
+ dict[str, Any],
1410
+ cast(dict[str, Any], cleaned_document.get("capabilities", {})).get(
1411
+ "await_external", {}
1412
+ ),
1413
+ ).get("interactive"),
1414
+ ]
1415
+ )
1416
+ for interactive_definition in interactive_definitions:
1417
+ if isinstance(interactive_definition, dict):
1418
+ interactive_definition.pop("gate", None)
1419
+ return cleaned_document
1420
+
1421
+
1422
+ def _new_migration_context(source_document: dict[str, Any]) -> _MigrationContext:
1423
+ capabilities = cast(dict[str, Any], source_document.get("capabilities") or {})
1424
+ overrides = cast(dict[str, Any], source_document.get("overrides") or {})
1425
+ return _MigrationContext(
1426
+ source_document=source_document,
1427
+ domains=cast(dict[str, dict[str, Any]], source_document.get("domains") or {}),
1428
+ slots=cast(dict[str, dict[str, Any]], source_document.get("slots") or {}),
1429
+ use_declarations=cast(list[dict[str, Any]], source_document.get("uses") or []),
1430
+ derive_definitions=cast(list[dict[str, Any]], source_document.get("derive") or []),
1431
+ confirmation_definition=cast(dict[str, Any] | None, source_document.get("confirm")),
1432
+ terminal_definition=cast(dict[str, Any] | None, source_document.get("terminal")),
1433
+ override_gates=cast(dict[str, dict[str, Any]], overrides.get("gates") or {}),
1434
+ await_capability=cast(dict[str, Any] | None, capabilities.get("await_external")),
1435
+ )
1436
+
1437
+
1438
+ def _migrate_document(migration_context: _MigrationContext) -> dict[str, Any]:
1439
+ source_document = migration_context.source_document
1440
+ preview_document: dict[str, Any] = {
1441
+ "schema": V3_PREVIEW_SCHEMA_IDENTIFIER,
1442
+ "flow": source_document["flow"],
1443
+ "version": source_document["version"],
1444
+ "route": _canonical_clone(source_document["route"]),
1445
+ }
1446
+ for native_top_level_key in ("service", "config"):
1447
+ if native_top_level_key in source_document:
1448
+ preview_document[native_top_level_key] = _canonical_clone(
1449
+ source_document[native_top_level_key]
1450
+ )
1451
+
1452
+ preview_steps = [
1453
+ _migrate_path_step(path_step, path_index, migration_context)
1454
+ for path_index, path_step in enumerate(cast(list[dict[str, Any]], source_document["path"]))
1455
+ ]
1456
+ _insert_unanchored_derives(preview_steps, migration_context)
1457
+ preview_document["steps"] = preview_steps
1458
+ _collect_loss_entries(migration_context)
1459
+ if migration_context.loss_entries_by_path:
1460
+ ordered_loss_entries = sorted(
1461
+ migration_context.loss_entries_by_path.values(),
1462
+ key=lambda loss_entry: loss_entry.source_path,
1463
+ )
1464
+ preview_document["v2_passthrough"] = {
1465
+ "contract": V3_PREVIEW_LOSS_POLICY_CONTRACT,
1466
+ "entries": [loss_entry.as_dict() for loss_entry in ordered_loss_entries],
1467
+ }
1468
+ return cast(dict[str, Any], _canonical_clone(preview_document))
1469
+
1470
+
1471
+ def _migrate_path_step(
1472
+ path_step: dict[str, Any],
1473
+ path_index: int,
1474
+ migration_context: _MigrationContext,
1475
+ ) -> dict[str, Any]:
1476
+ if "slot" in path_step:
1477
+ return _migrate_collect_step(path_step, path_index, migration_context)
1478
+ if "confirm" in path_step:
1479
+ return _migrate_confirm_step(path_step, path_index, migration_context)
1480
+ if "use" in path_step:
1481
+ return _migrate_use_step(path_step, migration_context)
1482
+ if "derive" in path_step:
1483
+ return _migrate_explicit_derive_step(path_step, path_index, migration_context)
1484
+ if "terminal" in path_step:
1485
+ return _migrate_submit_step(path_index, migration_context)
1486
+ if "await_external" in path_step:
1487
+ return _migrate_await_step(path_step, path_index, migration_context)
1488
+ raise V3PreviewMigrationError(f"/path/{path_index} has no supported step kind")
1489
+
1490
+
1491
+ def _migrate_collect_step(
1492
+ path_step: dict[str, Any],
1493
+ path_index: int,
1494
+ migration_context: _MigrationContext,
1495
+ ) -> dict[str, Any]:
1496
+ slot_name = cast(str, path_step["slot"])
1497
+ preview_step = _migrate_slot_step(
1498
+ kind="collect",
1499
+ slot_name=slot_name,
1500
+ path_step=path_step,
1501
+ path_index=path_index,
1502
+ migration_context=migration_context,
1503
+ )
1504
+ _migrate_path_presentation(path_step, path_index, preview_step, slot_name, migration_context)
1505
+ _apply_step_gate(path_step, slot_name, preview_step, migration_context)
1506
+ return preview_step
1507
+
1508
+
1509
+ def _migrate_confirm_step(
1510
+ path_step: dict[str, Any],
1511
+ path_index: int,
1512
+ migration_context: _MigrationContext,
1513
+ ) -> dict[str, Any]:
1514
+ slot_name = cast(str, path_step["confirm"])
1515
+ preview_step = _migrate_slot_step(
1516
+ kind="confirm",
1517
+ slot_name=slot_name,
1518
+ path_step=path_step,
1519
+ path_index=path_index,
1520
+ migration_context=migration_context,
1521
+ )
1522
+ _migrate_path_presentation(path_step, path_index, preview_step, slot_name, migration_context)
1523
+ _apply_step_gate(path_step, slot_name, preview_step, migration_context)
1524
+ if "on_reject" in path_step:
1525
+ preview_step["on_reject"] = _canonical_clone(path_step["on_reject"])
1526
+ if "correctable" in path_step:
1527
+ preview_step["correction_hub"] = path_step["correctable"]
1528
+ if path_step.get("correctable") is True:
1529
+ _merge_confirmation_definition(path_step, path_index, preview_step, migration_context)
1530
+ return preview_step
1531
+
1532
+
1533
+ def _migrate_slot_step(
1534
+ *,
1535
+ kind: str,
1536
+ slot_name: str,
1537
+ path_step: dict[str, Any],
1538
+ path_index: int,
1539
+ migration_context: _MigrationContext,
1540
+ ) -> dict[str, Any]:
1541
+ slot_definition = migration_context.slots.get(slot_name)
1542
+ if slot_definition is None:
1543
+ raise V3PreviewMigrationError(
1544
+ f"/path/{path_index}/{kind} references undeclared slot {slot_name!r}"
1545
+ )
1546
+ domain_name = cast(str, slot_definition["domain"])
1547
+ preview_step: dict[str, Any] = {
1548
+ kind: slot_name,
1549
+ "domain": _migrate_domain(domain_name, migration_context),
1550
+ }
1551
+ migration_context.placed_slot_names.add(slot_name)
1552
+ if "step" in path_step:
1553
+ preview_step["id"] = path_step["step"]
1554
+ for slot_contract_key in _SLOT_CONTRACT_KEYS:
1555
+ if slot_contract_key in slot_definition:
1556
+ preview_step[slot_contract_key] = _canonical_clone(slot_definition[slot_contract_key])
1557
+ if "requires" in slot_definition:
1558
+ preview_step["requires"] = [
1559
+ _slot_reference(required_slot_name)
1560
+ for required_slot_name in cast(list[str], slot_definition["requires"])
1561
+ ]
1562
+ return preview_step
1563
+
1564
+
1565
+ def _migrate_domain(domain_name: str, migration_context: _MigrationContext) -> dict[str, Any]:
1566
+ domain_definition = migration_context.domains.get(domain_name)
1567
+ if domain_definition is None:
1568
+ raise V3PreviewMigrationError(f"slot references undeclared domain {domain_name!r}")
1569
+ migration_context.used_domain_names.add(domain_name)
1570
+ domain_type = cast(str, domain_definition.get("type", "categorical"))
1571
+ preview_domain: dict[str, Any] = {"type": domain_type}
1572
+ normalization = cast(dict[str, Any], domain_definition.get("normalize") or {})
1573
+ native_normalization = {
1574
+ normalization_key: normalization[normalization_key]
1575
+ for normalization_key in (
1576
+ "accent_fold",
1577
+ "number_words",
1578
+ "affirmation",
1579
+ "emoji_veto",
1580
+ )
1581
+ if normalization_key in normalization
1582
+ }
1583
+ if native_normalization:
1584
+ preview_domain["normalize"] = native_normalization
1585
+ if "optional" in domain_definition:
1586
+ preview_domain["optional"] = domain_definition["optional"]
1587
+ for numeric_constraint in ("minimum", "maximum"):
1588
+ if numeric_constraint in domain_definition:
1589
+ preview_domain[numeric_constraint] = domain_definition[numeric_constraint]
1590
+ if domain_type in {"categorical", "bool"}:
1591
+ migrated_options = _migrate_options(
1592
+ domain_name, domain_definition, normalization, migration_context
1593
+ )
1594
+ if domain_type == "categorical":
1595
+ if normalization.get("number_words") is True and any(
1596
+ option["value"] is None for option in migrated_options
1597
+ ):
1598
+ _materialize_nullable_number_aliases(migrated_options)
1599
+ cast(dict[str, Any], preview_domain.get("normalize", {})).pop("number_words", None)
1600
+ if not preview_domain.get("normalize"):
1601
+ preview_domain.pop("normalize", None)
1602
+ null_option = next(
1603
+ (option for option in migrated_options if option["value"] is None), None
1604
+ )
1605
+ preview_domain["options"] = [
1606
+ option for option in migrated_options if option["value"] is not None
1607
+ ]
1608
+ if null_option is not None:
1609
+ preview_domain["accepts_null"] = True
1610
+ if null_aliases := null_option.get("aliases"):
1611
+ preview_domain["null_aliases"] = null_aliases
1612
+ else:
1613
+ preview_domain["options"] = migrated_options
1614
+ if domain_type == "bool":
1615
+ for ignored_domain_key in ("values", "rows"):
1616
+ if ignored_domain_key in domain_definition:
1617
+ _record_loss(
1618
+ migration_context,
1619
+ V3PreviewLossCategory.INVALID_BOOLEAN_DOMAIN_FIELD,
1620
+ _json_pointer("domains", domain_name, ignored_domain_key),
1621
+ domain_definition[ignored_domain_key],
1622
+ )
1623
+ if domain_type not in {"categorical", "bool"}:
1624
+ for ignored_domain_key in ("values", "rows"):
1625
+ if ignored_domain_key in domain_definition:
1626
+ _record_loss(
1627
+ migration_context,
1628
+ V3PreviewLossCategory.INVALID_NON_CHOICE_DOMAIN_FIELD,
1629
+ _json_pointer("domains", domain_name, ignored_domain_key),
1630
+ domain_definition[ignored_domain_key],
1631
+ )
1632
+ if "synonyms" in normalization:
1633
+ _record_loss(
1634
+ migration_context,
1635
+ V3PreviewLossCategory.INVALID_NON_CHOICE_SYNONYMS,
1636
+ _json_pointer("domains", domain_name, "normalize", "synonyms"),
1637
+ normalization["synonyms"],
1638
+ )
1639
+ return preview_domain
1640
+
1641
+
1642
+ def _materialize_nullable_number_aliases(migrated_options: list[dict[str, Any]]) -> None:
1643
+ option_values = [option["value"] for option in migrated_options]
1644
+ for number_input, target_value in categorical_number_bindings(option_values).items():
1645
+ target_option = next(
1646
+ option
1647
+ for option in migrated_options
1648
+ if _same_json_scalar(option["value"], target_value)
1649
+ )
1650
+ target_option["aliases"] = sorted(
1651
+ {*cast(list[str], target_option.get("aliases", [])), number_input}
1652
+ )
1653
+
1654
+
1655
+ def _migrate_options(
1656
+ domain_name: str,
1657
+ domain_definition: dict[str, Any],
1658
+ normalization: dict[str, Any],
1659
+ migration_context: _MigrationContext,
1660
+ ) -> list[dict[str, Any]]:
1661
+ domain_type = cast(str, domain_definition.get("type", "categorical"))
1662
+ option_values: list[Any] = (
1663
+ [True, False]
1664
+ if domain_type == "bool"
1665
+ else cast(list[Any], domain_definition.get("values") or [])
1666
+ )
1667
+ aliases_by_option = _aliases_by_option(
1668
+ domain_name, option_values, normalization, migration_context
1669
+ )
1670
+ domain_rows = cast(list[dict[str, Any]], domain_definition.get("rows") or [])
1671
+ preview_options: list[dict[str, Any]] = []
1672
+ for option_value in option_values:
1673
+ option_label = (
1674
+ "Yes"
1675
+ if option_value is True
1676
+ else "No"
1677
+ if option_value is False
1678
+ else ""
1679
+ if option_value is None
1680
+ else str(option_value)
1681
+ )
1682
+ preview_option: dict[str, Any] = {"value": option_value, "label": option_label}
1683
+ option_aliases = aliases_by_option.get(_json_identity(option_value), [])
1684
+ if option_aliases:
1685
+ preview_option["aliases"] = sorted(option_aliases)
1686
+ matching_rows = [
1687
+ (row_index, domain_row)
1688
+ for row_index, domain_row in enumerate(domain_rows)
1689
+ if _same_json_scalar(domain_row["value"], option_value)
1690
+ ]
1691
+ if matching_rows:
1692
+ selected_domain_row = matching_rows[-1][1]
1693
+ if "description" in selected_domain_row:
1694
+ preview_option["description"] = selected_domain_row["description"]
1695
+ for overridden_row_index, domain_row in matching_rows[:-1]:
1696
+ _record_loss(
1697
+ migration_context,
1698
+ V3PreviewLossCategory.DUPLICATE_DOMAIN_ROW,
1699
+ _json_pointer("domains", domain_name, "rows", overridden_row_index),
1700
+ domain_row,
1701
+ )
1702
+ preview_options.append(preview_option)
1703
+
1704
+ for row_index, domain_row in enumerate(domain_rows):
1705
+ if not any(
1706
+ _same_json_scalar(domain_row["value"], option_value) for option_value in option_values
1707
+ ):
1708
+ _record_loss(
1709
+ migration_context,
1710
+ V3PreviewLossCategory.ORPHAN_DOMAIN_ROW,
1711
+ _json_pointer("domains", domain_name, "rows", row_index),
1712
+ domain_row,
1713
+ )
1714
+ return preview_options
1715
+
1716
+
1717
+ def _aliases_by_option(
1718
+ domain_name: str,
1719
+ option_values: list[Any],
1720
+ normalization: dict[str, Any],
1721
+ migration_context: _MigrationContext,
1722
+ ) -> dict[str, list[str]]:
1723
+ aliases_by_option: dict[str, list[str]] = {}
1724
+ synonyms = cast(dict[str, Any], normalization.get("synonyms") or {})
1725
+ for option_alias, synonym_target in synonyms.items():
1726
+ matched_option = next(
1727
+ (
1728
+ option_value
1729
+ for option_value in option_values
1730
+ if _same_json_scalar(option_value, synonym_target)
1731
+ ),
1732
+ None,
1733
+ )
1734
+ matched_null = synonym_target is None and any(
1735
+ option_value is None for option_value in option_values
1736
+ )
1737
+ if matched_option is None and not matched_null:
1738
+ _record_loss(
1739
+ migration_context,
1740
+ V3PreviewLossCategory.INVALID_SYNONYM_TARGET,
1741
+ _json_pointer("domains", domain_name, "normalize", "synonyms", option_alias),
1742
+ synonym_target,
1743
+ )
1744
+ continue
1745
+ aliases_by_option.setdefault(_json_identity(matched_option), []).append(option_alias)
1746
+ return aliases_by_option
1747
+
1748
+
1749
+ def _migrate_path_presentation(
1750
+ path_step: dict[str, Any],
1751
+ path_index: int,
1752
+ preview_step: dict[str, Any],
1753
+ slot_name: str,
1754
+ migration_context: _MigrationContext,
1755
+ ) -> None:
1756
+ if "prompt" in path_step:
1757
+ preview_step["prompt"] = _canonical_clone(path_step["prompt"])
1758
+ if "interactive" in path_step:
1759
+ slot_definition = migration_context.slots[slot_name]
1760
+ preview_step["ui"] = _migrate_ui(
1761
+ cast(dict[str, Any], path_step["interactive"]),
1762
+ _json_pointer("path", path_index, "interactive"),
1763
+ cast(str, slot_definition["domain"]),
1764
+ migration_context,
1765
+ )
1766
+ for predicate_key in ("ask_when", "skip_when"):
1767
+ if predicate_key in path_step:
1768
+ preview_step[predicate_key] = _migrate_predicate(
1769
+ cast(dict[str, Any], path_step[predicate_key]), migration_context
1770
+ )
1771
+
1772
+
1773
+ def _apply_step_gate(
1774
+ path_step: dict[str, Any],
1775
+ default_step_identifier: str,
1776
+ preview_step: dict[str, Any],
1777
+ migration_context: _MigrationContext,
1778
+ ) -> None:
1779
+ step_identifier = cast(str, path_step.get("step", default_step_identifier))
1780
+ gate_predicate = migration_context.override_gates.get(step_identifier)
1781
+ if gate_predicate is not None:
1782
+ preview_step["gate"] = _migrate_predicate(gate_predicate, migration_context)
1783
+ migration_context.consumed_override_gate_names.add(step_identifier)
1784
+
1785
+
1786
+ def _merge_confirmation_definition(
1787
+ path_step: dict[str, Any],
1788
+ path_index: int,
1789
+ preview_step: dict[str, Any],
1790
+ migration_context: _MigrationContext,
1791
+ ) -> None:
1792
+ confirmation_definition = migration_context.confirmation_definition
1793
+ if confirmation_definition is None:
1794
+ raise V3PreviewMigrationError(
1795
+ f"/path/{path_index}/correctable requires the top-level /confirm definition"
1796
+ )
1797
+ path_step_identifier = cast(str, path_step.get("step", path_step["confirm"]))
1798
+ if confirmation_definition["step"] != path_step_identifier:
1799
+ raise V3PreviewMigrationError(
1800
+ f"/confirm/step does not match /path/{path_index}: "
1801
+ f"{confirmation_definition['step']!r} != {path_step_identifier!r}"
1802
+ )
1803
+ if confirmation_definition["slot"] != path_step["confirm"]:
1804
+ raise V3PreviewMigrationError(f"/confirm/slot does not match /path/{path_index}/confirm")
1805
+ for presentation_key, preview_key in (("prompt", "prompt"), ("interactive", "ui")):
1806
+ confirmation_presentation = confirmation_definition.get(presentation_key)
1807
+ path_presentation = path_step.get(presentation_key)
1808
+ if confirmation_presentation is None:
1809
+ continue
1810
+ if path_presentation is not None and path_presentation != confirmation_presentation:
1811
+ raise V3PreviewMigrationError(
1812
+ f"/confirm/{presentation_key} conflicts with /path/{path_index}/{presentation_key}"
1813
+ )
1814
+ if path_presentation is None:
1815
+ if presentation_key == "interactive":
1816
+ slot_definition = migration_context.slots[cast(str, path_step["confirm"])]
1817
+ preview_step[preview_key] = _migrate_ui(
1818
+ cast(dict[str, Any], confirmation_presentation),
1819
+ _json_pointer("confirm", "interactive"),
1820
+ cast(str, slot_definition["domain"]),
1821
+ migration_context,
1822
+ )
1823
+ else:
1824
+ preview_step[preview_key] = _canonical_clone(confirmation_presentation)
1825
+ preview_step["correctable"] = [
1826
+ _slot_reference(correctable_slot_name)
1827
+ for correctable_slot_name in cast(list[str], confirmation_definition["correctable"])
1828
+ ]
1829
+ confirmation_target = confirmation_definition.get("on_confirm")
1830
+ if confirmation_target is None:
1831
+ terminal_definition = migration_context.terminal_definition
1832
+ if terminal_definition is None:
1833
+ raise V3PreviewMigrationError(
1834
+ f"/path/{path_index}/correctable has no explicit or terminal confirmation target"
1835
+ )
1836
+ confirmation_target = terminal_definition.get("step", "terminal")
1837
+ preview_step["on_confirm"] = _step_reference(cast(str, confirmation_target))
1838
+ migration_context.confirmation_consumed = True
1839
+
1840
+
1841
+ def _migrate_use_step(
1842
+ path_step: dict[str, Any], migration_context: _MigrationContext
1843
+ ) -> dict[str, Any]:
1844
+ subflow_reference = cast(str, path_step["use"])
1845
+ preview_step: dict[str, Any] = {"use": subflow_reference}
1846
+ declaration_match = next(
1847
+ (
1848
+ (declaration_index, use_declaration)
1849
+ for declaration_index, use_declaration in enumerate(migration_context.use_declarations)
1850
+ if declaration_index not in migration_context.consumed_use_indexes
1851
+ and use_declaration["ref"] == subflow_reference
1852
+ ),
1853
+ None,
1854
+ )
1855
+ if declaration_match is not None:
1856
+ declaration_index, use_declaration = declaration_match
1857
+ migration_context.consumed_use_indexes.add(declaration_index)
1858
+ if "with" in use_declaration:
1859
+ preview_step["with"] = _canonical_clone(use_declaration["with"])
1860
+ return preview_step
1861
+
1862
+
1863
+ def _migrate_explicit_derive_step(
1864
+ path_step: dict[str, Any],
1865
+ path_index: int,
1866
+ migration_context: _MigrationContext,
1867
+ ) -> dict[str, Any]:
1868
+ derive_target = cast(str, path_step["derive"])
1869
+ definition_match = next(
1870
+ (
1871
+ (derive_index, derive_definition)
1872
+ for derive_index, derive_definition in enumerate(migration_context.derive_definitions)
1873
+ if derive_index not in migration_context.consumed_derive_indexes
1874
+ and derive_definition["writes"] == derive_target
1875
+ ),
1876
+ None,
1877
+ )
1878
+ if definition_match is None:
1879
+ raise V3PreviewMigrationError(
1880
+ f"/path/{path_index}/derive references missing definition {derive_target!r}"
1881
+ )
1882
+ derive_index, derive_definition = definition_match
1883
+ migration_context.consumed_derive_indexes.add(derive_index)
1884
+ preview_step = _migrate_derive_definition(derive_definition, migration_context)
1885
+ if "step" in path_step:
1886
+ preview_step["id"] = path_step["step"]
1887
+ return preview_step
1888
+
1889
+
1890
+ def _migrate_derive_definition(
1891
+ derive_definition: dict[str, Any], migration_context: _MigrationContext
1892
+ ) -> dict[str, Any]:
1893
+ source_slots = cast(list[str], derive_definition["from"])
1894
+ preview_step: dict[str, Any] = {
1895
+ "derive": derive_definition["writes"],
1896
+ "from": [_source_reference(source_slot, migration_context) for source_slot in source_slots],
1897
+ "lookup": _canonical_clone(derive_definition["lookup"]),
1898
+ }
1899
+ if "default" in derive_definition:
1900
+ derive_default = derive_definition["default"]
1901
+ if isinstance(derive_default, str) and derive_default.startswith("$from["):
1902
+ try:
1903
+ source_index = int(derive_default.removeprefix("$from[").removesuffix("]"))
1904
+ preview_step["default"] = _source_reference(
1905
+ source_slots[source_index], migration_context
1906
+ )
1907
+ except (ValueError, IndexError) as migration_error:
1908
+ raise V3PreviewMigrationError(
1909
+ f"invalid derive fallback reference {derive_default!r}"
1910
+ ) from migration_error
1911
+ else:
1912
+ preview_step["default"] = _canonical_clone(derive_default)
1913
+ return preview_step
1914
+
1915
+
1916
+ def _insert_unanchored_derives(
1917
+ preview_steps: list[dict[str, Any]], migration_context: _MigrationContext
1918
+ ) -> None:
1919
+ last_inserted_identifier_by_anchor: dict[str, str] = {}
1920
+ for derive_index, derive_definition in enumerate(migration_context.derive_definitions):
1921
+ if derive_index in migration_context.consumed_derive_indexes:
1922
+ continue
1923
+ preview_derive_step = _migrate_derive_definition(derive_definition, migration_context)
1924
+ migration_context.consumed_derive_indexes.add(derive_index)
1925
+ after_identifier = derive_definition.get("after")
1926
+ if after_identifier is None:
1927
+ preview_steps.append(preview_derive_step)
1928
+ continue
1929
+ effective_anchor = last_inserted_identifier_by_anchor.get(
1930
+ cast(str, after_identifier), cast(str, after_identifier)
1931
+ )
1932
+ anchor_index = next(
1933
+ (
1934
+ step_index
1935
+ for step_index, preview_step in enumerate(preview_steps)
1936
+ if _preview_step_identifier(preview_step) == effective_anchor
1937
+ ),
1938
+ None,
1939
+ )
1940
+ if anchor_index is None:
1941
+ preview_steps.append(preview_derive_step)
1942
+ _record_loss(
1943
+ migration_context,
1944
+ V3PreviewLossCategory.UNKNOWN_DERIVE_ANCHOR,
1945
+ _json_pointer("derive", derive_index, "after"),
1946
+ after_identifier,
1947
+ )
1948
+ else:
1949
+ preview_steps.insert(anchor_index + 1, preview_derive_step)
1950
+ inserted_identifier = _preview_step_identifier(preview_derive_step)
1951
+ if inserted_identifier is None:
1952
+ raise V3PreviewMigrationError("migrated derive has no stable identifier")
1953
+ last_inserted_identifier_by_anchor[cast(str, after_identifier)] = inserted_identifier
1954
+
1955
+
1956
+ def _migrate_submit_step(path_index: int, migration_context: _MigrationContext) -> dict[str, Any]:
1957
+ terminal_definition = migration_context.terminal_definition
1958
+ if terminal_definition is None:
1959
+ raise V3PreviewMigrationError(
1960
+ f"/path/{path_index}/terminal has no top-level /terminal definition"
1961
+ )
1962
+ if migration_context.terminal_consumed:
1963
+ raise V3PreviewMigrationError("flowspec/3-draft preview supports a singular submit step")
1964
+ preview_inputs: dict[str, Any] = {}
1965
+ input_indexes_by_parameter: dict[str, list[int]] = {}
1966
+ for input_index, input_binding in enumerate(
1967
+ cast(list[dict[str, Any]], terminal_definition.get("input") or [])
1968
+ ):
1969
+ parameter_name = cast(str, input_binding["param"])
1970
+ input_indexes_by_parameter.setdefault(parameter_name, []).append(input_index)
1971
+ preview_inputs[parameter_name] = _source_reference(
1972
+ cast(str, input_binding["slot"]), migration_context
1973
+ )
1974
+ for duplicate_indexes in input_indexes_by_parameter.values():
1975
+ for duplicate_index in duplicate_indexes[:-1]:
1976
+ _record_loss(
1977
+ migration_context,
1978
+ V3PreviewLossCategory.DUPLICATE_TERMINAL_INPUT_PARAMETER,
1979
+ _json_pointer("terminal", "input", duplicate_index),
1980
+ cast(list[dict[str, Any]], terminal_definition["input"])[duplicate_index],
1981
+ )
1982
+
1983
+ preview_outputs = {
1984
+ state_key: _result_reference(cast(str, result_path))
1985
+ for state_key, result_path in cast(
1986
+ dict[str, Any], terminal_definition.get("outputs") or {}
1987
+ ).items()
1988
+ }
1989
+ preview_step: dict[str, Any] = {
1990
+ "submit": terminal_definition["tool"],
1991
+ "id": terminal_definition["step"],
1992
+ "idempotent": terminal_definition["idempotent"],
1993
+ "outcomes": _migrate_outcomes(cast(dict[str, Any], terminal_definition["outcomes"])),
1994
+ }
1995
+ if preview_inputs:
1996
+ preview_step["input"] = preview_inputs
1997
+ if preview_outputs:
1998
+ preview_step["outputs"] = preview_outputs
1999
+ if "empty_payload" in terminal_definition:
2000
+ preview_step["empty_payload"] = _canonical_clone(terminal_definition["empty_payload"])
2001
+ migration_context.terminal_consumed = True
2002
+ return preview_step
2003
+
2004
+
2005
+ def _migrate_outcomes(outcomes: dict[str, Any]) -> dict[str, Any]:
2006
+ return cast(dict[str, Any], _canonical_clone(outcomes))
2007
+
2008
+
2009
+ def _migrate_await_step(
2010
+ path_step: dict[str, Any],
2011
+ path_index: int,
2012
+ migration_context: _MigrationContext,
2013
+ ) -> dict[str, Any]:
2014
+ await_capability = migration_context.await_capability
2015
+ if await_capability is None:
2016
+ raise V3PreviewMigrationError(
2017
+ f"/path/{path_index}/await_external has no /capabilities/await_external definition"
2018
+ )
2019
+ if migration_context.await_capability_consumed:
2020
+ raise V3PreviewMigrationError("flowspec/3-draft preview supports a singular await step")
2021
+ path_identifier = path_step.get("step")
2022
+ capability_identifier = await_capability.get("step")
2023
+ if (
2024
+ path_identifier is not None
2025
+ and capability_identifier is not None
2026
+ and path_identifier != capability_identifier
2027
+ ):
2028
+ raise V3PreviewMigrationError(
2029
+ f"/path/{path_index}/step conflicts with /capabilities/await_external/step"
2030
+ )
2031
+ step_identifier = cast(str, path_identifier or capability_identifier or "await_external")
2032
+ preview_step: dict[str, Any] = {
2033
+ "await": await_capability["kind"],
2034
+ "id": step_identifier,
2035
+ "resume_on": await_capability["resume_on"],
2036
+ }
2037
+ _merge_await_presentation(
2038
+ "prompt", "prompt", path_step, path_index, await_capability, preview_step, migration_context
2039
+ )
2040
+ _merge_await_presentation(
2041
+ "interactive",
2042
+ "ui",
2043
+ path_step,
2044
+ path_index,
2045
+ await_capability,
2046
+ preview_step,
2047
+ migration_context,
2048
+ )
2049
+ if "ui" not in preview_step:
2050
+ raise V3PreviewMigrationError(
2051
+ f"/path/{path_index}/await_external has no out-of-band CTA contract"
2052
+ )
2053
+ if "on_resume" in await_capability:
2054
+ preview_on_resume = _migrate_on_resume(
2055
+ cast(dict[str, Any], await_capability["on_resume"]), migration_context
2056
+ )
2057
+ if preview_on_resume:
2058
+ preview_step["on_resume"] = preview_on_resume
2059
+ if "resume" in await_capability:
2060
+ _record_loss(
2061
+ migration_context,
2062
+ V3PreviewLossCategory.AWAIT_RESUME_CONTRACT,
2063
+ _json_pointer("capabilities", "await_external", "resume"),
2064
+ await_capability["resume"],
2065
+ )
2066
+ if "timeout_seconds" in await_capability:
2067
+ _record_loss(
2068
+ migration_context,
2069
+ V3PreviewLossCategory.AWAIT_TIMEOUT_DURATION,
2070
+ _json_pointer("capabilities", "await_external", "timeout_seconds"),
2071
+ await_capability["timeout_seconds"],
2072
+ )
2073
+ if "timeout" in await_capability:
2074
+ preview_step["timeout"] = _migrate_transition(
2075
+ cast(dict[str, Any], await_capability["timeout"])
2076
+ )
2077
+ if "recovery" in await_capability:
2078
+ preview_step["recovery"] = {
2079
+ recovery_event: _migrate_transition(cast(dict[str, Any], transition))
2080
+ for recovery_event, transition in cast(
2081
+ dict[str, Any], await_capability["recovery"]
2082
+ ).items()
2083
+ }
2084
+ migration_context.await_capability_consumed = True
2085
+ return preview_step
2086
+
2087
+
2088
+ def _merge_await_presentation(
2089
+ source_key: str,
2090
+ preview_key: str,
2091
+ path_step: dict[str, Any],
2092
+ path_index: int,
2093
+ await_capability: dict[str, Any],
2094
+ preview_step: dict[str, Any],
2095
+ migration_context: _MigrationContext,
2096
+ ) -> None:
2097
+ path_presentation = path_step.get(source_key)
2098
+ capability_presentation = await_capability.get(source_key)
2099
+ selected_presentation = path_presentation or capability_presentation
2100
+ if selected_presentation is None:
2101
+ return
2102
+ if (
2103
+ path_presentation is not None
2104
+ and capability_presentation is not None
2105
+ and capability_presentation != path_presentation
2106
+ ):
2107
+ _record_loss(
2108
+ migration_context,
2109
+ V3PreviewLossCategory.SHADOWED_AWAIT_PRESENTATION,
2110
+ _json_pointer("capabilities", "await_external", source_key),
2111
+ capability_presentation,
2112
+ )
2113
+ if source_key == "interactive":
2114
+ preview_step[preview_key] = _migrate_ui(
2115
+ cast(dict[str, Any], selected_presentation),
2116
+ _json_pointer("path", path_index, source_key)
2117
+ if path_presentation is not None
2118
+ else _json_pointer("capabilities", "await_external", source_key),
2119
+ None,
2120
+ migration_context,
2121
+ )
2122
+ else:
2123
+ preview_step[preview_key] = _canonical_clone(selected_presentation)
2124
+
2125
+
2126
+ def _migrate_on_resume(
2127
+ on_resume: dict[str, Any], migration_context: _MigrationContext
2128
+ ) -> dict[str, Any]:
2129
+ preview_on_resume: dict[str, Any] = {}
2130
+ if "set" in on_resume:
2131
+ preview_on_resume["set"] = _migrate_binding_map(cast(dict[str, Any], on_resume["set"]))
2132
+ if "enrich" in on_resume:
2133
+ enrichment = on_resume["enrich"]
2134
+ if isinstance(enrichment, str):
2135
+ _record_loss(
2136
+ migration_context,
2137
+ V3PreviewLossCategory.LEGACY_AWAIT_ENRICHMENT,
2138
+ _json_pointer("capabilities", "await_external", "on_resume", "enrich"),
2139
+ enrichment,
2140
+ )
2141
+ else:
2142
+ enrichment_definition = cast(dict[str, Any], enrichment)
2143
+ preview_enrichment: dict[str, Any] = {"tool": enrichment_definition["tool"]}
2144
+ if "optional" in enrichment_definition:
2145
+ preview_enrichment["optional"] = enrichment_definition["optional"]
2146
+ if "input" in enrichment_definition:
2147
+ preview_enrichment["input"] = _migrate_binding_map(
2148
+ cast(dict[str, Any], enrichment_definition["input"])
2149
+ )
2150
+ if "set" in enrichment_definition:
2151
+ preview_enrichment["set"] = _migrate_binding_map(
2152
+ cast(dict[str, Any], enrichment_definition["set"])
2153
+ )
2154
+ preview_on_resume["enrich"] = preview_enrichment
2155
+ return preview_on_resume
2156
+
2157
+
2158
+ def _migrate_transition(transition: dict[str, Any]) -> dict[str, Any]:
2159
+ transition_target = cast(str, transition["goto"])
2160
+ preview_transition: dict[str, Any] = {
2161
+ "goto": "END" if transition_target == "END" else _step_reference(transition_target)
2162
+ }
2163
+ if "set" in transition:
2164
+ preview_transition["set"] = _canonical_clone(transition["set"])
2165
+ return preview_transition
2166
+
2167
+
2168
+ def _migrate_ui(
2169
+ interactive: dict[str, Any],
2170
+ source_pointer: str,
2171
+ expected_domain_name: str | None,
2172
+ migration_context: _MigrationContext,
2173
+ ) -> dict[str, Any]:
2174
+ preview_ui: dict[str, Any] = {}
2175
+ for interactive_key, interactive_value in interactive.items():
2176
+ if interactive_key == "gate":
2177
+ _record_loss(
2178
+ migration_context,
2179
+ V3PreviewLossCategory.LEGACY_INTERACTIVE_GATE,
2180
+ f"{source_pointer}/gate",
2181
+ interactive_value,
2182
+ )
2183
+ continue
2184
+ if interactive_key == "from_domain":
2185
+ if expected_domain_name != interactive_value:
2186
+ _record_loss(
2187
+ migration_context,
2188
+ V3PreviewLossCategory.MISMATCHED_INTERACTIVE_DOMAIN,
2189
+ f"{source_pointer}/from_domain",
2190
+ interactive_value,
2191
+ )
2192
+ continue
2193
+ if interactive_key == "next_step":
2194
+ preview_ui[interactive_key] = _step_reference(cast(str, interactive_value))
2195
+ continue
2196
+ if interactive_key == "prefill_from":
2197
+ preview_ui[interactive_key] = [
2198
+ _slot_reference(prefill_slot_name)
2199
+ for prefill_slot_name in cast(list[str], interactive_value)
2200
+ ]
2201
+ continue
2202
+ if interactive_key == "options_when":
2203
+ preview_ui[interactive_key] = [
2204
+ {
2205
+ "value": option_gate["value"],
2206
+ "gate": _migrate_predicate(
2207
+ cast(dict[str, Any], option_gate["gate"]), migration_context
2208
+ ),
2209
+ }
2210
+ for option_gate in cast(list[dict[str, Any]], interactive_value)
2211
+ ]
2212
+ continue
2213
+ preview_ui[interactive_key] = _canonical_clone(interactive_value)
2214
+ return preview_ui
2215
+
2216
+
2217
+ def _migrate_predicate(
2218
+ predicate: dict[str, Any], migration_context: _MigrationContext
2219
+ ) -> dict[str, Any]:
2220
+ operator, operand = next(iter(predicate.items()))
2221
+ if operator in {"and", "or"}:
2222
+ return {
2223
+ operator: [
2224
+ _migrate_predicate(child_predicate, migration_context)
2225
+ for child_predicate in cast(list[dict[str, Any]], operand)
2226
+ ]
2227
+ }
2228
+ if operator == "not":
2229
+ return {operator: _migrate_predicate(cast(dict[str, Any], operand), migration_context)}
2230
+ if operator == "is_present":
2231
+ return {operator: _migrate_predicate_reference(cast(str, operand), migration_context)}
2232
+ if operator == "in":
2233
+ membership_operands = cast(list[Any], operand)
2234
+ return {
2235
+ operator: [
2236
+ _migrate_predicate_reference(cast(str, membership_operands[0]), migration_context),
2237
+ _canonical_clone(membership_operands[1]),
2238
+ ]
2239
+ }
2240
+ comparison_operands = cast(list[Any], operand)
2241
+ return {
2242
+ operator: [
2243
+ _migrate_predicate_operand(comparison_operands[0], migration_context),
2244
+ _migrate_predicate_operand(comparison_operands[1], migration_context),
2245
+ ]
2246
+ }
2247
+
2248
+
2249
+ def _migrate_predicate_operand(predicate_operand: Any, migration_context: _MigrationContext) -> Any:
2250
+ if isinstance(predicate_operand, dict) and set(predicate_operand) == {"literal"}:
2251
+ return _canonical_clone(predicate_operand["literal"])
2252
+ if isinstance(predicate_operand, str):
2253
+ namespace, separator, reference_path = predicate_operand.partition(".")
2254
+ if separator and namespace == "slots" and reference_path:
2255
+ return _source_reference(reference_path, migration_context)
2256
+ if separator and namespace in _PREDICATE_REFERENCE_NAMESPACES and reference_path:
2257
+ return {_PREDICATE_REFERENCE_NAMESPACES[namespace]: reference_path}
2258
+ return _canonical_clone(predicate_operand)
2259
+
2260
+
2261
+ def _migrate_predicate_reference(
2262
+ reference: str, migration_context: _MigrationContext
2263
+ ) -> dict[str, str]:
2264
+ migrated_reference = _migrate_predicate_operand(reference, migration_context)
2265
+ if not isinstance(migrated_reference, dict):
2266
+ raise V3PreviewMigrationError(f"predicate reference is not namespaced: {reference!r}")
2267
+ return cast(dict[str, str], migrated_reference)
2268
+
2269
+
2270
+ def _migrate_binding_map(bindings: dict[str, Any]) -> dict[str, Any]:
2271
+ return {
2272
+ binding_target: _migrate_binding(binding_source)
2273
+ for binding_target, binding_source in bindings.items()
2274
+ }
2275
+
2276
+
2277
+ def _migrate_binding(binding_source: Any) -> Any:
2278
+ if isinstance(binding_source, str):
2279
+ for source_prefix, reference_key in (
2280
+ ("$token.", "$token"),
2281
+ ("$result.", "$result"),
2282
+ ):
2283
+ if binding_source.startswith(source_prefix):
2284
+ return {reference_key: binding_source.removeprefix(source_prefix)}
2285
+ return _canonical_clone(binding_source)
2286
+
2287
+
2288
+ def _collect_loss_entries(migration_context: _MigrationContext) -> None:
2289
+ source_document = migration_context.source_document
2290
+ if "entry" in source_document:
2291
+ _record_loss(
2292
+ migration_context,
2293
+ V3PreviewLossCategory.ENTRY_CONTRACT,
2294
+ _json_pointer("entry"),
2295
+ source_document["entry"],
2296
+ )
2297
+ if "auto_flow" in source_document:
2298
+ _record_loss(
2299
+ migration_context,
2300
+ V3PreviewLossCategory.AUTOMATIC_FLOW_CONTRACT,
2301
+ _json_pointer("auto_flow"),
2302
+ source_document["auto_flow"],
2303
+ )
2304
+
2305
+ capabilities = cast(dict[str, Any], source_document.get("capabilities") or {})
2306
+ for capability_name, capability_definition in capabilities.items():
2307
+ if capability_name == "await_external" and migration_context.await_capability_consumed:
2308
+ continue
2309
+ _record_loss(
2310
+ migration_context,
2311
+ V3PreviewLossCategory.IMPLICIT_AWAIT_CAPABILITY
2312
+ if capability_name == "await_external"
2313
+ else V3PreviewLossCategory.AGENT_CAPABILITY,
2314
+ _json_pointer("capabilities", capability_name),
2315
+ capability_definition,
2316
+ )
2317
+
2318
+ for domain_name, domain_definition in migration_context.domains.items():
2319
+ if domain_name not in migration_context.used_domain_names:
2320
+ _record_loss(
2321
+ migration_context,
2322
+ V3PreviewLossCategory.UNUSED_DOMAIN_DECLARATION,
2323
+ _json_pointer("domains", domain_name),
2324
+ domain_definition,
2325
+ )
2326
+ for slot_name, slot_definition in migration_context.slots.items():
2327
+ if slot_name not in migration_context.placed_slot_names:
2328
+ _record_loss(
2329
+ migration_context,
2330
+ V3PreviewLossCategory.UNUSED_SLOT_DECLARATION,
2331
+ _json_pointer("slots", slot_name),
2332
+ slot_definition,
2333
+ )
2334
+ for use_index, use_declaration in enumerate(migration_context.use_declarations):
2335
+ if use_index not in migration_context.consumed_use_indexes:
2336
+ _record_loss(
2337
+ migration_context,
2338
+ V3PreviewLossCategory.UNUSED_SUBFLOW_DECLARATION,
2339
+ _json_pointer("uses", use_index),
2340
+ use_declaration,
2341
+ )
2342
+ if (
2343
+ migration_context.confirmation_definition is not None
2344
+ and not migration_context.confirmation_consumed
2345
+ ):
2346
+ _record_loss(
2347
+ migration_context,
2348
+ V3PreviewLossCategory.UNUSED_CONFIRMATION_CONTRACT,
2349
+ _json_pointer("confirm"),
2350
+ migration_context.confirmation_definition,
2351
+ )
2352
+ if (
2353
+ migration_context.terminal_definition is not None
2354
+ and not migration_context.terminal_consumed
2355
+ ):
2356
+ _record_loss(
2357
+ migration_context,
2358
+ V3PreviewLossCategory.UNUSED_TERMINAL_CONTRACT,
2359
+ _json_pointer("terminal"),
2360
+ migration_context.terminal_definition,
2361
+ )
2362
+ for gate_name, gate_predicate in migration_context.override_gates.items():
2363
+ if gate_name not in migration_context.consumed_override_gate_names:
2364
+ _record_loss(
2365
+ migration_context,
2366
+ V3PreviewLossCategory.UNUSED_OVERRIDE_GATE,
2367
+ _json_pointer("overrides", "gates", gate_name),
2368
+ gate_predicate,
2369
+ )
2370
+ for top_level_key, top_level_fragment in source_document.items():
2371
+ if top_level_key not in _TOP_LEVEL_NATIVE_KEYS:
2372
+ _record_loss(
2373
+ migration_context,
2374
+ V3PreviewLossCategory.UNKNOWN_TOP_LEVEL_FRAGMENT,
2375
+ _json_pointer(top_level_key),
2376
+ top_level_fragment,
2377
+ )
2378
+
2379
+
2380
+ def _record_loss(
2381
+ migration_context: _MigrationContext,
2382
+ category: V3PreviewLossCategory,
2383
+ source_path: str,
2384
+ source_fragment: Any,
2385
+ ) -> None:
2386
+ if source_path in migration_context.loss_entries_by_path:
2387
+ raise V3PreviewMigrationError(f"source path {source_path!r} was classified more than once")
2388
+ migration_context.loss_entries_by_path[source_path] = V3PreviewLossEntry(
2389
+ source_path=source_path,
2390
+ category=category,
2391
+ disposition=_loss_policy_rules()[category].disposition,
2392
+ _source_fragment_json=_canonical_json(source_fragment),
2393
+ )
2394
+
2395
+
2396
+ def _preview_step_identifier(preview_step: dict[str, Any]) -> str | None:
2397
+ explicit_identifier = preview_step.get("id")
2398
+ if isinstance(explicit_identifier, str):
2399
+ return explicit_identifier
2400
+ for step_kind in ("collect", "confirm"):
2401
+ step_target = preview_step.get(step_kind)
2402
+ if isinstance(step_target, str):
2403
+ return step_target
2404
+ derive_target = preview_step.get("derive")
2405
+ if isinstance(derive_target, str):
2406
+ return f"derive_{derive_target}"
2407
+ return None
2408
+
2409
+
2410
+ def _slot_reference(slot_path: str) -> dict[str, str]:
2411
+ return {"$slot": slot_path}
2412
+
2413
+
2414
+ def _source_reference(source_name: str, migration_context: _MigrationContext) -> dict[str, str]:
2415
+ derive_targets = {
2416
+ cast(str, derive_definition["writes"])
2417
+ for derive_definition in migration_context.derive_definitions
2418
+ }
2419
+ return (
2420
+ {"$derive": source_name}
2421
+ if source_name in derive_targets and source_name not in migration_context.slots
2422
+ else _slot_reference(source_name)
2423
+ )
2424
+
2425
+
2426
+ def _step_reference(step_identifier: str) -> dict[str, str]:
2427
+ return {"$step": step_identifier}
2428
+
2429
+
2430
+ def _result_reference(result_path: str) -> dict[str, str]:
2431
+ return {"$result": result_path.removeprefix("result.") if result_path != "result" else "$"}
2432
+
2433
+
2434
+ def _same_json_scalar(left_scalar: Any, right_scalar: Any) -> bool:
2435
+ return type(left_scalar) is type(right_scalar) and left_scalar == right_scalar
2436
+
2437
+
2438
+ def _json_identity(json_scalar: Any) -> str:
2439
+ return json.dumps(json_scalar, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
2440
+
2441
+
2442
+ def _json_pointer(*path_segments: str | int) -> str:
2443
+ escaped_segments = [
2444
+ str(path_segment).replace("~", "~0").replace("/", "~1") for path_segment in path_segments
2445
+ ]
2446
+ return "/" + "/".join(escaped_segments)
2447
+
2448
+
2449
+ def _canonical_json(json_value: Any) -> str:
2450
+ return json.dumps(
2451
+ json_value,
2452
+ ensure_ascii=False,
2453
+ sort_keys=True,
2454
+ separators=(",", ":"),
2455
+ allow_nan=False,
2456
+ )
2457
+
2458
+
2459
+ def _canonical_clone(json_value: Any) -> Any:
2460
+ return json.loads(_canonical_json(json_value))