fdai-deployment-cli 0.1.2__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 (203) hide show
  1. fdai_deployment_cli/__about__.py +3 -0
  2. fdai_deployment_cli/__init__.py +5 -0
  3. fdai_deployment_cli/__main__.py +5 -0
  4. fdai_deployment_cli/acr_publish.py +585 -0
  5. fdai_deployment_cli/aks_catalog_review.py +137 -0
  6. fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
  7. fdai_deployment_cli/aks_job_environment.py +59 -0
  8. fdai_deployment_cli/aks_job_execution.py +794 -0
  9. fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
  10. fdai_deployment_cli/aks_preflight.py +422 -0
  11. fdai_deployment_cli/aks_readiness.py +220 -0
  12. fdai_deployment_cli/aks_service_update.py +180 -0
  13. fdai_deployment_cli/aks_workload_jobs.py +281 -0
  14. fdai_deployment_cli/application_state_adoption.py +347 -0
  15. fdai_deployment_cli/azure_actor.py +61 -0
  16. fdai_deployment_cli/azure_naming.py +252 -0
  17. fdai_deployment_cli/azure_retail_prices.py +173 -0
  18. fdai_deployment_cli/bootstrap_approval.py +99 -0
  19. fdai_deployment_cli/bootstrap_channel.py +321 -0
  20. fdai_deployment_cli/bootstrap_executor.py +311 -0
  21. fdai_deployment_cli/bootstrap_plan.py +237 -0
  22. fdai_deployment_cli/bootstrap_reconcile.py +402 -0
  23. fdai_deployment_cli/bootstrap_support.py +138 -0
  24. fdai_deployment_cli/bootstrap_worker.py +448 -0
  25. fdai_deployment_cli/bundle.py +352 -0
  26. fdai_deployment_cli/capacity.py +143 -0
  27. fdai_deployment_cli/catalog_review_profile.py +306 -0
  28. fdai_deployment_cli/cli.py +798 -0
  29. fdai_deployment_cli/cli_help.py +214 -0
  30. fdai_deployment_cli/cli_parser.py +791 -0
  31. fdai_deployment_cli/cli_plan.py +450 -0
  32. fdai_deployment_cli/compiler.py +99 -0
  33. fdai_deployment_cli/console_artifact.py +218 -0
  34. fdai_deployment_cli/console_config.py +214 -0
  35. fdai_deployment_cli/console_update.py +832 -0
  36. fdai_deployment_cli/contracts.py +307 -0
  37. fdai_deployment_cli/control_package.py +140 -0
  38. fdai_deployment_cli/deadline_transport.py +70 -0
  39. fdai_deployment_cli/deployment_cost.py +131 -0
  40. fdai_deployment_cli/deployment_deadline.py +28 -0
  41. fdai_deployment_cli/deployment_kit.py +790 -0
  42. fdai_deployment_cli/deployment_kit_cache.py +191 -0
  43. fdai_deployment_cli/deployment_progress.py +648 -0
  44. fdai_deployment_cli/development_authority_cli.py +56 -0
  45. fdai_deployment_cli/development_authority_profile.py +319 -0
  46. fdai_deployment_cli/doctor.py +136 -0
  47. fdai_deployment_cli/entitlement_preflight.py +68 -0
  48. fdai_deployment_cli/entitlement_transport.py +49 -0
  49. fdai_deployment_cli/entra_bootstrap.py +579 -0
  50. fdai_deployment_cli/entra_control_profile.py +214 -0
  51. fdai_deployment_cli/entra_graph.py +349 -0
  52. fdai_deployment_cli/entra_profiles.py +215 -0
  53. fdai_deployment_cli/entra_source.py +244 -0
  54. fdai_deployment_cli/execution_bundle.py +478 -0
  55. fdai_deployment_cli/execution_copies.py +63 -0
  56. fdai_deployment_cli/foundation_adoption.py +438 -0
  57. fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
  58. fdai_deployment_cli/foundation_adoption_host.py +157 -0
  59. fdai_deployment_cli/foundation_adoption_transport.py +51 -0
  60. fdai_deployment_cli/foundation_failure.py +72 -0
  61. fdai_deployment_cli/foundation_image.py +168 -0
  62. fdai_deployment_cli/foundation_input.py +235 -0
  63. fdai_deployment_cli/foundation_output.py +154 -0
  64. fdai_deployment_cli/foundation_plan.py +381 -0
  65. fdai_deployment_cli/foundation_process.py +64 -0
  66. fdai_deployment_cli/foundation_progress.py +231 -0
  67. fdai_deployment_cli/github_actions.py +171 -0
  68. fdai_deployment_cli/github_apply_artifact.py +259 -0
  69. fdai_deployment_cli/github_artifact_io.py +48 -0
  70. fdai_deployment_cli/github_dispatch.py +248 -0
  71. fdai_deployment_cli/github_plan_artifact.py +181 -0
  72. fdai_deployment_cli/github_provider_artifact.py +170 -0
  73. fdai_deployment_cli/github_transport.py +46 -0
  74. fdai_deployment_cli/github_workflow_values.py +312 -0
  75. fdai_deployment_cli/hub_image_deploy.py +354 -0
  76. fdai_deployment_cli/hub_image_stage.py +162 -0
  77. fdai_deployment_cli/hub_image_trust.py +281 -0
  78. fdai_deployment_cli/hub_ownership.py +161 -0
  79. fdai_deployment_cli/hub_workload_handoff.py +264 -0
  80. fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
  81. fdai_deployment_cli/identity_profile.py +227 -0
  82. fdai_deployment_cli/image_targets.py +197 -0
  83. fdai_deployment_cli/installation_scope.py +280 -0
  84. fdai_deployment_cli/license.py +311 -0
  85. fdai_deployment_cli/license_issue.py +239 -0
  86. fdai_deployment_cli/lifecycle_authorization.py +252 -0
  87. fdai_deployment_cli/lifecycle_commands.py +173 -0
  88. fdai_deployment_cli/lifecycle_configuration.py +722 -0
  89. fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
  90. fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
  91. fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
  92. fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
  93. fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
  94. fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
  95. fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
  96. fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
  97. fdai_deployment_cli/lifecycle_enrollment.py +45 -0
  98. fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
  99. fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
  100. fdai_deployment_cli/lifecycle_native.py +247 -0
  101. fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
  102. fdai_deployment_cli/lifecycle_native_transition.py +339 -0
  103. fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
  104. fdai_deployment_cli/lifecycle_plan.py +800 -0
  105. fdai_deployment_cli/lifecycle_promotion.py +185 -0
  106. fdai_deployment_cli/lifecycle_release.py +308 -0
  107. fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
  108. fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
  109. fdai_deployment_cli/notification_recipients_input.py +222 -0
  110. fdai_deployment_cli/observer_artifact.py +106 -0
  111. fdai_deployment_cli/oci_archive.py +353 -0
  112. fdai_deployment_cli/offline_kit.py +797 -0
  113. fdai_deployment_cli/offline_prepare.py +135 -0
  114. fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
  115. fdai_deployment_cli/plan_input.py +101 -0
  116. fdai_deployment_cli/private_output.py +192 -0
  117. fdai_deployment_cli/profile.py +70 -0
  118. fdai_deployment_cli/progress.py +183 -0
  119. fdai_deployment_cli/readiness.py +286 -0
  120. fdai_deployment_cli/run_command_receiver.py +418 -0
  121. fdai_deployment_cli/runtime_build.py +386 -0
  122. fdai_deployment_cli/runtime_profile.py +287 -0
  123. fdai_deployment_cli/runtime_release.py +745 -0
  124. fdai_deployment_cli/runtime_stage.py +82 -0
  125. fdai_deployment_cli/runtime_support_installation.py +215 -0
  126. fdai_deployment_cli/simulation.py +80 -0
  127. fdai_deployment_cli/source_application_inputs.py +397 -0
  128. fdai_deployment_cli/source_azure.py +586 -0
  129. fdai_deployment_cli/source_change_cli.py +107 -0
  130. fdai_deployment_cli/source_change_parser.py +75 -0
  131. fdai_deployment_cli/source_change_plan.py +181 -0
  132. fdai_deployment_cli/source_changed_update.py +196 -0
  133. fdai_deployment_cli/source_deploy.py +170 -0
  134. fdai_deployment_cli/source_foundation.py +174 -0
  135. fdai_deployment_cli/source_host_tools.py +163 -0
  136. fdai_deployment_cli/source_image_stage.py +400 -0
  137. fdai_deployment_cli/source_input.py +173 -0
  138. fdai_deployment_cli/source_projection.py +164 -0
  139. fdai_deployment_cli/source_receiver.py +76 -0
  140. fdai_deployment_cli/source_recovery.py +264 -0
  141. fdai_deployment_cli/source_runtime.py +81 -0
  142. fdai_deployment_cli/source_runtime_support.py +368 -0
  143. fdai_deployment_cli/source_service_update.py +726 -0
  144. fdai_deployment_cli/source_service_update_cli.py +47 -0
  145. fdai_deployment_cli/source_snapshot.py +178 -0
  146. fdai_deployment_cli/source_teardown.py +574 -0
  147. fdai_deployment_cli/source_terraform.py +85 -0
  148. fdai_deployment_cli/source_transport.py +399 -0
  149. fdai_deployment_cli/standalone_aks_inventory.py +558 -0
  150. fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
  151. fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
  152. fdai_deployment_cli/standalone_application.py +977 -0
  153. fdai_deployment_cli/standalone_application_completion.py +151 -0
  154. fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
  155. fdai_deployment_cli/standalone_application_recovery.py +116 -0
  156. fdai_deployment_cli/standalone_approval_input.py +24 -0
  157. fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
  158. fdai_deployment_cli/standalone_catalog_review.py +251 -0
  159. fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
  160. fdai_deployment_cli/standalone_console_publish.py +174 -0
  161. fdai_deployment_cli/standalone_database_principals.py +41 -0
  162. fdai_deployment_cli/standalone_deploy.py +638 -0
  163. fdai_deployment_cli/standalone_development_authority.py +166 -0
  164. fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
  165. fdai_deployment_cli/standalone_foundation_transition.py +767 -0
  166. fdai_deployment_cli/standalone_host.py +4608 -0
  167. fdai_deployment_cli/standalone_host_rebind.py +226 -0
  168. fdai_deployment_cli/standalone_host_residual.py +104 -0
  169. fdai_deployment_cli/standalone_host_state.py +145 -0
  170. fdai_deployment_cli/standalone_host_values.py +563 -0
  171. fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
  172. fdai_deployment_cli/standalone_license_installation.py +177 -0
  173. fdai_deployment_cli/standalone_management_egress.py +83 -0
  174. fdai_deployment_cli/standalone_migration_evidence.py +38 -0
  175. fdai_deployment_cli/standalone_operational_evidence.py +356 -0
  176. fdai_deployment_cli/standalone_planned_outputs.py +147 -0
  177. fdai_deployment_cli/standalone_private_access_probe.py +100 -0
  178. fdai_deployment_cli/standalone_product_profile.py +110 -0
  179. fdai_deployment_cli/standalone_remote_prepare.py +442 -0
  180. fdai_deployment_cli/standalone_residual_apply.py +540 -0
  181. fdai_deployment_cli/standalone_residual_successor.py +431 -0
  182. fdai_deployment_cli/standalone_review.py +266 -0
  183. fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
  184. fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
  185. fdai_deployment_cli/standalone_stage_targets.py +325 -0
  186. fdai_deployment_cli/standalone_status.py +67 -0
  187. fdai_deployment_cli/standalone_terraform_environment.py +237 -0
  188. fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
  189. fdai_deployment_cli/standalone_trial_activation.py +193 -0
  190. fdai_deployment_cli/state.py +434 -0
  191. fdai_deployment_cli/state_handoff.py +253 -0
  192. fdai_deployment_cli/status_projection.py +97 -0
  193. fdai_deployment_cli/support_install.py +257 -0
  194. fdai_deployment_cli/target.py +46 -0
  195. fdai_deployment_cli/trust/capabilities.json +62 -0
  196. fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
  197. fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
  198. fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
  199. fdai_deployment_cli/trust_roots.py +50 -0
  200. fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
  201. fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
  202. fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
  203. fdai_deployment_cli-0.1.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,800 @@
1
+ """Pure shadow-only Lifecycle Plan admission and constraint checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass, field as dataclass_field
9
+ from datetime import datetime
10
+ from typing import Literal, cast
11
+
12
+ from fdai_deployment_cli.contracts import canonical_bytes
13
+ from fdai_deployment_cli.lifecycle_configuration import (
14
+ ConfigurationValidationError,
15
+ resolve_configuration_layers,
16
+ )
17
+ from fdai_deployment_cli.lifecycle_release import LIFECYCLE_IMAGE_SECTIONS, LifecycleRelease
18
+ from fdai_service_contracts.catalog_canonical import SignatureVerifier as SignatureVerifier
19
+ from fdai_deployment_cli.runtime_release import (
20
+ RecallRecord,
21
+ ReleaseDecision,
22
+ evaluate_recall_candidate,
23
+ validate_schema_transition,
24
+ )
25
+
26
+ CapabilityMode = Literal["shadow", "enforce"]
27
+ SchemaDirection = Literal["upgrade", "rollback"]
28
+ PlanType = Literal[
29
+ "install",
30
+ "upgrade",
31
+ "configuration-change",
32
+ "recall-rolloff",
33
+ "rollback",
34
+ "drift-reconciliation",
35
+ ]
36
+ SuppressionOrigin = Literal["manual", "failure"]
37
+
38
+ _MODE_ORDER: Mapping[CapabilityMode, int] = {"shadow": 0, "enforce": 1}
39
+ _SCOPE_ID = re.compile(r"[a-z][a-z0-9._-]*\Z", re.ASCII)
40
+ _SUPPORTED_PLAN_TYPES = frozenset(
41
+ {
42
+ "install",
43
+ "upgrade",
44
+ "configuration-change",
45
+ "recall-rolloff",
46
+ "rollback",
47
+ "drift-reconciliation",
48
+ }
49
+ )
50
+ _PLAN_PAYLOAD_KEYS = frozenset(
51
+ {
52
+ "audience",
53
+ "capability_ids",
54
+ "configuration_revision_digest",
55
+ "declared_duration_minutes",
56
+ "entity_ids",
57
+ "envelope",
58
+ "expires_at",
59
+ "fencing_generation",
60
+ "hub_key_epoch",
61
+ "hub_key_id",
62
+ "plan_id",
63
+ "plan_type",
64
+ "release_regions",
65
+ "rollback_target_plan_id",
66
+ "sequence",
67
+ "source_state_digest",
68
+ "target_release_digest",
69
+ "target_release_id",
70
+ }
71
+ )
72
+ _PLAN_OPTIONAL_PAYLOAD_KEYS = frozenset({"ownership_digests"})
73
+ _ENVELOPE_PAYLOAD_KEYS = frozenset(
74
+ {"capability_modes", "destructive_allowed", "entity_ids", "max_duration_minutes", "regions"}
75
+ )
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class LifecycleEffectEnvelope:
80
+ """Maximum effect surface a Plan may use; a Hub Plan can only narrow it."""
81
+
82
+ entity_ids: frozenset[str]
83
+ regions: frozenset[str]
84
+ capability_modes: Mapping[str, CapabilityMode]
85
+ destructive_allowed: bool
86
+ max_duration_minutes: int
87
+
88
+ def __post_init__(self) -> None:
89
+ if not isinstance(self.destructive_allowed, bool):
90
+ raise TypeError("destructive_allowed MUST be a boolean")
91
+ if (
92
+ isinstance(self.max_duration_minutes, bool)
93
+ or not isinstance(self.max_duration_minutes, int)
94
+ or self.max_duration_minutes < 1
95
+ ):
96
+ raise ValueError("max_duration_minutes MUST be positive")
97
+ for capability, mode in self.capability_modes.items():
98
+ if not isinstance(capability, str):
99
+ raise TypeError("capability mode keys MUST be strings")
100
+ if mode not in _MODE_ORDER:
101
+ raise ValueError("capability mode is unsupported")
102
+
103
+ def narrowed_by(self, maximum: LifecycleEffectEnvelope) -> bool:
104
+ """Return whether this envelope is equal to or narrower than the local maximum."""
105
+
106
+ if not self.entity_ids <= maximum.entity_ids:
107
+ return False
108
+ if not self.regions <= maximum.regions:
109
+ return False
110
+ if self.destructive_allowed and not maximum.destructive_allowed:
111
+ return False
112
+ if self.max_duration_minutes > maximum.max_duration_minutes:
113
+ return False
114
+ for capability, requested_mode in self.capability_modes.items():
115
+ maximum_mode = maximum.capability_modes.get(capability)
116
+ if maximum_mode is None or _MODE_ORDER[requested_mode] > _MODE_ORDER[maximum_mode]:
117
+ return False
118
+ return True
119
+
120
+
121
+ @dataclass(frozen=True, slots=True)
122
+ class LifecyclePlan:
123
+ """Parsed Plan fields relevant to local shadow-only admission."""
124
+
125
+ plan_id: str
126
+ audience: str
127
+ hub_key_epoch: int
128
+ hub_key_id: str
129
+ source_state_digest: str
130
+ sequence: int
131
+ fencing_generation: int
132
+ plan_type: PlanType
133
+ target_release_id: str
134
+ target_release_digest: str
135
+ configuration_revision_digest: str
136
+ entity_ids: frozenset[str]
137
+ capability_ids: tuple[str, ...]
138
+ release_regions: frozenset[str]
139
+ rollback_target_plan_id: str | None
140
+ declared_duration_minutes: int
141
+ envelope: LifecycleEffectEnvelope
142
+ expires_at: datetime
143
+ signed_payload: bytes
144
+ signature: bytes
145
+ ownership_digests: Mapping[str, str] = dataclass_field(default_factory=dict)
146
+
147
+ def __post_init__(self) -> None:
148
+ if isinstance(self.sequence, bool) or not isinstance(self.sequence, int):
149
+ raise TypeError("sequence MUST be an integer")
150
+ if self.sequence < 0:
151
+ raise ValueError("sequence MUST be non-negative")
152
+ if not isinstance(self.plan_type, str):
153
+ raise TypeError("plan_type MUST be a string")
154
+ if (
155
+ isinstance(self.declared_duration_minutes, bool)
156
+ or not isinstance(self.declared_duration_minutes, int)
157
+ or self.declared_duration_minutes < 1
158
+ ):
159
+ raise ValueError("declared_duration_minutes MUST be positive")
160
+ if any(
161
+ _SCOPE_ID.fullmatch(entity_id) is None
162
+ or re.fullmatch(r"sha256:[0-9a-f]{64}", digest, re.ASCII) is None
163
+ for entity_id, digest in self.ownership_digests.items()
164
+ ):
165
+ raise ValueError("ownership digests MUST bind canonical Entity ids to SHA-256 digests")
166
+ if not set(self.ownership_digests) <= self.entity_ids:
167
+ raise ValueError("ownership digests MUST stay inside the Plan Entity set")
168
+
169
+
170
+ @dataclass(frozen=True, slots=True)
171
+ class PlanAdmissionState:
172
+ """Durable local state used to reject replayed or cross-installation Plans."""
173
+
174
+ expected_audience: str
175
+ expected_source_state_digest: str
176
+ last_accepted_sequence: int
177
+ current_hub_key_epoch: int
178
+ current_fencing_generation: int
179
+ active_hub_key_ids: frozenset[str]
180
+ revoked_hub_key_ids: frozenset[str]
181
+ hub_key_epochs: Mapping[str, int]
182
+
183
+
184
+ @dataclass(frozen=True, slots=True)
185
+ class PlanAdmissionDecision:
186
+ """Typed allow or deny result for Plan admission."""
187
+
188
+ allowed: bool
189
+ reason_code: str
190
+ details: tuple[str, ...] = ()
191
+
192
+
193
+ @dataclass(frozen=True, slots=True)
194
+ class ArtifactRequirement:
195
+ """One artifact digest required inside the installation boundary."""
196
+
197
+ name: str
198
+ digest: str
199
+
200
+
201
+ @dataclass(frozen=True, slots=True)
202
+ class DataResidencyRequirement:
203
+ """Allowed and proposed processing regions for a candidate Plan."""
204
+
205
+ allowed_regions: frozenset[str]
206
+ release_regions: frozenset[str]
207
+
208
+
209
+ @dataclass(frozen=True, slots=True)
210
+ class MaintenanceWindow:
211
+ """A UTC maintenance window opened by customer configuration."""
212
+
213
+ starts_at: datetime
214
+ ends_at: datetime
215
+ allows_downtime: bool
216
+
217
+ def __post_init__(self) -> None:
218
+ _require_aware_datetime(self.starts_at, "starts_at")
219
+ _require_aware_datetime(self.ends_at, "ends_at")
220
+
221
+
222
+ @dataclass(frozen=True, slots=True)
223
+ class SuppressionWindow:
224
+ """A UTC suppression window for an installation, entity, or plan type."""
225
+
226
+ scope: str
227
+ starts_at: datetime
228
+ ends_at: datetime
229
+ origin: SuppressionOrigin = "manual"
230
+ failed_plan_id: str | None = None
231
+
232
+ def __post_init__(self) -> None:
233
+ _require_aware_datetime(self.starts_at, "starts_at")
234
+ _require_aware_datetime(self.ends_at, "ends_at")
235
+
236
+
237
+ @dataclass(frozen=True, slots=True)
238
+ class LifecycleConstraintContext:
239
+ """Candidate Plan facts and local evidence for pure constraint evaluation."""
240
+
241
+ now: datetime
242
+ plan_id: str
243
+ plan_type: PlanType
244
+ rollback_target_plan_id: str | None
245
+ declared_duration_minutes: int
246
+ requires_downtime: bool
247
+ target_release_version: str
248
+ candidate_release: LifecycleRelease
249
+ configuration_revision_digest: str
250
+ current_schema_revision: int
251
+ version_range: str
252
+ configuration_schema: Mapping[str, object]
253
+ environment_config: Mapping[str, object]
254
+ entity_overrides: tuple[Mapping[str, object], ...]
255
+ available_artifact_digests: frozenset[str]
256
+ data_residency: DataResidencyRequirement
257
+ maintenance_windows: tuple[MaintenanceWindow, ...]
258
+ suppression_windows: tuple[SuppressionWindow, ...]
259
+ entity_ids: frozenset[str]
260
+ capability_ids: tuple[str, ...]
261
+ recall_records: tuple[RecallRecord, ...]
262
+
263
+
264
+ @dataclass(frozen=True, slots=True)
265
+ class ConstraintBlock:
266
+ """One blocking lifecycle constraint with a stable reason code."""
267
+
268
+ reason_code: str
269
+ details: tuple[str, ...] = ()
270
+
271
+
272
+ def evaluate_plan_admission(
273
+ plan: LifecyclePlan,
274
+ *,
275
+ local_state: PlanAdmissionState,
276
+ locally_derived_maximum: LifecycleEffectEnvelope,
277
+ verify_signature: SignatureVerifier,
278
+ trusted_now: datetime,
279
+ ) -> PlanAdmissionDecision:
280
+ """Admit a signed Plan only when local replay, key, and envelope checks pass."""
281
+
282
+ if not plan.signed_payload or not plan.signature:
283
+ return _deny("plan_signature_invalid", plan.plan_id)
284
+ try:
285
+ verified = verify_signature(plan.hub_key_id, plan.signed_payload, plan.signature)
286
+ except (TypeError, ValueError):
287
+ return _deny("plan_signature_invalid", plan.plan_id)
288
+ if verified is not True:
289
+ return _deny("plan_signature_invalid", plan.plan_id)
290
+ if plan.signed_payload != canonical_plan_payload(plan):
291
+ return _deny("plan_payload_mismatch", plan.plan_id)
292
+ if plan.plan_type not in _SUPPORTED_PLAN_TYPES:
293
+ return _deny("plan_type_unsupported", plan.plan_type)
294
+ if plan.hub_key_id in local_state.revoked_hub_key_ids:
295
+ return _deny("hub_key_revoked", plan.hub_key_id)
296
+ if plan.hub_key_id not in local_state.active_hub_key_ids:
297
+ return _deny("hub_key_not_active", plan.hub_key_id)
298
+ if plan.hub_key_epoch != local_state.current_hub_key_epoch:
299
+ return _deny("hub_key_epoch_mismatch", str(plan.hub_key_epoch))
300
+ if local_state.hub_key_epochs.get(plan.hub_key_id) != plan.hub_key_epoch:
301
+ return _deny("hub_key_epoch_mismatch", plan.hub_key_id)
302
+ if trusted_now.tzinfo is None or trusted_now.utcoffset() is None:
303
+ return _deny("trusted_clock_timezone_missing")
304
+ if plan.expires_at.tzinfo is None or plan.expires_at.utcoffset() is None:
305
+ return _deny("plan_expiry_timezone_missing", plan.plan_id)
306
+ if plan.expires_at <= trusted_now:
307
+ return _deny("plan_expired", plan.plan_id)
308
+ if plan.audience != local_state.expected_audience:
309
+ return _deny("plan_audience_mismatch", plan.audience)
310
+ if plan.sequence <= local_state.last_accepted_sequence:
311
+ return _deny("plan_sequence_stale", str(plan.sequence))
312
+ if plan.fencing_generation != local_state.current_fencing_generation:
313
+ return _deny("plan_fencing_generation_mismatch", str(plan.fencing_generation))
314
+ if plan.source_state_digest != local_state.expected_source_state_digest:
315
+ return _deny("plan_source_state_stale", plan.source_state_digest)
316
+ if not plan.entity_ids <= plan.envelope.entity_ids:
317
+ return _deny("plan_entities_exceed_envelope", plan.plan_id)
318
+ if plan.declared_duration_minutes > plan.envelope.max_duration_minutes:
319
+ return _deny("plan_duration_exceeds_envelope", plan.plan_id)
320
+ if not set(plan.capability_ids) <= set(plan.envelope.capability_modes):
321
+ return _deny("plan_capabilities_exceed_envelope", plan.plan_id)
322
+ if not plan.release_regions <= plan.envelope.regions:
323
+ return _deny("plan_regions_exceed_envelope", plan.plan_id)
324
+ if not plan.envelope.narrowed_by(locally_derived_maximum):
325
+ return _deny("plan_envelope_exceeds_local_maximum", plan.plan_id)
326
+ return PlanAdmissionDecision(True, "allowed")
327
+
328
+
329
+ def canonical_plan_payload(plan: LifecyclePlan) -> bytes:
330
+ """Return the byte-for-byte JSON payload the Hub key must sign."""
331
+
332
+ payload: dict[str, object] = {
333
+ "audience": plan.audience,
334
+ "envelope": {
335
+ "capability_modes": dict(sorted(plan.envelope.capability_modes.items())),
336
+ "destructive_allowed": plan.envelope.destructive_allowed,
337
+ "entity_ids": sorted(plan.envelope.entity_ids),
338
+ "max_duration_minutes": plan.envelope.max_duration_minutes,
339
+ "regions": sorted(plan.envelope.regions),
340
+ },
341
+ "expires_at": plan.expires_at.isoformat(),
342
+ "fencing_generation": plan.fencing_generation,
343
+ "hub_key_epoch": plan.hub_key_epoch,
344
+ "hub_key_id": plan.hub_key_id,
345
+ "capability_ids": list(plan.capability_ids),
346
+ "configuration_revision_digest": plan.configuration_revision_digest,
347
+ "declared_duration_minutes": plan.declared_duration_minutes,
348
+ "entity_ids": sorted(plan.entity_ids),
349
+ "plan_id": plan.plan_id,
350
+ "plan_type": plan.plan_type,
351
+ "rollback_target_plan_id": plan.rollback_target_plan_id,
352
+ "sequence": plan.sequence,
353
+ "source_state_digest": plan.source_state_digest,
354
+ "release_regions": sorted(plan.release_regions),
355
+ "target_release_digest": plan.target_release_digest,
356
+ "target_release_id": plan.target_release_id,
357
+ }
358
+ if plan.ownership_digests:
359
+ payload["ownership_digests"] = dict(sorted(plan.ownership_digests.items()))
360
+ return canonical_bytes(payload)
361
+
362
+
363
+ def decode_canonical_plan_payload(signed_payload: bytes, signature: bytes) -> LifecyclePlan:
364
+ """Parse Hub-signed Plan bytes into a ``LifecyclePlan``; the inverse of
365
+ ``canonical_plan_payload``.
366
+
367
+ The decoder checks shape only. It never verifies the signature, so callers MUST pass the
368
+ result to ``evaluate_plan_admission`` before trusting any field. Admission also rejects
369
+ bytes that parse but are not in canonical form. Raises ``ValueError`` with a sanitized
370
+ message for invalid UTF-8 or JSON, duplicate, unknown, or missing fields, wrong types,
371
+ an unsupported plan type or capability mode, and an ``expires_at`` without a timezone.
372
+ """
373
+
374
+ if not isinstance(signed_payload, bytes) or not isinstance(signature, bytes):
375
+ raise TypeError("signed_payload and signature MUST be bytes")
376
+ try:
377
+ document = json.loads(
378
+ signed_payload.decode("utf-8"),
379
+ object_pairs_hook=_unique_plan_object,
380
+ parse_constant=_reject_plan_constant,
381
+ )
382
+ except UnicodeDecodeError as error:
383
+ raise ValueError("plan payload MUST be UTF-8 JSON") from error
384
+ except json.JSONDecodeError as error:
385
+ raise ValueError("plan payload MUST be valid JSON") from error
386
+ except RecursionError as error:
387
+ raise ValueError("plan payload nesting is too deep") from error
388
+ fields = _plan_object(document, None, "plan payload")
389
+ unknown = set(fields) - _PLAN_PAYLOAD_KEYS - _PLAN_OPTIONAL_PAYLOAD_KEYS
390
+ if unknown:
391
+ raise ValueError(f"plan payload contains unknown fields: {', '.join(sorted(unknown))}")
392
+ missing = _PLAN_PAYLOAD_KEYS - set(fields)
393
+ if missing:
394
+ raise ValueError(f"plan payload is missing fields: {', '.join(sorted(missing))}")
395
+ envelope = _plan_object(fields["envelope"], _ENVELOPE_PAYLOAD_KEYS, "envelope")
396
+ raw_modes = _plan_object(envelope["capability_modes"], None, "envelope.capability_modes")
397
+ capability_modes: dict[str, CapabilityMode] = {}
398
+ for capability, mode in raw_modes.items():
399
+ if mode not in _MODE_ORDER:
400
+ raise ValueError("envelope.capability_modes contains an unsupported mode")
401
+ capability_modes[capability] = cast(CapabilityMode, mode)
402
+ plan_type = _plan_text(fields["plan_type"], "plan_type")
403
+ if plan_type not in _SUPPORTED_PLAN_TYPES:
404
+ raise ValueError("plan_type is unsupported")
405
+ expires_at_text = _plan_text(fields["expires_at"], "expires_at")
406
+ try:
407
+ expires_at = datetime.fromisoformat(expires_at_text)
408
+ except ValueError as error:
409
+ raise ValueError("expires_at MUST be an ISO 8601 timestamp") from error
410
+ _require_aware_datetime(expires_at, "expires_at")
411
+ rollback_target = fields["rollback_target_plan_id"]
412
+ try:
413
+ return LifecyclePlan(
414
+ plan_id=_plan_text(fields["plan_id"], "plan_id"),
415
+ audience=_plan_text(fields["audience"], "audience"),
416
+ hub_key_epoch=_plan_integer(fields["hub_key_epoch"], "hub_key_epoch"),
417
+ hub_key_id=_plan_text(fields["hub_key_id"], "hub_key_id"),
418
+ source_state_digest=_plan_text(fields["source_state_digest"], "source_state_digest"),
419
+ sequence=_plan_integer(fields["sequence"], "sequence"),
420
+ fencing_generation=_plan_integer(fields["fencing_generation"], "fencing_generation"),
421
+ plan_type=cast(PlanType, plan_type),
422
+ target_release_id=_plan_text(fields["target_release_id"], "target_release_id"),
423
+ target_release_digest=_plan_text(
424
+ fields["target_release_digest"], "target_release_digest"
425
+ ),
426
+ configuration_revision_digest=_plan_text(
427
+ fields["configuration_revision_digest"], "configuration_revision_digest"
428
+ ),
429
+ entity_ids=frozenset(_plan_texts(fields["entity_ids"], "entity_ids")),
430
+ capability_ids=_plan_texts(fields["capability_ids"], "capability_ids"),
431
+ release_regions=frozenset(_plan_texts(fields["release_regions"], "release_regions")),
432
+ rollback_target_plan_id=(
433
+ None
434
+ if rollback_target is None
435
+ else _plan_text(rollback_target, "rollback_target_plan_id")
436
+ ),
437
+ declared_duration_minutes=_plan_integer(
438
+ fields["declared_duration_minutes"], "declared_duration_minutes"
439
+ ),
440
+ envelope=LifecycleEffectEnvelope(
441
+ entity_ids=frozenset(_plan_texts(envelope["entity_ids"], "envelope.entity_ids")),
442
+ regions=frozenset(_plan_texts(envelope["regions"], "envelope.regions")),
443
+ capability_modes=capability_modes,
444
+ destructive_allowed=_plan_boolean(
445
+ envelope["destructive_allowed"], "envelope.destructive_allowed"
446
+ ),
447
+ max_duration_minutes=_plan_integer(
448
+ envelope["max_duration_minutes"], "envelope.max_duration_minutes"
449
+ ),
450
+ ),
451
+ expires_at=expires_at,
452
+ signed_payload=signed_payload,
453
+ signature=signature,
454
+ ownership_digests=_plan_digest_mapping(
455
+ fields.get("ownership_digests", {}), "ownership_digests"
456
+ ),
457
+ )
458
+ except TypeError as error:
459
+ raise ValueError(f"plan payload field is invalid: {error}") from error
460
+
461
+
462
+ def _unique_plan_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
463
+ result: dict[str, object] = {}
464
+ for key, value in pairs:
465
+ if key in result:
466
+ raise ValueError("plan payload contains a duplicate field")
467
+ result[key] = value
468
+ return result
469
+
470
+
471
+ def _reject_plan_constant(value: str) -> object:
472
+ raise ValueError("plan payload contains a non-finite number")
473
+
474
+
475
+ def _plan_object(value: object, required: frozenset[str] | None, label: str) -> dict[str, object]:
476
+ if not isinstance(value, dict):
477
+ raise ValueError(f"{label} MUST be a JSON object")
478
+ if required is not None:
479
+ unknown = set(value) - required
480
+ if unknown:
481
+ raise ValueError(f"{label} contains unknown fields: {', '.join(sorted(unknown))}")
482
+ missing = required - set(value)
483
+ if missing:
484
+ raise ValueError(f"{label} is missing fields: {', '.join(sorted(missing))}")
485
+ return value
486
+
487
+
488
+ def _plan_text(value: object, field: str) -> str:
489
+ if not isinstance(value, str):
490
+ raise ValueError(f"{field} MUST be a string")
491
+ return value
492
+
493
+
494
+ def _plan_integer(value: object, field: str) -> int:
495
+ if isinstance(value, bool) or not isinstance(value, int):
496
+ raise ValueError(f"{field} MUST be an integer")
497
+ return value
498
+
499
+
500
+ def _plan_boolean(value: object, field: str) -> bool:
501
+ if not isinstance(value, bool):
502
+ raise ValueError(f"{field} MUST be a boolean")
503
+ return value
504
+
505
+
506
+ def _plan_texts(value: object, field: str) -> tuple[str, ...]:
507
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
508
+ raise ValueError(f"{field} MUST be a list of strings")
509
+ return tuple(value)
510
+
511
+
512
+ def _plan_digest_mapping(value: object, field: str) -> dict[str, str]:
513
+ parsed = _plan_object(value, None, field)
514
+ if any(
515
+ _SCOPE_ID.fullmatch(key) is None
516
+ or not isinstance(digest, str)
517
+ or re.fullmatch(r"sha256:[0-9a-f]{64}", digest, re.ASCII) is None
518
+ for key, digest in parsed.items()
519
+ ):
520
+ raise ValueError(f"{field} MUST map canonical Entity ids to SHA-256 digests")
521
+ return {key: cast(str, digest) for key, digest in parsed.items()}
522
+
523
+
524
+ def evaluate_lifecycle_constraints(
525
+ context: LifecycleConstraintContext,
526
+ *,
527
+ admitted_plan: LifecyclePlan,
528
+ ) -> tuple[ConstraintBlock, ...]:
529
+ """Return every blocking constraint for a candidate Plan without side effects."""
530
+
531
+ blocks: list[ConstraintBlock] = []
532
+ context_block = _plan_context_block(context, admitted_plan)
533
+ if context_block is not None:
534
+ blocks.append(context_block)
535
+ if context_block.reason_code == "plan_type_unsupported":
536
+ return tuple(blocks)
537
+ if context.candidate_release.digest != admitted_plan.target_release_digest:
538
+ blocks.append(
539
+ ConstraintBlock(
540
+ "target_release_digest_mismatch",
541
+ (context.candidate_release.digest, admitted_plan.target_release_digest),
542
+ )
543
+ )
544
+ _append_if_blocked(blocks, _maintenance_decision(context))
545
+ _append_if_blocked(blocks, _suppression_decision(context))
546
+ version_range_block = _version_range_block(
547
+ context.target_release_version, context.version_range
548
+ )
549
+ if version_range_block is not None:
550
+ blocks.append(version_range_block)
551
+ _append_if_blocked(
552
+ blocks,
553
+ validate_schema_transition(
554
+ current_schema_revision=context.current_schema_revision,
555
+ candidate=context.candidate_release,
556
+ direction=_schema_direction(context.plan_type),
557
+ ),
558
+ )
559
+ required_artifacts = _required_artifacts(context.candidate_release)
560
+ if not required_artifacts:
561
+ blocks.append(ConstraintBlock("artifact_requirements_missing"))
562
+ else:
563
+ missing = tuple(
564
+ artifact.name
565
+ for artifact in required_artifacts
566
+ if artifact.digest not in context.available_artifact_digests
567
+ )
568
+ if missing:
569
+ blocks.append(ConstraintBlock("artifact_unavailable", missing))
570
+ override_block = _override_coverage_block(context)
571
+ if override_block is not None:
572
+ blocks.append(override_block)
573
+ if not context.data_residency.release_regions:
574
+ blocks.append(ConstraintBlock("data_residency_missing"))
575
+ elif not context.data_residency.release_regions <= context.data_residency.allowed_regions:
576
+ blocks.append(
577
+ ConstraintBlock(
578
+ "data_residency_mismatch",
579
+ tuple(sorted(context.data_residency.release_regions)),
580
+ )
581
+ )
582
+ _append_if_blocked(
583
+ blocks,
584
+ evaluate_recall_candidate(
585
+ release_id=context.target_release_version,
586
+ capability_ids=context.capability_ids,
587
+ recall_records=context.recall_records,
588
+ ),
589
+ )
590
+ return tuple(blocks)
591
+
592
+
593
+ def _maintenance_decision(context: LifecycleConstraintContext) -> ReleaseDecision:
594
+ if context.declared_duration_minutes < 1:
595
+ return ReleaseDecision(False, "maintenance_window_unavailable")
596
+ requires_downtime = context.requires_downtime or bool(
597
+ context.entity_ids & set(context.candidate_release.downtime_entities)
598
+ )
599
+ for window in context.maintenance_windows:
600
+ if window.starts_at >= window.ends_at:
601
+ return ReleaseDecision(
602
+ False, "maintenance_window_invalid", (window.starts_at.isoformat(),)
603
+ )
604
+ if not window.starts_at <= context.now < window.ends_at:
605
+ continue
606
+ if requires_downtime and not window.allows_downtime:
607
+ continue
608
+ remaining_seconds = (window.ends_at - context.now).total_seconds()
609
+ if remaining_seconds >= context.declared_duration_minutes * 60:
610
+ return ReleaseDecision(True, "allowed")
611
+ return ReleaseDecision(False, "maintenance_window_unavailable")
612
+
613
+
614
+ def _suppression_decision(context: LifecycleConstraintContext) -> ReleaseDecision:
615
+ for window in context.suppression_windows:
616
+ if window.starts_at >= window.ends_at:
617
+ return ReleaseDecision(False, "suppression_window_invalid", (window.scope,))
618
+ if window.origin not in {"manual", "failure"}:
619
+ return ReleaseDecision(False, "suppression_window_invalid", (window.scope,))
620
+ if not window.starts_at <= context.now < window.ends_at:
621
+ continue
622
+ scope_decision = _suppression_scope_decision(window)
623
+ if scope_decision is not None:
624
+ return scope_decision
625
+ if (
626
+ window.origin == "failure"
627
+ and context.plan_type == "rollback"
628
+ and window.failed_plan_id is not None
629
+ and window.failed_plan_id == context.rollback_target_plan_id
630
+ ):
631
+ continue
632
+ if _scope_matches(window.scope, context):
633
+ return ReleaseDecision(False, "suppression_window_active", (window.scope,))
634
+ return ReleaseDecision(True, "allowed")
635
+
636
+
637
+ def _suppression_scope_decision(window: SuppressionWindow) -> ReleaseDecision | None:
638
+ if window.scope == "installation":
639
+ return None
640
+ if window.scope.startswith("plan:"):
641
+ plan_type = window.scope.removeprefix("plan:")
642
+ if plan_type not in _SUPPORTED_PLAN_TYPES:
643
+ return ReleaseDecision(False, "suppression_scope_invalid", (window.scope,))
644
+ return None
645
+ if window.scope.startswith("entity:"):
646
+ entity_id = window.scope.removeprefix("entity:")
647
+ if _SCOPE_ID.fullmatch(entity_id) is None:
648
+ return ReleaseDecision(False, "suppression_scope_invalid", (window.scope,))
649
+ return None
650
+ return ReleaseDecision(False, "suppression_scope_invalid", (window.scope,))
651
+
652
+
653
+ def _scope_matches(scope: str, context: LifecycleConstraintContext) -> bool:
654
+ if scope == "installation":
655
+ return True
656
+ if scope == f"plan:{context.plan_type}":
657
+ return True
658
+ return any(scope == f"entity:{entity_id}" for entity_id in context.entity_ids)
659
+
660
+
661
+ def _schema_direction(plan_type: PlanType) -> SchemaDirection:
662
+ if plan_type in {"rollback", "recall-rolloff"}:
663
+ return "rollback"
664
+ if plan_type in {"install", "upgrade", "configuration-change", "drift-reconciliation"}:
665
+ return "upgrade"
666
+ raise ValueError(f"unsupported plan type: {plan_type}")
667
+
668
+
669
+ def _plan_context_block(
670
+ context: LifecycleConstraintContext, plan: LifecyclePlan
671
+ ) -> ConstraintBlock | None:
672
+ if context.plan_type not in _SUPPORTED_PLAN_TYPES:
673
+ return ConstraintBlock("plan_type_unsupported", (str(context.plan_type),))
674
+ if plan.plan_type not in _SUPPORTED_PLAN_TYPES:
675
+ return ConstraintBlock("plan_type_unsupported", (str(plan.plan_type),))
676
+ comparisons = (
677
+ ("plan_id", context.plan_id, plan.plan_id),
678
+ ("plan_type", context.plan_type, plan.plan_type),
679
+ ("target_release_version", context.target_release_version, plan.target_release_id),
680
+ (
681
+ "configuration_revision_digest",
682
+ context.configuration_revision_digest,
683
+ plan.configuration_revision_digest,
684
+ ),
685
+ ("entity_ids", context.entity_ids, plan.entity_ids),
686
+ ("capability_ids", context.capability_ids, plan.capability_ids),
687
+ ("release_regions", context.data_residency.release_regions, plan.release_regions),
688
+ ("rollback_target_plan_id", context.rollback_target_plan_id, plan.rollback_target_plan_id),
689
+ (
690
+ "declared_duration_minutes",
691
+ context.declared_duration_minutes,
692
+ plan.declared_duration_minutes,
693
+ ),
694
+ )
695
+ for field, observed, expected in comparisons:
696
+ if observed != expected:
697
+ return ConstraintBlock("plan_context_mismatch", (field,))
698
+ return None
699
+
700
+
701
+ def _require_aware_datetime(value: datetime, field: str) -> None:
702
+ if value.tzinfo is None or value.utcoffset() is None:
703
+ raise ValueError(f"{field} MUST include timezone information")
704
+
705
+
706
+ def _required_artifacts(release: LifecycleRelease) -> tuple[ArtifactRequirement, ...]:
707
+ catalog = release.to_mapping()
708
+ result: list[ArtifactRequirement] = []
709
+ for section in LIFECYCLE_IMAGE_SECTIONS:
710
+ records = catalog.get(section)
711
+ if not isinstance(records, Mapping):
712
+ continue
713
+ for name, raw_record in records.items():
714
+ if not isinstance(name, str) or not isinstance(raw_record, Mapping):
715
+ continue
716
+ digest = raw_record.get("image_digest")
717
+ if isinstance(digest, str):
718
+ result.append(ArtifactRequirement(f"{section}/{name}", digest))
719
+ return tuple(sorted(result, key=lambda artifact: artifact.name))
720
+
721
+
722
+ def required_artifact_digests(release: LifecycleRelease) -> frozenset[str]:
723
+ """Return the immutable image digests declared by a Release manifest."""
724
+
725
+ return frozenset(artifact.digest for artifact in _required_artifacts(release))
726
+
727
+
728
+ def _version_range_block(release_version: str, version_range: str) -> ConstraintBlock | None:
729
+ try:
730
+ resolve_configuration_layers(
731
+ release_version=release_version,
732
+ configuration_schema=_version_range_schema(),
733
+ environment_config={},
734
+ entity_overrides=({"versions": version_range, "values": {"eligible": True}},),
735
+ )
736
+ except ConfigurationValidationError as error:
737
+ if error.code == "missing_matching_override_block":
738
+ return ConstraintBlock("release_version_outside_range", (version_range,))
739
+ return ConstraintBlock(_configuration_reason_code(error), error.path)
740
+ return None
741
+
742
+
743
+ def _override_coverage_block(context: LifecycleConstraintContext) -> ConstraintBlock | None:
744
+ try:
745
+ resolve_configuration_layers(
746
+ release_version=context.target_release_version,
747
+ configuration_schema=context.configuration_schema,
748
+ environment_config=context.environment_config,
749
+ entity_overrides=context.entity_overrides,
750
+ )
751
+ except ConfigurationValidationError as error:
752
+ if error.code == "missing_matching_override_block":
753
+ return ConstraintBlock("override_coverage_missing", error.path)
754
+ return ConstraintBlock(_configuration_reason_code(error), error.path)
755
+ return None
756
+
757
+
758
+ def _configuration_reason_code(error: ConfigurationValidationError) -> str:
759
+ if error.code == "invalid_version_range":
760
+ return "version_range_invalid"
761
+ if error.code == "invalid_release_version":
762
+ return "release_version_invalid"
763
+ return f"configuration_{error.code}"
764
+
765
+
766
+ def _version_range_schema() -> Mapping[str, object]:
767
+ return {
768
+ "eligible": {
769
+ "default": False,
770
+ "x-fdai-axis": "Release channel subscription",
771
+ "x-fdai-owner": "customer",
772
+ }
773
+ }
774
+
775
+
776
+ def _append_if_blocked(blocks: list[ConstraintBlock], decision: ReleaseDecision) -> None:
777
+ if not decision.allowed:
778
+ blocks.append(ConstraintBlock(decision.reason_code, decision.details))
779
+
780
+
781
+ def _deny(reason_code: str, *details: str) -> PlanAdmissionDecision:
782
+ return PlanAdmissionDecision(False, reason_code, tuple(details))
783
+
784
+
785
+ from fdai_deployment_cli.lifecycle_authorization import ( # noqa: E402
786
+ LifecycleAuthorizationReceipt as LifecycleAuthorizationReceipt,
787
+ LifecyclePhase as LifecyclePhase,
788
+ LifecyclePhaseCompletionReceipt as LifecyclePhaseCompletionReceipt,
789
+ evaluate_phase_authorization as evaluate_phase_authorization,
790
+ exact_plan_digest_for as exact_plan_digest_for,
791
+ issue_lifecycle_authorization_receipt as issue_lifecycle_authorization_receipt,
792
+ )
793
+ from fdai_deployment_cli.lifecycle_commands import ( # noqa: E402
794
+ BreakGlassBypass as BreakGlassBypass,
795
+ LifecycleCommand as LifecycleCommand,
796
+ LifecycleCommandKind as LifecycleCommandKind,
797
+ overrides_adopted as overrides_adopted,
798
+ require_valid_lifecycle_command as require_valid_lifecycle_command,
799
+ validate_lifecycle_command as validate_lifecycle_command,
800
+ )