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,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