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,1034 @@
1
+ """Deterministic, declarative migration of persisted FlowSpec service state.
2
+
3
+ The runtime deliberately rejects state produced by a different resolved flow
4
+ contract. This module is the explicit bridge between two such contracts. A
5
+ migration copies whole top-level partition members, supplies literal defaults,
6
+ or drops members; it never evaluates callbacks or an expression language.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import copy
12
+ import hashlib
13
+ import json
14
+ import re
15
+ from collections.abc import Mapping
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Final, Literal, cast
18
+ from urllib.parse import unquote
19
+
20
+ from jsonschema import Draft202012Validator, FormatChecker
21
+ from jsonschema.exceptions import SchemaError
22
+ from pydantic_core import PydanticSerializationError
23
+
24
+ from .clock import UtcClock, utc_timestamp
25
+ from .ir import FlowIR
26
+ from .json_codec import validate_json_value
27
+ from .models import ServiceMetadata, ServiceState
28
+
29
+ StatePartition = Literal["data", "internal", "payload"]
30
+
31
+ _DIGEST_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[0-9a-f]{64}$")
32
+ _PLAN_FORMAT: Final[str] = "flowspec2/state-migration-plan@1"
33
+ _REPORT_FORMAT: Final[str] = "flowspec2/state-migration-report@1"
34
+ _PARTITIONS: Final[tuple[StatePartition, ...]] = ("data", "internal", "payload")
35
+ _ANNOTATION_KEYWORDS: Final[frozenset[str]] = frozenset(
36
+ {
37
+ "$anchor",
38
+ "$comment",
39
+ "$id",
40
+ "$schema",
41
+ "default",
42
+ "deprecated",
43
+ "description",
44
+ "examples",
45
+ "readOnly",
46
+ "title",
47
+ "writeOnly",
48
+ }
49
+ )
50
+ _JSON_TYPES: Final[frozenset[str]] = frozenset(
51
+ {"array", "boolean", "integer", "null", "number", "object", "string"}
52
+ )
53
+
54
+
55
+ class StateMigrationError(ValueError):
56
+ """Raised when a plan or state cannot be migrated without ambiguity."""
57
+
58
+
59
+ def _require_json_value(json_value: Any, *, boundary: str) -> None:
60
+ try:
61
+ validate_json_value(json_value, boundary=boundary)
62
+ except ValueError as value_error:
63
+ raise StateMigrationError(str(value_error)) from value_error
64
+
65
+
66
+ def _canonical_json(json_value: Any) -> str:
67
+ return json.dumps(
68
+ json_value,
69
+ ensure_ascii=False,
70
+ allow_nan=False,
71
+ separators=(",", ":"),
72
+ sort_keys=True,
73
+ )
74
+
75
+
76
+ def _digest(json_value: Any) -> str:
77
+ return hashlib.sha256(_canonical_json(json_value).encode("utf-8")).hexdigest()
78
+
79
+
80
+ def _escape_pointer_segment(segment: str) -> str:
81
+ return segment.replace("~", "~0").replace("/", "~1")
82
+
83
+
84
+ def _json_pointer(path_segments: tuple[str | int, ...]) -> str:
85
+ return "".join(f"/{_escape_pointer_segment(str(segment))}" for segment in path_segments) or "/"
86
+
87
+
88
+ @dataclass(frozen=True, order=True)
89
+ class StatePath:
90
+ """One whole top-level member in a generated state partition."""
91
+
92
+ partition: StatePartition
93
+ member: str
94
+
95
+ def __post_init__(self) -> None:
96
+ if self.partition not in _PARTITIONS:
97
+ raise StateMigrationError(f"unsupported state partition {self.partition!r}")
98
+ if not isinstance(self.member, str) or not self.member:
99
+ raise StateMigrationError("state path member must be a non-empty string")
100
+
101
+ @property
102
+ def pointer(self) -> str:
103
+ """Return the canonical JSON Pointer for this partition member."""
104
+
105
+ return f"/{self.partition}/{_escape_pointer_segment(self.member)}"
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class StateCopy:
110
+ """Copy one complete JSON value from a source path to a target path."""
111
+
112
+ source: StatePath
113
+ target: StatePath
114
+
115
+
116
+ @dataclass(frozen=True, init=False)
117
+ class StateDefault:
118
+ """A literal target value stored internally as immutable canonical JSON."""
119
+
120
+ target: StatePath
121
+ _canonical_value_json: str = field(repr=False)
122
+
123
+ def __init__(self, target: StatePath, default_value: Any) -> None:
124
+ _require_json_value(default_value, boundary=f"migration default {target.pointer}")
125
+ object.__setattr__(self, "target", target)
126
+ object.__setattr__(self, "_canonical_value_json", _canonical_json(default_value))
127
+
128
+ @property
129
+ def default_value(self) -> Any:
130
+ """Return a fresh mutable copy of the declared JSON literal."""
131
+
132
+ return json.loads(self._canonical_value_json)
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class FlowStateContract:
137
+ """Exact source or target provenance pinned by a migration plan."""
138
+
139
+ ir_format: str
140
+ flow: str
141
+ version: str
142
+ source_digest: str
143
+ flow_ir_digest: str
144
+ dependency_digest: str
145
+ profile_digest: str
146
+ state_schema_digest: str
147
+
148
+ def __post_init__(self) -> None:
149
+ for field_name in ("ir_format", "flow", "version"):
150
+ field_value = cast(str, getattr(self, field_name))
151
+ if not isinstance(field_value, str) or not field_value.strip():
152
+ raise StateMigrationError(f"{field_name} must be a non-empty string")
153
+ for field_name in (
154
+ "source_digest",
155
+ "flow_ir_digest",
156
+ "dependency_digest",
157
+ "profile_digest",
158
+ "state_schema_digest",
159
+ ):
160
+ field_value = cast(str, getattr(self, field_name))
161
+ if not isinstance(field_value, str) or _DIGEST_PATTERN.fullmatch(field_value) is None:
162
+ raise StateMigrationError(f"{field_name} must be a lowercase SHA-256 digest")
163
+
164
+ @classmethod
165
+ def from_ir(cls, flow_ir: FlowIR) -> FlowStateContract:
166
+ """Project the exact persisted-state contract from canonical IR."""
167
+
168
+ return cls(
169
+ ir_format=flow_ir.ir_format,
170
+ flow=flow_ir.flow,
171
+ version=flow_ir.version,
172
+ source_digest=flow_ir.source_digest,
173
+ flow_ir_digest=flow_ir.digest,
174
+ dependency_digest=flow_ir.dependency_digest,
175
+ profile_digest=flow_ir.profile_digest,
176
+ state_schema_digest=_digest(flow_ir.state_schema()),
177
+ )
178
+
179
+ def to_dict(self) -> dict[str, str]:
180
+ """Return the stable JSON representation used by plans and reports."""
181
+
182
+ return {
183
+ "ir_format": self.ir_format,
184
+ "flow": self.flow,
185
+ "version": self.version,
186
+ "source_digest": self.source_digest,
187
+ "flow_ir_digest": self.flow_ir_digest,
188
+ "dependency_digest": self.dependency_digest,
189
+ "profile_digest": self.profile_digest,
190
+ "state_schema_digest": self.state_schema_digest,
191
+ }
192
+
193
+
194
+ @dataclass(frozen=True)
195
+ class StateMigrationPlan:
196
+ """Closed declarative transformation between two exact flow contracts."""
197
+
198
+ source: FlowStateContract
199
+ target: FlowStateContract
200
+ copies: tuple[StateCopy, ...] = ()
201
+ defaults: tuple[StateDefault, ...] = ()
202
+ drops: tuple[StatePath, ...] = ()
203
+
204
+ def __post_init__(self) -> None:
205
+ object.__setattr__(self, "copies", tuple(self.copies))
206
+ object.__setattr__(self, "defaults", tuple(self.defaults))
207
+ object.__setattr__(self, "drops", tuple(self.drops))
208
+ if not all(isinstance(copy_definition, StateCopy) for copy_definition in self.copies):
209
+ raise StateMigrationError("copies must contain only StateCopy declarations")
210
+ if not all(
211
+ isinstance(default_definition, StateDefault) for default_definition in self.defaults
212
+ ):
213
+ raise StateMigrationError("defaults must contain only StateDefault declarations")
214
+ if not all(isinstance(drop_path, StatePath) for drop_path in self.drops):
215
+ raise StateMigrationError("drops must contain only StatePath declarations")
216
+
217
+ source_paths = [copy_definition.source for copy_definition in self.copies]
218
+ source_paths.extend(self.drops)
219
+ duplicate_source_paths = _duplicates(source_paths)
220
+ if duplicate_source_paths:
221
+ raise StateMigrationError(
222
+ "source paths must have one disposition: "
223
+ + ", ".join(path.pointer for path in duplicate_source_paths)
224
+ )
225
+
226
+ target_paths = [copy_definition.target for copy_definition in self.copies]
227
+ target_paths.extend(default_definition.target for default_definition in self.defaults)
228
+ duplicate_target_paths = _duplicates(target_paths)
229
+ if duplicate_target_paths:
230
+ raise StateMigrationError(
231
+ "target paths must have one producer: "
232
+ + ", ".join(path.pointer for path in duplicate_target_paths)
233
+ )
234
+
235
+ def _payload(self) -> dict[str, Any]:
236
+ sorted_copies = sorted(
237
+ self.copies,
238
+ key=lambda copy_definition: (
239
+ copy_definition.source.pointer,
240
+ copy_definition.target.pointer,
241
+ ),
242
+ )
243
+ sorted_defaults = sorted(
244
+ self.defaults,
245
+ key=lambda default_definition: default_definition.target.pointer,
246
+ )
247
+ return {
248
+ "format": _PLAN_FORMAT,
249
+ "source": self.source.to_dict(),
250
+ "target": self.target.to_dict(),
251
+ "copies": [
252
+ {
253
+ "source": copy_definition.source.pointer,
254
+ "target": copy_definition.target.pointer,
255
+ }
256
+ for copy_definition in sorted_copies
257
+ ],
258
+ "defaults": [
259
+ {
260
+ "target": default_definition.target.pointer,
261
+ "value": default_definition.default_value,
262
+ }
263
+ for default_definition in sorted_defaults
264
+ ],
265
+ "drops": sorted(drop_path.pointer for drop_path in self.drops),
266
+ }
267
+
268
+ @property
269
+ def digest(self) -> str:
270
+ """Return the stable digest of the complete declarative plan."""
271
+
272
+ return _digest(self._payload())
273
+
274
+ def canonical_json(self) -> str:
275
+ """Serialize the plan independently of declaration ordering."""
276
+
277
+ return _canonical_json({**self._payload(), "digest": self.digest})
278
+
279
+
280
+ def _duplicates(paths: list[StatePath]) -> tuple[StatePath, ...]:
281
+ counts: dict[StatePath, int] = {}
282
+ for path in paths:
283
+ counts[path] = counts.get(path, 0) + 1
284
+ return tuple(sorted(path for path, count in counts.items() if count > 1))
285
+
286
+
287
+ @dataclass(frozen=True, init=False)
288
+ class StateMigrationLossReport:
289
+ """Canonical evidence for every copied, defaulted, and discarded path."""
290
+
291
+ source: FlowStateContract
292
+ target: FlowStateContract
293
+ plan_digest: str
294
+ source_state_digest: str
295
+ target_state_digest: str
296
+ copied: tuple[StateCopy, ...]
297
+ defaulted: tuple[str, ...]
298
+ dropped: tuple[str, ...]
299
+ digest: str
300
+
301
+ def __init__(
302
+ self,
303
+ *,
304
+ plan: StateMigrationPlan,
305
+ source_state: ServiceState,
306
+ target_state: ServiceState,
307
+ ) -> None:
308
+ copied = tuple(
309
+ sorted(
310
+ plan.copies,
311
+ key=lambda copy_definition: (
312
+ copy_definition.source.pointer,
313
+ copy_definition.target.pointer,
314
+ ),
315
+ )
316
+ )
317
+ defaulted = tuple(
318
+ sorted(default_definition.target.pointer for default_definition in plan.defaults)
319
+ )
320
+ dropped_paths = [drop_path.pointer for drop_path in plan.drops]
321
+ if source_state.agent_response is not None:
322
+ dropped_paths.append("/agent_response")
323
+
324
+ object.__setattr__(self, "source", plan.source)
325
+ object.__setattr__(self, "target", plan.target)
326
+ object.__setattr__(self, "plan_digest", plan.digest)
327
+ object.__setattr__(self, "source_state_digest", _state_digest(source_state))
328
+ object.__setattr__(self, "target_state_digest", _state_digest(target_state))
329
+ object.__setattr__(self, "copied", copied)
330
+ object.__setattr__(self, "defaulted", defaulted)
331
+ object.__setattr__(self, "dropped", tuple(sorted(dropped_paths)))
332
+ object.__setattr__(self, "digest", _digest(self._payload()))
333
+
334
+ def _payload(self) -> dict[str, Any]:
335
+ return {
336
+ "format": _REPORT_FORMAT,
337
+ "source": self.source.to_dict(),
338
+ "target": self.target.to_dict(),
339
+ "plan_digest": self.plan_digest,
340
+ "source_state_digest": self.source_state_digest,
341
+ "target_state_digest": self.target_state_digest,
342
+ "copied": [
343
+ {"source": copy_definition.source.pointer, "target": copy_definition.target.pointer}
344
+ for copy_definition in self.copied
345
+ ],
346
+ "defaulted": list(self.defaulted),
347
+ "dropped": list(self.dropped),
348
+ }
349
+
350
+ def canonical_json(self) -> str:
351
+ """Serialize the report with its self-verifying digest."""
352
+
353
+ return _canonical_json({**self._payload(), "digest": self.digest})
354
+
355
+ def verify(
356
+ self,
357
+ *,
358
+ plan: StateMigrationPlan,
359
+ source_state: ServiceState,
360
+ target_state: ServiceState,
361
+ ) -> None:
362
+ """Raise if this report does not bind the supplied plan and states."""
363
+
364
+ expected_report = StateMigrationLossReport(
365
+ plan=plan,
366
+ source_state=source_state,
367
+ target_state=target_state,
368
+ )
369
+ if self != expected_report:
370
+ raise StateMigrationError(
371
+ "migration report does not match the supplied plan and states"
372
+ )
373
+
374
+
375
+ @dataclass(frozen=True)
376
+ class StateMigrationResult:
377
+ """A fresh runtime state and the evidence binding it to the migration."""
378
+
379
+ state: ServiceState
380
+ report: StateMigrationLossReport
381
+
382
+
383
+ def _state_projection(state: ServiceState) -> dict[str, Any]:
384
+ try:
385
+ projection = state.model_dump(mode="json")
386
+ except PydanticSerializationError as serialization_error:
387
+ raise StateMigrationError(
388
+ f"service state is not canonically JSON serializable: {serialization_error}"
389
+ ) from serialization_error
390
+ _require_json_value(projection, boundary="service state")
391
+ return projection
392
+
393
+
394
+ def _state_digest(state: ServiceState) -> str:
395
+ return _digest(_state_projection(state))
396
+
397
+
398
+ def _partition_projection(state: ServiceState) -> dict[str, dict[str, Any]]:
399
+ return {
400
+ partition: copy.deepcopy(cast(dict[str, Any], getattr(state, partition)))
401
+ for partition in _PARTITIONS
402
+ }
403
+
404
+
405
+ def _validate_ir_contract(
406
+ declared_contract: FlowStateContract,
407
+ flow_ir: FlowIR,
408
+ *,
409
+ role: str,
410
+ ) -> None:
411
+ actual_contract = FlowStateContract.from_ir(flow_ir)
412
+ if declared_contract == actual_contract:
413
+ return
414
+ for field_name in (
415
+ "ir_format",
416
+ "flow",
417
+ "version",
418
+ "source_digest",
419
+ "flow_ir_digest",
420
+ "dependency_digest",
421
+ "profile_digest",
422
+ "state_schema_digest",
423
+ ):
424
+ if getattr(declared_contract, field_name) != getattr(actual_contract, field_name):
425
+ raise StateMigrationError(
426
+ f"plan {role} {field_name} does not match the supplied {role} FlowIR"
427
+ )
428
+ raise StateMigrationError(f"plan {role} contract does not match the supplied {role} FlowIR")
429
+
430
+
431
+ def _validate_source_envelope(state: ServiceState, contract: FlowStateContract) -> None:
432
+ if state.status != "progress":
433
+ raise StateMigrationError("only active states with status 'progress' can be migrated")
434
+ if not state.user_id.strip():
435
+ raise StateMigrationError("source state user_id must be non-empty")
436
+ if state.service_name != contract.flow:
437
+ raise StateMigrationError("source state service_name does not match the source contract")
438
+ if state.metadata.await_resume is not None:
439
+ raise StateMigrationError(
440
+ "a state with an accepted external-resume signal cannot be migrated safely"
441
+ )
442
+ expected_metadata = {
443
+ "flow_version": contract.version,
444
+ "flow_ir_digest": contract.flow_ir_digest,
445
+ "dependency_digest": contract.dependency_digest,
446
+ "profile_digest": contract.profile_digest,
447
+ }
448
+ for metadata_field, expected_value in expected_metadata.items():
449
+ if getattr(state.metadata, metadata_field) != expected_value:
450
+ raise StateMigrationError(
451
+ f"source state metadata {metadata_field} does not match the source contract"
452
+ )
453
+
454
+
455
+ def _schema_validator(schema: dict[str, Any], *, role: str) -> Draft202012Validator:
456
+ try:
457
+ Draft202012Validator.check_schema(schema)
458
+ except SchemaError as schema_error:
459
+ raise StateMigrationError(
460
+ f"{role} generated state schema is invalid: {schema_error.message}"
461
+ ) from schema_error
462
+ return Draft202012Validator(schema, format_checker=FormatChecker())
463
+
464
+
465
+ def _validate_partitions(
466
+ partitions: dict[str, dict[str, Any]],
467
+ validator: Draft202012Validator,
468
+ *,
469
+ role: str,
470
+ ) -> None:
471
+ _require_json_value(partitions, boundary=f"{role} state partitions")
472
+ validation_errors = sorted(
473
+ validator.iter_errors(partitions),
474
+ key=lambda error: (
475
+ tuple(str(segment) for segment in error.absolute_path),
476
+ tuple(str(segment) for segment in error.absolute_schema_path),
477
+ str(error.validator),
478
+ ),
479
+ )
480
+ if not validation_errors:
481
+ return
482
+ validation_error = validation_errors[0]
483
+ raise StateMigrationError(
484
+ f"{role} state at {_json_pointer(tuple(validation_error.absolute_path))}: "
485
+ f"{validation_error.message}"
486
+ )
487
+
488
+
489
+ def _member_schema(root_schema: dict[str, Any], path: StatePath, *, role: str) -> Any:
490
+ properties = root_schema.get("properties")
491
+ partition_schema = properties.get(path.partition) if isinstance(properties, Mapping) else None
492
+ partition_properties = (
493
+ partition_schema.get("properties") if isinstance(partition_schema, Mapping) else None
494
+ )
495
+ if isinstance(partition_properties, Mapping) and path.member in partition_properties:
496
+ return partition_properties[path.member]
497
+ if isinstance(partition_schema, Mapping):
498
+ additional_properties = partition_schema.get("additionalProperties", True)
499
+ if additional_properties is not False:
500
+ return additional_properties
501
+ raise StateMigrationError(
502
+ f"{role} path {path.pointer} is forbidden by the generated state schema"
503
+ )
504
+
505
+
506
+ def _schema_without_annotations(schema: Any) -> Any:
507
+ if isinstance(schema, Mapping):
508
+ normalized_mapping = {
509
+ key: _schema_without_annotations(nested_schema)
510
+ for key, nested_schema in schema.items()
511
+ if key not in _ANNOTATION_KEYWORDS
512
+ }
513
+ if isinstance(normalized_mapping.get("type"), list):
514
+ normalized_mapping["type"] = sorted(normalized_mapping["type"])
515
+ return normalized_mapping
516
+ if isinstance(schema, list):
517
+ return [_schema_without_annotations(nested_schema) for nested_schema in schema]
518
+ return schema
519
+
520
+
521
+ def _local_reference_target(root_schema: Any, reference: str) -> Any:
522
+ if not reference.startswith("#"):
523
+ raise StateMigrationError(f"non-local state schema reference {reference!r}")
524
+ fragment = unquote(reference[1:])
525
+ if not fragment:
526
+ return root_schema
527
+ if not fragment.startswith("/"):
528
+ raise StateMigrationError(f"unsupported state schema anchor reference {reference!r}")
529
+ target = root_schema
530
+ for encoded_segment in fragment[1:].split("/"):
531
+ segment = encoded_segment.replace("~1", "/").replace("~0", "~")
532
+ if isinstance(target, Mapping) and segment in target:
533
+ target = target[segment]
534
+ continue
535
+ if isinstance(target, list) and segment.isdigit() and int(segment) < len(target):
536
+ target = target[int(segment)]
537
+ continue
538
+ raise StateMigrationError(f"unresolved state schema reference {reference!r}")
539
+ return target
540
+
541
+
542
+ def _schema_with_resolved_reference(schema: Any, root_schema: Any) -> Any:
543
+ if not isinstance(schema, Mapping) or "$ref" not in schema:
544
+ return schema
545
+ reference = schema["$ref"]
546
+ if not isinstance(reference, str):
547
+ raise StateMigrationError("state schema $ref must be a string")
548
+ referenced_schema = _local_reference_target(root_schema, reference)
549
+ siblings = {key: sibling_schema for key, sibling_schema in schema.items() if key != "$ref"}
550
+ if not siblings:
551
+ return referenced_schema
552
+ return {"allOf": [referenced_schema, siblings]}
553
+
554
+
555
+ def _finite_schema_values(schema: Any, root_schema: Any) -> tuple[Any, ...] | None:
556
+ schema = _schema_with_resolved_reference(schema, root_schema)
557
+ if schema is False:
558
+ return ()
559
+ if not isinstance(schema, Mapping):
560
+ return None
561
+ if "const" in schema:
562
+ return (copy.deepcopy(schema["const"]),)
563
+ if isinstance(schema.get("enum"), list):
564
+ return tuple(copy.deepcopy(schema["enum"]))
565
+ for union_keyword in ("anyOf", "oneOf"):
566
+ branches = schema.get(union_keyword)
567
+ if not isinstance(branches, list):
568
+ continue
569
+ finite_branches = [_finite_schema_values(branch, root_schema) for branch in branches]
570
+ if any(branch_values is None for branch_values in finite_branches):
571
+ return None
572
+ unique_values: dict[str, Any] = {}
573
+ for branch_values in finite_branches:
574
+ for branch_value in cast(tuple[Any, ...], branch_values):
575
+ unique_values[_canonical_json(branch_value)] = branch_value
576
+ return tuple(unique_values[key] for key in sorted(unique_values))
577
+ return None
578
+
579
+
580
+ def _schema_types(schema: Any) -> frozenset[str]:
581
+ if schema is False:
582
+ return frozenset()
583
+ if schema is True or not isinstance(schema, Mapping) or "type" not in schema:
584
+ return _JSON_TYPES
585
+ declared_type = schema["type"]
586
+ if isinstance(declared_type, str):
587
+ return frozenset({declared_type})
588
+ if isinstance(declared_type, list):
589
+ return frozenset(cast(list[str], declared_type))
590
+ return frozenset()
591
+
592
+
593
+ def _types_are_subset(source_types: frozenset[str], target_types: frozenset[str]) -> bool:
594
+ for source_type in source_types:
595
+ if source_type in target_types:
596
+ continue
597
+ if source_type == "integer" and "number" in target_types:
598
+ continue
599
+ return False
600
+ return True
601
+
602
+
603
+ def _schema_accepts(
604
+ schema: Any,
605
+ root_schema: Any,
606
+ instance: Any,
607
+ ) -> bool:
608
+ try:
609
+ validator = Draft202012Validator(
610
+ cast(dict[str, Any], root_schema),
611
+ format_checker=FormatChecker(),
612
+ )
613
+ return validator.evolve(schema=schema).is_valid(instance)
614
+ except Exception as validation_error: # noqa: BLE001
615
+ raise StateMigrationError(
616
+ f"state schema fragment could not be evaluated: {validation_error}"
617
+ ) from validation_error
618
+
619
+
620
+ def _minimum_is_stronger(source: Any, target: Any) -> bool:
621
+ if target is None:
622
+ return True
623
+ return isinstance(source, (int, float)) and source >= target
624
+
625
+
626
+ def _maximum_is_stronger(source: Any, target: Any) -> bool:
627
+ if target is None:
628
+ return True
629
+ return isinstance(source, (int, float)) and source <= target
630
+
631
+
632
+ def _lower_bound(schema: Mapping[str, Any]) -> tuple[int | float, bool] | None:
633
+ candidates = [
634
+ (cast(int | float, schema[keyword]), keyword == "exclusiveMinimum")
635
+ for keyword in ("minimum", "exclusiveMinimum")
636
+ if isinstance(schema.get(keyword), (int, float)) and not isinstance(schema[keyword], bool)
637
+ ]
638
+ if not candidates:
639
+ return None
640
+ return max(candidates, key=lambda bound: (bound[0], bound[1]))
641
+
642
+
643
+ def _upper_bound(schema: Mapping[str, Any]) -> tuple[int | float, bool] | None:
644
+ candidates = [
645
+ (cast(int | float, schema[keyword]), keyword == "exclusiveMaximum")
646
+ for keyword in ("maximum", "exclusiveMaximum")
647
+ if isinstance(schema.get(keyword), (int, float)) and not isinstance(schema[keyword], bool)
648
+ ]
649
+ if not candidates:
650
+ return None
651
+ return min(candidates, key=lambda bound: (bound[0], not bound[1]))
652
+
653
+
654
+ def _lower_bound_is_stronger(
655
+ source_schema: Mapping[str, Any],
656
+ target_schema: Mapping[str, Any],
657
+ ) -> bool:
658
+ target_bound = _lower_bound(target_schema)
659
+ if target_bound is None:
660
+ return True
661
+ source_bound = _lower_bound(source_schema)
662
+ if source_bound is None:
663
+ return False
664
+ source_value, source_exclusive = source_bound
665
+ target_value, target_exclusive = target_bound
666
+ return source_value > target_value or (
667
+ source_value == target_value and (source_exclusive or not target_exclusive)
668
+ )
669
+
670
+
671
+ def _upper_bound_is_stronger(
672
+ source_schema: Mapping[str, Any],
673
+ target_schema: Mapping[str, Any],
674
+ ) -> bool:
675
+ target_bound = _upper_bound(target_schema)
676
+ if target_bound is None:
677
+ return True
678
+ source_bound = _upper_bound(source_schema)
679
+ if source_bound is None:
680
+ return False
681
+ source_value, source_exclusive = source_bound
682
+ target_value, target_exclusive = target_bound
683
+ return source_value < target_value or (
684
+ source_value == target_value and (source_exclusive or not target_exclusive)
685
+ )
686
+
687
+
688
+ def _schema_is_proven_subset(
689
+ source_schema: Any,
690
+ source_root_schema: Any,
691
+ target_schema: Any,
692
+ target_root_schema: Any,
693
+ ) -> bool:
694
+ if target_schema is True or source_schema is False:
695
+ return True
696
+ if target_schema is False:
697
+ return source_schema is False
698
+ if _schema_without_annotations(source_schema) == _schema_without_annotations(target_schema):
699
+ return True
700
+ source_schema = _schema_with_resolved_reference(source_schema, source_root_schema)
701
+ target_schema = _schema_with_resolved_reference(target_schema, target_root_schema)
702
+
703
+ finite_source_values = _finite_schema_values(source_schema, source_root_schema)
704
+ if finite_source_values is not None:
705
+ return all(
706
+ _schema_accepts(target_schema, target_root_schema, source_value)
707
+ for source_value in finite_source_values
708
+ )
709
+ if not isinstance(source_schema, Mapping) or not isinstance(target_schema, Mapping):
710
+ return False
711
+
712
+ for union_keyword in ("anyOf", "oneOf"):
713
+ source_branches = source_schema.get(union_keyword)
714
+ if isinstance(source_branches, list):
715
+ source_siblings = {
716
+ key: sibling_schema
717
+ for key, sibling_schema in source_schema.items()
718
+ if key != union_keyword
719
+ }
720
+ return all(
721
+ _schema_is_proven_subset(
722
+ {"allOf": [source_siblings, branch]} if source_siblings else branch,
723
+ source_root_schema,
724
+ target_schema,
725
+ target_root_schema,
726
+ )
727
+ for branch in source_branches
728
+ )
729
+ source_all_of = source_schema.get("allOf")
730
+ if isinstance(source_all_of, list) and any(
731
+ _schema_is_proven_subset(
732
+ branch,
733
+ source_root_schema,
734
+ target_schema,
735
+ target_root_schema,
736
+ )
737
+ for branch in source_all_of
738
+ ):
739
+ return True
740
+
741
+ target_all_of = target_schema.get("allOf")
742
+ if isinstance(target_all_of, list):
743
+ target_siblings = {
744
+ key: sibling_schema for key, sibling_schema in target_schema.items() if key != "allOf"
745
+ }
746
+ return (
747
+ not target_siblings
748
+ or _schema_is_proven_subset(
749
+ source_schema,
750
+ source_root_schema,
751
+ target_siblings,
752
+ target_root_schema,
753
+ )
754
+ ) and all(
755
+ _schema_is_proven_subset(
756
+ source_schema,
757
+ source_root_schema,
758
+ branch,
759
+ target_root_schema,
760
+ )
761
+ for branch in target_all_of
762
+ )
763
+ target_branches = target_schema.get("anyOf")
764
+ if isinstance(target_branches, list):
765
+ target_siblings = {
766
+ key: sibling_schema for key, sibling_schema in target_schema.items() if key != "anyOf"
767
+ }
768
+ return (
769
+ not target_siblings
770
+ or _schema_is_proven_subset(
771
+ source_schema,
772
+ source_root_schema,
773
+ target_siblings,
774
+ target_root_schema,
775
+ )
776
+ ) and any(
777
+ _schema_is_proven_subset(
778
+ source_schema,
779
+ source_root_schema,
780
+ branch,
781
+ target_root_schema,
782
+ )
783
+ for branch in target_branches
784
+ )
785
+ if "oneOf" in target_schema:
786
+ return False
787
+
788
+ if "const" in target_schema or "enum" in target_schema or "not" in target_schema:
789
+ return False
790
+ source_types = _schema_types(source_schema)
791
+ target_types = _schema_types(target_schema)
792
+ if not _types_are_subset(source_types, target_types):
793
+ return False
794
+
795
+ handled_target_keywords = {"type", *_ANNOTATION_KEYWORDS}
796
+ if "string" in source_types:
797
+ handled_target_keywords.update({"format", "maxLength", "minLength", "pattern"})
798
+ if not _minimum_is_stronger(source_schema.get("minLength"), target_schema.get("minLength")):
799
+ return False
800
+ if not _maximum_is_stronger(source_schema.get("maxLength"), target_schema.get("maxLength")):
801
+ return False
802
+ for keyword in ("format", "pattern"):
803
+ if keyword in target_schema and source_schema.get(keyword) != target_schema[keyword]:
804
+ return False
805
+ if {"integer", "number"} & source_types:
806
+ handled_target_keywords.update(
807
+ {"exclusiveMaximum", "exclusiveMinimum", "maximum", "minimum", "multipleOf"}
808
+ )
809
+ if not _lower_bound_is_stronger(source_schema, target_schema):
810
+ return False
811
+ if not _upper_bound_is_stronger(source_schema, target_schema):
812
+ return False
813
+ if "multipleOf" in target_schema and source_schema.get("multipleOf") != target_schema.get(
814
+ "multipleOf"
815
+ ):
816
+ return False
817
+ if "object" in source_types:
818
+ handled_target_keywords.update(
819
+ {"additionalProperties", "maxProperties", "minProperties", "properties", "required"}
820
+ )
821
+ if not _minimum_is_stronger(
822
+ source_schema.get("minProperties"), target_schema.get("minProperties")
823
+ ):
824
+ return False
825
+ if not _maximum_is_stronger(
826
+ source_schema.get("maxProperties"), target_schema.get("maxProperties")
827
+ ):
828
+ return False
829
+ source_required = set(cast(list[str], source_schema.get("required", [])))
830
+ target_required = set(cast(list[str], target_schema.get("required", [])))
831
+ if not target_required <= source_required:
832
+ return False
833
+ source_properties = cast(dict[str, Any], source_schema.get("properties", {}))
834
+ target_properties = cast(dict[str, Any], target_schema.get("properties", {}))
835
+ source_additional = source_schema.get("additionalProperties", True)
836
+ target_additional = target_schema.get("additionalProperties", True)
837
+ for property_name, target_property_schema in target_properties.items():
838
+ source_property_schema = source_properties.get(property_name, source_additional)
839
+ if source_property_schema is False:
840
+ continue
841
+ if not _schema_is_proven_subset(
842
+ source_property_schema,
843
+ source_root_schema,
844
+ target_property_schema,
845
+ target_root_schema,
846
+ ):
847
+ return False
848
+ if target_additional is False:
849
+ if source_additional is not False:
850
+ return False
851
+ if any(
852
+ property_name not in target_properties and property_schema is not False
853
+ for property_name, property_schema in source_properties.items()
854
+ ):
855
+ return False
856
+ elif target_additional is not True:
857
+ if any(
858
+ property_schema is not False
859
+ and not _schema_is_proven_subset(
860
+ property_schema,
861
+ source_root_schema,
862
+ target_additional,
863
+ target_root_schema,
864
+ )
865
+ for property_name, property_schema in source_properties.items()
866
+ if property_name not in target_properties
867
+ ):
868
+ return False
869
+ if source_additional is True or not _schema_is_proven_subset(
870
+ source_additional,
871
+ source_root_schema,
872
+ target_additional,
873
+ target_root_schema,
874
+ ):
875
+ return False
876
+
877
+ return not (set(target_schema) - handled_target_keywords)
878
+
879
+
880
+ def _validate_plan_paths(
881
+ plan: StateMigrationPlan,
882
+ source_schema: dict[str, Any],
883
+ target_schema: dict[str, Any],
884
+ ) -> None:
885
+ for copy_definition in sorted(
886
+ plan.copies,
887
+ key=lambda definition: (definition.source.pointer, definition.target.pointer),
888
+ ):
889
+ source_member_schema = _member_schema(
890
+ source_schema,
891
+ copy_definition.source,
892
+ role="source",
893
+ )
894
+ target_member_schema = _member_schema(
895
+ target_schema,
896
+ copy_definition.target,
897
+ role="target",
898
+ )
899
+ if not _schema_is_proven_subset(
900
+ source_member_schema,
901
+ source_schema,
902
+ target_member_schema,
903
+ target_schema,
904
+ ):
905
+ raise StateMigrationError(
906
+ f"copy {copy_definition.source.pointer} -> {copy_definition.target.pointer} "
907
+ "is not proven schema-compatible"
908
+ )
909
+ for default_definition in sorted(
910
+ plan.defaults,
911
+ key=lambda definition: definition.target.pointer,
912
+ ):
913
+ target_member_schema = _member_schema(
914
+ target_schema,
915
+ default_definition.target,
916
+ role="target",
917
+ )
918
+ if not _schema_accepts(
919
+ target_member_schema,
920
+ target_schema,
921
+ default_definition.default_value,
922
+ ):
923
+ raise StateMigrationError(
924
+ f"default for {default_definition.target.pointer} violates its target schema"
925
+ )
926
+ for drop_path in sorted(plan.drops):
927
+ _member_schema(source_schema, drop_path, role="source")
928
+
929
+
930
+ def _validate_source_coverage(state: ServiceState, plan: StateMigrationPlan) -> None:
931
+ declared_source_paths = {
932
+ *(copy_definition.source for copy_definition in plan.copies),
933
+ *plan.drops,
934
+ }
935
+ actual_source_paths = {
936
+ StatePath(partition, member)
937
+ for partition in _PARTITIONS
938
+ for member in cast(dict[str, Any], getattr(state, partition))
939
+ }
940
+ missing_source_paths = sorted(declared_source_paths - actual_source_paths)
941
+ if missing_source_paths:
942
+ raise StateMigrationError(
943
+ "declared source paths are missing from the active state: "
944
+ + ", ".join(path.pointer for path in missing_source_paths)
945
+ )
946
+ unaccounted_source_paths = sorted(actual_source_paths - declared_source_paths)
947
+ if unaccounted_source_paths:
948
+ raise StateMigrationError(
949
+ "active state paths require an explicit copy or drop: "
950
+ + ", ".join(path.pointer for path in unaccounted_source_paths)
951
+ )
952
+
953
+
954
+ def _build_target_partitions(
955
+ state: ServiceState,
956
+ plan: StateMigrationPlan,
957
+ ) -> dict[str, dict[str, Any]]:
958
+ target_partitions: dict[str, dict[str, Any]] = {partition: {} for partition in _PARTITIONS}
959
+ for copy_definition in sorted(
960
+ plan.copies,
961
+ key=lambda definition: (definition.source.pointer, definition.target.pointer),
962
+ ):
963
+ source_partition = cast(dict[str, Any], getattr(state, copy_definition.source.partition))
964
+ target_partitions[copy_definition.target.partition][copy_definition.target.member] = (
965
+ copy.deepcopy(source_partition[copy_definition.source.member])
966
+ )
967
+ for default_definition in sorted(
968
+ plan.defaults,
969
+ key=lambda definition: definition.target.pointer,
970
+ ):
971
+ target_partitions[default_definition.target.partition][default_definition.target.member] = (
972
+ default_definition.default_value
973
+ )
974
+ return target_partitions
975
+
976
+
977
+ def migrate_service_state(
978
+ state: ServiceState,
979
+ *,
980
+ source_ir: FlowIR,
981
+ target_ir: FlowIR,
982
+ plan: StateMigrationPlan,
983
+ clock: UtcClock,
984
+ ) -> StateMigrationResult:
985
+ """Atomically migrate one active state between two exact IR contracts.
986
+
987
+ The source is never mutated. Every present partition member must have one
988
+ explicit disposition, every referenced path must exist in its generated
989
+ schema and in the active source state, and every copy must be statically
990
+ proven compatible. The derived ``agent_response`` is cleared; an accepted
991
+ external-resume signal is rejected rather than silently reinterpreted.
992
+ """
993
+
994
+ _validate_ir_contract(plan.source, source_ir, role="source")
995
+ _validate_ir_contract(plan.target, target_ir, role="target")
996
+ _validate_source_envelope(state, plan.source)
997
+ source_schema = source_ir.state_schema()
998
+ target_schema = target_ir.state_schema()
999
+ source_validator = _schema_validator(source_schema, role="source")
1000
+ target_validator = _schema_validator(target_schema, role="target")
1001
+ _validate_partitions(
1002
+ _partition_projection(state),
1003
+ source_validator,
1004
+ role="source",
1005
+ )
1006
+ _validate_plan_paths(plan, source_schema, target_schema)
1007
+ _validate_source_coverage(state, plan)
1008
+
1009
+ target_partitions = _build_target_partitions(state, plan)
1010
+ _validate_partitions(target_partitions, target_validator, role="target")
1011
+ migrated_state = ServiceState(
1012
+ user_id=state.user_id,
1013
+ service_name=plan.target.flow,
1014
+ status="progress",
1015
+ data=target_partitions["data"],
1016
+ internal=target_partitions["internal"],
1017
+ payload=target_partitions["payload"],
1018
+ metadata=ServiceMetadata(
1019
+ created_at=state.metadata.created_at,
1020
+ updated_at=utc_timestamp(clock),
1021
+ flow_version=plan.target.version,
1022
+ flow_ir_digest=plan.target.flow_ir_digest,
1023
+ dependency_digest=plan.target.dependency_digest,
1024
+ profile_digest=plan.target.profile_digest,
1025
+ saved=state.metadata.saved,
1026
+ ),
1027
+ agent_response=None,
1028
+ )
1029
+ report = StateMigrationLossReport(
1030
+ plan=plan,
1031
+ source_state=state,
1032
+ target_state=migrated_state,
1033
+ )
1034
+ return StateMigrationResult(state=migrated_state, report=report)