1use std::collections::{BTreeMap, BTreeSet};
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use utoipa::ToSchema;
6
7use crate::extraction_input_digest;
8
9use super::{
10 DeliveryDecision, DeliveryEffects, DeliveryIssue, DeliveryIssueCode, DeliveryPolicyInputs,
11 DeploymentObservation, DeploymentPlan, DeploymentReceipt, EnvironmentVerification,
12 GatewayConfigurationPlan, GatewayObservation, GatewayObservationProvider,
13 OperatorObservationAuthorityProvider, PolicyEvidence, ReleaseRollbackConstraints,
14 ReleaseTrustProvider, SecretProvider, ServiceRelease,
15 deployment_observation_integrity_is_valid, deployment_plan_integrity_is_valid,
16 deployment_receipt_integrity_is_valid, environment_verification_authority_is_valid,
17 gateway_observation_integrity_is_valid, gateway_plan_authority_is_valid, issue,
18 production_policy_evidence_is_valid, service_release_integrity_is_valid,
19};
20
21pub const RELIABILITY_CONTRACT_PROTOCOL: &str = "lenso.reliability-contract.v1";
22pub const CANARY_PLAN_PROTOCOL: &str = "lenso.canary-plan.v1";
23pub const CANARY_DECISION_PROTOCOL: &str = "lenso.canary-decision.v1";
24pub const RELIABILITY_OBSERVATION_PROTOCOL: &str = "lenso.reliability-observation.v1";
25pub const ROLLBACK_PLAN_PROTOCOL: &str = "lenso.rollback-plan.v1";
26pub const ROLLBACK_SAFETY_PROTOCOL: &str = "lenso.rollback-safety-evidence.v1";
27pub const ROLLBACK_CONVERGENCE_PROTOCOL: &str = "lenso.rollback-convergence.v1";
28pub const ROLLBACK_RECEIPT_PROTOCOL: &str = "lenso.rollback-receipt.v1";
29
30pub trait ReliabilityObservationProvider: std::fmt::Debug + Send + Sync {
31 fn sign(&self, collector_id: &str, observation_id: &str) -> Option<String>;
32
33 fn verify(&self, collector_id: &str, observation_id: &str, proof: &str) -> bool;
34}
35
36#[derive(Debug, Clone, Default)]
37pub struct DeterministicReliabilityObservationProvider {
38 collector_keys: BTreeMap<String, String>,
39}
40
41impl DeterministicReliabilityObservationProvider {
42 #[must_use]
43 pub fn new<I, K, V>(collector_keys: I) -> Self
44 where
45 I: IntoIterator<Item = (K, V)>,
46 K: Into<String>,
47 V: Into<String>,
48 {
49 Self {
50 collector_keys: collector_keys
51 .into_iter()
52 .map(|(collector, key)| (collector.into(), key.into()))
53 .collect(),
54 }
55 }
56
57 fn expected_proof(&self, collector_id: &str, observation_id: &str) -> Option<String> {
58 let key = self.collector_keys.get(collector_id)?;
59 Some(extraction_input_digest(
60 format!("{key}\0{observation_id}").as_bytes(),
61 ))
62 }
63}
64
65impl ReliabilityObservationProvider for DeterministicReliabilityObservationProvider {
66 fn sign(&self, collector_id: &str, observation_id: &str) -> Option<String> {
67 self.expected_proof(collector_id, observation_id)
68 }
69
70 fn verify(&self, collector_id: &str, observation_id: &str, proof: &str) -> bool {
71 self.expected_proof(collector_id, observation_id).as_deref() == Some(proof)
72 }
73}
74
75pub trait RollbackSafetyProvider: std::fmt::Debug + Send + Sync {
76 fn provider_id(&self) -> &str;
77
78 fn sign(&self, evidence_id: &str) -> Option<String>;
79
80 fn verify(&self, evidence_id: &str, proof: &str) -> bool;
81}
82
83#[derive(Debug, Clone)]
84pub struct DeterministicRollbackSafetyProvider {
85 provider_id: String,
86 key: String,
87}
88
89impl DeterministicRollbackSafetyProvider {
90 #[must_use]
91 pub fn new(provider_id: impl Into<String>, key: impl Into<String>) -> Self {
92 Self {
93 provider_id: provider_id.into(),
94 key: key.into(),
95 }
96 }
97
98 fn expected_proof(&self, evidence_id: &str) -> String {
99 extraction_input_digest(format!("{}\0{evidence_id}", self.key).as_bytes())
100 }
101}
102
103impl RollbackSafetyProvider for DeterministicRollbackSafetyProvider {
104 fn provider_id(&self) -> &str {
105 &self.provider_id
106 }
107
108 fn sign(&self, evidence_id: &str) -> Option<String> {
109 Some(self.expected_proof(evidence_id))
110 }
111
112 fn verify(&self, evidence_id: &str, proof: &str) -> bool {
113 self.expected_proof(evidence_id) == proof
114 }
115}
116
117#[derive(
118 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
119)]
120#[serde(rename_all = "snake_case")]
121pub enum DependencyCriticality {
122 Critical,
123 Degradable,
124 Optional,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
128#[serde(rename_all = "camelCase")]
129pub struct DependencyReliability {
130 pub dependency_id: String,
131 pub criticality: DependencyCriticality,
132 #[serde(default)]
133 pub allowed_degraded_modes: Vec<String>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
137#[serde(rename_all = "camelCase")]
138pub struct DeliveryReliabilityContract {
139 pub protocol: String,
140 pub contract_id: String,
141 pub minimum_observation_seconds: u64,
142 pub minimum_sample_count: u64,
143 pub minimum_availability_basis_points: u32,
144 pub maximum_latency_p99_ms: u64,
145 pub maximum_error_budget_used_basis_points: u32,
146 pub maximum_queue_backlog: u64,
147 pub maximum_workflow_backlog: u64,
148 pub maximum_timer_lag_ms: u64,
149 pub maximum_retry_exhaustion: u64,
150 pub maximum_compensation_pressure: u64,
151 pub minimum_healthy_failure_domains: u32,
152 pub dependencies: Vec<DependencyReliability>,
153}
154
155#[must_use]
158pub fn reliability_contract_digest(contract: &DeliveryReliabilityContract) -> String {
159 extraction_input_digest(
160 serde_json::to_vec(contract).expect("Reliability Contract must serialize"),
161 )
162}
163
164#[derive(Debug, Clone)]
165pub struct CanaryPlanInput {
166 pub release: ServiceRelease,
167 pub production_deployment: DeploymentPlan,
168 pub production_deployment_receipt: DeploymentReceipt,
169 pub production_deployment_observation: DeploymentObservation,
170 pub reliability_contract: DeliveryReliabilityContract,
171 pub policy: PolicyEvidence,
172 pub policy_inputs: DeliveryPolicyInputs,
173 pub environment_verification: EnvironmentVerification,
174 pub previous_known_good_deployment: DeploymentPlan,
175 pub previous_known_good_receipt: DeploymentReceipt,
176 pub previous_known_good_observation: DeploymentObservation,
177 pub previous_known_good_release: ServiceRelease,
178 pub previous_known_good_policy: PolicyEvidence,
179 pub previous_known_good_policy_inputs: DeliveryPolicyInputs,
180 pub previous_known_good_gateway: GatewayConfigurationPlan,
181 pub previous_known_good_gateway_observation: GatewayObservation,
182 pub initial_percent: u8,
183 pub maximum_percent: u8,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
187#[serde(rename_all = "camelCase")]
188pub struct CanaryPlan {
189 pub protocol: String,
190 pub plan_id: String,
191 pub plan_digest: String,
192 pub release_id: String,
193 pub release_digest: String,
194 pub production_deployment_plan_id: String,
195 pub production_deployment_digest: String,
196 pub production_deployment_receipt_id: String,
197 pub production_deployment_observation_id: String,
198 pub production_environment: String,
199 pub production_expected_environment_revision: u64,
200 pub reliability_contract: DeliveryReliabilityContract,
201 pub release_rollback_constraints: ReleaseRollbackConstraints,
202 pub policy_evidence_id: String,
203 pub policy_evidence_digest: String,
204 pub environment_verification_id: String,
205 pub environment_verification_digest: String,
206 pub previous_known_good_plan_id: String,
207 pub previous_known_good_deployment_digest: String,
208 pub previous_known_good_release_id: String,
209 pub previous_known_good_release_digest: String,
210 pub previous_known_good_receipt_id: String,
211 pub previous_known_good_observation_id: String,
212 pub previous_known_good_policy_evidence_id: String,
213 pub previous_known_good_policy_evidence_digest: String,
214 pub previous_known_good_gateway_plan_id: String,
215 pub previous_known_good_gateway_plan_digest: String,
216 pub previous_known_good_gateway_configuration_identity: String,
217 pub previous_known_good_gateway_observation_id: String,
218 pub previous_known_good_gateway_observation_revision: u64,
219 pub previous_known_good_gateway_observed_after: String,
220 pub initial_percent: u8,
221 pub maximum_percent: u8,
222 pub workload_ids: Vec<String>,
223 pub effects: DeliveryEffects,
224}
225
226pub fn plan_canary(
227 input: CanaryPlanInput,
228 trust_provider: &dyn ReleaseTrustProvider,
229 secret_provider: &dyn SecretProvider,
230 operator_observation_provider: &dyn OperatorObservationAuthorityProvider,
231 gateway_observation_provider: &dyn GatewayObservationProvider,
232) -> Result<CanaryPlan, Vec<DeliveryIssue>> {
233 let mut issues = Vec::new();
234 if !service_release_integrity_is_valid(&input.release)
235 || !deployment_plan_integrity_is_valid(&input.production_deployment)
236 || !deployment_receipt_integrity_is_valid(
237 &input.production_deployment_receipt,
238 &input.production_deployment,
239 )
240 || !deployment_observation_integrity_is_valid(
241 &input.production_deployment_observation,
242 &input.production_deployment,
243 &input.production_deployment_receipt,
244 )
245 || input.production_deployment_observation.drifted
246 || !input.production_deployment_observation.fresh
247 || input.production_deployment.release_id != input.release.release_id
248 || input.production_deployment.release_digest != input.release.release_digest
249 {
250 issues.push(issue(
251 DeliveryIssueCode::ReleaseTampered,
252 "Canary planning requires the exact integrity-valid Service Release and production Deployment plan.",
253 "Use the immutable promoted release and its digest-pinned production Deployment plan.",
254 "Refresh the production plan before starting a canary.",
255 ));
256 }
257 if input.policy_inputs.release != input.release
258 || !production_policy_evidence_is_valid(
259 &input.policy,
260 &input.policy_inputs,
261 trust_provider,
262 secret_provider,
263 )
264 || !environment_verification_authority_is_valid(
265 &input.environment_verification,
266 operator_observation_provider,
267 gateway_observation_provider,
268 )
269 || input.policy.decision != DeliveryDecision::Passed
270 || input.policy.evaluated_subject != input.release.release_id
271 || input.environment_verification.decision != DeliveryDecision::Passed
272 || input.environment_verification.release_id != input.release.release_id
273 {
274 issues.push(issue(
275 DeliveryIssueCode::PolicyEvidenceMissing,
276 "Canary planning requires passing Policy Evidence and Environment Verification for the exact release.",
277 "Refresh policy and environment evidence without rebuilding the release.",
278 "Verify the release again before starting a production canary.",
279 ));
280 }
281 if !deployment_plan_integrity_is_valid(&input.previous_known_good_deployment)
282 || !service_release_integrity_is_valid(&input.previous_known_good_release)
283 || !deployment_receipt_integrity_is_valid(
284 &input.previous_known_good_receipt,
285 &input.previous_known_good_deployment,
286 )
287 || !deployment_observation_integrity_is_valid(
288 &input.previous_known_good_observation,
289 &input.previous_known_good_deployment,
290 &input.previous_known_good_receipt,
291 )
292 || input.previous_known_good_observation.drifted
293 || !input.previous_known_good_observation.fresh
294 || input.previous_known_good_deployment.environment
295 != input.production_deployment.environment
296 || input.previous_known_good_deployment.release_id
297 != input.previous_known_good_release.release_id
298 || input.previous_known_good_deployment.release_digest
299 != input.previous_known_good_release.release_digest
300 || input
301 .previous_known_good_deployment
302 .workloads
303 .iter()
304 .any(|workload| {
305 !input
306 .previous_known_good_release
307 .workloads
308 .iter()
309 .any(|known_good| {
310 known_good.workload_id == workload.workload_id
311 && known_good.artifact_digest == workload.artifact_digest
312 })
313 })
314 || input.previous_known_good_release.release_id == input.release.release_id
315 || input.previous_known_good_release.release_digest == input.release.release_digest
316 || input
317 .previous_known_good_deployment
318 .workloads
319 .iter()
320 .all(|previous| {
321 input
322 .production_deployment
323 .workloads
324 .iter()
325 .any(|candidate| {
326 candidate.workload_id == previous.workload_id
327 && candidate.artifact_digest == previous.artifact_digest
328 })
329 })
330 || input.previous_known_good_policy_inputs.release != input.previous_known_good_release
331 || input.previous_known_good_policy_inputs.config.revision_id
332 != input.previous_known_good_deployment.config_revision_id
333 || !production_policy_evidence_is_valid(
334 &input.previous_known_good_policy,
335 &input.previous_known_good_policy_inputs,
336 trust_provider,
337 secret_provider,
338 )
339 || input.previous_known_good_policy.decision != DeliveryDecision::Passed
340 || input.previous_known_good_policy.evaluated_subject
341 != input.previous_known_good_release.release_id
342 || !gateway_plan_authority_is_valid(&input.previous_known_good_gateway, trust_provider)
343 || input.previous_known_good_gateway.edge_release_id
344 != input.previous_known_good_release.release_id
345 || input.previous_known_good_gateway.edge_release_digest
346 != input.previous_known_good_release.release_digest
347 || input.previous_known_good_deployment.gateway_plan_digest
348 != input.previous_known_good_gateway.plan_digest
349 || !gateway_observation_integrity_is_valid(
350 &input.previous_known_good_gateway_observation,
351 gateway_observation_provider,
352 )
353 || input
354 .previous_known_good_gateway_observation
355 .configuration_identity
356 != input.previous_known_good_gateway.configuration_identity
357 || input.previous_known_good_gateway_observation.revision
358 != input.previous_known_good_gateway.expected_gateway_revision
359 || input.previous_known_good_gateway_observation.observed_after
360 != input.previous_known_good_observation.source_observation_id
361 || !input.previous_known_good_gateway_observation.fresh
362 {
363 issues.push(issue(
364 DeliveryIssueCode::RollbackUnsafe,
365 "Canary planning requires an integrity-valid previous known-good Deployment in the same environment.",
366 "Retain a verified rollback target and its complete adapter inputs.",
367 "Provide a known-good Deployment before starting the canary.",
368 ));
369 }
370 if input.initial_percent == 0
371 || input.initial_percent > input.maximum_percent
372 || input.maximum_percent > 100
373 || input.reliability_contract.protocol != RELIABILITY_CONTRACT_PROTOCOL
374 || input.release.reliability_contract.digest
375 != reliability_contract_digest(&input.reliability_contract)
376 || input.reliability_contract.minimum_observation_seconds == 0
377 || input.reliability_contract.minimum_sample_count == 0
378 || input.reliability_contract.minimum_availability_basis_points > 10_000
379 || input
380 .reliability_contract
381 .maximum_error_budget_used_basis_points
382 > 10_000
383 || input.reliability_contract.minimum_healthy_failure_domains == 0
384 {
385 issues.push(issue(
386 DeliveryIssueCode::DeploymentInputInvalid,
387 "Canary bounds are invalid or the Reliability Contract differs from the exact contract bound by the signed Service Release.",
388 "Declare a non-zero bounded canary and use the content-addressed Service-level objectives from the release.",
389 "Correct the Reliability Contract and plan the canary again.",
390 ));
391 }
392 let dependency_ids = input
393 .reliability_contract
394 .dependencies
395 .iter()
396 .map(|dependency| dependency.dependency_id.as_str())
397 .collect::<BTreeSet<_>>();
398 if dependency_ids.len() != input.reliability_contract.dependencies.len() {
399 issues.push(issue(
400 DeliveryIssueCode::DeploymentInputInvalid,
401 "Reliability dependency declarations must have unique identifiers.",
402 "Declare each critical, degradable, or optional dependency exactly once.",
403 "Correct the Reliability Contract and plan the canary again.",
404 ));
405 }
406 if !issues.is_empty() {
407 return Err(issues);
408 }
409 let mut workload_ids = input
410 .release
411 .workloads
412 .iter()
413 .map(|workload| workload.workload_id.clone())
414 .collect::<Vec<_>>();
415 workload_ids.sort();
416 let digest_input = CanaryPlanDigestInput {
417 protocol: CANARY_PLAN_PROTOCOL,
418 release_id: &input.release.release_id,
419 release_digest: &input.release.release_digest,
420 production_deployment_plan_id: &input.production_deployment.plan_id,
421 production_deployment_digest: &input.production_deployment.plan_digest,
422 production_deployment_receipt_id: &input.production_deployment_receipt.receipt_id,
423 production_deployment_observation_id: &input
424 .production_deployment_observation
425 .observation_id,
426 production_environment: &input.production_deployment.environment,
427 production_expected_environment_revision: input
428 .production_deployment
429 .expected_environment_revision,
430 reliability_contract: &input.reliability_contract,
431 release_rollback_constraints: input.release.rollback,
432 policy_evidence_id: &input.policy.evidence_id,
433 policy_evidence_digest: &input.policy.evidence_digest,
434 environment_verification_id: &input.environment_verification.verification_id,
435 environment_verification_digest: &input.environment_verification.verification_digest,
436 previous_known_good_plan_id: &input.previous_known_good_deployment.plan_id,
437 previous_known_good_digest: &input.previous_known_good_deployment.plan_digest,
438 previous_known_good_release_id: &input.previous_known_good_deployment.release_id,
439 previous_known_good_release_digest: &input.previous_known_good_deployment.release_digest,
440 previous_known_good_receipt_id: &input.previous_known_good_receipt.receipt_id,
441 previous_known_good_observation_id: &input.previous_known_good_observation.observation_id,
442 previous_known_good_policy_evidence_id: &input.previous_known_good_policy.evidence_id,
443 previous_known_good_policy_evidence_digest: &input
444 .previous_known_good_policy
445 .evidence_digest,
446 previous_known_good_gateway_plan_id: &input.previous_known_good_gateway.plan_id,
447 previous_known_good_gateway_plan_digest: &input.previous_known_good_gateway.plan_digest,
448 previous_known_good_gateway_configuration_identity: &input
449 .previous_known_good_gateway
450 .configuration_identity,
451 previous_known_good_gateway_observation_id: &input
452 .previous_known_good_gateway_observation
453 .observation_id,
454 previous_known_good_gateway_observation_revision: input
455 .previous_known_good_gateway_observation
456 .revision,
457 previous_known_good_gateway_observed_after: &input
458 .previous_known_good_gateway_observation
459 .observed_after,
460 initial_percent: input.initial_percent,
461 maximum_percent: input.maximum_percent,
462 workload_ids: &workload_ids,
463 };
464 let effects = DeliveryEffects::default();
465 let plan_digest = digest_json(&(digest_input, &effects));
466 Ok(CanaryPlan {
467 protocol: CANARY_PLAN_PROTOCOL.to_owned(),
468 plan_id: format!("canary-plan:{plan_digest}"),
469 plan_digest,
470 release_id: input.release.release_id,
471 release_digest: input.release.release_digest,
472 production_deployment_plan_id: input.production_deployment.plan_id,
473 production_deployment_digest: input.production_deployment.plan_digest,
474 production_deployment_receipt_id: input.production_deployment_receipt.receipt_id,
475 production_deployment_observation_id: input
476 .production_deployment_observation
477 .observation_id,
478 production_environment: input.production_deployment.environment,
479 production_expected_environment_revision: input
480 .production_deployment
481 .expected_environment_revision,
482 reliability_contract: input.reliability_contract,
483 release_rollback_constraints: input.release.rollback,
484 policy_evidence_id: input.policy.evidence_id,
485 policy_evidence_digest: input.policy.evidence_digest,
486 environment_verification_id: input.environment_verification.verification_id,
487 environment_verification_digest: input.environment_verification.verification_digest,
488 previous_known_good_plan_id: input.previous_known_good_deployment.plan_id,
489 previous_known_good_deployment_digest: input.previous_known_good_deployment.plan_digest,
490 previous_known_good_release_id: input.previous_known_good_deployment.release_id,
491 previous_known_good_release_digest: input.previous_known_good_deployment.release_digest,
492 previous_known_good_receipt_id: input.previous_known_good_receipt.receipt_id,
493 previous_known_good_observation_id: input.previous_known_good_observation.observation_id,
494 previous_known_good_policy_evidence_id: input.previous_known_good_policy.evidence_id,
495 previous_known_good_policy_evidence_digest: input
496 .previous_known_good_policy
497 .evidence_digest,
498 previous_known_good_gateway_plan_id: input.previous_known_good_gateway.plan_id,
499 previous_known_good_gateway_plan_digest: input.previous_known_good_gateway.plan_digest,
500 previous_known_good_gateway_configuration_identity: input
501 .previous_known_good_gateway
502 .configuration_identity,
503 previous_known_good_gateway_observation_id: input
504 .previous_known_good_gateway_observation
505 .observation_id,
506 previous_known_good_gateway_observation_revision: input
507 .previous_known_good_gateway_observation
508 .revision,
509 previous_known_good_gateway_observed_after: input
510 .previous_known_good_gateway_observation
511 .observed_after,
512 initial_percent: input.initial_percent,
513 maximum_percent: input.maximum_percent,
514 workload_ids,
515 effects,
516 })
517}
518
519#[derive(Serialize)]
520#[serde(rename_all = "camelCase")]
521struct CanaryPlanDigestInput<'a> {
522 protocol: &'a str,
523 release_id: &'a str,
524 release_digest: &'a str,
525 production_deployment_plan_id: &'a str,
526 production_deployment_digest: &'a str,
527 production_deployment_receipt_id: &'a str,
528 production_deployment_observation_id: &'a str,
529 production_environment: &'a str,
530 production_expected_environment_revision: u64,
531 reliability_contract: &'a DeliveryReliabilityContract,
532 release_rollback_constraints: ReleaseRollbackConstraints,
533 policy_evidence_id: &'a str,
534 policy_evidence_digest: &'a str,
535 environment_verification_id: &'a str,
536 environment_verification_digest: &'a str,
537 previous_known_good_plan_id: &'a str,
538 previous_known_good_digest: &'a str,
539 previous_known_good_release_id: &'a str,
540 previous_known_good_release_digest: &'a str,
541 previous_known_good_receipt_id: &'a str,
542 previous_known_good_observation_id: &'a str,
543 previous_known_good_policy_evidence_id: &'a str,
544 previous_known_good_policy_evidence_digest: &'a str,
545 previous_known_good_gateway_plan_id: &'a str,
546 previous_known_good_gateway_plan_digest: &'a str,
547 previous_known_good_gateway_configuration_identity: &'a str,
548 previous_known_good_gateway_observation_id: &'a str,
549 previous_known_good_gateway_observation_revision: u64,
550 previous_known_good_gateway_observed_after: &'a str,
551 initial_percent: u8,
552 maximum_percent: u8,
553 workload_ids: &'a [String],
554}
555
556#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
557#[serde(rename_all = "camelCase")]
558pub struct DependencyReliabilityObservation {
559 pub dependency_id: String,
560 pub available: bool,
561 #[serde(default, skip_serializing_if = "Option::is_none")]
562 pub active_degraded_mode: Option<String>,
563}
564
565#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
566#[serde(rename_all = "camelCase")]
567pub struct ReliabilityObservation {
568 pub protocol: String,
569 pub observation_id: String,
570 pub canary_plan_id: String,
571 pub canary_plan_digest: String,
572 pub release_id: String,
573 pub release_digest: String,
574 pub environment: String,
575 pub deployment_plan_id: String,
576 pub deployment_plan_digest: String,
577 pub deployment_observation_id: String,
578 pub collector_id: String,
579 pub collector_proof: String,
580 pub observed_revision: u64,
581 pub freshness_horizon_revision: u64,
582 pub fresh: bool,
583 pub observation_window_seconds: u64,
584 pub sample_count: u64,
585 pub generic_process_healthy: bool,
586 pub workload_readiness: BTreeMap<String, bool>,
587 pub workload_liveness: BTreeMap<String, bool>,
588 #[serde(default, skip_serializing_if = "Option::is_none")]
589 pub availability_basis_points: Option<u32>,
590 #[serde(default, skip_serializing_if = "Option::is_none")]
591 pub latency_p99_ms: Option<u64>,
592 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub error_budget_used_basis_points: Option<u32>,
594 #[serde(default, skip_serializing_if = "Option::is_none")]
595 pub queue_backlog: Option<u64>,
596 #[serde(default, skip_serializing_if = "Option::is_none")]
597 pub workflow_backlog: Option<u64>,
598 #[serde(default, skip_serializing_if = "Option::is_none")]
599 pub timer_lag_ms: Option<u64>,
600 #[serde(default, skip_serializing_if = "Option::is_none")]
601 pub retry_exhaustion: Option<u64>,
602 #[serde(default, skip_serializing_if = "Option::is_none")]
603 pub compensation_pressure: Option<u64>,
604 pub dependencies: Vec<DependencyReliabilityObservation>,
605 pub failure_domains: BTreeMap<String, bool>,
606 pub scaling_check_passed: Option<bool>,
607 pub disruption_check_passed: Option<bool>,
608 pub availability_check_passed: Option<bool>,
609 #[serde(default)]
610 pub evidence_references: Vec<String>,
611}
612
613#[must_use]
614pub fn seal_reliability_observation(
615 plan: &CanaryPlan,
616 deployment_observation: &DeploymentObservation,
617 provider: &dyn ReliabilityObservationProvider,
618 mut observation: ReliabilityObservation,
619) -> Result<ReliabilityObservation, DeliveryIssue> {
620 observation.protocol = RELIABILITY_OBSERVATION_PROTOCOL.to_owned();
621 observation.canary_plan_id = plan.plan_id.clone();
622 observation.canary_plan_digest = plan.plan_digest.clone();
623 observation.release_id = plan.release_id.clone();
624 observation.release_digest = plan.release_digest.clone();
625 observation.environment = plan.production_environment.clone();
626 observation.deployment_plan_id = plan.production_deployment_plan_id.clone();
627 observation.deployment_plan_digest = plan.production_deployment_digest.clone();
628 observation.deployment_observation_id = deployment_observation.observation_id.clone();
629 if !observation
630 .evidence_references
631 .contains(&deployment_observation.observation_id)
632 {
633 observation
634 .evidence_references
635 .push(deployment_observation.observation_id.clone());
636 }
637 observation.evidence_references.sort();
638 observation.observation_id = format!(
639 "reliability-observation:{}",
640 reliability_observation_digest(&observation)
641 );
642 let Some(proof) = provider.sign(&observation.collector_id, &observation.observation_id) else {
643 return Err(issue(
644 DeliveryIssueCode::ReliabilityEvidenceMissing,
645 "The Reliability collector is not authorized by the selected observation provider.",
646 "Use a configured collector identity without exposing its signing material.",
647 "Configure the Reliability observation provider and collect the window again.",
648 ));
649 };
650 observation.collector_proof = proof;
651 Ok(observation)
652}
653
654#[must_use]
655pub fn reliability_observation_integrity_is_valid(
656 observation: &ReliabilityObservation,
657 provider: &dyn ReliabilityObservationProvider,
658) -> bool {
659 observation.protocol == RELIABILITY_OBSERVATION_PROTOCOL
660 && observation.observation_id
661 == format!(
662 "reliability-observation:{}",
663 reliability_observation_digest(observation)
664 )
665 && provider.verify(
666 &observation.collector_id,
667 &observation.observation_id,
668 &observation.collector_proof,
669 )
670}
671
672fn reliability_observation_digest(observation: &ReliabilityObservation) -> String {
673 #[derive(Serialize)]
674 #[serde(rename_all = "camelCase")]
675 struct DigestInput<'a> {
676 protocol: &'a str,
677 canary_plan_id: &'a str,
678 canary_plan_digest: &'a str,
679 release_id: &'a str,
680 release_digest: &'a str,
681 environment: &'a str,
682 deployment_plan_id: &'a str,
683 deployment_plan_digest: &'a str,
684 deployment_observation_id: &'a str,
685 collector_id: &'a str,
686 observed_revision: u64,
687 freshness_horizon_revision: u64,
688 fresh: bool,
689 observation_window_seconds: u64,
690 sample_count: u64,
691 generic_process_healthy: bool,
692 workload_readiness: &'a BTreeMap<String, bool>,
693 workload_liveness: &'a BTreeMap<String, bool>,
694 availability_basis_points: Option<u32>,
695 latency_p99_ms: Option<u64>,
696 error_budget_used_basis_points: Option<u32>,
697 queue_backlog: Option<u64>,
698 workflow_backlog: Option<u64>,
699 timer_lag_ms: Option<u64>,
700 retry_exhaustion: Option<u64>,
701 compensation_pressure: Option<u64>,
702 dependencies: &'a [DependencyReliabilityObservation],
703 failure_domains: &'a BTreeMap<String, bool>,
704 scaling_check_passed: Option<bool>,
705 disruption_check_passed: Option<bool>,
706 availability_check_passed: Option<bool>,
707 evidence_references: &'a [String],
708 }
709 digest_json(&DigestInput {
710 protocol: observation.protocol.as_str(),
711 canary_plan_id: observation.canary_plan_id.as_str(),
712 canary_plan_digest: observation.canary_plan_digest.as_str(),
713 release_id: observation.release_id.as_str(),
714 release_digest: observation.release_digest.as_str(),
715 environment: observation.environment.as_str(),
716 deployment_plan_id: observation.deployment_plan_id.as_str(),
717 deployment_plan_digest: observation.deployment_plan_digest.as_str(),
718 deployment_observation_id: observation.deployment_observation_id.as_str(),
719 collector_id: observation.collector_id.as_str(),
720 observed_revision: observation.observed_revision,
721 freshness_horizon_revision: observation.freshness_horizon_revision,
722 fresh: observation.fresh,
723 observation_window_seconds: observation.observation_window_seconds,
724 sample_count: observation.sample_count,
725 generic_process_healthy: observation.generic_process_healthy,
726 workload_readiness: &observation.workload_readiness,
727 workload_liveness: &observation.workload_liveness,
728 availability_basis_points: observation.availability_basis_points,
729 latency_p99_ms: observation.latency_p99_ms,
730 error_budget_used_basis_points: observation.error_budget_used_basis_points,
731 queue_backlog: observation.queue_backlog,
732 workflow_backlog: observation.workflow_backlog,
733 timer_lag_ms: observation.timer_lag_ms,
734 retry_exhaustion: observation.retry_exhaustion,
735 compensation_pressure: observation.compensation_pressure,
736 dependencies: observation.dependencies.as_slice(),
737 failure_domains: &observation.failure_domains,
738 scaling_check_passed: observation.scaling_check_passed,
739 disruption_check_passed: observation.disruption_check_passed,
740 availability_check_passed: observation.availability_check_passed,
741 evidence_references: observation.evidence_references.as_slice(),
742 })
743}
744
745#[derive(
746 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
747)]
748#[serde(rename_all = "snake_case")]
749pub enum CanaryOutcome {
750 Expand,
751 HoldDegraded,
752 Pause,
753 Rollback,
754 Converged,
755}
756
757#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
758#[serde(rename_all = "camelCase")]
759pub struct CanaryDecision {
760 pub protocol: String,
761 pub decision_id: String,
762 pub plan_id: String,
763 pub observation_id: String,
764 pub decision: DeliveryDecision,
765 pub outcome: CanaryOutcome,
766 pub current_percent: u8,
767 pub next_percent: u8,
768 pub issues: Vec<DeliveryIssue>,
769 pub active_degraded_modes: Vec<String>,
770 pub evidence_references: Vec<String>,
771 pub effects: DeliveryEffects,
772}
773
774#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
775#[serde(rename_all = "camelCase")]
776pub struct CanaryState {
777 pub plan_id: String,
778 pub current_percent: u8,
779 #[serde(default)]
780 pub observations: Vec<ReliabilityObservation>,
781 #[serde(default)]
782 pub decisions: Vec<CanaryDecision>,
783}
784
785impl CanaryState {
786 #[must_use]
787 pub fn new(plan_id: impl Into<String>) -> Self {
788 Self {
789 plan_id: plan_id.into(),
790 current_percent: 0,
791 observations: Vec::new(),
792 decisions: Vec::new(),
793 }
794 }
795}
796
797#[must_use]
798pub fn evaluate_canary(
799 state: &mut CanaryState,
800 plan: &CanaryPlan,
801 observation: ReliabilityObservation,
802 provider: &dyn ReliabilityObservationProvider,
803) -> CanaryDecision {
804 evaluate_canary_internal(state, plan, observation, provider, true)
805}
806
807fn evaluate_canary_internal(
808 state: &mut CanaryState,
809 plan: &CanaryPlan,
810 mut observation: ReliabilityObservation,
811 provider: &dyn ReliabilityObservationProvider,
812 verify_history: bool,
813) -> CanaryDecision {
814 if !canary_plan_integrity_is_valid(plan) {
815 let issues = vec![issue(
816 DeliveryIssueCode::StaleInput,
817 "The Canary plan identity or protected evidence was modified after planning.",
818 "Use the exact content-addressed Canary plan.",
819 "Regenerate the Canary plan from current production evidence.",
820 )];
821 let mut blocked = CanaryDecision {
822 protocol: CANARY_DECISION_PROTOCOL.to_owned(),
823 decision_id: String::new(),
824 plan_id: plan.plan_id.clone(),
825 observation_id: observation.observation_id,
826 decision: DeliveryDecision::Blocked,
827 outcome: CanaryOutcome::Pause,
828 current_percent: state.current_percent,
829 next_percent: state.current_percent,
830 issues,
831 active_degraded_modes: Vec::new(),
832 evidence_references: Vec::new(),
833 effects: DeliveryEffects::default(),
834 };
835 blocked.decision_id = format!("canary-decision:{}", canary_decision_digest(&blocked));
836 return blocked;
837 }
838 observation.evidence_references.sort();
839 if verify_history && !canary_history_is_valid(state, plan, provider) {
840 return blocked_canary_decision(
841 plan,
842 state.current_percent,
843 observation.observation_id,
844 "The Canary state is not the verified result of its append-only observation and decision history.",
845 "Rebuild state from every signed observation and canonical decision before accepting another observation.",
846 "Repair the evidence ledger before changing traffic exposure.",
847 );
848 }
849 let replay_observation_index = state
850 .observations
851 .iter()
852 .position(|stored| stored.observation_id == observation.observation_id);
853 let replay_decision_index = state
854 .decisions
855 .iter()
856 .position(|decision| decision.observation_id == observation.observation_id);
857 if replay_observation_index.is_some() || replay_decision_index.is_some() {
858 if replay_observation_index == replay_decision_index
859 && let Some(index) = replay_observation_index
860 && state.observations[index] == observation
861 {
862 return state.decisions[index].clone();
863 }
864 return blocked_canary_decision(
865 plan,
866 state.current_percent,
867 observation.observation_id,
868 "The Reliability observation replay does not match a verified Canary history.",
869 "Preserve each signed observation and recomputed decision as one append-only pair.",
870 "Repair the evidence ledger and collect a new observation for the current exposure.",
871 );
872 }
873 if state
874 .decisions
875 .last()
876 .is_some_and(|decision| decision.outcome == CanaryOutcome::Rollback)
877 {
878 return blocked_canary_decision(
879 plan,
880 state.current_percent,
881 observation.observation_id,
882 "The Canary rollout already reached its terminal rollback boundary.",
883 "Create a new Service Release and Canary plan instead of reopening a rolled-back release.",
884 "Keep exposure at zero and investigate the failed release.",
885 );
886 }
887 if state.current_percent == 0 {
888 state.current_percent = plan.initial_percent;
889 }
890 let current_percent = state.current_percent;
891 let mut issues = Vec::new();
892 let mut active_degraded_modes = Vec::new();
893 let workload_ids = plan
894 .workload_ids
895 .iter()
896 .map(String::as_str)
897 .collect::<BTreeSet<_>>();
898 let readiness_ids = observation
899 .workload_readiness
900 .keys()
901 .map(String::as_str)
902 .collect::<BTreeSet<_>>();
903 let liveness_ids = observation
904 .workload_liveness
905 .keys()
906 .map(String::as_str)
907 .collect::<BTreeSet<_>>();
908 let required_metrics_present = observation.availability_basis_points.is_some()
909 && observation.latency_p99_ms.is_some()
910 && observation.error_budget_used_basis_points.is_some()
911 && observation.queue_backlog.is_some()
912 && observation.workflow_backlog.is_some()
913 && observation.timer_lag_ms.is_some()
914 && observation.retry_exhaustion.is_some()
915 && observation.compensation_pressure.is_some()
916 && observation.scaling_check_passed.is_some()
917 && observation.disruption_check_passed.is_some()
918 && observation.availability_check_passed.is_some();
919 let observed_dependencies = observation
920 .dependencies
921 .iter()
922 .map(|dependency| dependency.dependency_id.as_str())
923 .collect::<BTreeSet<_>>();
924 let dependencies_are_unique = observed_dependencies.len() == observation.dependencies.len();
925 let declared_dependencies = plan
926 .reliability_contract
927 .dependencies
928 .iter()
929 .map(|dependency| dependency.dependency_id.as_str())
930 .collect::<BTreeSet<_>>();
931 let reliability_evidence_invalid = state.plan_id != plan.plan_id
932 || !reliability_observation_integrity_is_valid(&observation, provider)
933 || observation.canary_plan_id != plan.plan_id
934 || observation.canary_plan_digest != plan.plan_digest
935 || observation.release_id != plan.release_id
936 || observation.release_digest != plan.release_digest
937 || observation.environment != plan.production_environment
938 || observation.deployment_plan_id != plan.production_deployment_plan_id
939 || observation.deployment_plan_digest != plan.production_deployment_digest
940 || observation.deployment_observation_id != plan.production_deployment_observation_id
941 || observation.collector_id.trim().is_empty()
942 || observation.observed_revision <= plan.production_expected_environment_revision
943 || observation.freshness_horizon_revision < observation.observed_revision
944 || !observation.fresh
945 || observation.observation_window_seconds
946 < plan.reliability_contract.minimum_observation_seconds
947 || observation.sample_count < plan.reliability_contract.minimum_sample_count
948 || !required_metrics_present
949 || readiness_ids != workload_ids
950 || liveness_ids != workload_ids
951 || observed_dependencies != declared_dependencies
952 || !dependencies_are_unique;
953 if reliability_evidence_invalid {
954 issues.push(issue(
955 DeliveryIssueCode::ReliabilityEvidenceMissing,
956 "Canary evaluation lacks fresh, complete Service Reliability observations or the minimum observation window.",
957 "Collect every declared Workload, objective, dependency, backlog, timer, retry, compensation, and failure-domain observation.",
958 "Hold exposure and refresh the Service Reliability evidence.",
959 ));
960 }
961 let mut objective_breach = observation
962 .workload_readiness
963 .values()
964 .chain(observation.workload_liveness.values())
965 .any(|healthy| !healthy);
966 objective_breach |= observation
967 .availability_basis_points
968 .is_some_and(|value| value < plan.reliability_contract.minimum_availability_basis_points);
969 objective_breach |= observation
970 .latency_p99_ms
971 .is_some_and(|value| value > plan.reliability_contract.maximum_latency_p99_ms);
972 objective_breach |= observation
973 .error_budget_used_basis_points
974 .is_some_and(|value| {
975 value
976 > plan
977 .reliability_contract
978 .maximum_error_budget_used_basis_points
979 });
980 objective_breach |= observation
981 .queue_backlog
982 .is_some_and(|value| value > plan.reliability_contract.maximum_queue_backlog);
983 objective_breach |= observation
984 .workflow_backlog
985 .is_some_and(|value| value > plan.reliability_contract.maximum_workflow_backlog);
986 objective_breach |= observation
987 .timer_lag_ms
988 .is_some_and(|value| value > plan.reliability_contract.maximum_timer_lag_ms);
989 objective_breach |= observation
990 .retry_exhaustion
991 .is_some_and(|value| value > plan.reliability_contract.maximum_retry_exhaustion);
992 objective_breach |= observation
993 .compensation_pressure
994 .is_some_and(|value| value > plan.reliability_contract.maximum_compensation_pressure);
995 objective_breach |= observation
996 .failure_domains
997 .values()
998 .filter(|healthy| **healthy)
999 .count()
1000 < plan.reliability_contract.minimum_healthy_failure_domains as usize;
1001
1002 let observations_by_dependency = observation
1003 .dependencies
1004 .iter()
1005 .map(|item| (item.dependency_id.as_str(), item))
1006 .collect::<BTreeMap<_, _>>();
1007 for dependency in &plan.reliability_contract.dependencies {
1008 let Some(observed) = observations_by_dependency.get(dependency.dependency_id.as_str())
1009 else {
1010 continue;
1011 };
1012 if observed.available {
1013 continue;
1014 }
1015 match dependency.criticality {
1016 DependencyCriticality::Critical => objective_breach = true,
1017 DependencyCriticality::Degradable => {
1018 if let Some(mode) = observed.active_degraded_mode.as_ref()
1019 && dependency.allowed_degraded_modes.contains(mode)
1020 {
1021 active_degraded_modes.push(format!("{}:{mode}", dependency.dependency_id));
1022 } else {
1023 objective_breach = true;
1024 }
1025 }
1026 DependencyCriticality::Optional => {
1027 active_degraded_modes.push(format!("{}:unavailable", dependency.dependency_id));
1028 }
1029 }
1030 }
1031 if reliability_evidence_invalid {
1032 objective_breach = false;
1033 active_degraded_modes.clear();
1034 }
1035 if objective_breach {
1036 issues.push(issue(
1037 DeliveryIssueCode::CanaryBreach,
1038 "The canary breached one or more declared Service Reliability objectives.",
1039 "Limit exposure and evaluate the verified rollback boundary.",
1040 "Start bounded rollback when safe, otherwise pause for intervention.",
1041 ));
1042 }
1043 let expansion_checks_passed = observation.scaling_check_passed == Some(true)
1044 && observation.disruption_check_passed == Some(true)
1045 && observation.availability_check_passed == Some(true);
1046 let (decision, outcome, next_percent) = if objective_breach {
1047 (DeliveryDecision::Blocked, CanaryOutcome::Rollback, 0)
1048 } else if !issues.is_empty() || !expansion_checks_passed {
1049 if !expansion_checks_passed && issues.is_empty() {
1050 issues.push(issue(
1051 DeliveryIssueCode::ReliabilityEvidenceMissing,
1052 "Scaling, disruption, or availability safety checks do not permit canary expansion.",
1053 "Satisfy all declared availability and disruption constraints.",
1054 "Hold exposure and refresh rollout safety evidence.",
1055 ));
1056 }
1057 (
1058 DeliveryDecision::Blocked,
1059 CanaryOutcome::Pause,
1060 current_percent,
1061 )
1062 } else if !active_degraded_modes.is_empty() {
1063 (
1064 DeliveryDecision::Advisory,
1065 CanaryOutcome::HoldDegraded,
1066 current_percent,
1067 )
1068 } else if current_percent >= plan.maximum_percent {
1069 (
1070 DeliveryDecision::Passed,
1071 CanaryOutcome::Converged,
1072 current_percent,
1073 )
1074 } else {
1075 (
1076 DeliveryDecision::Passed,
1077 CanaryOutcome::Expand,
1078 current_percent.saturating_mul(2).min(plan.maximum_percent),
1079 )
1080 };
1081 let mut result = CanaryDecision {
1082 protocol: CANARY_DECISION_PROTOCOL.to_owned(),
1083 decision_id: String::new(),
1084 plan_id: plan.plan_id.clone(),
1085 observation_id: observation.observation_id.clone(),
1086 decision,
1087 outcome,
1088 current_percent,
1089 next_percent,
1090 issues,
1091 active_degraded_modes,
1092 evidence_references: observation.evidence_references.clone(),
1093 effects: DeliveryEffects::default(),
1094 };
1095 result.decision_id = format!("canary-decision:{}", canary_decision_digest(&result));
1096 state.observations.push(observation);
1097 state.decisions.push(result.clone());
1098 if matches!(
1099 outcome,
1100 CanaryOutcome::Expand | CanaryOutcome::Converged | CanaryOutcome::Rollback
1101 ) {
1102 state.current_percent = next_percent;
1103 }
1104 result
1105}
1106
1107fn canary_history_is_valid(
1108 state: &CanaryState,
1109 plan: &CanaryPlan,
1110 provider: &dyn ReliabilityObservationProvider,
1111) -> bool {
1112 let observation_ids = state
1113 .observations
1114 .iter()
1115 .map(|stored| stored.observation_id.as_str())
1116 .collect::<BTreeSet<_>>();
1117 let decision_observation_ids = state
1118 .decisions
1119 .iter()
1120 .map(|decision| decision.observation_id.as_str())
1121 .collect::<BTreeSet<_>>();
1122 if state.plan_id != plan.plan_id
1123 || state.current_percent > plan.maximum_percent
1124 || state.observations.len() != state.decisions.len()
1125 || observation_ids.len() != state.observations.len()
1126 || decision_observation_ids.len() != state.decisions.len()
1127 || state
1128 .observations
1129 .iter()
1130 .zip(&state.decisions)
1131 .any(|(stored, decision)| stored.observation_id != decision.observation_id)
1132 {
1133 return false;
1134 }
1135 let mut rebuilt = CanaryState::new(plan.plan_id.clone());
1136 for (stored, decision) in state.observations.iter().zip(&state.decisions) {
1137 let recomputed =
1138 evaluate_canary_internal(&mut rebuilt, plan, stored.clone(), provider, false);
1139 if recomputed != *decision || !canary_decision_integrity_is_valid(decision) {
1140 return false;
1141 }
1142 }
1143 rebuilt.current_percent == state.current_percent
1144}
1145
1146fn blocked_canary_decision(
1147 plan: &CanaryPlan,
1148 current_percent: u8,
1149 observation_id: String,
1150 message: &str,
1151 remediation: &str,
1152 next_action: &str,
1153) -> CanaryDecision {
1154 let mut blocked = CanaryDecision {
1155 protocol: CANARY_DECISION_PROTOCOL.to_owned(),
1156 decision_id: String::new(),
1157 plan_id: plan.plan_id.clone(),
1158 observation_id,
1159 decision: DeliveryDecision::Blocked,
1160 outcome: CanaryOutcome::Pause,
1161 current_percent,
1162 next_percent: current_percent,
1163 issues: vec![issue(
1164 DeliveryIssueCode::StaleInput,
1165 message,
1166 remediation,
1167 next_action,
1168 )],
1169 active_degraded_modes: Vec::new(),
1170 evidence_references: Vec::new(),
1171 effects: DeliveryEffects::default(),
1172 };
1173 blocked.decision_id = format!("canary-decision:{}", canary_decision_digest(&blocked));
1174 blocked
1175}
1176
1177#[must_use]
1178pub fn canary_plan_integrity_is_valid(plan: &CanaryPlan) -> bool {
1179 plan.protocol == CANARY_PLAN_PROTOCOL
1180 && plan.plan_id == format!("canary-plan:{}", plan.plan_digest)
1181 && digest_json(&(
1182 CanaryPlanDigestInput {
1183 protocol: plan.protocol.as_str(),
1184 release_id: plan.release_id.as_str(),
1185 release_digest: plan.release_digest.as_str(),
1186 production_deployment_plan_id: plan.production_deployment_plan_id.as_str(),
1187 production_deployment_digest: plan.production_deployment_digest.as_str(),
1188 production_deployment_receipt_id: plan.production_deployment_receipt_id.as_str(),
1189 production_deployment_observation_id: plan
1190 .production_deployment_observation_id
1191 .as_str(),
1192 production_environment: plan.production_environment.as_str(),
1193 production_expected_environment_revision: plan
1194 .production_expected_environment_revision,
1195 reliability_contract: &plan.reliability_contract,
1196 release_rollback_constraints: plan.release_rollback_constraints,
1197 policy_evidence_id: plan.policy_evidence_id.as_str(),
1198 policy_evidence_digest: plan.policy_evidence_digest.as_str(),
1199 environment_verification_id: plan.environment_verification_id.as_str(),
1200 environment_verification_digest: plan.environment_verification_digest.as_str(),
1201 previous_known_good_plan_id: plan.previous_known_good_plan_id.as_str(),
1202 previous_known_good_digest: plan.previous_known_good_deployment_digest.as_str(),
1203 previous_known_good_release_id: plan.previous_known_good_release_id.as_str(),
1204 previous_known_good_release_digest: plan
1205 .previous_known_good_release_digest
1206 .as_str(),
1207 previous_known_good_receipt_id: plan.previous_known_good_receipt_id.as_str(),
1208 previous_known_good_observation_id: plan
1209 .previous_known_good_observation_id
1210 .as_str(),
1211 previous_known_good_policy_evidence_id: plan
1212 .previous_known_good_policy_evidence_id
1213 .as_str(),
1214 previous_known_good_policy_evidence_digest: plan
1215 .previous_known_good_policy_evidence_digest
1216 .as_str(),
1217 previous_known_good_gateway_plan_id: plan
1218 .previous_known_good_gateway_plan_id
1219 .as_str(),
1220 previous_known_good_gateway_plan_digest: plan
1221 .previous_known_good_gateway_plan_digest
1222 .as_str(),
1223 previous_known_good_gateway_configuration_identity: plan
1224 .previous_known_good_gateway_configuration_identity
1225 .as_str(),
1226 previous_known_good_gateway_observation_id: plan
1227 .previous_known_good_gateway_observation_id
1228 .as_str(),
1229 previous_known_good_gateway_observation_revision: plan
1230 .previous_known_good_gateway_observation_revision,
1231 previous_known_good_gateway_observed_after: plan
1232 .previous_known_good_gateway_observed_after
1233 .as_str(),
1234 initial_percent: plan.initial_percent,
1235 maximum_percent: plan.maximum_percent,
1236 workload_ids: plan.workload_ids.as_slice(),
1237 },
1238 &plan.effects,
1239 )) == plan.plan_digest
1240}
1241
1242#[derive(Serialize)]
1243#[serde(rename_all = "camelCase")]
1244struct CanaryDecisionDigestInput<'a> {
1245 protocol: &'a str,
1246 plan_id: &'a str,
1247 observation_id: &'a str,
1248 decision: DeliveryDecision,
1249 outcome: CanaryOutcome,
1250 current_percent: u8,
1251 next_percent: u8,
1252 issues: &'a [DeliveryIssue],
1253 active_degraded_modes: &'a [String],
1254 evidence_references: &'a [String],
1255 effects: &'a DeliveryEffects,
1256}
1257
1258fn canary_decision_digest(decision: &CanaryDecision) -> String {
1259 digest_json(&CanaryDecisionDigestInput {
1260 protocol: decision.protocol.as_str(),
1261 plan_id: decision.plan_id.as_str(),
1262 observation_id: decision.observation_id.as_str(),
1263 decision: decision.decision,
1264 outcome: decision.outcome,
1265 current_percent: decision.current_percent,
1266 next_percent: decision.next_percent,
1267 issues: decision.issues.as_slice(),
1268 active_degraded_modes: decision.active_degraded_modes.as_slice(),
1269 evidence_references: decision.evidence_references.as_slice(),
1270 effects: &decision.effects,
1271 })
1272}
1273
1274#[must_use]
1275pub fn canary_decision_integrity_is_valid(decision: &CanaryDecision) -> bool {
1276 decision.protocol == CANARY_DECISION_PROTOCOL
1277 && decision.decision_id == format!("canary-decision:{}", canary_decision_digest(decision))
1278 && decision.effects == DeliveryEffects::default()
1279}
1280
1281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1282#[serde(rename_all = "camelCase")]
1283pub struct RollbackSafetyEvidence {
1284 pub protocol: String,
1285 pub evidence_id: String,
1286 pub canary_plan_id: String,
1287 pub failed_deployment_plan_id: String,
1288 pub previous_deployment_plan_id: String,
1289 pub expected_environment_revision: u64,
1290 pub provider_id: String,
1291 pub provider_proof: String,
1292 pub migrations_reversible: bool,
1293 pub destructive_changes_absent: bool,
1294 pub workflows_downgrade_safe: bool,
1295 pub config_revision_compatible: bool,
1296 pub secret_references_resolvable: bool,
1297 pub edge_configuration_compatible: bool,
1298 pub adapter_recovery_complete: bool,
1299 pub policy_approved: bool,
1300 #[serde(default)]
1301 pub evidence_references: Vec<String>,
1302}
1303
1304#[allow(clippy::struct_excessive_bools)]
1305#[derive(Debug, Clone, PartialEq, Eq)]
1306pub struct RollbackSafetyInput {
1307 pub migrations_reversible: bool,
1308 pub destructive_changes_absent: bool,
1309 pub workflows_downgrade_safe: bool,
1310 pub config_revision_compatible: bool,
1311 pub secret_references_resolvable: bool,
1312 pub edge_configuration_compatible: bool,
1313 pub adapter_recovery_complete: bool,
1314 pub policy_approved: bool,
1315 pub evidence_references: Vec<String>,
1316}
1317
1318pub fn seal_rollback_safety_evidence(
1319 canary: &CanaryPlan,
1320 failed: &DeploymentPlan,
1321 previous: &DeploymentPlan,
1322 expected_environment_revision: u64,
1323 provider: &dyn RollbackSafetyProvider,
1324 input: RollbackSafetyInput,
1325) -> Result<RollbackSafetyEvidence, DeliveryIssue> {
1326 let mut evidence = RollbackSafetyEvidence {
1327 protocol: ROLLBACK_SAFETY_PROTOCOL.to_owned(),
1328 evidence_id: String::new(),
1329 canary_plan_id: canary.plan_id.clone(),
1330 failed_deployment_plan_id: failed.plan_id.clone(),
1331 previous_deployment_plan_id: previous.plan_id.clone(),
1332 expected_environment_revision,
1333 provider_id: provider.provider_id().to_owned(),
1334 provider_proof: String::new(),
1335 migrations_reversible: input.migrations_reversible,
1336 destructive_changes_absent: input.destructive_changes_absent,
1337 workflows_downgrade_safe: input.workflows_downgrade_safe,
1338 config_revision_compatible: input.config_revision_compatible,
1339 secret_references_resolvable: input.secret_references_resolvable,
1340 edge_configuration_compatible: input.edge_configuration_compatible,
1341 adapter_recovery_complete: input.adapter_recovery_complete,
1342 policy_approved: input.policy_approved,
1343 evidence_references: input.evidence_references,
1344 };
1345 evidence.evidence_references.sort();
1346 evidence.evidence_references.dedup();
1347 evidence.evidence_id = format!(
1348 "rollback-safety:{}",
1349 rollback_safety_evidence_digest(&evidence)
1350 );
1351 evidence.provider_proof = provider.sign(&evidence.evidence_id).ok_or_else(|| {
1352 issue(
1353 DeliveryIssueCode::RollbackUnsafe,
1354 "The rollback safety provider refused to attest the exact recovery inputs.",
1355 "Collect complete migration, workflow, configuration, secret, edge, adapter, and policy evidence.",
1356 "Pause recovery and refresh rollback safety evidence.",
1357 )
1358 })?;
1359 Ok(evidence)
1360}
1361
1362#[must_use]
1363pub fn rollback_safety_evidence_integrity_is_valid(
1364 evidence: &RollbackSafetyEvidence,
1365 provider: &dyn RollbackSafetyProvider,
1366) -> bool {
1367 evidence.protocol == ROLLBACK_SAFETY_PROTOCOL
1368 && evidence.provider_id == provider.provider_id()
1369 && evidence.evidence_id
1370 == format!(
1371 "rollback-safety:{}",
1372 rollback_safety_evidence_digest(evidence)
1373 )
1374 && provider.verify(&evidence.evidence_id, &evidence.provider_proof)
1375}
1376
1377fn rollback_safety_evidence_digest(evidence: &RollbackSafetyEvidence) -> String {
1378 digest_json(&(
1379 evidence.protocol.as_str(),
1380 evidence.canary_plan_id.as_str(),
1381 evidence.failed_deployment_plan_id.as_str(),
1382 evidence.previous_deployment_plan_id.as_str(),
1383 evidence.expected_environment_revision,
1384 evidence.provider_id.as_str(),
1385 evidence.migrations_reversible,
1386 evidence.destructive_changes_absent,
1387 evidence.workflows_downgrade_safe,
1388 evidence.config_revision_compatible,
1389 evidence.secret_references_resolvable,
1390 evidence.edge_configuration_compatible,
1391 evidence.adapter_recovery_complete,
1392 evidence.policy_approved,
1393 evidence.evidence_references.as_slice(),
1394 ))
1395}
1396
1397#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1398#[serde(rename_all = "camelCase")]
1399pub struct RollbackPlan {
1400 pub protocol: String,
1401 pub plan_id: String,
1402 pub plan_digest: String,
1403 pub canary_plan_id: String,
1404 pub canary_plan_digest: String,
1405 pub canary_decision_id: String,
1406 pub environment: String,
1407 pub expected_environment_revision: u64,
1408 pub failed_release_id: String,
1409 pub failed_release_digest: String,
1410 pub failed_deployment_plan_id: String,
1411 pub failed_deployment_plan_digest: String,
1412 pub failed_config_revision_id: String,
1413 pub failed_workload_digests: BTreeMap<String, String>,
1414 pub failed_gateway_plan_id: String,
1415 pub failed_gateway_plan_digest: String,
1416 pub previous_release_id: String,
1417 pub previous_release_digest: String,
1418 pub previous_deployment_plan_id: String,
1419 pub previous_deployment_plan_digest: String,
1420 pub previous_config_revision_id: String,
1421 pub previous_workload_digests: BTreeMap<String, String>,
1422 pub previous_gateway_plan_id: String,
1423 pub previous_gateway_plan_digest: String,
1424 pub previous_gateway_configuration_identity: String,
1425 pub previous_gateway_revision: u64,
1426 pub release_rollback_constraints: ReleaseRollbackConstraints,
1427 pub safety: RollbackSafetyEvidence,
1428 pub automatic_allowed: bool,
1429 pub issues: Vec<DeliveryIssue>,
1430 pub prohibited_actions: Vec<String>,
1431 pub effects: DeliveryEffects,
1432}
1433
1434pub fn plan_rollback(
1435 canary: &CanaryPlan,
1436 breach: &CanaryDecision,
1437 breach_observation: &ReliabilityObservation,
1438 reliability_provider: &dyn ReliabilityObservationProvider,
1439 failed: &DeploymentPlan,
1440 failed_gateway: &GatewayConfigurationPlan,
1441 previous: &DeploymentPlan,
1442 previous_gateway: &GatewayConfigurationPlan,
1443 expected_environment_revision: u64,
1444 mut safety: RollbackSafetyEvidence,
1445 safety_provider: &dyn RollbackSafetyProvider,
1446 trust_provider: &dyn ReleaseTrustProvider,
1447) -> Result<RollbackPlan, Vec<DeliveryIssue>> {
1448 safety.evidence_references.sort();
1449 safety.evidence_references.dedup();
1450 let mut expected_canary_state = CanaryState::new(canary.plan_id.clone());
1451 expected_canary_state.current_percent = breach.current_percent;
1452 let expected_breach = evaluate_canary_internal(
1453 &mut expected_canary_state,
1454 canary,
1455 breach_observation.clone(),
1456 reliability_provider,
1457 false,
1458 );
1459 if !canary_plan_integrity_is_valid(canary)
1460 || !canary_decision_integrity_is_valid(breach)
1461 || !reliability_observation_integrity_is_valid(breach_observation, reliability_provider)
1462 || breach.observation_id != breach_observation.observation_id
1463 || breach != &expected_breach
1464 || breach.plan_id != canary.plan_id
1465 || breach.decision != DeliveryDecision::Blocked
1466 || breach.outcome != CanaryOutcome::Rollback
1467 || !deployment_plan_integrity_is_valid(failed)
1468 || !deployment_plan_integrity_is_valid(previous)
1469 || !gateway_plan_authority_is_valid(failed_gateway, trust_provider)
1470 || !gateway_plan_authority_is_valid(previous_gateway, trust_provider)
1471 || failed.plan_id != canary.production_deployment_plan_id
1472 || failed.plan_digest != canary.production_deployment_digest
1473 || previous.plan_id != canary.previous_known_good_plan_id
1474 || previous.plan_digest != canary.previous_known_good_deployment_digest
1475 || previous.release_id != canary.previous_known_good_release_id
1476 || previous.release_digest != canary.previous_known_good_release_digest
1477 || failed.plan_id == previous.plan_id
1478 || failed.release_id == previous.release_id
1479 || failed.gateway_plan_digest != failed_gateway.plan_digest
1480 || previous.gateway_plan_digest != previous_gateway.plan_digest
1481 || failed_gateway.edge_release_id != failed.release_id
1482 || failed_gateway.edge_release_digest != failed.release_digest
1483 || previous_gateway.edge_release_id != previous.release_id
1484 || previous_gateway.edge_release_digest != previous.release_digest
1485 || failed_gateway.configuration_identity == previous_gateway.configuration_identity
1486 || failed.environment != previous.environment
1487 || failed.environment != failed_gateway.environment
1488 || previous.environment != previous_gateway.environment
1489 || !rollback_safety_evidence_integrity_is_valid(&safety, safety_provider)
1490 || safety.canary_plan_id != canary.plan_id
1491 || safety.failed_deployment_plan_id != failed.plan_id
1492 || safety.previous_deployment_plan_id != previous.plan_id
1493 || safety.expected_environment_revision != expected_environment_revision
1494 {
1495 return Err(vec![issue(
1496 DeliveryIssueCode::RollbackUnsafe,
1497 "Rollback planning requires an objective canary breach and exact current and previous Deployment plans.",
1498 "Preserve the failed and previous known-good release evidence.",
1499 "Refresh canary and Deployment evidence before rollback.",
1500 )]);
1501 }
1502 let automatic_allowed = canary.release_rollback_constraints.automatic_allowed
1503 && safety.migrations_reversible
1504 && safety.destructive_changes_absent
1505 && safety.workflows_downgrade_safe
1506 && safety.config_revision_compatible
1507 && safety.secret_references_resolvable
1508 && safety.edge_configuration_compatible
1509 && safety.adapter_recovery_complete
1510 && safety.policy_approved;
1511 let issues = if automatic_allowed {
1512 Vec::new()
1513 } else {
1514 vec![issue(
1515 DeliveryIssueCode::RollbackIncomplete,
1516 "Automatic rollback is unsafe, incomplete, or lacks required recovery evidence.",
1517 "Limit exposure without deleting Service Data or reversing irreversible effects.",
1518 "Pause for an explicit intervention Approval Boundary.",
1519 )]
1520 };
1521 let prohibited_actions = vec![
1522 "delete_service_data".to_owned(),
1523 "reverse_irreversible_migration".to_owned(),
1524 "retire_contract_version".to_owned(),
1525 "change_trust_root".to_owned(),
1526 "invent_business_compensation".to_owned(),
1527 ];
1528 let failed_workload_digests = deployment_workload_digests(failed);
1529 let previous_workload_digests = deployment_workload_digests(previous);
1530 let mut plan = RollbackPlan {
1531 protocol: ROLLBACK_PLAN_PROTOCOL.to_owned(),
1532 plan_id: String::new(),
1533 plan_digest: String::new(),
1534 canary_plan_id: canary.plan_id.clone(),
1535 canary_plan_digest: canary.plan_digest.clone(),
1536 canary_decision_id: breach.decision_id.clone(),
1537 environment: failed.environment.clone(),
1538 expected_environment_revision,
1539 failed_release_id: failed.release_id.clone(),
1540 failed_release_digest: failed.release_digest.clone(),
1541 failed_deployment_plan_id: failed.plan_id.clone(),
1542 failed_deployment_plan_digest: failed.plan_digest.clone(),
1543 failed_config_revision_id: failed.config_revision_id.clone(),
1544 failed_workload_digests,
1545 failed_gateway_plan_id: failed_gateway.plan_id.clone(),
1546 failed_gateway_plan_digest: failed.gateway_plan_digest.clone(),
1547 previous_release_id: previous.release_id.clone(),
1548 previous_release_digest: previous.release_digest.clone(),
1549 previous_deployment_plan_id: previous.plan_id.clone(),
1550 previous_deployment_plan_digest: previous.plan_digest.clone(),
1551 previous_config_revision_id: previous.config_revision_id.clone(),
1552 previous_workload_digests,
1553 previous_gateway_plan_id: previous_gateway.plan_id.clone(),
1554 previous_gateway_plan_digest: previous.gateway_plan_digest.clone(),
1555 previous_gateway_configuration_identity: previous_gateway.configuration_identity.clone(),
1556 previous_gateway_revision: previous_gateway.expected_gateway_revision,
1557 release_rollback_constraints: canary.release_rollback_constraints,
1558 safety,
1559 automatic_allowed,
1560 issues,
1561 prohibited_actions,
1562 effects: DeliveryEffects::default(),
1563 };
1564 plan.plan_digest = rollback_plan_digest(&plan);
1565 plan.plan_id = format!("rollback-plan:{}", plan.plan_digest);
1566 Ok(plan)
1567}
1568
1569fn deployment_workload_digests(plan: &DeploymentPlan) -> BTreeMap<String, String> {
1570 plan.workloads
1571 .iter()
1572 .map(|workload| {
1573 (
1574 workload.workload_id.clone(),
1575 workload.artifact_digest.clone(),
1576 )
1577 })
1578 .collect()
1579}
1580
1581#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1582#[serde(rename_all = "camelCase")]
1583pub struct RollbackConvergenceEvidence {
1584 pub protocol: String,
1585 pub evidence_id: String,
1586 pub evidence_digest: String,
1587 pub rollback_plan_id: String,
1588 pub previous_deployment_plan_id: String,
1589 pub previous_deployment_plan_digest: String,
1590 pub previous_deployment_receipt_id: String,
1591 pub deployment_observation_id: String,
1592 pub previous_gateway_plan_id: String,
1593 pub previous_gateway_plan_digest: String,
1594 pub gateway_observation_id: String,
1595 pub provider_id: String,
1596 pub provider_proof: String,
1597 pub observed_release_id: String,
1598 pub observed_release_digest: String,
1599 pub observed_config_revision_id: String,
1600 pub observed_workload_digests: BTreeMap<String, String>,
1601 pub observed_gateway_configuration_identity: String,
1602 pub observed_gateway_revision: u64,
1603 pub fresh: bool,
1604 #[serde(default)]
1605 pub evidence_references: Vec<String>,
1606}
1607
1608#[must_use]
1609pub fn observe_rollback_convergence(
1610 plan: &RollbackPlan,
1611 previous_deployment: &DeploymentPlan,
1612 previous_receipt: &DeploymentReceipt,
1613 deployment: &DeploymentObservation,
1614 previous_gateway: &GatewayConfigurationPlan,
1615 gateway: &GatewayObservation,
1616 trust_provider: &dyn ReleaseTrustProvider,
1617 gateway_observation_provider: &dyn GatewayObservationProvider,
1618 convergence_provider: &dyn RollbackSafetyProvider,
1619 mut evidence_references: Vec<String>,
1620) -> Result<RollbackConvergenceEvidence, DeliveryIssue> {
1621 if !rollback_plan_integrity_is_valid(plan)
1622 || !deployment_plan_integrity_is_valid(previous_deployment)
1623 || previous_deployment.plan_id != plan.previous_deployment_plan_id
1624 || previous_deployment.plan_digest != plan.previous_deployment_plan_digest
1625 || previous_deployment.release_id != plan.previous_release_id
1626 || previous_deployment.release_digest != plan.previous_release_digest
1627 || previous_deployment.config_revision_id != plan.previous_config_revision_id
1628 || deployment_workload_digests(previous_deployment) != plan.previous_workload_digests
1629 || !deployment_receipt_integrity_is_valid(previous_receipt, previous_deployment)
1630 || !deployment_observation_integrity_is_valid(
1631 deployment,
1632 previous_deployment,
1633 previous_receipt,
1634 )
1635 || !gateway_plan_authority_is_valid(previous_gateway, trust_provider)
1636 || previous_gateway.plan_id != plan.previous_gateway_plan_id
1637 || previous_gateway.plan_digest != plan.previous_gateway_plan_digest
1638 || previous_gateway.configuration_identity != plan.previous_gateway_configuration_identity
1639 || previous_gateway.expected_gateway_revision != plan.previous_gateway_revision
1640 || !gateway_observation_integrity_is_valid(gateway, gateway_observation_provider)
1641 || gateway.configuration_identity != previous_gateway.configuration_identity
1642 || gateway.revision != previous_gateway.expected_gateway_revision
1643 || gateway.observed_after != deployment.source_observation_id
1644 || !gateway.fresh
1645 {
1646 return Err(issue(
1647 DeliveryIssueCode::RollbackIncomplete,
1648 "Rollback convergence requires the exact previous Deployment plan and receipt plus trusted Deployment and Gateway observations.",
1649 "Preserve the adapter receipts and collect observations for the exact previous known-good plans.",
1650 "Re-observe both adapters through the configured convergence provider.",
1651 ));
1652 }
1653 evidence_references.extend([
1654 previous_deployment.plan_id.clone(),
1655 previous_deployment.plan_digest.clone(),
1656 previous_receipt.receipt_id.clone(),
1657 deployment.observation_id.clone(),
1658 previous_gateway.plan_id.clone(),
1659 previous_gateway.plan_digest.clone(),
1660 gateway.observation_id.clone(),
1661 ]);
1662 evidence_references.sort();
1663 evidence_references.dedup();
1664 let mut evidence = RollbackConvergenceEvidence {
1665 protocol: ROLLBACK_CONVERGENCE_PROTOCOL.to_owned(),
1666 evidence_id: String::new(),
1667 evidence_digest: String::new(),
1668 rollback_plan_id: plan.plan_id.clone(),
1669 previous_deployment_plan_id: previous_deployment.plan_id.clone(),
1670 previous_deployment_plan_digest: previous_deployment.plan_digest.clone(),
1671 previous_deployment_receipt_id: previous_receipt.receipt_id.clone(),
1672 deployment_observation_id: deployment.observation_id.clone(),
1673 previous_gateway_plan_id: previous_gateway.plan_id.clone(),
1674 previous_gateway_plan_digest: previous_gateway.plan_digest.clone(),
1675 gateway_observation_id: gateway.observation_id.clone(),
1676 provider_id: convergence_provider.provider_id().to_owned(),
1677 provider_proof: String::new(),
1678 observed_release_id: deployment.observed_release_id.clone(),
1679 observed_release_digest: deployment.observed_release_digest.clone(),
1680 observed_config_revision_id: deployment.config_revision_id.clone(),
1681 observed_workload_digests: deployment.observed_workload_digests.clone(),
1682 observed_gateway_configuration_identity: gateway.configuration_identity.clone(),
1683 observed_gateway_revision: gateway.revision,
1684 fresh: true,
1685 evidence_references,
1686 };
1687 evidence.evidence_digest = rollback_convergence_digest(&evidence);
1688 evidence.evidence_id = format!("rollback-convergence:{}", evidence.evidence_digest);
1689 evidence.provider_proof = convergence_provider
1690 .sign(&evidence.evidence_id)
1691 .ok_or_else(|| {
1692 issue(
1693 DeliveryIssueCode::RollbackIncomplete,
1694 "The convergence provider refused to attest the exact post-rollback observations.",
1695 "Use the configured provider for the exact previous Deployment and Gateway evidence.",
1696 "Pause completion and refresh convergence evidence.",
1697 )
1698 })?;
1699 Ok(evidence)
1700}
1701
1702#[must_use]
1703pub fn rollback_convergence_integrity_is_valid(
1704 evidence: &RollbackConvergenceEvidence,
1705 plan: &RollbackPlan,
1706 provider: &dyn RollbackSafetyProvider,
1707) -> bool {
1708 evidence.protocol == ROLLBACK_CONVERGENCE_PROTOCOL
1709 && evidence.evidence_id == format!("rollback-convergence:{}", evidence.evidence_digest)
1710 && evidence.evidence_digest == rollback_convergence_digest(evidence)
1711 && evidence.rollback_plan_id == plan.plan_id
1712 && evidence.previous_deployment_plan_id == plan.previous_deployment_plan_id
1713 && evidence.previous_deployment_plan_digest == plan.previous_deployment_plan_digest
1714 && !evidence.previous_deployment_receipt_id.trim().is_empty()
1715 && evidence.previous_gateway_plan_id == plan.previous_gateway_plan_id
1716 && evidence.previous_gateway_plan_digest == plan.previous_gateway_plan_digest
1717 && evidence.provider_id == provider.provider_id()
1718 && provider.verify(&evidence.evidence_id, &evidence.provider_proof)
1719 && evidence.observed_release_id == plan.previous_release_id
1720 && evidence.observed_release_digest == plan.previous_release_digest
1721 && evidence.observed_config_revision_id == plan.previous_config_revision_id
1722 && evidence.observed_workload_digests == plan.previous_workload_digests
1723 && evidence.observed_gateway_configuration_identity
1724 == plan.previous_gateway_configuration_identity
1725 && evidence.observed_gateway_revision == plan.previous_gateway_revision
1726 && evidence.fresh
1727}
1728
1729fn rollback_convergence_digest(evidence: &RollbackConvergenceEvidence) -> String {
1730 digest_json(&RollbackConvergenceDigestInput {
1731 protocol: &evidence.protocol,
1732 rollback_plan_id: &evidence.rollback_plan_id,
1733 previous_deployment_plan_id: &evidence.previous_deployment_plan_id,
1734 previous_deployment_plan_digest: &evidence.previous_deployment_plan_digest,
1735 previous_deployment_receipt_id: &evidence.previous_deployment_receipt_id,
1736 deployment_observation_id: &evidence.deployment_observation_id,
1737 previous_gateway_plan_id: &evidence.previous_gateway_plan_id,
1738 previous_gateway_plan_digest: &evidence.previous_gateway_plan_digest,
1739 gateway_observation_id: &evidence.gateway_observation_id,
1740 provider_id: &evidence.provider_id,
1741 observed_release_id: &evidence.observed_release_id,
1742 observed_release_digest: &evidence.observed_release_digest,
1743 observed_config_revision_id: &evidence.observed_config_revision_id,
1744 observed_workload_digests: &evidence.observed_workload_digests,
1745 observed_gateway_configuration_identity: &evidence.observed_gateway_configuration_identity,
1746 observed_gateway_revision: evidence.observed_gateway_revision,
1747 fresh: evidence.fresh,
1748 evidence_references: &evidence.evidence_references,
1749 })
1750}
1751
1752#[derive(Serialize)]
1753#[serde(rename_all = "camelCase")]
1754struct RollbackConvergenceDigestInput<'a> {
1755 protocol: &'a str,
1756 rollback_plan_id: &'a str,
1757 previous_deployment_plan_id: &'a str,
1758 previous_deployment_plan_digest: &'a str,
1759 previous_deployment_receipt_id: &'a str,
1760 deployment_observation_id: &'a str,
1761 previous_gateway_plan_id: &'a str,
1762 previous_gateway_plan_digest: &'a str,
1763 gateway_observation_id: &'a str,
1764 provider_id: &'a str,
1765 observed_release_id: &'a str,
1766 observed_release_digest: &'a str,
1767 observed_config_revision_id: &'a str,
1768 observed_workload_digests: &'a BTreeMap<String, String>,
1769 observed_gateway_configuration_identity: &'a str,
1770 observed_gateway_revision: u64,
1771 fresh: bool,
1772 evidence_references: &'a [String],
1773}
1774
1775#[derive(
1776 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
1777)]
1778#[serde(rename_all = "snake_case")]
1779pub enum RollbackOutcome {
1780 RolledBack,
1781 InterventionRequired,
1782}
1783
1784#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1785#[serde(rename_all = "camelCase")]
1786pub struct RollbackReceipt {
1787 pub protocol: String,
1788 pub receipt_id: String,
1789 pub plan_id: String,
1790 pub actor: String,
1791 pub outcome: RollbackOutcome,
1792 pub restored_release_id: String,
1793 pub restored_config_revision_id: String,
1794 pub environment_revision_before: u64,
1795 pub environment_revision_after: u64,
1796 pub exposure_percent: u8,
1797 pub remaining_risks: Vec<DeliveryIssue>,
1798 pub approval_boundary_required: bool,
1799 pub evidence_references: Vec<String>,
1800 pub effects: DeliveryEffects,
1801}
1802
1803#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1804#[serde(rename_all = "camelCase")]
1805pub struct RollbackState {
1806 pub environment: String,
1807 pub active_release_id: String,
1808 pub active_config_revision_id: String,
1809 pub environment_revision: u64,
1810 pub exposure_percent: u8,
1811 #[serde(default)]
1812 pub history: Vec<RollbackReceipt>,
1813}
1814
1815impl RollbackState {
1816 #[must_use]
1817 pub fn new(
1818 environment: impl Into<String>,
1819 active_release_id: impl Into<String>,
1820 active_config_revision_id: impl Into<String>,
1821 environment_revision: u64,
1822 exposure_percent: u8,
1823 ) -> Self {
1824 Self {
1825 environment: environment.into(),
1826 active_release_id: active_release_id.into(),
1827 active_config_revision_id: active_config_revision_id.into(),
1828 environment_revision,
1829 exposure_percent,
1830 history: Vec::new(),
1831 }
1832 }
1833}
1834
1835pub fn apply_rollback(
1836 state: &mut RollbackState,
1837 plan: &RollbackPlan,
1838 convergence: Option<&RollbackConvergenceEvidence>,
1839 convergence_provider: &dyn RollbackSafetyProvider,
1840 actor: &str,
1841) -> Result<RollbackReceipt, Vec<DeliveryIssue>> {
1842 if !rollback_plan_integrity_is_valid(plan) || actor.trim().is_empty() {
1843 return Err(vec![issue(
1844 DeliveryIssueCode::StaleInput,
1845 "Rollback plan integrity or actor identity no longer matches the protected operation.",
1846 "Use the exact content-addressed plan and authenticated recovery actor.",
1847 "Refresh rollback evidence before retrying.",
1848 )]);
1849 }
1850 if plan.automatic_allowed
1851 && !convergence.is_some_and(|evidence| {
1852 rollback_convergence_integrity_is_valid(evidence, plan, convergence_provider)
1853 })
1854 {
1855 return Err(vec![issue(
1856 DeliveryIssueCode::RollbackIncomplete,
1857 "Rollback cannot be declared complete before the previous Deployment and Gateway converge.",
1858 "Collect fresh content-addressed post-rollback observations from both adapters.",
1859 "Re-observe the previous release and apply the rollback receipt again.",
1860 )]);
1861 }
1862 if let Some(receipt) = state
1863 .history
1864 .iter()
1865 .find(|receipt| receipt.plan_id == plan.plan_id)
1866 {
1867 return rollback_receipt_integrity_is_valid(
1868 receipt,
1869 plan,
1870 convergence,
1871 convergence_provider,
1872 actor,
1873 )
1874 .then(|| receipt.clone())
1875 .ok_or_else(|| {
1876 vec![issue(
1877 DeliveryIssueCode::StaleInput,
1878 "The completed rollback receipt no longer matches the exact plan, actor, and convergence evidence.",
1879 "Preserve the protected rollback inputs and append-only receipt together.",
1880 "Restore the original evidence or create a new rollback plan.",
1881 )]
1882 });
1883 }
1884 if state.environment != plan.environment
1885 || state.environment_revision != plan.expected_environment_revision
1886 || state.active_release_id != plan.failed_release_id
1887 || state.active_config_revision_id != plan.failed_config_revision_id
1888 {
1889 return Err(vec![issue(
1890 DeliveryIssueCode::StaleInput,
1891 "Rollback state, environment, failed release, or actor no longer matches the plan.",
1892 "Refresh the durable rollout state without overwriting concurrent recovery evidence.",
1893 "Re-plan rollback from the current environment revision.",
1894 )]);
1895 }
1896 let revision_before = state.environment_revision;
1897 let (outcome, restored_release_id, restored_config_revision_id, effects) =
1898 if plan.automatic_allowed {
1899 state.active_release_id = plan.previous_release_id.clone();
1900 state.active_config_revision_id = plan.previous_config_revision_id.clone();
1901 state.environment_revision += 1;
1902 state.exposure_percent = 0;
1903 (
1904 RollbackOutcome::RolledBack,
1905 plan.previous_release_id.clone(),
1906 plan.previous_config_revision_id.clone(),
1907 DeliveryEffects {
1908 mutates_environment: true,
1909 mutates_configuration: true,
1910 mutates_gateway: true,
1911 mutates_deployment: true,
1912 appends_ledger: true,
1913 },
1914 )
1915 } else {
1916 (
1917 RollbackOutcome::InterventionRequired,
1918 state.active_release_id.clone(),
1919 state.active_config_revision_id.clone(),
1920 DeliveryEffects {
1921 appends_ledger: true,
1922 ..DeliveryEffects::default()
1923 },
1924 )
1925 };
1926 let mut evidence_references = plan.safety.evidence_references.clone();
1927 if let Some(convergence) = convergence {
1928 evidence_references.push(convergence.evidence_id.clone());
1929 evidence_references.extend(convergence.evidence_references.clone());
1930 }
1931 evidence_references.sort();
1932 evidence_references.dedup();
1933 let receipt_id = format!(
1934 "rollback-receipt:{}",
1935 digest_json(&(
1936 ROLLBACK_RECEIPT_PROTOCOL,
1937 plan.plan_id.as_str(),
1938 actor,
1939 outcome,
1940 restored_release_id.as_str(),
1941 restored_config_revision_id.as_str(),
1942 revision_before,
1943 state.environment_revision,
1944 state.exposure_percent,
1945 evidence_references.as_slice(),
1946 &effects,
1947 ))
1948 );
1949 let receipt = RollbackReceipt {
1950 protocol: ROLLBACK_RECEIPT_PROTOCOL.to_owned(),
1951 receipt_id,
1952 plan_id: plan.plan_id.clone(),
1953 actor: actor.to_owned(),
1954 outcome,
1955 restored_release_id,
1956 restored_config_revision_id,
1957 environment_revision_before: revision_before,
1958 environment_revision_after: state.environment_revision,
1959 exposure_percent: state.exposure_percent,
1960 remaining_risks: plan.issues.clone(),
1961 approval_boundary_required: !plan.automatic_allowed,
1962 evidence_references,
1963 effects,
1964 };
1965 state.history.push(receipt.clone());
1966 Ok(receipt)
1967}
1968
1969#[must_use]
1970pub fn rollback_receipt_integrity_is_valid(
1971 receipt: &RollbackReceipt,
1972 plan: &RollbackPlan,
1973 convergence: Option<&RollbackConvergenceEvidence>,
1974 convergence_provider: &dyn RollbackSafetyProvider,
1975 actor: &str,
1976) -> bool {
1977 let mut evidence_references = plan.safety.evidence_references.clone();
1978 if let Some(convergence) = convergence {
1979 evidence_references.push(convergence.evidence_id.clone());
1980 evidence_references.extend(convergence.evidence_references.clone());
1981 }
1982 evidence_references.sort();
1983 evidence_references.dedup();
1984 let (outcome, restored_release_id, restored_config_revision_id, expected_after, effects) =
1985 if plan.automatic_allowed {
1986 (
1987 RollbackOutcome::RolledBack,
1988 plan.previous_release_id.as_str(),
1989 plan.previous_config_revision_id.as_str(),
1990 receipt.environment_revision_before + 1,
1991 DeliveryEffects {
1992 mutates_environment: true,
1993 mutates_configuration: true,
1994 mutates_gateway: true,
1995 mutates_deployment: true,
1996 appends_ledger: true,
1997 },
1998 )
1999 } else {
2000 (
2001 RollbackOutcome::InterventionRequired,
2002 plan.failed_release_id.as_str(),
2003 plan.failed_config_revision_id.as_str(),
2004 receipt.environment_revision_before,
2005 DeliveryEffects {
2006 appends_ledger: true,
2007 ..DeliveryEffects::default()
2008 },
2009 )
2010 };
2011 receipt.protocol == ROLLBACK_RECEIPT_PROTOCOL
2012 && rollback_plan_integrity_is_valid(plan)
2013 && (!plan.automatic_allowed
2014 || convergence.is_some_and(|evidence| {
2015 rollback_convergence_integrity_is_valid(evidence, plan, convergence_provider)
2016 }))
2017 && receipt.plan_id == plan.plan_id
2018 && receipt.actor == actor
2019 && receipt.outcome == outcome
2020 && receipt.restored_release_id == restored_release_id
2021 && receipt.restored_config_revision_id == restored_config_revision_id
2022 && receipt.environment_revision_before == plan.expected_environment_revision
2023 && receipt.environment_revision_after == expected_after
2024 && (!plan.automatic_allowed || receipt.exposure_percent == 0)
2025 && receipt.remaining_risks == plan.issues
2026 && receipt.approval_boundary_required == !plan.automatic_allowed
2027 && receipt.evidence_references == evidence_references
2028 && receipt.effects == effects
2029 && receipt.receipt_id
2030 == format!(
2031 "rollback-receipt:{}",
2032 digest_json(&(
2033 ROLLBACK_RECEIPT_PROTOCOL,
2034 plan.plan_id.as_str(),
2035 actor,
2036 outcome,
2037 restored_release_id,
2038 restored_config_revision_id,
2039 receipt.environment_revision_before,
2040 receipt.environment_revision_after,
2041 receipt.exposure_percent,
2042 evidence_references.as_slice(),
2043 &effects,
2044 ))
2045 )
2046}
2047
2048#[must_use]
2049pub fn rollback_plan_integrity_is_valid(plan: &RollbackPlan) -> bool {
2050 plan.protocol == ROLLBACK_PLAN_PROTOCOL
2051 && plan.plan_id == format!("rollback-plan:{}", plan.plan_digest)
2052 && rollback_plan_digest(plan) == plan.plan_digest
2053 && plan.failed_deployment_plan_id != plan.previous_deployment_plan_id
2054 && plan.failed_release_id != plan.previous_release_id
2055 && (!plan.automatic_allowed || plan.release_rollback_constraints.automatic_allowed)
2056 && plan.effects == DeliveryEffects::default()
2057}
2058
2059fn rollback_plan_digest(plan: &RollbackPlan) -> String {
2060 #[derive(Serialize)]
2061 #[serde(rename_all = "camelCase")]
2062 struct DigestInput<'a> {
2063 protocol: &'a str,
2064 canary_plan_id: &'a str,
2065 canary_plan_digest: &'a str,
2066 canary_decision_id: &'a str,
2067 environment: &'a str,
2068 expected_environment_revision: u64,
2069 failed_release_id: &'a str,
2070 failed_release_digest: &'a str,
2071 failed_deployment_plan_id: &'a str,
2072 failed_deployment_plan_digest: &'a str,
2073 failed_config_revision_id: &'a str,
2074 failed_workload_digests: &'a BTreeMap<String, String>,
2075 failed_gateway_plan_id: &'a str,
2076 failed_gateway_plan_digest: &'a str,
2077 previous_release_id: &'a str,
2078 previous_release_digest: &'a str,
2079 previous_deployment_plan_id: &'a str,
2080 previous_deployment_plan_digest: &'a str,
2081 previous_config_revision_id: &'a str,
2082 previous_workload_digests: &'a BTreeMap<String, String>,
2083 previous_gateway_plan_id: &'a str,
2084 previous_gateway_plan_digest: &'a str,
2085 previous_gateway_configuration_identity: &'a str,
2086 previous_gateway_revision: u64,
2087 release_rollback_constraints: ReleaseRollbackConstraints,
2088 safety: &'a RollbackSafetyEvidence,
2089 automatic_allowed: bool,
2090 issues: &'a [DeliveryIssue],
2091 prohibited_actions: &'a [String],
2092 }
2093 digest_json(&DigestInput {
2094 protocol: plan.protocol.as_str(),
2095 canary_plan_id: plan.canary_plan_id.as_str(),
2096 canary_plan_digest: plan.canary_plan_digest.as_str(),
2097 canary_decision_id: plan.canary_decision_id.as_str(),
2098 environment: plan.environment.as_str(),
2099 expected_environment_revision: plan.expected_environment_revision,
2100 failed_release_id: plan.failed_release_id.as_str(),
2101 failed_release_digest: plan.failed_release_digest.as_str(),
2102 failed_deployment_plan_id: plan.failed_deployment_plan_id.as_str(),
2103 failed_deployment_plan_digest: plan.failed_deployment_plan_digest.as_str(),
2104 failed_config_revision_id: plan.failed_config_revision_id.as_str(),
2105 failed_workload_digests: &plan.failed_workload_digests,
2106 failed_gateway_plan_id: plan.failed_gateway_plan_id.as_str(),
2107 failed_gateway_plan_digest: plan.failed_gateway_plan_digest.as_str(),
2108 previous_release_id: plan.previous_release_id.as_str(),
2109 previous_release_digest: plan.previous_release_digest.as_str(),
2110 previous_deployment_plan_id: plan.previous_deployment_plan_id.as_str(),
2111 previous_deployment_plan_digest: plan.previous_deployment_plan_digest.as_str(),
2112 previous_config_revision_id: plan.previous_config_revision_id.as_str(),
2113 previous_workload_digests: &plan.previous_workload_digests,
2114 previous_gateway_plan_id: plan.previous_gateway_plan_id.as_str(),
2115 previous_gateway_plan_digest: plan.previous_gateway_plan_digest.as_str(),
2116 previous_gateway_configuration_identity: plan
2117 .previous_gateway_configuration_identity
2118 .as_str(),
2119 previous_gateway_revision: plan.previous_gateway_revision,
2120 release_rollback_constraints: plan.release_rollback_constraints,
2121 safety: &plan.safety,
2122 automatic_allowed: plan.automatic_allowed,
2123 issues: plan.issues.as_slice(),
2124 prohibited_actions: plan.prohibited_actions.as_slice(),
2125 })
2126}
2127
2128fn digest_json(value: &impl Serialize) -> String {
2129 extraction_input_digest(serde_json::to_vec(value).expect("rollout values must serialize"))
2130}