flowspec2 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. flowspec2/__init__.py +105 -0
  2. flowspec2/authoring/__init__.py +275 -0
  3. flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
  4. flowspec2/authoring/authoring-evidence.schema.json +455 -0
  5. flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
  6. flowspec2/authoring/benchmark.py +1409 -0
  7. flowspec2/authoring/corpus/await_correction.case.json +220 -0
  8. flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
  9. flowspec2/authoring/corpus/gated_derive.case.json +100 -0
  10. flowspec2/authoring/corpus/linear.case.json +55 -0
  11. flowspec2/authoring/corpus/manifest.json +31 -0
  12. flowspec2/authoring/corpus/subflow.case.json +66 -0
  13. flowspec2/authoring/corpus/terminal.case.json +94 -0
  14. flowspec2/authoring/corpus.py +307 -0
  15. flowspec2/authoring/ctk.py +836 -0
  16. flowspec2/authoring/detached_signature.py +120 -0
  17. flowspec2/authoring/evidence.py +311 -0
  18. flowspec2/authoring/evidence_signature.py +187 -0
  19. flowspec2/authoring/evidence_verification.py +229 -0
  20. flowspec2/authoring/gemini.py +215 -0
  21. flowspec2/authoring/operational-corpus.json +61 -0
  22. flowspec2/authoring/operational.py +956 -0
  23. flowspec2/authoring/operational_providers.py +167 -0
  24. flowspec2/authoring/presentation_review.py +1013 -0
  25. flowspec2/authoring/presentation_review_signature.py +305 -0
  26. flowspec2/authoring/projection.py +299 -0
  27. flowspec2/authoring/provider_prompt.py +83 -0
  28. flowspec2/backends/__init__.py +82 -0
  29. flowspec2/backends/http.py +189 -0
  30. flowspec2/checker.py +238 -0
  31. flowspec2/cli.py +789 -0
  32. flowspec2/cli_parser.py +345 -0
  33. flowspec2/clock.py +23 -0
  34. flowspec2/compat/__init__.py +47 -0
  35. flowspec2/compat/models.py +82 -0
  36. flowspec2/compat/open_workflow.py +616 -0
  37. flowspec2/compat/rasa.py +19 -0
  38. flowspec2/compat/rasa_export.py +876 -0
  39. flowspec2/compat/rasa_import.py +992 -0
  40. flowspec2/compat/rasa_shared.py +270 -0
  41. flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
  42. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
  43. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
  44. flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
  45. flowspec2/compat/tool_profiles.py +149 -0
  46. flowspec2/compat/yaml.py +147 -0
  47. flowspec2/compiler.py +957 -0
  48. flowspec2/compiler_contracts.py +17 -0
  49. flowspec2/compiler_resume_contracts.py +594 -0
  50. flowspec2/compiler_schema_relations.py +1830 -0
  51. flowspec2/compiler_tool_contracts.py +245 -0
  52. flowspec2/compiler_value_contracts.py +447 -0
  53. flowspec2/derive.py +32 -0
  54. flowspec2/diagnostics.py +94 -0
  55. flowspec2/domains.py +489 -0
  56. flowspec2/experimental/__init__.py +57 -0
  57. flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
  58. flowspec2/experimental/v3-preview-loss-policy.json +161 -0
  59. flowspec2/experimental/v3_lowering.py +928 -0
  60. flowspec2/experimental/v3_preview.py +2460 -0
  61. flowspec2/flowspec-2.schema.json +635 -0
  62. flowspec2/interactive.py +300 -0
  63. flowspec2/ir.py +1459 -0
  64. flowspec2/json_codec.py +120 -0
  65. flowspec2/llm.py +366 -0
  66. flowspec2/models.py +121 -0
  67. flowspec2/nodes.py +1537 -0
  68. flowspec2/observability.py +151 -0
  69. flowspec2/predicates.py +89 -0
  70. flowspec2/profiles.py +118 -0
  71. flowspec2/py.typed +0 -0
  72. flowspec2/runtime.py +1059 -0
  73. flowspec2/schema.py +54 -0
  74. flowspec2/schema_contracts.py +203 -0
  75. flowspec2/semantic_derive_contracts.py +530 -0
  76. flowspec2/semantic_path_contracts.py +528 -0
  77. flowspec2/semantic_predicate_contracts.py +355 -0
  78. flowspec2/semantic_schema_contracts.py +137 -0
  79. flowspec2/semantic_source_contracts.py +21 -0
  80. flowspec2/semantic_state_contracts.py +450 -0
  81. flowspec2/semantic_support.py +40 -0
  82. flowspec2/semantics.py +410 -0
  83. flowspec2/state_migration.py +1034 -0
  84. flowspec2/subflows/__init__.py +1117 -0
  85. flowspec2/subflows/address.py +328 -0
  86. flowspec2/subflows/identification.py +1031 -0
  87. flowspec2/tools.py +1154 -0
  88. flowspec2-1.0.0.dist-info/METADATA +482 -0
  89. flowspec2-1.0.0.dist-info/RECORD +92 -0
  90. flowspec2-1.0.0.dist-info/WHEEL +4 -0
  91. flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
  92. flowspec2-1.0.0.dist-info/licenses/LICENSE +21 -0
flowspec2/semantics.py ADDED
@@ -0,0 +1,410 @@
1
+ """Aggregate semantic linking for structurally valid FlowSpec2 documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, cast
6
+
7
+ from jsonschema import Draft202012Validator, FormatChecker
8
+
9
+ from .diagnostics import DiagnosticLocation, FlowDiagnostic
10
+ from .ir import normalize_flow
11
+ from .profiles import FlowProfile, capability_is_requested
12
+ from .semantic_source_contracts import (
13
+ derive_contracts,
14
+ domain_contracts,
15
+ entry_schema_contracts,
16
+ path_contracts,
17
+ predicate_contracts,
18
+ rail_reference_contracts,
19
+ slot_contracts,
20
+ state_writer_contracts,
21
+ )
22
+ from .semantic_support import json_pointer, mutable_json, semantic_diagnostic
23
+
24
+
25
+ class FlowLinkError(ValueError):
26
+ """Raised when a structurally valid flow fails semantic or profile linking."""
27
+
28
+ def __init__(self, diagnostics: tuple[FlowDiagnostic, ...]) -> None:
29
+ self.diagnostics = diagnostics
30
+ rendered_diagnostics = "; ".join(
31
+ f"{diagnostic.code} at {diagnostic.path or '/'}: {diagnostic.message}"
32
+ for diagnostic in diagnostics
33
+ )
34
+ super().__init__(f"FlowSpec2 linking failed: {rendered_diagnostics}")
35
+
36
+
37
+ def profile_contracts(
38
+ document: dict[str, Any],
39
+ profile: FlowProfile,
40
+ ) -> tuple[list[FlowDiagnostic], frozenset[str] | None, dict[str, int]]:
41
+ diagnostics: list[FlowDiagnostic] = []
42
+ exposed_slots: set[str] = set()
43
+ exposed_slot_positions: dict[str, int] = {}
44
+ exposed_slot_owners: dict[str, tuple[str, int]] = {}
45
+ state_key_owners: dict[str, tuple[str, int, bool]] = {}
46
+ has_open_manifest = False
47
+ required_capabilities: set[tuple[str, str]] = set()
48
+ use_anchor_positions = {
49
+ cast(str, path_step["use"]): path_index
50
+ for path_index, path_step in enumerate(document["path"])
51
+ if "use" in path_step
52
+ }
53
+ authored_state_writers: dict[str, list[tuple[str, str]]] = {}
54
+
55
+ def add_authored_state_writer(state_key: str, path: str, writer_kind: str) -> None:
56
+ authored_state_writers.setdefault(state_key, []).append((path, writer_kind))
57
+
58
+ for slot_name in document.get("slots", {}):
59
+ add_authored_state_writer(slot_name, json_pointer("slots", slot_name), "slot")
60
+ for derive_index, derive_definition in enumerate(document.get("derive", [])):
61
+ add_authored_state_writer(
62
+ cast(str, derive_definition["writes"]),
63
+ json_pointer("derive", derive_index, "writes"),
64
+ "derive",
65
+ )
66
+ if (entry_definition := document.get("entry")) is not None and entry_definition.get("writes"):
67
+ add_authored_state_writer(
68
+ cast(str, entry_definition["writes"]),
69
+ "/entry/writes",
70
+ "entry",
71
+ )
72
+ if (terminal_definition := document.get("terminal")) is not None:
73
+ for output_key in terminal_definition.get("outputs", {}):
74
+ add_authored_state_writer(
75
+ output_key,
76
+ json_pointer("terminal", "outputs", output_key),
77
+ "terminal",
78
+ )
79
+ success_set = cast(
80
+ dict[str, Any],
81
+ cast(dict[str, Any], terminal_definition.get("outcomes", {}))
82
+ .get("success", {})
83
+ .get("set", {}),
84
+ )
85
+ for state_key in success_set:
86
+ add_authored_state_writer(
87
+ state_key,
88
+ json_pointer("terminal", "outcomes", "success", "set", state_key),
89
+ "terminal",
90
+ )
91
+
92
+ for domain_name, domain_definition in document.get("domains", {}).items():
93
+ domain_type = domain_definition["type"]
94
+ if domain_type not in profile.domain_types:
95
+ diagnostics.append(
96
+ semantic_diagnostic(
97
+ "FLOWSPEC_PROFILE_DOMAIN_UNSUPPORTED",
98
+ json_pointer("domains", domain_name, "type"),
99
+ f"profile {profile.identifier!r} does not implement domain type {domain_type!r}",
100
+ )
101
+ )
102
+
103
+ for use_index, use_definition in enumerate(document.get("uses", [])):
104
+ reference = use_definition["ref"]
105
+ if not profile.subflows.has(reference):
106
+ diagnostics.append(
107
+ semantic_diagnostic(
108
+ "FLOWSPEC_PROFILE_SUBFLOW_UNAVAILABLE",
109
+ json_pointer("uses", use_index, "ref"),
110
+ f"profile {profile.identifier!r} does not register subflow {reference!r}",
111
+ )
112
+ )
113
+ has_open_manifest = True
114
+ continue
115
+ definition = profile.subflows.definition(reference)
116
+ if definition.legacy_manifest and not profile.allow_legacy_contracts:
117
+ diagnostics.append(
118
+ semantic_diagnostic(
119
+ "FLOWSPEC_PROFILE_LEGACY_SUBFLOW_MANIFEST_FORBIDDEN",
120
+ json_pointer("uses", use_index, "ref"),
121
+ f"profile {profile.identifier!r} requires a typed manifest for "
122
+ f"subflow {reference!r}",
123
+ )
124
+ )
125
+ if definition.exposed_slots is None:
126
+ has_open_manifest = True
127
+ else:
128
+ exposed_slots.update(definition.exposed_slots)
129
+ for exposed_slot in definition.exposed_slots:
130
+ if (first_owner := exposed_slot_owners.get(exposed_slot)) is not None:
131
+ diagnostics.append(
132
+ semantic_diagnostic(
133
+ "FLOWSPEC_PROFILE_SUBFLOW_SLOT_COLLISION",
134
+ json_pointer("uses", use_index, "ref"),
135
+ f"subflow slot {exposed_slot!r} is also owned by {first_owner[0]!r}",
136
+ related=(
137
+ DiagnosticLocation(
138
+ path=json_pointer("uses", first_owner[1], "ref"),
139
+ message="First owning subflow declaration.",
140
+ ),
141
+ ),
142
+ )
143
+ )
144
+ else:
145
+ exposed_slot_owners[exposed_slot] = (reference, use_index)
146
+ if reference in use_anchor_positions:
147
+ exposed_slot_positions[exposed_slot] = use_anchor_positions[reference]
148
+ for colliding_slot in sorted(set(document.get("slots", {})) & definition.exposed_slots):
149
+ diagnostics.append(
150
+ semantic_diagnostic(
151
+ "FLOWSPEC_PROFILE_SUBFLOW_SLOT_COLLISION",
152
+ json_pointer("slots", colliding_slot),
153
+ f"slot {colliding_slot!r} is owned by subflow {reference!r}",
154
+ related=(
155
+ DiagnosticLocation(
156
+ path=json_pointer("uses", use_index, "ref"),
157
+ message="Owning subflow declaration.",
158
+ ),
159
+ ),
160
+ suggested_fix="Remove the duplicate top-level slot declaration.",
161
+ )
162
+ )
163
+ for state_key in sorted(definition.owned_state_keys):
164
+ state_key_is_exposed = (
165
+ definition.exposed_slots is not None and state_key in definition.exposed_slots
166
+ )
167
+ if (first_state_owner := state_key_owners.get(state_key)) is not None:
168
+ if not (state_key_is_exposed and first_state_owner[2]):
169
+ diagnostics.append(
170
+ semantic_diagnostic(
171
+ "FLOWSPEC_PROFILE_SUBFLOW_STATE_COLLISION",
172
+ json_pointer("uses", use_index, "ref"),
173
+ f"subflow state key {state_key!r} is also owned by "
174
+ f"{first_state_owner[0]!r}",
175
+ related=(
176
+ DiagnosticLocation(
177
+ path=json_pointer("uses", first_state_owner[1], "ref"),
178
+ message="First owning subflow declaration.",
179
+ ),
180
+ ),
181
+ )
182
+ )
183
+ else:
184
+ state_key_owners[state_key] = (
185
+ reference,
186
+ use_index,
187
+ state_key_is_exposed,
188
+ )
189
+ for writer_path, writer_kind in authored_state_writers.get(state_key, []):
190
+ if writer_kind == "slot" and state_key_is_exposed:
191
+ continue
192
+ diagnostics.append(
193
+ semantic_diagnostic(
194
+ "FLOWSPEC_PROFILE_SUBFLOW_STATE_COLLISION",
195
+ writer_path,
196
+ f"{writer_kind} writes state key {state_key!r}, which is owned by "
197
+ f"subflow {reference!r}",
198
+ related=(
199
+ DiagnosticLocation(
200
+ path=json_pointer("uses", use_index, "ref"),
201
+ message="Owning subflow declaration.",
202
+ ),
203
+ ),
204
+ suggested_fix="Remove the duplicate writer or use a distinct state key.",
205
+ )
206
+ )
207
+ required_capabilities.update(
208
+ (capability, json_pointer("uses", use_index, "ref"))
209
+ for capability in definition.capabilities
210
+ )
211
+ for tool_name, required_version in definition.required_tools.items():
212
+ tool_path = json_pointer("uses", use_index, "ref")
213
+ if not profile.tools.has(tool_name):
214
+ diagnostics.append(
215
+ semantic_diagnostic(
216
+ "FLOWSPEC_PROFILE_SUBFLOW_TOOL_UNAVAILABLE",
217
+ tool_path,
218
+ f"subflow {reference!r} requires tool {tool_name!r} at version "
219
+ f"{required_version!r}, but the profile does not register it",
220
+ )
221
+ )
222
+ continue
223
+ actual_version = profile.tools.definition(tool_name).version
224
+ if (
225
+ profile.tools.definition(tool_name).legacy_contract
226
+ and not profile.allow_legacy_contracts
227
+ ):
228
+ diagnostics.append(
229
+ semantic_diagnostic(
230
+ "FLOWSPEC_PROFILE_LEGACY_TOOL_CONTRACT_FORBIDDEN",
231
+ tool_path,
232
+ f"profile {profile.identifier!r} requires a typed contract for "
233
+ f"tool {tool_name!r}",
234
+ )
235
+ )
236
+ if actual_version != required_version:
237
+ diagnostics.append(
238
+ semantic_diagnostic(
239
+ "FLOWSPEC_PROFILE_SUBFLOW_TOOL_VERSION_MISMATCH",
240
+ tool_path,
241
+ f"subflow {reference!r} requires tool {tool_name!r} at version "
242
+ f"{required_version!r}, but the profile provides {actual_version!r}",
243
+ )
244
+ )
245
+ configuration_schema = mutable_json(definition.configuration_schema)
246
+ configuration_validator = Draft202012Validator(
247
+ configuration_schema,
248
+ format_checker=FormatChecker(),
249
+ )
250
+ for configuration_error in sorted(
251
+ configuration_validator.iter_errors(use_definition.get("with", {})),
252
+ key=lambda error: tuple(str(segment) for segment in error.absolute_path),
253
+ ):
254
+ error_path = json_pointer(
255
+ "uses",
256
+ use_index,
257
+ "with",
258
+ *tuple(configuration_error.absolute_path),
259
+ )
260
+ diagnostics.append(
261
+ semantic_diagnostic(
262
+ "FLOWSPEC_PROFILE_SUBFLOW_CONFIGURATION_INVALID",
263
+ error_path,
264
+ f"configuration for {definition.ref!r}: {configuration_error.message}",
265
+ )
266
+ )
267
+
268
+ tool_references: list[tuple[str, str]] = []
269
+ if (entry := document.get("entry")) is not None:
270
+ tool_references.append((entry["tool"], "/entry/tool"))
271
+ if (terminal := document.get("terminal")) is not None:
272
+ tool_references.append((terminal["tool"], "/terminal/tool"))
273
+ await_external = cast(
274
+ dict[str, Any] | None,
275
+ cast(dict[str, Any], document.get("capabilities", {})).get("await_external"),
276
+ )
277
+ if await_external is not None:
278
+ enrichment = cast(
279
+ str | dict[str, Any] | None,
280
+ cast(dict[str, Any], await_external.get("on_resume", {})).get("enrich"),
281
+ )
282
+ if not profile.allow_legacy_contracts and (
283
+ not isinstance(await_external.get("resume"), dict) or isinstance(enrichment, str)
284
+ ):
285
+ diagnostics.append(
286
+ semantic_diagnostic(
287
+ "FLOWSPEC_PROFILE_LEGACY_AWAIT_CONTRACT_FORBIDDEN",
288
+ "/capabilities/await_external",
289
+ f"profile {profile.identifier!r} requires a versioned typed resume "
290
+ "contract and object-form enrichment",
291
+ )
292
+ )
293
+ if isinstance(enrichment, str):
294
+ tool_references.append((enrichment, "/capabilities/await_external/on_resume/enrich"))
295
+ elif isinstance(enrichment, dict):
296
+ tool_references.append(
297
+ (
298
+ enrichment["tool"],
299
+ "/capabilities/await_external/on_resume/enrich/tool",
300
+ )
301
+ )
302
+ for tool_name, tool_path in tool_references:
303
+ if not profile.tools.has(tool_name):
304
+ diagnostics.append(
305
+ semantic_diagnostic(
306
+ "FLOWSPEC_PROFILE_TOOL_UNAVAILABLE",
307
+ tool_path,
308
+ f"tool {tool_name!r} is not registered in profile {profile.identifier!r}",
309
+ )
310
+ )
311
+ elif (
312
+ profile.tools.definition(tool_name).legacy_contract
313
+ and not profile.allow_legacy_contracts
314
+ ):
315
+ diagnostics.append(
316
+ semantic_diagnostic(
317
+ "FLOWSPEC_PROFILE_LEGACY_TOOL_CONTRACT_FORBIDDEN",
318
+ tool_path,
319
+ f"profile {profile.identifier!r} requires a typed contract for "
320
+ f"tool {tool_name!r}",
321
+ )
322
+ )
323
+
324
+ for capability_name, capability_value in document.get("capabilities", {}).items():
325
+ if capability_is_requested(capability_value):
326
+ required_capabilities.add(
327
+ (capability_name, json_pointer("capabilities", capability_name))
328
+ )
329
+ if "auto_flow" in document:
330
+ required_capabilities.add(("auto_flow", "/auto_flow"))
331
+ for capability_name, capability_path in sorted(required_capabilities):
332
+ if capability_name not in profile.capabilities:
333
+ diagnostics.append(
334
+ semantic_diagnostic(
335
+ "FLOWSPEC_PROFILE_CAPABILITY_UNAVAILABLE",
336
+ capability_path,
337
+ f"profile {profile.identifier!r} does not provide capability {capability_name!r}",
338
+ )
339
+ )
340
+
341
+ return (
342
+ diagnostics,
343
+ None if has_open_manifest else frozenset(exposed_slots),
344
+ exposed_slot_positions,
345
+ )
346
+
347
+
348
+ def semantic_diagnostics(
349
+ flow_document: dict[str, Any],
350
+ *,
351
+ external_slots: frozenset[str] | None = None,
352
+ external_steps: frozenset[str] = frozenset(),
353
+ profile: FlowProfile | None = None,
354
+ ) -> tuple[FlowDiagnostic, ...]:
355
+ """Link all source contracts and return every deterministic finding.
356
+
357
+ ``external_slots=None`` defers profile-owned references to compilation. A
358
+ profile-aware caller passes the complete exposed-slot set to make unknown
359
+ references an aggregate semantic error instead.
360
+ """
361
+
362
+ document = normalize_flow(flow_document)
363
+ profile_diagnostics: list[FlowDiagnostic] = []
364
+ profile_slot_positions: dict[str, int] = {}
365
+ if profile is not None:
366
+ profile_diagnostics, profile_slots, profile_slot_positions = profile_contracts(
367
+ document, profile
368
+ )
369
+ if profile_slots is not None:
370
+ external_slots = profile_slots
371
+ diagnostics, identifiers, slot_positions = path_contracts(document)
372
+ slot_positions = {**profile_slot_positions, **slot_positions}
373
+ diagnostics.extend(profile_diagnostics)
374
+ diagnostics.extend(domain_contracts(document))
375
+ diagnostics.extend(slot_contracts(document, slot_positions, external_slots=external_slots))
376
+ diagnostics.extend(
377
+ derive_contracts(
378
+ document,
379
+ identifiers,
380
+ external_slots=external_slots,
381
+ external_steps=external_steps,
382
+ )
383
+ )
384
+ diagnostics.extend(entry_schema_contracts(document, external_slots=external_slots))
385
+ diagnostics.extend(state_writer_contracts(document, external_slots=external_slots))
386
+ diagnostics.extend(
387
+ predicate_contracts(
388
+ document,
389
+ external_slots=external_slots,
390
+ profile=profile,
391
+ )
392
+ )
393
+ diagnostics.extend(
394
+ rail_reference_contracts(
395
+ document,
396
+ identifiers,
397
+ slot_positions,
398
+ external_slots=external_slots,
399
+ )
400
+ )
401
+ return tuple(
402
+ sorted(
403
+ set(diagnostics),
404
+ key=lambda diagnostic: (
405
+ diagnostic.path,
406
+ diagnostic.code,
407
+ diagnostic.message,
408
+ ),
409
+ )
410
+ )