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