Skip to main content

lenso_service/production_delivery/
rollout.rs

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/// Returns the canonical digest that a signed Service Release must bind for this
156/// exact Reliability Contract.
157#[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}