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.
- flowspec2/__init__.py +105 -0
- flowspec2/authoring/__init__.py +275 -0
- flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
- flowspec2/authoring/authoring-evidence.schema.json +455 -0
- flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
- flowspec2/authoring/benchmark.py +1409 -0
- flowspec2/authoring/corpus/await_correction.case.json +220 -0
- flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
- flowspec2/authoring/corpus/gated_derive.case.json +100 -0
- flowspec2/authoring/corpus/linear.case.json +55 -0
- flowspec2/authoring/corpus/manifest.json +31 -0
- flowspec2/authoring/corpus/subflow.case.json +66 -0
- flowspec2/authoring/corpus/terminal.case.json +94 -0
- flowspec2/authoring/corpus.py +307 -0
- flowspec2/authoring/ctk.py +836 -0
- flowspec2/authoring/detached_signature.py +120 -0
- flowspec2/authoring/evidence.py +311 -0
- flowspec2/authoring/evidence_signature.py +187 -0
- flowspec2/authoring/evidence_verification.py +229 -0
- flowspec2/authoring/gemini.py +215 -0
- flowspec2/authoring/operational-corpus.json +61 -0
- flowspec2/authoring/operational.py +956 -0
- flowspec2/authoring/operational_providers.py +167 -0
- flowspec2/authoring/presentation_review.py +1013 -0
- flowspec2/authoring/presentation_review_signature.py +305 -0
- flowspec2/authoring/projection.py +299 -0
- flowspec2/authoring/provider_prompt.py +83 -0
- flowspec2/backends/__init__.py +82 -0
- flowspec2/backends/http.py +189 -0
- flowspec2/checker.py +238 -0
- flowspec2/cli.py +789 -0
- flowspec2/cli_parser.py +345 -0
- flowspec2/clock.py +23 -0
- flowspec2/compat/__init__.py +47 -0
- flowspec2/compat/models.py +82 -0
- flowspec2/compat/open_workflow.py +616 -0
- flowspec2/compat/rasa.py +19 -0
- flowspec2/compat/rasa_export.py +876 -0
- flowspec2/compat/rasa_import.py +992 -0
- flowspec2/compat/rasa_shared.py +270 -0
- flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
- flowspec2/compat/tool_profiles.py +149 -0
- flowspec2/compat/yaml.py +147 -0
- flowspec2/compiler.py +957 -0
- flowspec2/compiler_contracts.py +17 -0
- flowspec2/compiler_resume_contracts.py +594 -0
- flowspec2/compiler_schema_relations.py +1830 -0
- flowspec2/compiler_tool_contracts.py +245 -0
- flowspec2/compiler_value_contracts.py +447 -0
- flowspec2/derive.py +32 -0
- flowspec2/diagnostics.py +94 -0
- flowspec2/domains.py +489 -0
- flowspec2/experimental/__init__.py +57 -0
- flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
- flowspec2/experimental/v3-preview-loss-policy.json +161 -0
- flowspec2/experimental/v3_lowering.py +928 -0
- flowspec2/experimental/v3_preview.py +2460 -0
- flowspec2/flowspec-2.schema.json +635 -0
- flowspec2/interactive.py +300 -0
- flowspec2/ir.py +1459 -0
- flowspec2/json_codec.py +120 -0
- flowspec2/llm.py +366 -0
- flowspec2/models.py +121 -0
- flowspec2/nodes.py +1537 -0
- flowspec2/observability.py +151 -0
- flowspec2/predicates.py +89 -0
- flowspec2/profiles.py +118 -0
- flowspec2/py.typed +0 -0
- flowspec2/runtime.py +1059 -0
- flowspec2/schema.py +54 -0
- flowspec2/schema_contracts.py +203 -0
- flowspec2/semantic_derive_contracts.py +530 -0
- flowspec2/semantic_path_contracts.py +528 -0
- flowspec2/semantic_predicate_contracts.py +355 -0
- flowspec2/semantic_schema_contracts.py +137 -0
- flowspec2/semantic_source_contracts.py +21 -0
- flowspec2/semantic_state_contracts.py +450 -0
- flowspec2/semantic_support.py +40 -0
- flowspec2/semantics.py +410 -0
- flowspec2/state_migration.py +1034 -0
- flowspec2/subflows/__init__.py +1117 -0
- flowspec2/subflows/address.py +328 -0
- flowspec2/subflows/identification.py +1031 -0
- flowspec2/tools.py +1154 -0
- flowspec2-1.0.0.dist-info/METADATA +482 -0
- flowspec2-1.0.0.dist-info/RECORD +92 -0
- flowspec2-1.0.0.dist-info/WHEEL +4 -0
- flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
- flowspec2-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
"""Versioned reusable subflows (mixins) referenced by ``uses[]`` as ``name@major``.
|
|
2
|
+
|
|
3
|
+
A subflow splices its own nodes/slots/routers into the compiled graph at its
|
|
4
|
+
anchor, returns its slot-to-collector mapping for compiler-validated upward
|
|
5
|
+
references, and inherits idempotency/reset/attempt defaults. Each ends with
|
|
6
|
+
a ``<name>_done`` no-op exit node whose router returns ``NEXT`` — that single
|
|
7
|
+
exit lets internal branches (``anonymous``, confirmed-address) skip the remaining
|
|
8
|
+
internal nodes cleanly.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from types import MappingProxyType
|
|
18
|
+
from typing import Any, Literal, Protocol, TypeAlias, cast
|
|
19
|
+
from urllib.parse import unquote
|
|
20
|
+
|
|
21
|
+
from jsonschema import Draft202012Validator, FormatChecker
|
|
22
|
+
from jsonschema.exceptions import SchemaError
|
|
23
|
+
|
|
24
|
+
from ..nodes import FlowContext, NodeDesc
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _mutable_json(value: Any) -> Any:
|
|
28
|
+
if isinstance(value, Mapping):
|
|
29
|
+
return {key: _mutable_json(nested_value) for key, nested_value in value.items()}
|
|
30
|
+
if isinstance(value, tuple):
|
|
31
|
+
return [_mutable_json(nested_value) for nested_value in value]
|
|
32
|
+
if isinstance(value, list):
|
|
33
|
+
return [_mutable_json(nested_value) for nested_value in value]
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _immutable_json(value: Any) -> Any:
|
|
38
|
+
if isinstance(value, Mapping):
|
|
39
|
+
return MappingProxyType(
|
|
40
|
+
{key: _immutable_json(nested_value) for key, nested_value in value.items()}
|
|
41
|
+
)
|
|
42
|
+
if isinstance(value, (list, tuple)):
|
|
43
|
+
return tuple(_immutable_json(nested_value) for nested_value in value)
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_CategoryMatch: TypeAlias = tuple[bool, bool]
|
|
48
|
+
_BoundaryMatch: TypeAlias = tuple[_CategoryMatch, ...]
|
|
49
|
+
_ALWAYS_MATCHES: _CategoryMatch = (True, True)
|
|
50
|
+
_NEVER_MATCHES: _CategoryMatch = (False, False)
|
|
51
|
+
_SOMETIMES_MATCHES: _CategoryMatch = (True, False)
|
|
52
|
+
_JSON_VALUE_CATEGORIES: tuple[str, ...] = (
|
|
53
|
+
"object",
|
|
54
|
+
"array",
|
|
55
|
+
"boolean",
|
|
56
|
+
"null",
|
|
57
|
+
"number",
|
|
58
|
+
"string",
|
|
59
|
+
)
|
|
60
|
+
_OBJECT_CATEGORY_INDEX = _JSON_VALUE_CATEGORIES.index("object")
|
|
61
|
+
_DRAFT_2020_12_URIS = frozenset(
|
|
62
|
+
{
|
|
63
|
+
"https://json-schema.org/draft/2020-12/schema",
|
|
64
|
+
"https://json-schema.org/draft/2020-12/schema#",
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
_SINGLE_SUBSCHEMA_KEYWORDS = (
|
|
68
|
+
"additionalProperties",
|
|
69
|
+
"contains",
|
|
70
|
+
"contentSchema",
|
|
71
|
+
"else",
|
|
72
|
+
"if",
|
|
73
|
+
"items",
|
|
74
|
+
"not",
|
|
75
|
+
"propertyNames",
|
|
76
|
+
"then",
|
|
77
|
+
"unevaluatedItems",
|
|
78
|
+
"unevaluatedProperties",
|
|
79
|
+
)
|
|
80
|
+
_ARRAY_SUBSCHEMA_KEYWORDS = ("allOf", "anyOf", "oneOf", "prefixItems")
|
|
81
|
+
_MAPPING_SUBSCHEMA_KEYWORDS = (
|
|
82
|
+
"$defs",
|
|
83
|
+
"definitions",
|
|
84
|
+
"dependentSchemas",
|
|
85
|
+
"patternProperties",
|
|
86
|
+
"properties",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _nested_schemas(schema: Mapping[str, Any]) -> tuple[Any, ...]:
|
|
91
|
+
nested_schemas: list[Any] = []
|
|
92
|
+
for keyword in _SINGLE_SUBSCHEMA_KEYWORDS:
|
|
93
|
+
if keyword in schema:
|
|
94
|
+
nested_schemas.append(schema[keyword])
|
|
95
|
+
for keyword in _ARRAY_SUBSCHEMA_KEYWORDS:
|
|
96
|
+
keyword_schemas = schema.get(keyword)
|
|
97
|
+
if isinstance(keyword_schemas, (list, tuple)):
|
|
98
|
+
nested_schemas.extend(keyword_schemas)
|
|
99
|
+
for keyword in _MAPPING_SUBSCHEMA_KEYWORDS:
|
|
100
|
+
keyword_schemas = schema.get(keyword)
|
|
101
|
+
if isinstance(keyword_schemas, Mapping):
|
|
102
|
+
nested_schemas.extend(keyword_schemas.values())
|
|
103
|
+
return tuple(nested_schemas)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _local_anchor_target(root_schema: Mapping[str, Any], anchor: str) -> Any:
|
|
107
|
+
matching_targets: list[Any] = []
|
|
108
|
+
|
|
109
|
+
def visit(schema_fragment: Any) -> None:
|
|
110
|
+
if isinstance(schema_fragment, Mapping):
|
|
111
|
+
if schema_fragment.get("$anchor") == anchor:
|
|
112
|
+
matching_targets.append(schema_fragment)
|
|
113
|
+
for nested_schema in _nested_schemas(schema_fragment):
|
|
114
|
+
visit(nested_schema)
|
|
115
|
+
|
|
116
|
+
visit(root_schema)
|
|
117
|
+
if len(matching_targets) != 1:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"local schema anchor {anchor!r} must resolve exactly once; "
|
|
120
|
+
f"resolved {len(matching_targets)} times"
|
|
121
|
+
)
|
|
122
|
+
return matching_targets[0]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _decode_json_pointer_segment(encoded_segment: str, reference: str) -> str:
|
|
126
|
+
if re.search(r"~(?:[^01]|$)", encoded_segment) is not None:
|
|
127
|
+
raise ValueError(f"local schema reference has an invalid JSON Pointer: {reference!r}")
|
|
128
|
+
return encoded_segment.replace("~1", "/").replace("~0", "~")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _local_reference_target(root_schema: Mapping[str, Any], reference: str) -> Any:
|
|
132
|
+
if not reference.startswith("#"):
|
|
133
|
+
raise ValueError(f"subflow schemas only support local references, got {reference!r}")
|
|
134
|
+
fragment = unquote(reference[1:])
|
|
135
|
+
if not fragment:
|
|
136
|
+
return root_schema
|
|
137
|
+
if not fragment.startswith("/"):
|
|
138
|
+
return _local_anchor_target(root_schema, fragment)
|
|
139
|
+
|
|
140
|
+
referenced_schema: Any = root_schema
|
|
141
|
+
for encoded_segment in fragment[1:].split("/"):
|
|
142
|
+
reference_segment = _decode_json_pointer_segment(encoded_segment, reference)
|
|
143
|
+
if isinstance(referenced_schema, Mapping) and reference_segment in referenced_schema:
|
|
144
|
+
referenced_schema = referenced_schema[reference_segment]
|
|
145
|
+
continue
|
|
146
|
+
if isinstance(referenced_schema, (list, tuple)) and reference_segment.isdigit():
|
|
147
|
+
reference_index = int(reference_segment)
|
|
148
|
+
if reference_index < len(referenced_schema):
|
|
149
|
+
referenced_schema = referenced_schema[reference_index]
|
|
150
|
+
continue
|
|
151
|
+
raise ValueError(f"local schema reference does not resolve: {reference!r}")
|
|
152
|
+
if not isinstance(referenced_schema, (Mapping, bool)):
|
|
153
|
+
raise ValueError(f"local schema reference does not resolve to a schema: {reference!r}")
|
|
154
|
+
return referenced_schema
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _validate_local_schema_references(
|
|
158
|
+
schema: Any,
|
|
159
|
+
root_schema: Mapping[str, Any],
|
|
160
|
+
*,
|
|
161
|
+
is_root: bool = True,
|
|
162
|
+
) -> None:
|
|
163
|
+
if isinstance(schema, bool):
|
|
164
|
+
return
|
|
165
|
+
if not isinstance(schema, Mapping):
|
|
166
|
+
return
|
|
167
|
+
unsupported_reference_keywords = {
|
|
168
|
+
"$dynamicAnchor",
|
|
169
|
+
"$dynamicRef",
|
|
170
|
+
"$recursiveAnchor",
|
|
171
|
+
"$recursiveRef",
|
|
172
|
+
} & set(schema)
|
|
173
|
+
if unsupported_reference_keywords:
|
|
174
|
+
unsupported_keyword = sorted(unsupported_reference_keywords)[0]
|
|
175
|
+
raise ValueError(f"subflow schemas do not support {unsupported_keyword}")
|
|
176
|
+
if not is_root and "$id" in schema:
|
|
177
|
+
raise ValueError("subflow schemas do not support nested $id resources")
|
|
178
|
+
if not is_root and "$schema" in schema:
|
|
179
|
+
raise ValueError("subflow schemas do not support nested $schema dialect changes")
|
|
180
|
+
if (reference := schema.get("$ref")) is not None:
|
|
181
|
+
if not isinstance(reference, str):
|
|
182
|
+
raise ValueError("subflow schema $ref must be a string")
|
|
183
|
+
_local_reference_target(root_schema, reference)
|
|
184
|
+
for nested_schema in _nested_schemas(schema):
|
|
185
|
+
_validate_local_schema_references(nested_schema, root_schema, is_root=False)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _validate_local_reference_cycles(root_schema: Mapping[str, Any]) -> None:
|
|
189
|
+
"""Reject recursive local resources that cannot be projected deterministically."""
|
|
190
|
+
|
|
191
|
+
visiting: set[int] = set()
|
|
192
|
+
visited: set[int] = set()
|
|
193
|
+
|
|
194
|
+
def visit(schema: Any) -> None:
|
|
195
|
+
if isinstance(schema, bool) or not isinstance(schema, Mapping):
|
|
196
|
+
return
|
|
197
|
+
schema_identifier = id(schema)
|
|
198
|
+
if schema_identifier in visiting:
|
|
199
|
+
raise ValueError("subflow schemas do not support local schema reference cycles")
|
|
200
|
+
if schema_identifier in visited:
|
|
201
|
+
return
|
|
202
|
+
visiting.add(schema_identifier)
|
|
203
|
+
if (reference := schema.get("$ref")) is not None:
|
|
204
|
+
visit(_local_reference_target(root_schema, cast(str, reference)))
|
|
205
|
+
for nested_schema in _nested_schemas(schema):
|
|
206
|
+
visit(nested_schema)
|
|
207
|
+
visiting.remove(schema_identifier)
|
|
208
|
+
visited.add(schema_identifier)
|
|
209
|
+
|
|
210
|
+
visit(root_schema)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _category_all(category_matches: list[_CategoryMatch]) -> _CategoryMatch:
|
|
214
|
+
return (
|
|
215
|
+
all(category_match[0] for category_match in category_matches),
|
|
216
|
+
all(category_match[1] for category_match in category_matches),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _category_any(category_matches: list[_CategoryMatch]) -> _CategoryMatch:
|
|
221
|
+
return (
|
|
222
|
+
any(category_match[0] for category_match in category_matches),
|
|
223
|
+
any(category_match[1] for category_match in category_matches),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _category_not(category_match: _CategoryMatch) -> _CategoryMatch:
|
|
228
|
+
can_match, must_match = category_match
|
|
229
|
+
return not must_match, not can_match
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _category_one_of(category_matches: list[_CategoryMatch]) -> _CategoryMatch:
|
|
233
|
+
possible_matches = [category_match for category_match in category_matches if category_match[0]]
|
|
234
|
+
guaranteed_matches = sum(category_match[1] for category_match in possible_matches)
|
|
235
|
+
return (
|
|
236
|
+
bool(possible_matches) and guaranteed_matches < 2,
|
|
237
|
+
guaranteed_matches == 1 and len(possible_matches) == 1,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _boundary_all(boundary_matches: list[_BoundaryMatch]) -> _BoundaryMatch:
|
|
242
|
+
return tuple(
|
|
243
|
+
_category_all([boundary_match[category_index] for boundary_match in boundary_matches])
|
|
244
|
+
for category_index in range(len(_JSON_VALUE_CATEGORIES))
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _boundary_any(boundary_matches: list[_BoundaryMatch]) -> _BoundaryMatch:
|
|
249
|
+
return tuple(
|
|
250
|
+
_category_any([boundary_match[category_index] for boundary_match in boundary_matches])
|
|
251
|
+
for category_index in range(len(_JSON_VALUE_CATEGORIES))
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _boundary_not(boundary_match: _BoundaryMatch) -> _BoundaryMatch:
|
|
256
|
+
return tuple(_category_not(category_match) for category_match in boundary_match)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _boundary_one_of(boundary_matches: list[_BoundaryMatch]) -> _BoundaryMatch:
|
|
260
|
+
return tuple(
|
|
261
|
+
_category_one_of([boundary_match[category_index] for boundary_match in boundary_matches])
|
|
262
|
+
for category_index in range(len(_JSON_VALUE_CATEGORIES))
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _type_boundary_match(schema_type: Any) -> _BoundaryMatch:
|
|
267
|
+
schema_types = {schema_type} if isinstance(schema_type, str) else set(schema_type)
|
|
268
|
+
return tuple(
|
|
269
|
+
(
|
|
270
|
+
_ALWAYS_MATCHES
|
|
271
|
+
if category in schema_types
|
|
272
|
+
else _SOMETIMES_MATCHES
|
|
273
|
+
if category == "number" and "integer" in schema_types
|
|
274
|
+
else _NEVER_MATCHES
|
|
275
|
+
)
|
|
276
|
+
for category in _JSON_VALUE_CATEGORIES
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _instance_category(instance: Any) -> str:
|
|
281
|
+
if isinstance(instance, Mapping):
|
|
282
|
+
return "object"
|
|
283
|
+
if isinstance(instance, list):
|
|
284
|
+
return "array"
|
|
285
|
+
if isinstance(instance, bool):
|
|
286
|
+
return "boolean"
|
|
287
|
+
if instance is None:
|
|
288
|
+
return "null"
|
|
289
|
+
if isinstance(instance, (int, float)):
|
|
290
|
+
return "number"
|
|
291
|
+
return "string"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _finite_boundary_match(instances: list[Any]) -> _BoundaryMatch:
|
|
295
|
+
return tuple(
|
|
296
|
+
(
|
|
297
|
+
any(_instance_category(instance) == category for instance in instances),
|
|
298
|
+
category == "null"
|
|
299
|
+
and any(instance is None for instance in instances)
|
|
300
|
+
or category == "boolean"
|
|
301
|
+
and any(instance is True for instance in instances)
|
|
302
|
+
and any(instance is False for instance in instances),
|
|
303
|
+
)
|
|
304
|
+
for category in _JSON_VALUE_CATEGORIES
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _category_restriction_boundary(
|
|
309
|
+
category: str,
|
|
310
|
+
category_match: _CategoryMatch,
|
|
311
|
+
) -> _BoundaryMatch:
|
|
312
|
+
return tuple(
|
|
313
|
+
category_match if candidate_category == category else _ALWAYS_MATCHES
|
|
314
|
+
for candidate_category in _JSON_VALUE_CATEGORIES
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _conjunctive_schema_fragments(
|
|
319
|
+
schema: Any,
|
|
320
|
+
root_schema: Mapping[str, Any],
|
|
321
|
+
) -> list[Any]:
|
|
322
|
+
pending_fragments = [schema]
|
|
323
|
+
visited_fragments: set[int] = set()
|
|
324
|
+
schema_fragments: list[Any] = []
|
|
325
|
+
while pending_fragments:
|
|
326
|
+
schema_fragment = pending_fragments.pop()
|
|
327
|
+
if not isinstance(schema_fragment, Mapping):
|
|
328
|
+
schema_fragments.append(schema_fragment)
|
|
329
|
+
continue
|
|
330
|
+
fragment_identifier = id(schema_fragment)
|
|
331
|
+
if fragment_identifier in visited_fragments:
|
|
332
|
+
continue
|
|
333
|
+
visited_fragments.add(fragment_identifier)
|
|
334
|
+
schema_fragments.append(schema_fragment)
|
|
335
|
+
if (reference := schema_fragment.get("$ref")) is not None:
|
|
336
|
+
pending_fragments.append(_local_reference_target(root_schema, cast(str, reference)))
|
|
337
|
+
if isinstance((all_of := schema_fragment.get("allOf")), (list, tuple)):
|
|
338
|
+
pending_fragments.extend(all_of)
|
|
339
|
+
return schema_fragments
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _canonical_json(value: Any) -> str:
|
|
343
|
+
return json.dumps(
|
|
344
|
+
value,
|
|
345
|
+
allow_nan=False,
|
|
346
|
+
ensure_ascii=False,
|
|
347
|
+
separators=(",", ":"),
|
|
348
|
+
sort_keys=True,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _finite_object_candidates(schema_fragments: list[Any]) -> dict[str, Any] | None:
|
|
353
|
+
candidate_objects: dict[str, Any] | None = None
|
|
354
|
+
for schema_fragment in schema_fragments:
|
|
355
|
+
if not isinstance(schema_fragment, Mapping):
|
|
356
|
+
continue
|
|
357
|
+
restricted_instances: list[Any] | None = None
|
|
358
|
+
if "const" in schema_fragment:
|
|
359
|
+
restricted_instances = [schema_fragment["const"]]
|
|
360
|
+
if isinstance((enum_members := schema_fragment.get("enum")), (list, tuple)):
|
|
361
|
+
restricted_instances = (
|
|
362
|
+
list(enum_members)
|
|
363
|
+
if restricted_instances is None
|
|
364
|
+
else [
|
|
365
|
+
instance
|
|
366
|
+
for instance in restricted_instances
|
|
367
|
+
if any(instance == enum_member for enum_member in enum_members)
|
|
368
|
+
]
|
|
369
|
+
)
|
|
370
|
+
if restricted_instances is None:
|
|
371
|
+
continue
|
|
372
|
+
restricted_objects = {
|
|
373
|
+
_canonical_json(instance): instance
|
|
374
|
+
for instance in restricted_instances
|
|
375
|
+
if isinstance(instance, Mapping)
|
|
376
|
+
}
|
|
377
|
+
candidate_objects = (
|
|
378
|
+
restricted_objects
|
|
379
|
+
if candidate_objects is None
|
|
380
|
+
else {
|
|
381
|
+
serialized_object: candidate_object
|
|
382
|
+
for serialized_object, candidate_object in candidate_objects.items()
|
|
383
|
+
if serialized_object in restricted_objects
|
|
384
|
+
}
|
|
385
|
+
)
|
|
386
|
+
return candidate_objects
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _schema_accepts_instance(
|
|
390
|
+
schema: Any,
|
|
391
|
+
root_schema: Mapping[str, Any],
|
|
392
|
+
instance: Any,
|
|
393
|
+
) -> bool:
|
|
394
|
+
validator = Draft202012Validator(root_schema, format_checker=FormatChecker())
|
|
395
|
+
return validator.evolve(schema=schema).is_valid(instance)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _object_constraints_may_match(
|
|
399
|
+
schema: Any,
|
|
400
|
+
root_schema: Mapping[str, Any],
|
|
401
|
+
) -> bool:
|
|
402
|
+
schema_fragments = _conjunctive_schema_fragments(schema, root_schema)
|
|
403
|
+
required_properties: set[str] = set()
|
|
404
|
+
expanded_dependency_schemas: set[int] = set()
|
|
405
|
+
while True:
|
|
406
|
+
if any(schema_fragment is False for schema_fragment in schema_fragments):
|
|
407
|
+
return False
|
|
408
|
+
previous_required_properties = frozenset(required_properties)
|
|
409
|
+
for schema_fragment in schema_fragments:
|
|
410
|
+
if not isinstance(schema_fragment, Mapping):
|
|
411
|
+
continue
|
|
412
|
+
required_properties.update(
|
|
413
|
+
property_name
|
|
414
|
+
for property_name in schema_fragment.get("required", ())
|
|
415
|
+
if isinstance(property_name, str)
|
|
416
|
+
)
|
|
417
|
+
dependencies_changed = True
|
|
418
|
+
while dependencies_changed:
|
|
419
|
+
dependencies_changed = False
|
|
420
|
+
for schema_fragment in schema_fragments:
|
|
421
|
+
if not isinstance(schema_fragment, Mapping):
|
|
422
|
+
continue
|
|
423
|
+
dependent_required = schema_fragment.get("dependentRequired")
|
|
424
|
+
if not isinstance(dependent_required, Mapping):
|
|
425
|
+
continue
|
|
426
|
+
for trigger_property, dependent_properties in dependent_required.items():
|
|
427
|
+
if trigger_property not in required_properties:
|
|
428
|
+
continue
|
|
429
|
+
previous_size = len(required_properties)
|
|
430
|
+
required_properties.update(
|
|
431
|
+
property_name
|
|
432
|
+
for property_name in dependent_properties
|
|
433
|
+
if isinstance(property_name, str)
|
|
434
|
+
)
|
|
435
|
+
dependencies_changed = (
|
|
436
|
+
dependencies_changed or len(required_properties) > previous_size
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
added_dependency_schema = False
|
|
440
|
+
for schema_fragment in tuple(schema_fragments):
|
|
441
|
+
if not isinstance(schema_fragment, Mapping):
|
|
442
|
+
continue
|
|
443
|
+
dependent_schemas = schema_fragment.get("dependentSchemas")
|
|
444
|
+
if not isinstance(dependent_schemas, Mapping):
|
|
445
|
+
continue
|
|
446
|
+
for trigger_property, dependent_schema in dependent_schemas.items():
|
|
447
|
+
if trigger_property not in required_properties:
|
|
448
|
+
continue
|
|
449
|
+
dependency_identifier = id(dependent_schema)
|
|
450
|
+
if dependency_identifier in expanded_dependency_schemas:
|
|
451
|
+
continue
|
|
452
|
+
expanded_dependency_schemas.add(dependency_identifier)
|
|
453
|
+
schema_fragments.extend(
|
|
454
|
+
_conjunctive_schema_fragments(dependent_schema, root_schema)
|
|
455
|
+
)
|
|
456
|
+
added_dependency_schema = True
|
|
457
|
+
if not added_dependency_schema and previous_required_properties == frozenset(
|
|
458
|
+
required_properties
|
|
459
|
+
):
|
|
460
|
+
break
|
|
461
|
+
|
|
462
|
+
minimum_properties = 0
|
|
463
|
+
maximum_properties: int | None = None
|
|
464
|
+
closed_property_sets: list[set[str]] = []
|
|
465
|
+
property_name_schemas: list[Any] = []
|
|
466
|
+
for schema_fragment in schema_fragments:
|
|
467
|
+
if not isinstance(schema_fragment, Mapping):
|
|
468
|
+
continue
|
|
469
|
+
if isinstance((fragment_minimum := schema_fragment.get("minProperties")), int):
|
|
470
|
+
minimum_properties = max(minimum_properties, fragment_minimum)
|
|
471
|
+
if isinstance((fragment_maximum := schema_fragment.get("maxProperties")), int):
|
|
472
|
+
maximum_properties = (
|
|
473
|
+
fragment_maximum
|
|
474
|
+
if maximum_properties is None
|
|
475
|
+
else min(maximum_properties, fragment_maximum)
|
|
476
|
+
)
|
|
477
|
+
if (property_names := schema_fragment.get("propertyNames")) is not None:
|
|
478
|
+
property_name_schemas.append(property_names)
|
|
479
|
+
if schema_fragment.get("additionalProperties") is False:
|
|
480
|
+
properties = schema_fragment.get("properties")
|
|
481
|
+
permitted_properties = set(properties) if isinstance(properties, Mapping) else set()
|
|
482
|
+
pattern_properties = schema_fragment.get("patternProperties")
|
|
483
|
+
if isinstance(pattern_properties, Mapping):
|
|
484
|
+
if any(
|
|
485
|
+
pattern_schema is not False for pattern_schema in pattern_properties.values()
|
|
486
|
+
):
|
|
487
|
+
continue
|
|
488
|
+
permitted_properties = {
|
|
489
|
+
property_name
|
|
490
|
+
for property_name in permitted_properties
|
|
491
|
+
if not any(
|
|
492
|
+
re.search(pattern, property_name) is not None
|
|
493
|
+
for pattern in pattern_properties
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
closed_property_sets.append(permitted_properties)
|
|
497
|
+
|
|
498
|
+
minimum_properties = max(minimum_properties, len(required_properties))
|
|
499
|
+
if maximum_properties is not None and minimum_properties > maximum_properties:
|
|
500
|
+
return False
|
|
501
|
+
if closed_property_sets:
|
|
502
|
+
permitted_properties = set.intersection(*closed_property_sets)
|
|
503
|
+
if not required_properties <= permitted_properties:
|
|
504
|
+
return False
|
|
505
|
+
if minimum_properties > len(permitted_properties):
|
|
506
|
+
return False
|
|
507
|
+
if any(
|
|
508
|
+
not _schema_accepts_instance(property_name_schema, root_schema, required_property)
|
|
509
|
+
for property_name_schema in property_name_schemas
|
|
510
|
+
for required_property in required_properties
|
|
511
|
+
):
|
|
512
|
+
return False
|
|
513
|
+
if minimum_properties and property_name_schemas:
|
|
514
|
+
property_name_boundary = _boundary_all(
|
|
515
|
+
[
|
|
516
|
+
_object_boundary_match(property_name_schema, root_schema)
|
|
517
|
+
for property_name_schema in property_name_schemas
|
|
518
|
+
]
|
|
519
|
+
)
|
|
520
|
+
if not property_name_boundary[_JSON_VALUE_CATEGORIES.index("string")][0]:
|
|
521
|
+
return False
|
|
522
|
+
|
|
523
|
+
for required_property in required_properties:
|
|
524
|
+
required_property_schemas: list[Any] = []
|
|
525
|
+
for schema_fragment in schema_fragments:
|
|
526
|
+
if not isinstance(schema_fragment, Mapping):
|
|
527
|
+
continue
|
|
528
|
+
properties = schema_fragment.get("properties")
|
|
529
|
+
if isinstance(properties, Mapping) and required_property in properties:
|
|
530
|
+
required_property_schemas.append(properties[required_property])
|
|
531
|
+
pattern_properties = schema_fragment.get("patternProperties")
|
|
532
|
+
if isinstance(pattern_properties, Mapping):
|
|
533
|
+
required_property_schemas.extend(
|
|
534
|
+
pattern_schema
|
|
535
|
+
for pattern, pattern_schema in pattern_properties.items()
|
|
536
|
+
if re.search(pattern, required_property) is not None
|
|
537
|
+
)
|
|
538
|
+
if required_property_schemas:
|
|
539
|
+
property_boundary = _boundary_all(
|
|
540
|
+
[
|
|
541
|
+
_object_boundary_match(property_schema, root_schema)
|
|
542
|
+
for property_schema in required_property_schemas
|
|
543
|
+
]
|
|
544
|
+
)
|
|
545
|
+
if not any(category_match[0] for category_match in property_boundary):
|
|
546
|
+
return False
|
|
547
|
+
|
|
548
|
+
finite_candidates = _finite_object_candidates(schema_fragments)
|
|
549
|
+
return finite_candidates is None or any(
|
|
550
|
+
_schema_accepts_instance(schema, root_schema, candidate_object)
|
|
551
|
+
for candidate_object in finite_candidates.values()
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _object_boundary_match(
|
|
556
|
+
schema: Any,
|
|
557
|
+
root_schema: Mapping[str, Any],
|
|
558
|
+
reference_stack: tuple[str, ...] = (),
|
|
559
|
+
) -> _BoundaryMatch:
|
|
560
|
+
if schema is False:
|
|
561
|
+
return tuple(_NEVER_MATCHES for _ in _JSON_VALUE_CATEGORIES)
|
|
562
|
+
if schema is True or not isinstance(schema, Mapping):
|
|
563
|
+
return tuple(_ALWAYS_MATCHES for _ in _JSON_VALUE_CATEGORIES)
|
|
564
|
+
|
|
565
|
+
boundary_matches: list[_BoundaryMatch] = []
|
|
566
|
+
if not _object_constraints_may_match(schema, root_schema):
|
|
567
|
+
boundary_matches.append(_category_restriction_boundary("object", _NEVER_MATCHES))
|
|
568
|
+
if (schema_type := schema.get("type")) is not None:
|
|
569
|
+
boundary_matches.append(_type_boundary_match(schema_type))
|
|
570
|
+
|
|
571
|
+
if "const" in schema:
|
|
572
|
+
boundary_matches.append(_finite_boundary_match([schema["const"]]))
|
|
573
|
+
if isinstance((enum_members := schema.get("enum")), (list, tuple)):
|
|
574
|
+
boundary_matches.append(_finite_boundary_match(list(enum_members)))
|
|
575
|
+
|
|
576
|
+
object_shape_keywords = {
|
|
577
|
+
"additionalProperties",
|
|
578
|
+
"dependentRequired",
|
|
579
|
+
"dependentSchemas",
|
|
580
|
+
"patternProperties",
|
|
581
|
+
"properties",
|
|
582
|
+
"propertyNames",
|
|
583
|
+
"unevaluatedProperties",
|
|
584
|
+
}
|
|
585
|
+
if object_shape_keywords & set(schema) or any(
|
|
586
|
+
keyword in schema for keyword in ("maxProperties", "minProperties", "required")
|
|
587
|
+
):
|
|
588
|
+
boundary_matches.append(_category_restriction_boundary("object", _SOMETIMES_MATCHES))
|
|
589
|
+
|
|
590
|
+
if (reference := schema.get("$ref")) is not None:
|
|
591
|
+
if reference in reference_stack:
|
|
592
|
+
boundary_matches.append(tuple(_SOMETIMES_MATCHES for _ in _JSON_VALUE_CATEGORIES))
|
|
593
|
+
else:
|
|
594
|
+
boundary_matches.append(
|
|
595
|
+
_object_boundary_match(
|
|
596
|
+
_local_reference_target(root_schema, cast(str, reference)),
|
|
597
|
+
root_schema,
|
|
598
|
+
(*reference_stack, cast(str, reference)),
|
|
599
|
+
)
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
all_of = schema.get("allOf")
|
|
603
|
+
if isinstance(all_of, (list, tuple)):
|
|
604
|
+
boundary_matches.append(
|
|
605
|
+
_boundary_all(
|
|
606
|
+
[_object_boundary_match(branch, root_schema, reference_stack) for branch in all_of]
|
|
607
|
+
)
|
|
608
|
+
)
|
|
609
|
+
if isinstance((any_of := schema.get("anyOf")), (list, tuple)):
|
|
610
|
+
boundary_matches.append(
|
|
611
|
+
_boundary_any(
|
|
612
|
+
[_object_boundary_match(branch, root_schema, reference_stack) for branch in any_of]
|
|
613
|
+
)
|
|
614
|
+
)
|
|
615
|
+
if isinstance((one_of := schema.get("oneOf")), (list, tuple)):
|
|
616
|
+
boundary_matches.append(
|
|
617
|
+
_boundary_one_of(
|
|
618
|
+
[_object_boundary_match(branch, root_schema, reference_stack) for branch in one_of]
|
|
619
|
+
)
|
|
620
|
+
)
|
|
621
|
+
if "not" in schema:
|
|
622
|
+
boundary_matches.append(
|
|
623
|
+
_boundary_not(_object_boundary_match(schema["not"], root_schema, reference_stack))
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
if "if" in schema:
|
|
627
|
+
condition_match = _object_boundary_match(schema["if"], root_schema, reference_stack)
|
|
628
|
+
boundary_matches.append(
|
|
629
|
+
_boundary_any(
|
|
630
|
+
[
|
|
631
|
+
_boundary_all(
|
|
632
|
+
[
|
|
633
|
+
condition_match,
|
|
634
|
+
_object_boundary_match(
|
|
635
|
+
schema.get("then", True), root_schema, reference_stack
|
|
636
|
+
),
|
|
637
|
+
]
|
|
638
|
+
),
|
|
639
|
+
_boundary_all(
|
|
640
|
+
[
|
|
641
|
+
_boundary_not(condition_match),
|
|
642
|
+
_object_boundary_match(
|
|
643
|
+
schema.get("else", True), root_schema, reference_stack
|
|
644
|
+
),
|
|
645
|
+
]
|
|
646
|
+
),
|
|
647
|
+
]
|
|
648
|
+
)
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
return (
|
|
652
|
+
_boundary_all(boundary_matches)
|
|
653
|
+
if boundary_matches
|
|
654
|
+
else tuple(_ALWAYS_MATCHES for _ in _JSON_VALUE_CATEGORIES)
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _validated_schema(
|
|
659
|
+
schema: Mapping[str, Any],
|
|
660
|
+
location: str,
|
|
661
|
+
*,
|
|
662
|
+
require_object_boundary: bool,
|
|
663
|
+
) -> Mapping[str, Any]:
|
|
664
|
+
mutable_schema = _mutable_json(schema)
|
|
665
|
+
try:
|
|
666
|
+
json.dumps(mutable_schema, allow_nan=False)
|
|
667
|
+
declared_dialect = mutable_schema.get("$schema")
|
|
668
|
+
if not isinstance(declared_dialect, (str, type(None))):
|
|
669
|
+
raise ValueError("subflow schema $schema must be a string")
|
|
670
|
+
if declared_dialect is not None and declared_dialect not in _DRAFT_2020_12_URIS:
|
|
671
|
+
raise ValueError(
|
|
672
|
+
f"subflow schemas must use JSON Schema Draft 2020-12, got {declared_dialect!r}"
|
|
673
|
+
)
|
|
674
|
+
Draft202012Validator.check_schema(mutable_schema)
|
|
675
|
+
_validate_local_schema_references(mutable_schema, mutable_schema)
|
|
676
|
+
_validate_local_reference_cycles(mutable_schema)
|
|
677
|
+
except (SchemaError, TypeError, ValueError) as error:
|
|
678
|
+
detail = error.message if isinstance(error, SchemaError) else str(error)
|
|
679
|
+
raise ValueError(f"{location} is not a valid JSON Schema: {detail}") from error
|
|
680
|
+
if require_object_boundary:
|
|
681
|
+
boundary_match = _object_boundary_match(mutable_schema, mutable_schema)
|
|
682
|
+
object_match = boundary_match[_OBJECT_CATEGORY_INDEX]
|
|
683
|
+
if not object_match[0]:
|
|
684
|
+
raise ValueError(
|
|
685
|
+
f"{location} cannot validate any object-shaped uses[].with configuration"
|
|
686
|
+
)
|
|
687
|
+
if any(
|
|
688
|
+
category_match[0]
|
|
689
|
+
for category_index, category_match in enumerate(boundary_match)
|
|
690
|
+
if category_index != _OBJECT_CATEGORY_INDEX
|
|
691
|
+
):
|
|
692
|
+
raise ValueError(
|
|
693
|
+
f"{location} must only validate object-shaped uses[].with configurations"
|
|
694
|
+
)
|
|
695
|
+
return cast(Mapping[str, Any], _immutable_json(mutable_schema))
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _validation_location(base_location: str, path: tuple[Any, ...]) -> str:
|
|
699
|
+
location = base_location
|
|
700
|
+
for segment in path:
|
|
701
|
+
if isinstance(segment, int):
|
|
702
|
+
location += f"[{segment}]"
|
|
703
|
+
elif isinstance(segment, str) and segment.isidentifier():
|
|
704
|
+
location += f".{segment}"
|
|
705
|
+
else:
|
|
706
|
+
location += f"[{segment!r}]"
|
|
707
|
+
return location
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
@dataclass
|
|
711
|
+
class SubflowBuild:
|
|
712
|
+
descriptors: list[NodeDesc]
|
|
713
|
+
entry_id: str
|
|
714
|
+
node_for_slot: dict[str, str] = field(default_factory=dict)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
class Subflow(Protocol):
|
|
718
|
+
name: str
|
|
719
|
+
major: int
|
|
720
|
+
|
|
721
|
+
def build(self, ctx: FlowContext, with_cfg: dict[str, Any]) -> SubflowBuild: ...
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
@dataclass(frozen=True)
|
|
725
|
+
class SubflowStateKey:
|
|
726
|
+
"""Partition and immutable validation contract for subflow-owned state."""
|
|
727
|
+
|
|
728
|
+
partition: Literal["data", "internal"]
|
|
729
|
+
schema: Mapping[str, Any]
|
|
730
|
+
|
|
731
|
+
def __post_init__(self) -> None:
|
|
732
|
+
if self.partition not in {"data", "internal"}:
|
|
733
|
+
raise ValueError(f"unsupported subflow state partition: {self.partition!r}")
|
|
734
|
+
object.__setattr__(
|
|
735
|
+
self,
|
|
736
|
+
"schema",
|
|
737
|
+
_validated_schema(
|
|
738
|
+
self.schema,
|
|
739
|
+
"subflow state key schema",
|
|
740
|
+
require_object_boundary=False,
|
|
741
|
+
),
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
def as_dict(self) -> dict[str, Any]:
|
|
745
|
+
"""Return a JSON-compatible state-key contract."""
|
|
746
|
+
|
|
747
|
+
return {
|
|
748
|
+
"partition": self.partition,
|
|
749
|
+
"schema": _mutable_json(self.schema),
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
@dataclass(frozen=True)
|
|
754
|
+
class SubflowDefinition:
|
|
755
|
+
"""Immutable authoring and integration contract for a versioned subflow."""
|
|
756
|
+
|
|
757
|
+
ref: str
|
|
758
|
+
description: str
|
|
759
|
+
configuration_schema: Mapping[str, Any]
|
|
760
|
+
exposed_slots: frozenset[str] | None = None
|
|
761
|
+
exposed_slot_schemas: Mapping[str, Mapping[str, Any]] = field(default_factory=dict)
|
|
762
|
+
capabilities: frozenset[str] = frozenset()
|
|
763
|
+
required_tools: Mapping[str, str] = field(default_factory=dict)
|
|
764
|
+
state_keys: Mapping[str, SubflowStateKey] | None = None
|
|
765
|
+
node_identifiers: frozenset[str] = frozenset()
|
|
766
|
+
legacy_manifest: bool = field(default=False, init=False)
|
|
767
|
+
_state_ownership_complete: bool = field(init=False, repr=False)
|
|
768
|
+
|
|
769
|
+
def __post_init__(self) -> None:
|
|
770
|
+
if re.fullmatch(r"[a-z][a-z0-9_]*@[0-9]+", self.ref) is None:
|
|
771
|
+
raise ValueError(f"subflow definition ref must be name@major: {self.ref!r}")
|
|
772
|
+
if not self.description.strip():
|
|
773
|
+
raise ValueError(f"subflow {self.ref!r} description must be non-empty")
|
|
774
|
+
object.__setattr__(
|
|
775
|
+
self,
|
|
776
|
+
"configuration_schema",
|
|
777
|
+
_validated_schema(
|
|
778
|
+
self.configuration_schema,
|
|
779
|
+
f"subflow {self.ref!r} configuration_schema",
|
|
780
|
+
require_object_boundary=True,
|
|
781
|
+
),
|
|
782
|
+
)
|
|
783
|
+
if self.exposed_slots is not None:
|
|
784
|
+
if any(not slot_name.strip() for slot_name in self.exposed_slots):
|
|
785
|
+
raise ValueError(f"subflow {self.ref!r} exposed slots must be non-empty")
|
|
786
|
+
object.__setattr__(self, "exposed_slots", frozenset(self.exposed_slots))
|
|
787
|
+
validated_slot_schemas = {
|
|
788
|
+
slot_name: _validated_schema(
|
|
789
|
+
slot_schema,
|
|
790
|
+
f"subflow {self.ref!r} exposed_slot_schemas[{slot_name!r}]",
|
|
791
|
+
require_object_boundary=False,
|
|
792
|
+
)
|
|
793
|
+
for slot_name, slot_schema in self.exposed_slot_schemas.items()
|
|
794
|
+
}
|
|
795
|
+
if self.exposed_slots is None and validated_slot_schemas:
|
|
796
|
+
raise ValueError(
|
|
797
|
+
f"subflow {self.ref!r} cannot declare slot schemas with open exposures"
|
|
798
|
+
)
|
|
799
|
+
if self.exposed_slots is not None and set(validated_slot_schemas) != self.exposed_slots:
|
|
800
|
+
raise ValueError(
|
|
801
|
+
f"subflow {self.ref!r} exposed slot schemas must exactly match exposed_slots"
|
|
802
|
+
)
|
|
803
|
+
object.__setattr__(
|
|
804
|
+
self,
|
|
805
|
+
"exposed_slot_schemas",
|
|
806
|
+
MappingProxyType(validated_slot_schemas),
|
|
807
|
+
)
|
|
808
|
+
if any(not capability.strip() for capability in self.capabilities):
|
|
809
|
+
raise ValueError(f"subflow {self.ref!r} capabilities must be non-empty")
|
|
810
|
+
object.__setattr__(self, "capabilities", frozenset(self.capabilities))
|
|
811
|
+
validated_required_tools: dict[str, str] = {}
|
|
812
|
+
for tool_name, tool_version in self.required_tools.items():
|
|
813
|
+
if re.fullmatch(r"[a-z][a-z0-9_]*", tool_name) is None:
|
|
814
|
+
raise ValueError(
|
|
815
|
+
f"subflow {self.ref!r} required tool name is invalid: {tool_name!r}"
|
|
816
|
+
)
|
|
817
|
+
if not tool_version.strip():
|
|
818
|
+
raise ValueError(
|
|
819
|
+
f"subflow {self.ref!r} required tool {tool_name!r} version must be non-empty"
|
|
820
|
+
)
|
|
821
|
+
validated_required_tools[tool_name] = tool_version
|
|
822
|
+
object.__setattr__(
|
|
823
|
+
self,
|
|
824
|
+
"required_tools",
|
|
825
|
+
MappingProxyType(validated_required_tools),
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
declared_state_keys = self.state_keys is not None
|
|
829
|
+
validated_state_keys = dict(self.state_keys or {})
|
|
830
|
+
for state_key, state_contract in validated_state_keys.items():
|
|
831
|
+
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_:-]*", state_key) is None:
|
|
832
|
+
raise ValueError(f"subflow {self.ref!r} state key is invalid: {state_key!r}")
|
|
833
|
+
if not isinstance(state_contract, SubflowStateKey):
|
|
834
|
+
raise TypeError(
|
|
835
|
+
f"subflow {self.ref!r} state key {state_key!r} must use SubflowStateKey"
|
|
836
|
+
)
|
|
837
|
+
if not declared_state_keys and self.exposed_slots is not None:
|
|
838
|
+
validated_state_keys.update(
|
|
839
|
+
{
|
|
840
|
+
exposed_slot: SubflowStateKey(
|
|
841
|
+
partition="data",
|
|
842
|
+
schema=validated_slot_schemas[exposed_slot],
|
|
843
|
+
)
|
|
844
|
+
for exposed_slot in self.exposed_slots
|
|
845
|
+
}
|
|
846
|
+
)
|
|
847
|
+
if declared_state_keys and self.exposed_slots is not None:
|
|
848
|
+
for exposed_slot in self.exposed_slots:
|
|
849
|
+
if (exposed_state_contract := validated_state_keys.get(exposed_slot)) is None:
|
|
850
|
+
raise ValueError(
|
|
851
|
+
f"subflow {self.ref!r} state_keys must include exposed slot "
|
|
852
|
+
f"{exposed_slot!r}"
|
|
853
|
+
)
|
|
854
|
+
if exposed_state_contract.partition != "data":
|
|
855
|
+
raise ValueError(
|
|
856
|
+
f"subflow {self.ref!r} exposed slot {exposed_slot!r} must use data state"
|
|
857
|
+
)
|
|
858
|
+
if _canonical_json(_mutable_json(exposed_state_contract.schema)) != _canonical_json(
|
|
859
|
+
_mutable_json(validated_slot_schemas[exposed_slot])
|
|
860
|
+
):
|
|
861
|
+
raise ValueError(
|
|
862
|
+
f"subflow {self.ref!r} exposed slot {exposed_slot!r} schemas disagree"
|
|
863
|
+
)
|
|
864
|
+
object.__setattr__(self, "state_keys", MappingProxyType(validated_state_keys))
|
|
865
|
+
object.__setattr__(
|
|
866
|
+
self,
|
|
867
|
+
"_state_ownership_complete",
|
|
868
|
+
declared_state_keys and self.exposed_slots is not None,
|
|
869
|
+
)
|
|
870
|
+
if any(not node_identifier.strip() for node_identifier in self.node_identifiers):
|
|
871
|
+
raise ValueError(f"subflow {self.ref!r} node identifiers must be non-empty")
|
|
872
|
+
object.__setattr__(self, "node_identifiers", frozenset(self.node_identifiers))
|
|
873
|
+
|
|
874
|
+
@property
|
|
875
|
+
def state_ownership_complete(self) -> bool:
|
|
876
|
+
"""Return whether the manifest closes the subflow's persistent state surface."""
|
|
877
|
+
|
|
878
|
+
return self._state_ownership_complete
|
|
879
|
+
|
|
880
|
+
@property
|
|
881
|
+
def owned_state_keys(self) -> Mapping[str, SubflowStateKey]:
|
|
882
|
+
"""Return every state key known to the manifest, including legacy exposures."""
|
|
883
|
+
|
|
884
|
+
return cast(Mapping[str, SubflowStateKey], self.state_keys)
|
|
885
|
+
|
|
886
|
+
def as_dict(self) -> dict[str, Any]:
|
|
887
|
+
"""Return a JSON-serializable catalog entry without exposing mutable state."""
|
|
888
|
+
|
|
889
|
+
return {
|
|
890
|
+
"ref": self.ref,
|
|
891
|
+
"description": self.description,
|
|
892
|
+
"configuration_schema": _mutable_json(self.configuration_schema),
|
|
893
|
+
"exposed_slots": (
|
|
894
|
+
sorted(self.exposed_slots) if self.exposed_slots is not None else None
|
|
895
|
+
),
|
|
896
|
+
"exposed_slot_schemas": {
|
|
897
|
+
slot_name: _mutable_json(slot_schema)
|
|
898
|
+
for slot_name, slot_schema in sorted(self.exposed_slot_schemas.items())
|
|
899
|
+
},
|
|
900
|
+
"capabilities": sorted(self.capabilities),
|
|
901
|
+
"required_tools": dict(sorted(self.required_tools.items())),
|
|
902
|
+
"state_keys": {
|
|
903
|
+
state_key: state_contract.as_dict()
|
|
904
|
+
for state_key, state_contract in sorted(self.owned_state_keys.items())
|
|
905
|
+
},
|
|
906
|
+
"state_ownership_complete": self.state_ownership_complete,
|
|
907
|
+
"node_identifiers": sorted(self.node_identifiers),
|
|
908
|
+
"legacy_manifest": self.legacy_manifest,
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
@classmethod
|
|
912
|
+
def legacy(cls, ref: str) -> SubflowDefinition:
|
|
913
|
+
legacy_definition = cls(
|
|
914
|
+
ref=ref,
|
|
915
|
+
description="Legacy subflow registered without a typed manifest.",
|
|
916
|
+
configuration_schema={"type": "object", "additionalProperties": True},
|
|
917
|
+
exposed_slots=None,
|
|
918
|
+
)
|
|
919
|
+
object.__setattr__(legacy_definition, "legacy_manifest", True)
|
|
920
|
+
return legacy_definition
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
class SubflowRegistry:
|
|
924
|
+
def __init__(self) -> None:
|
|
925
|
+
self._subflows: dict[str, Subflow] = {}
|
|
926
|
+
self._definitions: dict[str, SubflowDefinition] = {}
|
|
927
|
+
|
|
928
|
+
def register(
|
|
929
|
+
self,
|
|
930
|
+
subflow: Subflow,
|
|
931
|
+
*,
|
|
932
|
+
definition: SubflowDefinition | None = None,
|
|
933
|
+
) -> None:
|
|
934
|
+
ref = f"{subflow.name}@{subflow.major}"
|
|
935
|
+
if definition is not None and definition.ref != ref:
|
|
936
|
+
raise ValueError(
|
|
937
|
+
f"subflow definition ref {definition.ref!r} does not match registry ref {ref!r}"
|
|
938
|
+
)
|
|
939
|
+
self._subflows[ref] = subflow
|
|
940
|
+
if definition is not None:
|
|
941
|
+
self._definitions[ref] = definition
|
|
942
|
+
elif ref not in self._definitions:
|
|
943
|
+
self._definitions[ref] = SubflowDefinition.legacy(ref)
|
|
944
|
+
|
|
945
|
+
def has(self, ref: str) -> bool:
|
|
946
|
+
return ref in self._subflows
|
|
947
|
+
|
|
948
|
+
def get(self, ref: str) -> Subflow:
|
|
949
|
+
if ref not in self._subflows:
|
|
950
|
+
raise KeyError(f"subflow not registered: {ref!r}")
|
|
951
|
+
return self._subflows[ref]
|
|
952
|
+
|
|
953
|
+
def definition(self, ref: str) -> SubflowDefinition:
|
|
954
|
+
if ref not in self._definitions:
|
|
955
|
+
raise KeyError(f"subflow definition not registered: {ref!r}")
|
|
956
|
+
return self._definitions[ref]
|
|
957
|
+
|
|
958
|
+
@property
|
|
959
|
+
def definitions(self) -> Mapping[str, SubflowDefinition]:
|
|
960
|
+
return MappingProxyType(dict(self._definitions))
|
|
961
|
+
|
|
962
|
+
def validate_configuration(
|
|
963
|
+
self,
|
|
964
|
+
ref: str,
|
|
965
|
+
configuration: Mapping[str, Any],
|
|
966
|
+
*,
|
|
967
|
+
location: str,
|
|
968
|
+
) -> None:
|
|
969
|
+
definition = self.definition(ref)
|
|
970
|
+
schema = _mutable_json(definition.configuration_schema)
|
|
971
|
+
validator = Draft202012Validator(schema, format_checker=FormatChecker())
|
|
972
|
+
validation_errors = sorted(
|
|
973
|
+
validator.iter_errors(dict(configuration)),
|
|
974
|
+
key=lambda error: (
|
|
975
|
+
tuple(str(segment) for segment in error.absolute_path),
|
|
976
|
+
tuple(str(segment) for segment in error.absolute_schema_path),
|
|
977
|
+
str(error.validator),
|
|
978
|
+
),
|
|
979
|
+
)
|
|
980
|
+
if not validation_errors:
|
|
981
|
+
return
|
|
982
|
+
validation_error = validation_errors[0]
|
|
983
|
+
error_location = _validation_location(
|
|
984
|
+
location,
|
|
985
|
+
tuple(validation_error.absolute_path),
|
|
986
|
+
)
|
|
987
|
+
raise ValueError(f"{error_location}: {validation_error.message}")
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
_EXHAUSTION_MODES = ["reask", "skip", "default", "handoff", "END"]
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _address_definition() -> SubflowDefinition:
|
|
994
|
+
address_schema = {
|
|
995
|
+
"anyOf": [
|
|
996
|
+
{
|
|
997
|
+
"type": "object",
|
|
998
|
+
"additionalProperties": False,
|
|
999
|
+
"required": ["street"],
|
|
1000
|
+
"properties": {
|
|
1001
|
+
"street": {"type": "string", "minLength": 1},
|
|
1002
|
+
"kind": {"type": "string", "minLength": 1},
|
|
1003
|
+
"district": {"type": "string", "minLength": 1},
|
|
1004
|
+
"city": {"type": "string", "minLength": 1},
|
|
1005
|
+
},
|
|
1006
|
+
},
|
|
1007
|
+
{"type": "null"},
|
|
1008
|
+
]
|
|
1009
|
+
}
|
|
1010
|
+
return SubflowDefinition(
|
|
1011
|
+
ref="address@1",
|
|
1012
|
+
description="Collect, geocode, and optionally confirm a citizen address.",
|
|
1013
|
+
configuration_schema={
|
|
1014
|
+
"type": "object",
|
|
1015
|
+
"additionalProperties": False,
|
|
1016
|
+
"properties": {
|
|
1017
|
+
"required": {"type": "boolean"},
|
|
1018
|
+
"needs_confirmation": {"type": "boolean"},
|
|
1019
|
+
"max_attempts": {"type": "integer", "minimum": 1},
|
|
1020
|
+
"on_exhaust": {"enum": _EXHAUSTION_MODES},
|
|
1021
|
+
},
|
|
1022
|
+
},
|
|
1023
|
+
exposed_slots=frozenset({"address"}),
|
|
1024
|
+
exposed_slot_schemas={"address": address_schema},
|
|
1025
|
+
capabilities=frozenset({"geocoding"}),
|
|
1026
|
+
required_tools={"geocode": "1"},
|
|
1027
|
+
state_keys={
|
|
1028
|
+
"address": SubflowStateKey("data", address_schema),
|
|
1029
|
+
"address_confirmed": SubflowStateKey("data", {"type": "boolean"}),
|
|
1030
|
+
"address_needs_confirmation": SubflowStateKey("data", {"type": "boolean"}),
|
|
1031
|
+
"address_attempts": SubflowStateKey("data", {"type": "integer", "minimum": 1}),
|
|
1032
|
+
"address_completed": SubflowStateKey("data", {"type": "boolean"}),
|
|
1033
|
+
"address_defaulted": SubflowStateKey("data", {"type": "boolean"}),
|
|
1034
|
+
"address_skipped": SubflowStateKey("data", {"type": "boolean"}),
|
|
1035
|
+
},
|
|
1036
|
+
node_identifiers=frozenset({"collect_address", "confirm_address", "address_done"}),
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _identification_definition() -> SubflowDefinition:
|
|
1041
|
+
tax_id_schema = {"type": "string", "pattern": "^[0-9]{11}$"}
|
|
1042
|
+
email_schema = {"type": "string", "format": "email"}
|
|
1043
|
+
name_schema = {"type": "string", "minLength": 2}
|
|
1044
|
+
boolean_state = {"type": "boolean"}
|
|
1045
|
+
attempts_state = {"type": "integer", "minimum": 1}
|
|
1046
|
+
return SubflowDefinition(
|
|
1047
|
+
ref="identification@2",
|
|
1048
|
+
description="Collect citizen identity through Brazilian tax ID, gov.br, or anonymous continuation.",
|
|
1049
|
+
configuration_schema={
|
|
1050
|
+
"type": "object",
|
|
1051
|
+
"additionalProperties": False,
|
|
1052
|
+
"properties": {
|
|
1053
|
+
"required": {"type": "boolean"},
|
|
1054
|
+
"methods": {
|
|
1055
|
+
"type": "array",
|
|
1056
|
+
"minItems": 1,
|
|
1057
|
+
"uniqueItems": True,
|
|
1058
|
+
"items": {"enum": ["brazilian_tax_id", "govbr", "anonymous"]},
|
|
1059
|
+
},
|
|
1060
|
+
"max_attempts": {"type": "integer", "minimum": 1},
|
|
1061
|
+
"on_exhaust": {"enum": _EXHAUSTION_MODES},
|
|
1062
|
+
},
|
|
1063
|
+
},
|
|
1064
|
+
exposed_slots=frozenset({"brazilian_tax_id", "email", "name"}),
|
|
1065
|
+
exposed_slot_schemas={
|
|
1066
|
+
"brazilian_tax_id": tax_id_schema,
|
|
1067
|
+
"email": email_schema,
|
|
1068
|
+
"name": name_schema,
|
|
1069
|
+
},
|
|
1070
|
+
capabilities=frozenset({"external_authentication", "identity_lookup"}),
|
|
1071
|
+
required_tools={"brazilian_tax_id_lookup": "1", "get_user_info": "1"},
|
|
1072
|
+
state_keys={
|
|
1073
|
+
"brazilian_tax_id": SubflowStateKey("data", tax_id_schema),
|
|
1074
|
+
"email": SubflowStateKey("data", email_schema),
|
|
1075
|
+
"name": SubflowStateKey("data", name_schema),
|
|
1076
|
+
"phone": SubflowStateKey("data", {"type": "string"}),
|
|
1077
|
+
"identification_method": SubflowStateKey(
|
|
1078
|
+
"data", {"enum": ["brazilian_tax_id", "govbr", "anonymous"]}
|
|
1079
|
+
),
|
|
1080
|
+
"identification_skipped": SubflowStateKey("data", boolean_state),
|
|
1081
|
+
"identity_verified": SubflowStateKey("data", boolean_state),
|
|
1082
|
+
"email_processed": SubflowStateKey("data", boolean_state),
|
|
1083
|
+
"name_processed": SubflowStateKey("data", boolean_state),
|
|
1084
|
+
"govbr_auth_sent": SubflowStateKey("data", boolean_state),
|
|
1085
|
+
"govbr_authenticated": SubflowStateKey("data", boolean_state),
|
|
1086
|
+
"_attempts_method": SubflowStateKey("data", attempts_state),
|
|
1087
|
+
"_attempts_brazilian_tax_id": SubflowStateKey("data", attempts_state),
|
|
1088
|
+
"_attempts_email": SubflowStateKey("data", attempts_state),
|
|
1089
|
+
"_attempts_name": SubflowStateKey("data", attempts_state),
|
|
1090
|
+
"_brazilian_tax_id_lookup_derived:email": SubflowStateKey("internal", boolean_state),
|
|
1091
|
+
"_brazilian_tax_id_lookup_derived:name": SubflowStateKey("internal", boolean_state),
|
|
1092
|
+
"_await_external_sent:authenticate_govbr": SubflowStateKey("internal", boolean_state),
|
|
1093
|
+
"_await_external_completed:authenticate_govbr": SubflowStateKey(
|
|
1094
|
+
"internal", boolean_state
|
|
1095
|
+
),
|
|
1096
|
+
},
|
|
1097
|
+
node_identifiers=frozenset(
|
|
1098
|
+
{
|
|
1099
|
+
"select_identification_method",
|
|
1100
|
+
"authenticate_govbr",
|
|
1101
|
+
"collect_tax_id",
|
|
1102
|
+
"collect_email",
|
|
1103
|
+
"collect_name",
|
|
1104
|
+
"identification_done",
|
|
1105
|
+
}
|
|
1106
|
+
),
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def default_subflows() -> SubflowRegistry:
|
|
1111
|
+
from .address import AddressSubflow
|
|
1112
|
+
from .identification import IdentificationSubflow
|
|
1113
|
+
|
|
1114
|
+
reg = SubflowRegistry()
|
|
1115
|
+
reg.register(AddressSubflow(), definition=_address_definition())
|
|
1116
|
+
reg.register(IdentificationSubflow(), definition=_identification_definition())
|
|
1117
|
+
return reg
|