Skip to main content

lenso_service/production_delivery/
promotion.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
4use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use utoipa::ToSchema;
8
9use crate::extraction_input_digest;
10
11use super::{
12    ConfigRevision, DeliveryDecision, DeliveryEffects, DeliveryIssue, DeliveryIssueCode,
13    DeliveryPolicyInputs, DeploymentApplyRejection, DeploymentObservation, DeploymentPlan,
14    DeploymentReceipt, DeploymentState, GatewayConfigurationPlan, GatewayObservation,
15    GatewayObservationProvider, PolicyEvidence, ReleaseTrustEvidence, ReleaseTrustProvider,
16    SecretProvider, ServiceRelease, apply_deployment, config_revision_integrity_is_valid,
17    deployment_observation_integrity_is_valid, deployment_plan_integrity_is_valid,
18    deployment_receipt_integrity_is_valid, gateway_observation_integrity_is_valid,
19    gateway_plan_authority_is_valid, gateway_plan_integrity_is_valid, issue,
20    production_policy_evidence_is_valid, release_trust_evidence_integrity_is_valid,
21    service_release_integrity_is_valid,
22};
23
24pub const ENVIRONMENT_VERIFICATION_PROTOCOL: &str = "lenso.environment-verification.v1";
25pub const PROMOTION_PLAN_PROTOCOL: &str = "lenso.promotion-plan.v1";
26pub const PROMOTION_APPROVAL_PROTOCOL: &str = "lenso.promotion-approval.v1";
27pub const PROMOTION_RECEIPT_PROTOCOL: &str = "lenso.promotion-receipt.v1";
28pub const OPERATOR_OBSERVATION_CLAIMS_PROTOCOL: &str = "lenso.operator-observation-claims.v1";
29
30pub trait OperatorObservationAuthorityProvider: std::fmt::Debug + Send + Sync {
31    fn sign(&self, authority_id: &str, observation_digest: &str) -> Option<String>;
32
33    fn verify(&self, authority_id: &str, observation_digest: &str, proof: &str) -> bool;
34}
35
36#[derive(Debug, Clone, Default)]
37pub struct DeterministicOperatorObservationAuthorityProvider {
38    authority_keys: BTreeMap<String, String>,
39}
40
41impl DeterministicOperatorObservationAuthorityProvider {
42    #[must_use]
43    pub fn new<I, K, V>(authority_keys: I) -> Self
44    where
45        I: IntoIterator<Item = (K, V)>,
46        K: Into<String>,
47        V: Into<String>,
48    {
49        Self {
50            authority_keys: authority_keys
51                .into_iter()
52                .map(|(authority, key)| (authority.into(), key.into()))
53                .collect(),
54        }
55    }
56
57    fn expected_proof(&self, authority_id: &str, observation_digest: &str) -> Option<String> {
58        let key = self.authority_keys.get(authority_id)?;
59        Some(digest_json(&(
60            "lenso.operator-observation-authority-proof.v1",
61            authority_id,
62            observation_digest,
63            key.as_str(),
64        )))
65    }
66}
67
68impl OperatorObservationAuthorityProvider for DeterministicOperatorObservationAuthorityProvider {
69    fn sign(&self, authority_id: &str, observation_digest: &str) -> Option<String> {
70        self.expected_proof(authority_id, observation_digest)
71    }
72
73    fn verify(&self, authority_id: &str, observation_digest: &str, proof: &str) -> bool {
74        self.expected_proof(authority_id, observation_digest)
75            .as_deref()
76            == Some(proof)
77    }
78}
79
80/// Verify-only Operator observation authority backed by Ed25519 public keys.
81/// The protected-operation caller never receives signing material.
82#[derive(Debug, Clone, Default)]
83pub struct Ed25519OperatorObservationAuthorityProvider {
84    authority_keys: BTreeMap<String, VerifyingKey>,
85    signing_keys: BTreeMap<String, SigningKey>,
86}
87
88impl Ed25519OperatorObservationAuthorityProvider {
89    pub fn from_base64_public_keys<I, K, V>(authority_keys: I) -> Result<Self, String>
90    where
91        I: IntoIterator<Item = (K, V)>,
92        K: Into<String>,
93        V: AsRef<str>,
94    {
95        let mut parsed = BTreeMap::new();
96        for (authority_id, encoded) in authority_keys {
97            let bytes = BASE64
98                .decode(encoded.as_ref())
99                .map_err(|error| format!("invalid Ed25519 public key encoding: {error}"))?;
100            let bytes: [u8; 32] = bytes
101                .try_into()
102                .map_err(|_| "Ed25519 public keys must contain exactly 32 bytes".to_owned())?;
103            let key = VerifyingKey::from_bytes(&bytes)
104                .map_err(|error| format!("invalid Ed25519 public key: {error}"))?;
105            parsed.insert(authority_id.into(), key);
106        }
107        Ok(Self {
108            authority_keys: parsed,
109            signing_keys: BTreeMap::new(),
110        })
111    }
112
113    pub fn from_base64_private_keys<I, K, V>(authority_keys: I) -> Result<Self, String>
114    where
115        I: IntoIterator<Item = (K, V)>,
116        K: Into<String>,
117        V: AsRef<str>,
118    {
119        let mut signing_keys = BTreeMap::new();
120        let mut verifying_keys = BTreeMap::new();
121        for (authority_id, encoded) in authority_keys {
122            let authority_id = authority_id.into();
123            let bytes = BASE64
124                .decode(encoded.as_ref())
125                .map_err(|error| format!("invalid Ed25519 private key encoding: {error}"))?;
126            let bytes: [u8; 32] = bytes
127                .try_into()
128                .map_err(|_| "Ed25519 private keys must contain exactly 32 bytes".to_owned())?;
129            let signing_key = SigningKey::from_bytes(&bytes);
130            verifying_keys.insert(authority_id.clone(), signing_key.verifying_key());
131            signing_keys.insert(authority_id, signing_key);
132        }
133        Ok(Self {
134            authority_keys: verifying_keys,
135            signing_keys,
136        })
137    }
138}
139
140impl OperatorObservationAuthorityProvider for Ed25519OperatorObservationAuthorityProvider {
141    fn sign(&self, authority_id: &str, observation_digest: &str) -> Option<String> {
142        self.signing_keys
143            .get(authority_id)
144            .map(|key| BASE64.encode(key.sign(observation_digest.as_bytes()).to_bytes()))
145    }
146
147    fn verify(&self, authority_id: &str, observation_digest: &str, proof: &str) -> bool {
148        let Some(key) = self.authority_keys.get(authority_id) else {
149            return false;
150        };
151        let Ok(bytes) = BASE64.decode(proof) else {
152            return false;
153        };
154        let Ok(signature) = Signature::from_slice(&bytes) else {
155            return false;
156        };
157        key.verify(observation_digest.as_bytes(), &signature)
158            .is_ok()
159    }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
163#[serde(rename_all = "camelCase")]
164pub struct OperatorObservationClaims {
165    pub protocol: String,
166    pub service_id: String,
167    pub environment: String,
168    pub deployment_plan_id: String,
169    pub deployment_plan_digest: String,
170    pub expected_environment_revision: u64,
171    pub environment_revision: u64,
172    pub authority_context: String,
173    pub resource_uid: String,
174    pub resource_version: String,
175    pub desired_release_id: String,
176    pub desired_release_digest: String,
177    pub observed_release_id: String,
178    pub observed_release_digest: String,
179    pub desired_workload_digests: BTreeMap<String, String>,
180    pub observed_workload_digests: BTreeMap<String, String>,
181    pub workload_health: BTreeMap<String, bool>,
182    pub config_revision_id: String,
183    pub state: String,
184    pub rollout_phase: String,
185    pub rollback_state: String,
186    pub drifted: bool,
187    pub fresh: bool,
188    pub decision: DeliveryDecision,
189}
190
191#[must_use]
192pub fn operator_observation_claims_digest(claims: &OperatorObservationClaims) -> String {
193    digest_json(claims)
194}
195
196#[must_use]
197pub fn operator_observation_claims_from_deployment(
198    plan: &DeploymentPlan,
199    observation: &DeploymentObservation,
200    workload_health: BTreeMap<String, bool>,
201) -> OperatorObservationClaims {
202    let passed = observation.fresh
203        && !observation.drifted
204        && workload_health.values().all(|healthy| *healthy);
205    OperatorObservationClaims {
206        protocol: OPERATOR_OBSERVATION_CLAIMS_PROTOCOL.to_owned(),
207        service_id: plan.service_id.clone(),
208        environment: observation.environment.clone(),
209        deployment_plan_id: plan.plan_id.clone(),
210        deployment_plan_digest: plan.plan_digest.clone(),
211        expected_environment_revision: plan.expected_environment_revision,
212        environment_revision: plan.expected_environment_revision.saturating_add(1),
213        authority_context: plan.plan_id.clone(),
214        resource_uid: format!("synthetic:{}", plan.plan_id),
215        resource_version: "1".to_owned(),
216        desired_release_id: observation.desired_release_id.clone(),
217        desired_release_digest: plan.release_digest.clone(),
218        observed_release_id: observation.observed_release_id.clone(),
219        observed_release_digest: observation.observed_release_digest.clone(),
220        desired_workload_digests: observation.desired_workload_digests.clone(),
221        observed_workload_digests: observation.observed_workload_digests.clone(),
222        workload_health,
223        config_revision_id: observation.config_revision_id.clone(),
224        state: if passed { "ready" } else { "progressing" }.to_owned(),
225        rollout_phase: if passed { "ready" } else { "observing" }.to_owned(),
226        rollback_state: "available".to_owned(),
227        drifted: observation.drifted,
228        fresh: observation.fresh,
229        decision: if passed {
230            DeliveryDecision::Passed
231        } else {
232            DeliveryDecision::Blocked
233        },
234    }
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
238#[serde(rename_all = "camelCase")]
239pub struct OperatorObservationAttestation {
240    pub observation_id: String,
241    pub observation_digest: String,
242    pub authority_id: String,
243    pub authority_proof: String,
244    pub claims: OperatorObservationClaims,
245}
246
247pub fn attest_operator_observation(
248    claims: OperatorObservationClaims,
249    authority_id: impl Into<String>,
250    provider: &dyn OperatorObservationAuthorityProvider,
251) -> Result<OperatorObservationAttestation, DeliveryIssue> {
252    let observation_digest = operator_observation_claims_digest(&claims);
253    let authority_id = authority_id.into();
254    let authority_proof = provider
255        .sign(&authority_id, &observation_digest)
256        .ok_or_else(|| {
257            issue(
258                DeliveryIssueCode::ObservationStale,
259                "The Operator adapter authority refused to attest the observation.",
260                "Use the configured Operator observation authority at the adapter read boundary.",
261                "Collect a new Operator observation before verifying the environment.",
262            )
263        })?;
264    Ok(OperatorObservationAttestation {
265        observation_id: format!("operator-observation:{observation_digest}"),
266        observation_digest,
267        authority_id,
268        authority_proof,
269        claims,
270    })
271}
272
273#[must_use]
274pub fn operator_observation_attestation_is_valid(
275    attestation: &OperatorObservationAttestation,
276    provider: &dyn OperatorObservationAuthorityProvider,
277) -> bool {
278    attestation.claims.protocol == OPERATOR_OBSERVATION_CLAIMS_PROTOCOL
279        && attestation.observation_digest == operator_observation_claims_digest(&attestation.claims)
280        && attestation.observation_id
281            == format!("operator-observation:{}", attestation.observation_digest)
282        && provider.verify(
283            &attestation.authority_id,
284            &attestation.observation_digest,
285            &attestation.authority_proof,
286        )
287}
288
289#[must_use]
290pub fn operator_observation_matches_deployment(
291    attestation: &OperatorObservationAttestation,
292    plan: &DeploymentPlan,
293    receipt: &DeploymentReceipt,
294    observation: &DeploymentObservation,
295    workload_health: &BTreeMap<String, bool>,
296) -> bool {
297    let claims = &attestation.claims;
298    claims.protocol == OPERATOR_OBSERVATION_CLAIMS_PROTOCOL
299        && observation.source_observation_id == attestation.observation_id
300        && claims.service_id == plan.service_id
301        && claims.environment == plan.environment
302        && claims.deployment_plan_id == plan.plan_id
303        && claims.deployment_plan_digest == plan.plan_digest
304        && claims.expected_environment_revision == plan.expected_environment_revision
305        && claims.environment_revision == receipt.environment_revision_after
306        && claims.authority_context == plan.plan_id
307        && claims.desired_release_id == observation.desired_release_id
308        && claims.desired_release_digest == plan.release_digest
309        && claims.observed_release_id == observation.observed_release_id
310        && claims.observed_release_digest == observation.observed_release_digest
311        && claims.desired_workload_digests == observation.desired_workload_digests
312        && claims.observed_workload_digests == observation.observed_workload_digests
313        && claims.workload_health == *workload_health
314        && claims.config_revision_id == observation.config_revision_id
315        && claims.drifted == observation.drifted
316        && claims.fresh == observation.fresh
317        && claims.state == "ready"
318        && claims.rollout_phase == "ready"
319        && claims.decision == DeliveryDecision::Passed
320        && claims.fresh
321        && !claims.drifted
322        && claims.workload_health.values().all(|healthy| *healthy)
323}
324
325#[derive(Debug, Clone)]
326pub struct EnvironmentVerificationInput {
327    pub release: ServiceRelease,
328    pub trust: ReleaseTrustEvidence,
329    pub policy: PolicyEvidence,
330    pub policy_inputs: DeliveryPolicyInputs,
331    pub config: ConfigRevision,
332    pub deployment_plan: DeploymentPlan,
333    pub deployment: DeploymentReceipt,
334    pub deployment_observation: DeploymentObservation,
335    pub operator_observation: OperatorObservationAttestation,
336    pub gateway_plan: GatewayConfigurationPlan,
337    pub gateway_observation: GatewayObservation,
338    pub topology_digest: String,
339    pub workload_health: BTreeMap<String, bool>,
340    pub evidence_references: Vec<String>,
341    pub freshness_horizon_revision: u64,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
345#[serde(rename_all = "camelCase")]
346pub struct EnvironmentVerification {
347    pub protocol: String,
348    pub verification_id: String,
349    pub verification_digest: String,
350    pub environment: String,
351    pub environment_revision: u64,
352    pub release_id: String,
353    pub release_digest: String,
354    pub workload_digests: BTreeMap<String, String>,
355    pub workload_health: BTreeMap<String, bool>,
356    pub config_revision_id: String,
357    pub trust_evidence_digest: String,
358    pub policy_evidence_id: String,
359    pub policy_evidence_digest: String,
360    pub deployment_plan_id: String,
361    pub deployment_plan_digest: String,
362    pub deployment_receipt_id: String,
363    pub deployment_observation_id: String,
364    pub operator_observation_id: String,
365    pub operator_observation_digest: String,
366    pub operator_observation_authority_id: String,
367    pub operator_observation_authority_proof: String,
368    pub operator_observation_claims: OperatorObservationClaims,
369    pub gateway_plan_id: String,
370    pub gateway_plan_digest: String,
371    pub gateway_observation_id: String,
372    pub gateway_resource_uid: String,
373    pub gateway_resource_version: String,
374    pub gateway_authority_context: String,
375    pub gateway_configuration_identity: String,
376    pub gateway_observation_revision: u64,
377    pub gateway_observation_observed_after: String,
378    pub gateway_observation_fresh: bool,
379    pub gateway_observation_provider_id: String,
380    pub gateway_observation_provider_proof: String,
381    pub topology_digest: String,
382    pub evidence_references: Vec<String>,
383    pub freshness_horizon_revision: u64,
384    pub decision: DeliveryDecision,
385    pub issues: Vec<DeliveryIssue>,
386    pub effects: DeliveryEffects,
387}
388
389#[derive(Serialize)]
390#[serde(rename_all = "camelCase")]
391struct EnvironmentVerificationDigestInput<'a> {
392    protocol: &'a str,
393    environment: &'a str,
394    environment_revision: u64,
395    release_id: &'a str,
396    release_digest: &'a str,
397    workload_digests: &'a BTreeMap<String, String>,
398    workload_health: &'a BTreeMap<String, bool>,
399    config_revision_id: &'a str,
400    trust_evidence_digest: &'a str,
401    policy_evidence_id: &'a str,
402    policy_evidence_digest: &'a str,
403    deployment_plan_id: &'a str,
404    deployment_plan_digest: &'a str,
405    deployment_receipt_id: &'a str,
406    deployment_observation_id: &'a str,
407    operator_observation_id: &'a str,
408    operator_observation_digest: &'a str,
409    operator_observation_authority_id: &'a str,
410    operator_observation_authority_proof: &'a str,
411    operator_observation_claims: &'a OperatorObservationClaims,
412    gateway_plan_id: &'a str,
413    gateway_plan_digest: &'a str,
414    gateway_observation_id: &'a str,
415    gateway_resource_uid: &'a str,
416    gateway_resource_version: &'a str,
417    gateway_authority_context: &'a str,
418    gateway_configuration_identity: &'a str,
419    gateway_observation_revision: u64,
420    gateway_observation_observed_after: &'a str,
421    gateway_observation_fresh: bool,
422    gateway_observation_provider_id: &'a str,
423    gateway_observation_provider_proof: &'a str,
424    topology_digest: &'a str,
425    evidence_references: &'a [String],
426    freshness_horizon_revision: u64,
427    decision: DeliveryDecision,
428    issues: &'a [DeliveryIssue],
429}
430
431#[must_use]
432pub fn verify_staging_environment(
433    mut input: EnvironmentVerificationInput,
434    trust_provider: &dyn ReleaseTrustProvider,
435    secret_provider: &dyn SecretProvider,
436    operator_observation_provider: &dyn OperatorObservationAuthorityProvider,
437    gateway_observation_provider: &dyn GatewayObservationProvider,
438) -> EnvironmentVerification {
439    input.evidence_references.sort();
440    let mut issues = Vec::new();
441    if !service_release_integrity_is_valid(&input.release)
442        || !release_trust_evidence_integrity_is_valid(&input.trust, &input.release, trust_provider)
443        || input.trust.decision != DeliveryDecision::Passed
444    {
445        issues.push(issue(
446            DeliveryIssueCode::PolicyEvidenceMissing,
447            "Staging verification requires an integrity-valid and trusted Service Release.",
448            "Verify the exact signed release before deployment.",
449            "Refresh release trust evidence and verify staging again.",
450        ));
451    }
452    if input.policy_inputs.release != input.release
453        || input.policy_inputs.trust != input.trust
454        || input.policy_inputs.config != input.config
455        || !production_policy_evidence_is_valid(
456            &input.policy,
457            &input.policy_inputs,
458            trust_provider,
459            secret_provider,
460        )
461        || input.policy.decision != DeliveryDecision::Passed
462        || input.policy.evaluated_subject != input.release.release_id
463        || !deployment_binds_policy_evidence(&input.deployment_plan, &input.policy)
464    {
465        issues.push(issue(
466            DeliveryIssueCode::PolicyRuleBlocked,
467            "Staging verification requires passing Policy Evidence for the exact release.",
468            "Evaluate the selected Policy Pack over current canonical inputs.",
469            "Refresh Policy Evidence and verify staging again.",
470        ));
471    }
472    if !config_revision_integrity_is_valid(&input.config)
473        || input.config.contract_digest != input.release.config_contract.digest
474        || input.config.service_id != input.release.service_id
475        || input.deployment.config_revision_id != input.config.revision_id
476    {
477        issues.push(issue(
478            DeliveryIssueCode::ConfigContractMismatch,
479            "The observed Deployment does not use the validated Config Revision.",
480            "Deploy the exact validated Config Revision and opaque Secret References.",
481            "Correct configuration drift and verify staging again.",
482        ));
483    }
484    if !deployment_plan_integrity_is_valid(&input.deployment_plan)
485        || !deployment_receipt_integrity_is_valid(&input.deployment, &input.deployment_plan)
486        || !deployment_observation_integrity_is_valid(
487            &input.deployment_observation,
488            &input.deployment_plan,
489            &input.deployment,
490        )
491        || input.deployment_plan.release_id != input.release.release_id
492        || input.deployment_plan.release_digest != input.release.release_digest
493        || input.deployment_plan.config_revision_id != input.config.revision_id
494        || input.deployment_plan.gateway_plan_digest != input.gateway_plan.plan_digest
495        || input.deployment_observation.observed_release_digest != input.release.release_digest
496        || input.deployment_observation.drifted
497        || !input.deployment_observation.fresh
498        || !operator_observation_attestation_is_valid(
499            &input.operator_observation,
500            operator_observation_provider,
501        )
502        || !operator_observation_matches_deployment(
503            &input.operator_observation,
504            &input.deployment_plan,
505            &input.deployment,
506            &input.deployment_observation,
507            &input.workload_health,
508        )
509        || !operator_observation_evidence_is_bound(
510            &input.operator_observation,
511            &input.evidence_references,
512        )
513    {
514        issues.push(issue(
515            DeliveryIssueCode::ObservationStale,
516            "Staging Deployment observations are stale, drifted, or identify another release.",
517            "Refresh adapter observations after the exact release converges.",
518            "Reconcile staging and collect fresh Deployment evidence.",
519        ));
520    }
521    if !gateway_plan_authority_is_valid(&input.gateway_plan, trust_provider)
522        || input.gateway_plan.edge_release_id != input.release.release_id
523        || input.gateway_plan.edge_release_digest != input.release.release_digest
524        || !gateway_observation_integrity_is_valid(
525            &input.gateway_observation,
526            gateway_observation_provider,
527        )
528        || input.gateway_observation.plan_id != input.gateway_plan.plan_id
529        || input.gateway_observation.plan_digest != input.gateway_plan.plan_digest
530        || input.gateway_observation.environment != input.deployment.environment
531        || input.gateway_observation.release_id != input.release.release_id
532        || input.gateway_observation.release_digest != input.release.release_digest
533        || input.gateway_observation.resource_uid.trim().is_empty()
534        || input.gateway_observation.resource_version.trim().is_empty()
535        || input.gateway_observation.authority_context != input.gateway_plan.plan_id
536        || input.gateway_observation.configuration_identity
537            != input.gateway_plan.configuration_identity
538        || input.gateway_observation.revision != input.gateway_plan.expected_gateway_revision
539        || input.gateway_observation.observed_after
540            != input.deployment_observation.source_observation_id
541        || !input.gateway_observation.fresh
542    {
543        issues.push(issue(
544            DeliveryIssueCode::EdgeExposureUnsafe,
545            "Gateway observations do not match the planned Edge configuration.",
546            "Reconcile the exact Edge Contract and collect a fresh gateway observation.",
547            "Correct gateway drift and verify staging again.",
548        ));
549    }
550    let release_workloads = input
551        .release
552        .workloads
553        .iter()
554        .map(|workload| workload.workload_id.as_str())
555        .collect::<std::collections::BTreeSet<_>>();
556    if release_workloads.iter().any(|workload_id| {
557        !input
558            .workload_health
559            .get(*workload_id)
560            .copied()
561            .unwrap_or(false)
562    }) {
563        issues.push(issue(
564            DeliveryIssueCode::ObservationStale,
565            "One or more release Workloads lack fresh healthy staging observations.",
566            "Wait for Migration, API, and Worker convergence and collect Service-level health evidence.",
567            "Refresh Workload observations before Promotion.",
568        ));
569    }
570    if input.freshness_horizon_revision <= input.deployment.environment_revision_after {
571        issues.push(issue(
572            DeliveryIssueCode::ObservationStale,
573            "Environment Verification freshness horizon is already exhausted.",
574            "Choose a horizon beyond the observed environment revision.",
575            "Collect fresh staging evidence.",
576        ));
577    }
578    let workload_digests = input.deployment.workload_digests.clone();
579    let decision = if issues.is_empty() {
580        DeliveryDecision::Passed
581    } else {
582        DeliveryDecision::Blocked
583    };
584    let verification_digest = digest_json(&EnvironmentVerificationDigestInput {
585        protocol: ENVIRONMENT_VERIFICATION_PROTOCOL,
586        environment: input.deployment.environment.as_str(),
587        environment_revision: input.deployment.environment_revision_after,
588        release_id: input.release.release_id.as_str(),
589        release_digest: input.release.release_digest.as_str(),
590        workload_digests: &workload_digests,
591        workload_health: &input.workload_health,
592        config_revision_id: input.config.revision_id.as_str(),
593        trust_evidence_digest: input.trust.evidence_digest.as_str(),
594        policy_evidence_id: input.policy.evidence_id.as_str(),
595        policy_evidence_digest: input.policy.evidence_digest.as_str(),
596        deployment_plan_id: input.deployment_plan.plan_id.as_str(),
597        deployment_plan_digest: input.deployment_plan.plan_digest.as_str(),
598        deployment_receipt_id: input.deployment.receipt_id.as_str(),
599        deployment_observation_id: input.deployment_observation.observation_id.as_str(),
600        operator_observation_id: input.operator_observation.observation_id.as_str(),
601        operator_observation_digest: input.operator_observation.observation_digest.as_str(),
602        operator_observation_authority_id: input.operator_observation.authority_id.as_str(),
603        operator_observation_authority_proof: input.operator_observation.authority_proof.as_str(),
604        operator_observation_claims: &input.operator_observation.claims,
605        gateway_plan_id: input.gateway_plan.plan_id.as_str(),
606        gateway_plan_digest: input.gateway_plan.plan_digest.as_str(),
607        gateway_observation_id: input.gateway_observation.observation_id.as_str(),
608        gateway_resource_uid: input.gateway_observation.resource_uid.as_str(),
609        gateway_resource_version: input.gateway_observation.resource_version.as_str(),
610        gateway_authority_context: input.gateway_observation.authority_context.as_str(),
611        gateway_configuration_identity: input.gateway_observation.configuration_identity.as_str(),
612        gateway_observation_revision: input.gateway_observation.revision,
613        gateway_observation_observed_after: input.gateway_observation.observed_after.as_str(),
614        gateway_observation_fresh: input.gateway_observation.fresh,
615        gateway_observation_provider_id: input.gateway_observation.provider_id.as_str(),
616        gateway_observation_provider_proof: input.gateway_observation.provider_proof.as_str(),
617        topology_digest: input.topology_digest.as_str(),
618        evidence_references: input.evidence_references.as_slice(),
619        freshness_horizon_revision: input.freshness_horizon_revision,
620        decision,
621        issues: issues.as_slice(),
622    });
623    EnvironmentVerification {
624        protocol: ENVIRONMENT_VERIFICATION_PROTOCOL.to_owned(),
625        verification_id: format!("environment-verification:{verification_digest}"),
626        verification_digest,
627        environment: input.deployment.environment,
628        environment_revision: input.deployment.environment_revision_after,
629        release_id: input.release.release_id,
630        release_digest: input.release.release_digest,
631        workload_digests,
632        workload_health: input.workload_health,
633        config_revision_id: input.config.revision_id,
634        trust_evidence_digest: input.trust.evidence_digest,
635        policy_evidence_id: input.policy.evidence_id,
636        policy_evidence_digest: input.policy.evidence_digest,
637        deployment_plan_id: input.deployment_plan.plan_id,
638        deployment_plan_digest: input.deployment_plan.plan_digest,
639        deployment_receipt_id: input.deployment.receipt_id,
640        deployment_observation_id: input.deployment_observation.observation_id,
641        operator_observation_id: input.operator_observation.observation_id,
642        operator_observation_digest: input.operator_observation.observation_digest,
643        operator_observation_authority_id: input.operator_observation.authority_id,
644        operator_observation_authority_proof: input.operator_observation.authority_proof,
645        operator_observation_claims: input.operator_observation.claims,
646        gateway_plan_id: input.gateway_plan.plan_id,
647        gateway_plan_digest: input.gateway_plan.plan_digest,
648        gateway_observation_id: input.gateway_observation.observation_id,
649        gateway_resource_uid: input.gateway_observation.resource_uid,
650        gateway_resource_version: input.gateway_observation.resource_version,
651        gateway_authority_context: input.gateway_observation.authority_context,
652        gateway_configuration_identity: input.gateway_observation.configuration_identity,
653        gateway_observation_revision: input.gateway_observation.revision,
654        gateway_observation_observed_after: input.gateway_observation.observed_after,
655        gateway_observation_fresh: input.gateway_observation.fresh,
656        gateway_observation_provider_id: input.gateway_observation.provider_id,
657        gateway_observation_provider_proof: input.gateway_observation.provider_proof,
658        topology_digest: input.topology_digest,
659        evidence_references: input.evidence_references,
660        freshness_horizon_revision: input.freshness_horizon_revision,
661        decision,
662        issues,
663        effects: DeliveryEffects::default(),
664    }
665}
666
667#[must_use]
668pub fn environment_verification_digest(verification: &EnvironmentVerification) -> String {
669    digest_json(&EnvironmentVerificationDigestInput {
670        protocol: verification.protocol.as_str(),
671        environment: verification.environment.as_str(),
672        environment_revision: verification.environment_revision,
673        release_id: verification.release_id.as_str(),
674        release_digest: verification.release_digest.as_str(),
675        workload_digests: &verification.workload_digests,
676        workload_health: &verification.workload_health,
677        config_revision_id: verification.config_revision_id.as_str(),
678        trust_evidence_digest: verification.trust_evidence_digest.as_str(),
679        policy_evidence_id: verification.policy_evidence_id.as_str(),
680        policy_evidence_digest: verification.policy_evidence_digest.as_str(),
681        deployment_plan_id: verification.deployment_plan_id.as_str(),
682        deployment_plan_digest: verification.deployment_plan_digest.as_str(),
683        deployment_receipt_id: verification.deployment_receipt_id.as_str(),
684        deployment_observation_id: verification.deployment_observation_id.as_str(),
685        operator_observation_id: verification.operator_observation_id.as_str(),
686        operator_observation_digest: verification.operator_observation_digest.as_str(),
687        operator_observation_authority_id: verification.operator_observation_authority_id.as_str(),
688        operator_observation_authority_proof: verification
689            .operator_observation_authority_proof
690            .as_str(),
691        operator_observation_claims: &verification.operator_observation_claims,
692        gateway_plan_id: verification.gateway_plan_id.as_str(),
693        gateway_plan_digest: verification.gateway_plan_digest.as_str(),
694        gateway_observation_id: verification.gateway_observation_id.as_str(),
695        gateway_resource_uid: verification.gateway_resource_uid.as_str(),
696        gateway_resource_version: verification.gateway_resource_version.as_str(),
697        gateway_authority_context: verification.gateway_authority_context.as_str(),
698        gateway_configuration_identity: verification.gateway_configuration_identity.as_str(),
699        gateway_observation_revision: verification.gateway_observation_revision,
700        gateway_observation_observed_after: verification
701            .gateway_observation_observed_after
702            .as_str(),
703        gateway_observation_fresh: verification.gateway_observation_fresh,
704        gateway_observation_provider_id: verification.gateway_observation_provider_id.as_str(),
705        gateway_observation_provider_proof: verification
706            .gateway_observation_provider_proof
707            .as_str(),
708        topology_digest: verification.topology_digest.as_str(),
709        evidence_references: verification.evidence_references.as_slice(),
710        freshness_horizon_revision: verification.freshness_horizon_revision,
711        decision: verification.decision,
712        issues: verification.issues.as_slice(),
713    })
714}
715
716#[must_use]
717pub fn environment_verification_integrity_is_valid(verification: &EnvironmentVerification) -> bool {
718    verification.protocol == ENVIRONMENT_VERIFICATION_PROTOCOL
719        && verification.verification_id
720            == format!(
721                "environment-verification:{}",
722                verification.verification_digest
723            )
724        && environment_verification_digest(verification) == verification.verification_digest
725        && verification.decision
726            == if verification.issues.is_empty() {
727                DeliveryDecision::Passed
728            } else {
729                DeliveryDecision::Blocked
730            }
731        && verification.effects == DeliveryEffects::default()
732}
733
734fn operator_observation_evidence_is_bound(
735    observation: &OperatorObservationAttestation,
736    evidence_references: &[String],
737) -> bool {
738    let expected = [
739        observation.observation_id.clone(),
740        observation.observation_digest.clone(),
741        format!(
742            "operator-observation-authority:{}",
743            observation.authority_id
744        ),
745        format!("operator-observation-proof:{}", observation.authority_proof),
746    ];
747    expected
748        .iter()
749        .all(|reference| evidence_references.contains(reference))
750}
751
752#[must_use]
753pub fn environment_verification_authority_is_valid(
754    verification: &EnvironmentVerification,
755    operator_provider: &dyn OperatorObservationAuthorityProvider,
756    gateway_provider: &dyn GatewayObservationProvider,
757) -> bool {
758    let operator_observation = OperatorObservationAttestation {
759        observation_id: verification.operator_observation_id.clone(),
760        observation_digest: verification.operator_observation_digest.clone(),
761        authority_id: verification.operator_observation_authority_id.clone(),
762        authority_proof: verification.operator_observation_authority_proof.clone(),
763        claims: verification.operator_observation_claims.clone(),
764    };
765    let gateway_observation = GatewayObservation {
766        protocol: super::GATEWAY_OBSERVATION_PROTOCOL.to_owned(),
767        observation_id: verification.gateway_observation_id.clone(),
768        plan_id: verification.gateway_plan_id.clone(),
769        plan_digest: verification.gateway_plan_digest.clone(),
770        environment: verification.environment.clone(),
771        release_id: verification.release_id.clone(),
772        release_digest: verification.release_digest.clone(),
773        resource_uid: verification.gateway_resource_uid.clone(),
774        resource_version: verification.gateway_resource_version.clone(),
775        authority_context: verification.gateway_authority_context.clone(),
776        configuration_identity: verification.gateway_configuration_identity.clone(),
777        revision: verification.gateway_observation_revision,
778        observed_after: verification.gateway_observation_observed_after.clone(),
779        fresh: verification.gateway_observation_fresh,
780        provider_id: verification.gateway_observation_provider_id.clone(),
781        provider_proof: verification.gateway_observation_provider_proof.clone(),
782    };
783    environment_verification_integrity_is_valid(verification)
784        && operator_observation_attestation_is_valid(&operator_observation, operator_provider)
785        && verification.operator_observation_claims.environment == verification.environment
786        && verification
787            .operator_observation_claims
788            .environment_revision
789            == verification.environment_revision
790        && verification.operator_observation_claims.authority_context
791            == verification.deployment_plan_id
792        && verification.operator_observation_claims.deployment_plan_id
793            == verification.deployment_plan_id
794        && verification
795            .operator_observation_claims
796            .deployment_plan_digest
797            == verification.deployment_plan_digest
798        && verification.operator_observation_claims.desired_release_id == verification.release_id
799        && verification
800            .operator_observation_claims
801            .desired_release_digest
802            == verification.release_digest
803        && verification.operator_observation_claims.observed_release_id == verification.release_id
804        && verification
805            .operator_observation_claims
806            .observed_release_digest
807            == verification.release_digest
808        && verification
809            .operator_observation_claims
810            .desired_workload_digests
811            == verification.workload_digests
812        && verification
813            .operator_observation_claims
814            .observed_workload_digests
815            == verification.workload_digests
816        && verification.operator_observation_claims.workload_health == verification.workload_health
817        && verification.operator_observation_claims.config_revision_id
818            == verification.config_revision_id
819        && verification.operator_observation_claims.state == "ready"
820        && verification.operator_observation_claims.rollout_phase == "ready"
821        && verification.operator_observation_claims.decision == DeliveryDecision::Passed
822        && verification.operator_observation_claims.fresh
823        && !verification.operator_observation_claims.drifted
824        && operator_observation_evidence_is_bound(
825            &operator_observation,
826            &verification.evidence_references,
827        )
828        && gateway_observation_integrity_is_valid(&gateway_observation, gateway_provider)
829        && gateway_observation.plan_id == verification.gateway_plan_id
830        && gateway_observation.plan_digest == verification.gateway_plan_digest
831        && gateway_observation.environment == verification.environment
832        && gateway_observation.release_id == verification.release_id
833        && gateway_observation.release_digest == verification.release_digest
834        && !gateway_observation.resource_uid.trim().is_empty()
835        && !gateway_observation.resource_version.trim().is_empty()
836        && gateway_observation.authority_context == verification.gateway_plan_id
837        && gateway_observation.observed_after == verification.operator_observation_id
838        && gateway_observation.revision == verification.gateway_observation_revision
839        && gateway_observation.fresh
840}
841
842#[derive(Debug, Clone)]
843pub struct PromotionPlanInput {
844    pub source: EnvironmentVerification,
845    pub target_deployment: DeploymentPlan,
846    pub target_gateway: GatewayConfigurationPlan,
847    pub policy: PolicyEvidence,
848    pub policy_inputs: DeliveryPolicyInputs,
849    pub source_environment_revision: u64,
850    pub target_environment_revision: u64,
851    pub target_topology_digest: String,
852    pub secret_reference_ids: Vec<String>,
853    pub evidence_references: Vec<String>,
854}
855
856#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
857#[serde(rename_all = "camelCase")]
858pub struct PromotionPlan {
859    pub protocol: String,
860    pub plan_id: String,
861    pub plan_digest: String,
862    pub release_id: String,
863    pub release_digest: String,
864    pub workload_digests: BTreeMap<String, String>,
865    pub source_environment: String,
866    pub target_environment: String,
867    pub source_environment_revision: u64,
868    pub target_environment_revision: u64,
869    pub source_verification_id: String,
870    pub source_verification_digest: String,
871    pub policy_evidence_id: String,
872    pub policy_evidence_digest: String,
873    pub config_revision_id: String,
874    pub secret_reference_ids: Vec<String>,
875    pub target_deployment: DeploymentPlan,
876    pub target_gateway: GatewayConfigurationPlan,
877    pub source_topology_digest: String,
878    pub target_topology_digest: String,
879    pub evidence_references: Vec<String>,
880    pub freshness_horizon_revision: u64,
881    pub effects: DeliveryEffects,
882}
883
884#[derive(Serialize)]
885#[serde(rename_all = "camelCase")]
886struct PromotionPlanDigestInput<'a> {
887    protocol: &'a str,
888    release_id: &'a str,
889    release_digest: &'a str,
890    workload_digests: &'a BTreeMap<String, String>,
891    source_environment: &'a str,
892    target_environment: &'a str,
893    source_environment_revision: u64,
894    target_environment_revision: u64,
895    source_verification_id: &'a str,
896    source_verification_digest: &'a str,
897    policy_evidence_id: &'a str,
898    policy_evidence_digest: &'a str,
899    config_revision_id: &'a str,
900    secret_reference_ids: &'a [String],
901    target_deployment_digest: &'a str,
902    target_gateway_digest: &'a str,
903    source_topology_digest: &'a str,
904    target_topology_digest: &'a str,
905    evidence_references: &'a [String],
906    freshness_horizon_revision: u64,
907    effects: &'a DeliveryEffects,
908}
909
910pub fn plan_promotion(
911    mut input: PromotionPlanInput,
912    trust_provider: &dyn ReleaseTrustProvider,
913    secret_provider: &dyn SecretProvider,
914    operator_observation_provider: &dyn OperatorObservationAuthorityProvider,
915    gateway_observation_provider: &dyn GatewayObservationProvider,
916) -> Result<PromotionPlan, Vec<DeliveryIssue>> {
917    input.secret_reference_ids.sort();
918    input.evidence_references.sort();
919    let mut issues = Vec::new();
920    if !environment_verification_authority_is_valid(
921        &input.source,
922        operator_observation_provider,
923        gateway_observation_provider,
924    ) || input.source.decision != DeliveryDecision::Passed
925        || input.source_environment_revision != input.source.environment_revision
926    {
927        issues.push(issue(
928            DeliveryIssueCode::ObservationStale,
929            "Promotion requires passing source Environment Verification.",
930            "Deploy and verify the exact release in the source environment.",
931            "Refresh source verification and plan Promotion again.",
932        ));
933    }
934    if !deployment_plan_integrity_is_valid(&input.target_deployment)
935        || !gateway_plan_authority_is_valid(&input.target_gateway, trust_provider)
936        || input.target_gateway.edge_release_id != input.source.release_id
937        || input.target_gateway.edge_release_digest != input.source.release_digest
938        || input.target_deployment.gateway_plan_digest != input.target_gateway.plan_digest
939        || input.target_deployment.release_id != input.source.release_id
940        || input.target_deployment.release_digest != input.source.release_digest
941        || input.target_deployment.workloads.iter().any(|workload| {
942            input.source.workload_digests.get(&workload.workload_id)
943                != Some(&workload.artifact_digest)
944        })
945    {
946        issues.push(issue(
947            DeliveryIssueCode::ReleaseTampered,
948            "Promotion target attempts to rebuild, substitute, or change release Workload digests.",
949            "Use the exact source-verified Service Release and immutable Workload digests.",
950            "Regenerate the target Deployment plan without rebuilding.",
951        ));
952    }
953    if input.policy_inputs.release.release_id != input.source.release_id
954        || input.policy_inputs.release.release_digest != input.source.release_digest
955        || !production_policy_evidence_is_valid(
956            &input.policy,
957            &input.policy_inputs,
958            trust_provider,
959            secret_provider,
960        )
961        || input.policy.decision != DeliveryDecision::Passed
962        || input.policy.evaluated_subject != input.source.release_id
963        || !deployment_binds_policy_evidence(&input.target_deployment, &input.policy)
964    {
965        issues.push(issue(
966            DeliveryIssueCode::PolicyRuleBlocked,
967            "Promotion Policy Evidence is blocked, stale, or evaluates another release.",
968            "Evaluate the exact release and target evidence through the production Policy Pack.",
969            "Refresh Policy Evidence and plan Promotion again.",
970        ));
971    }
972    let mut target_secret_references = input.target_deployment.secret_reference_ids.clone();
973    target_secret_references.sort();
974    if input.target_deployment.config_revision_id != input.source.config_revision_id
975        || target_secret_references != input.secret_reference_ids
976    {
977        issues.push(issue(
978            DeliveryIssueCode::ConfigContractMismatch,
979            "Promotion configuration or Secret References differ from the reviewed source binding.",
980            "Bind the exact verified Config Revision and opaque Secret Reference identifiers.",
981            "Regenerate the target Deployment and Promotion plan.",
982        ));
983    }
984    if input.source.environment == input.target_deployment.environment
985        || input.target_deployment.environment != input.target_gateway.environment
986        || input.target_deployment.expected_environment_revision
987            != input.target_environment_revision
988        || input.source.freshness_horizon_revision < input.source_environment_revision
989    {
990        issues.push(issue(
991            DeliveryIssueCode::StaleInput,
992            "Promotion environment, topology, or freshness inputs are inconsistent.",
993            "Refresh source and target revisions and bind both adapters to the intended environment.",
994            "Correct stale inputs and plan Promotion again.",
995        ));
996    }
997    if !issues.is_empty() {
998        return Err(issues);
999    }
1000    let effects = DeliveryEffects::default();
1001    let plan_digest = digest_json(&PromotionPlanDigestInput {
1002        protocol: PROMOTION_PLAN_PROTOCOL,
1003        release_id: input.source.release_id.as_str(),
1004        release_digest: input.source.release_digest.as_str(),
1005        workload_digests: &input.source.workload_digests,
1006        source_environment: input.source.environment.as_str(),
1007        target_environment: input.target_deployment.environment.as_str(),
1008        source_environment_revision: input.source_environment_revision,
1009        target_environment_revision: input.target_environment_revision,
1010        source_verification_id: input.source.verification_id.as_str(),
1011        source_verification_digest: input.source.verification_digest.as_str(),
1012        policy_evidence_id: input.policy.evidence_id.as_str(),
1013        policy_evidence_digest: input.policy.evidence_digest.as_str(),
1014        config_revision_id: input.source.config_revision_id.as_str(),
1015        secret_reference_ids: input.secret_reference_ids.as_slice(),
1016        target_deployment_digest: input.target_deployment.plan_digest.as_str(),
1017        target_gateway_digest: input.target_gateway.plan_digest.as_str(),
1018        source_topology_digest: input.source.topology_digest.as_str(),
1019        target_topology_digest: input.target_topology_digest.as_str(),
1020        evidence_references: input.evidence_references.as_slice(),
1021        freshness_horizon_revision: input.source.freshness_horizon_revision,
1022        effects: &effects,
1023    });
1024    Ok(PromotionPlan {
1025        protocol: PROMOTION_PLAN_PROTOCOL.to_owned(),
1026        plan_id: format!("promotion-plan:{plan_digest}"),
1027        plan_digest,
1028        release_id: input.source.release_id,
1029        release_digest: input.source.release_digest,
1030        workload_digests: input.source.workload_digests,
1031        source_environment: input.source.environment,
1032        target_environment: input.target_deployment.environment.clone(),
1033        source_environment_revision: input.source_environment_revision,
1034        target_environment_revision: input.target_environment_revision,
1035        source_verification_id: input.source.verification_id,
1036        source_verification_digest: input.source.verification_digest,
1037        policy_evidence_id: input.policy.evidence_id,
1038        policy_evidence_digest: input.policy.evidence_digest,
1039        config_revision_id: input.source.config_revision_id,
1040        secret_reference_ids: input.secret_reference_ids,
1041        target_deployment: input.target_deployment,
1042        target_gateway: input.target_gateway,
1043        source_topology_digest: input.source.topology_digest,
1044        target_topology_digest: input.target_topology_digest,
1045        evidence_references: input.evidence_references,
1046        freshness_horizon_revision: input.source.freshness_horizon_revision,
1047        effects,
1048    })
1049}
1050
1051fn deployment_binds_policy_evidence(plan: &DeploymentPlan, policy: &PolicyEvidence) -> bool {
1052    let expected = [policy.evidence_id.as_str(), policy.evidence_digest.as_str()]
1053        .into_iter()
1054        .collect::<std::collections::BTreeSet<_>>();
1055    let actual = plan
1056        .policy_evidence_references
1057        .iter()
1058        .map(String::as_str)
1059        .collect::<std::collections::BTreeSet<_>>();
1060    actual == expected && plan.policy_evidence_references.len() == expected.len()
1061}
1062
1063#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1064#[serde(rename_all = "camelCase")]
1065pub struct PromotionApproval {
1066    pub protocol: String,
1067    pub approval_id: String,
1068    pub plan_digest: String,
1069    pub actor: String,
1070    pub authority: String,
1071    pub authority_proof: String,
1072    pub issued_for_target_revision: u64,
1073    pub approved: bool,
1074}
1075
1076pub trait PromotionApprovalAuthority {
1077    fn authority(&self) -> &str;
1078    fn issue_proof(&self, actor: &str, plan_digest: &str, target_revision: u64) -> Option<String>;
1079    fn verify_proof(
1080        &self,
1081        actor: &str,
1082        plan_digest: &str,
1083        target_revision: u64,
1084        proof: &str,
1085    ) -> bool;
1086}
1087
1088#[derive(Debug, Clone)]
1089pub struct DeterministicPromotionApprovalAuthority {
1090    authority: String,
1091    authorized_actors: BTreeSet<String>,
1092    signing_key: String,
1093}
1094
1095impl DeterministicPromotionApprovalAuthority {
1096    pub fn new(
1097        authority: impl Into<String>,
1098        authorized_actors: impl IntoIterator<Item = impl Into<String>>,
1099        signing_key: impl Into<String>,
1100    ) -> Self {
1101        Self {
1102            authority: authority.into(),
1103            authorized_actors: authorized_actors.into_iter().map(Into::into).collect(),
1104            signing_key: signing_key.into(),
1105        }
1106    }
1107
1108    fn proof(&self, actor: &str, plan_digest: &str, target_revision: u64) -> String {
1109        digest_json(&(
1110            "lenso.promotion-authority-proof.v1",
1111            self.authority.as_str(),
1112            actor,
1113            plan_digest,
1114            target_revision,
1115            self.signing_key.as_str(),
1116        ))
1117    }
1118}
1119
1120impl PromotionApprovalAuthority for DeterministicPromotionApprovalAuthority {
1121    fn authority(&self) -> &str {
1122        &self.authority
1123    }
1124
1125    fn issue_proof(&self, actor: &str, plan_digest: &str, target_revision: u64) -> Option<String> {
1126        self.authorized_actors
1127            .contains(actor)
1128            .then(|| self.proof(actor, plan_digest, target_revision))
1129    }
1130
1131    fn verify_proof(
1132        &self,
1133        actor: &str,
1134        plan_digest: &str,
1135        target_revision: u64,
1136        proof: &str,
1137    ) -> bool {
1138        self.authorized_actors.contains(actor)
1139            && proof == self.proof(actor, plan_digest, target_revision)
1140    }
1141}
1142
1143pub fn approve_promotion(
1144    plan: &PromotionPlan,
1145    actor: impl Into<String>,
1146    authority: &impl PromotionApprovalAuthority,
1147) -> Result<PromotionApproval, DeliveryIssue> {
1148    let actor = actor.into();
1149    let authority_name = authority.authority().to_owned();
1150    let authority_proof = authority.issue_proof(
1151        actor.as_str(),
1152        plan.plan_digest.as_str(),
1153        plan.target_environment_revision,
1154    );
1155    if !promotion_plan_integrity_is_valid(plan)
1156        || !actor.starts_with("user:")
1157        || authority_name.trim().is_empty()
1158        || authority_proof.is_none()
1159    {
1160        return Err(issue(
1161            DeliveryIssueCode::ApprovalInvalid,
1162            "Production Promotion approval requires an integrity-valid plan and explicit human actor authority.",
1163            "Bind a reviewed plan digest to an authorized human operator.",
1164            "Correct the Approval Boundary before apply.",
1165        ));
1166    }
1167    let approval_id = format!(
1168        "promotion-approval:{}",
1169        digest_json(&(
1170            PROMOTION_APPROVAL_PROTOCOL,
1171            plan.plan_digest.as_str(),
1172            actor.as_str(),
1173            authority_name.as_str(),
1174            authority_proof.as_deref(),
1175            plan.target_environment_revision,
1176            true,
1177        ))
1178    );
1179    Ok(PromotionApproval {
1180        protocol: PROMOTION_APPROVAL_PROTOCOL.to_owned(),
1181        approval_id,
1182        plan_digest: plan.plan_digest.clone(),
1183        actor,
1184        authority: authority_name,
1185        authority_proof: authority_proof.expect("authority proof was checked"),
1186        issued_for_target_revision: plan.target_environment_revision,
1187        approved: true,
1188    })
1189}
1190
1191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1192#[serde(rename_all = "camelCase")]
1193pub struct PromotionProtectedEvidence {
1194    pub source_verification_id: String,
1195    pub source_verification_digest: String,
1196    pub policy_evidence_id: String,
1197    pub policy_evidence_digest: String,
1198    pub source_environment_revision: u64,
1199    pub source_topology_digest: String,
1200    pub target_topology_digest: String,
1201    pub config_revision_id: String,
1202    pub secret_reference_ids: Vec<String>,
1203    pub evidence_references: Vec<String>,
1204}
1205
1206impl PromotionProtectedEvidence {
1207    #[must_use]
1208    pub fn from_plan(plan: &PromotionPlan) -> Self {
1209        Self {
1210            source_verification_id: plan.source_verification_id.clone(),
1211            source_verification_digest: plan.source_verification_digest.clone(),
1212            policy_evidence_id: plan.policy_evidence_id.clone(),
1213            policy_evidence_digest: plan.policy_evidence_digest.clone(),
1214            source_environment_revision: plan.source_environment_revision,
1215            source_topology_digest: plan.source_topology_digest.clone(),
1216            target_topology_digest: plan.target_topology_digest.clone(),
1217            config_revision_id: plan.config_revision_id.clone(),
1218            secret_reference_ids: plan.secret_reference_ids.clone(),
1219            evidence_references: plan.evidence_references.clone(),
1220        }
1221    }
1222}
1223
1224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1225#[serde(rename_all = "camelCase")]
1226pub struct PromotionReceipt {
1227    pub protocol: String,
1228    pub receipt_id: String,
1229    pub plan_id: String,
1230    pub approval_id: String,
1231    pub actor: String,
1232    pub source_environment: String,
1233    pub target_environment: String,
1234    pub release_id: String,
1235    pub release_digest: String,
1236    pub workload_digests: BTreeMap<String, String>,
1237    pub deployment_receipt: DeploymentReceipt,
1238    pub environment_revision_before: u64,
1239    pub environment_revision_after: u64,
1240    pub effects: DeliveryEffects,
1241}
1242
1243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1244#[serde(rename_all = "camelCase")]
1245pub struct PromotionState {
1246    pub environment: String,
1247    pub environment_revision: u64,
1248    pub coordination_available: bool,
1249    #[serde(default)]
1250    pub history: Vec<PromotionReceipt>,
1251}
1252
1253impl PromotionState {
1254    #[must_use]
1255    pub fn new(environment: impl Into<String>, environment_revision: u64) -> Self {
1256        Self {
1257            environment: environment.into(),
1258            environment_revision,
1259            coordination_available: true,
1260            history: Vec::new(),
1261        }
1262    }
1263}
1264
1265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1266#[serde(rename_all = "camelCase")]
1267pub struct PromotionApplyRejection {
1268    pub issues: Vec<DeliveryIssue>,
1269    pub effects: DeliveryEffects,
1270}
1271
1272pub fn apply_promotion(
1273    state: &mut PromotionState,
1274    deployment_state: &mut DeploymentState,
1275    plan: &PromotionPlan,
1276    approval: &PromotionApproval,
1277    protected_evidence: &PromotionProtectedEvidence,
1278    approval_authority: &impl PromotionApprovalAuthority,
1279) -> Result<PromotionReceipt, PromotionApplyRejection> {
1280    let valid_approval = promotion_approval_integrity_is_valid(approval, approval_authority)
1281        && approval.approved
1282        && approval.plan_digest == plan.plan_digest
1283        && approval.issued_for_target_revision == plan.target_environment_revision;
1284    if !promotion_plan_integrity_is_valid(plan) || !valid_approval {
1285        return Err(rejection(
1286            DeliveryIssueCode::ApprovalInvalid,
1287            "Promotion plan integrity or its exact human Approval Boundary is invalid.",
1288            "Review and approve the current plan digest with explicit production authority.",
1289            "Regenerate or reapprove the Promotion plan.",
1290        ));
1291    }
1292    let mut expected_evidence = PromotionProtectedEvidence::from_plan(plan);
1293    expected_evidence.secret_reference_ids.sort();
1294    expected_evidence.evidence_references.sort();
1295    let mut current_evidence = protected_evidence.clone();
1296    current_evidence.secret_reference_ids.sort();
1297    current_evidence.evidence_references.sort();
1298    if current_evidence != expected_evidence
1299        || current_evidence.source_environment_revision > plan.freshness_horizon_revision
1300    {
1301        return Err(rejection(
1302            DeliveryIssueCode::StaleInput,
1303            "Source verification, policy, configuration, topology, reliability, edge, or evidence changed after Promotion planning.",
1304            "Collect current protected evidence and bind a new content-addressed Promotion plan.",
1305            "Refresh staging and replan Promotion before mutation.",
1306        ));
1307    }
1308    if let Some(existing) = state
1309        .history
1310        .iter()
1311        .find(|item| item.plan_id == plan.plan_id)
1312    {
1313        return promotion_receipt_integrity_is_valid(existing, plan, approval)
1314            .then(|| existing.clone())
1315            .ok_or_else(|| {
1316                rejection(
1317                    DeliveryIssueCode::StaleInput,
1318                    "The completed Promotion receipt no longer matches the exact plan and Approval Boundary.",
1319                    "Preserve the immutable plan, approval, protected evidence, and receipt together.",
1320                    "Restore the original receipt or create and approve a new Promotion plan.",
1321                )
1322            });
1323    }
1324    if !state.coordination_available {
1325        return Err(rejection(
1326            DeliveryIssueCode::CoordinationUnavailable,
1327            "New Promotion is paused while coordination evidence is unavailable.",
1328            "Restore the System Plane and refresh protected-action evidence.",
1329            "Retry Promotion after coordination recovers.",
1330        ));
1331    }
1332    if state.environment != plan.target_environment
1333        || state.environment_revision != plan.target_environment_revision
1334        || deployment_state.environment != plan.target_environment
1335        || deployment_state.environment_revision
1336            != plan.target_deployment.expected_environment_revision
1337    {
1338        return Err(rejection(
1339            DeliveryIssueCode::ConcurrentMutation,
1340            "Target environment changed after Promotion planning.",
1341            "Use compare-and-set revisions and replan from current target observations.",
1342            "Refresh target state and plan Promotion again.",
1343        ));
1344    }
1345    let mut next_deployment_state = deployment_state.clone();
1346    let deployment_receipt = apply_deployment(&mut next_deployment_state, &plan.target_deployment)
1347        .map_err(deployment_rejection)?;
1348    let revision_before = state.environment_revision;
1349    state.environment_revision += 1;
1350    let effects = DeliveryEffects {
1351        mutates_environment: true,
1352        mutates_deployment: true,
1353        appends_ledger: true,
1354        ..DeliveryEffects::default()
1355    };
1356    let receipt_id = format!(
1357        "promotion-receipt:{}",
1358        digest_json(&(
1359            plan.plan_id.as_str(),
1360            approval.approval_id.as_str(),
1361            deployment_receipt.receipt_id.as_str(),
1362            revision_before,
1363            state.environment_revision,
1364        ))
1365    );
1366    let receipt = PromotionReceipt {
1367        protocol: PROMOTION_RECEIPT_PROTOCOL.to_owned(),
1368        receipt_id,
1369        plan_id: plan.plan_id.clone(),
1370        approval_id: approval.approval_id.clone(),
1371        actor: approval.actor.clone(),
1372        source_environment: plan.source_environment.clone(),
1373        target_environment: plan.target_environment.clone(),
1374        release_id: plan.release_id.clone(),
1375        release_digest: plan.release_digest.clone(),
1376        workload_digests: plan.workload_digests.clone(),
1377        deployment_receipt,
1378        environment_revision_before: revision_before,
1379        environment_revision_after: state.environment_revision,
1380        effects,
1381    };
1382    *deployment_state = next_deployment_state;
1383    state.history.push(receipt.clone());
1384    Ok(receipt)
1385}
1386
1387#[must_use]
1388pub fn promotion_receipt_integrity_is_valid(
1389    receipt: &PromotionReceipt,
1390    plan: &PromotionPlan,
1391    approval: &PromotionApproval,
1392) -> bool {
1393    let effects = DeliveryEffects {
1394        mutates_environment: true,
1395        mutates_deployment: true,
1396        appends_ledger: true,
1397        ..DeliveryEffects::default()
1398    };
1399    receipt.protocol == PROMOTION_RECEIPT_PROTOCOL
1400        && promotion_plan_integrity_is_valid(plan)
1401        && receipt.plan_id == plan.plan_id
1402        && receipt.approval_id == approval.approval_id
1403        && receipt.actor == approval.actor
1404        && receipt.source_environment == plan.source_environment
1405        && receipt.target_environment == plan.target_environment
1406        && receipt.release_id == plan.release_id
1407        && receipt.release_digest == plan.release_digest
1408        && receipt.workload_digests == plan.workload_digests
1409        && deployment_receipt_integrity_is_valid(
1410            &receipt.deployment_receipt,
1411            &plan.target_deployment,
1412        )
1413        && receipt.environment_revision_before == plan.target_environment_revision
1414        && receipt.environment_revision_after == receipt.environment_revision_before + 1
1415        && receipt.effects == effects
1416        && receipt.receipt_id
1417            == format!(
1418                "promotion-receipt:{}",
1419                digest_json(&(
1420                    plan.plan_id.as_str(),
1421                    approval.approval_id.as_str(),
1422                    receipt.deployment_receipt.receipt_id.as_str(),
1423                    receipt.environment_revision_before,
1424                    receipt.environment_revision_after,
1425                ))
1426            )
1427}
1428
1429#[must_use]
1430pub fn promotion_approval_integrity_is_valid(
1431    approval: &PromotionApproval,
1432    authority: &impl PromotionApprovalAuthority,
1433) -> bool {
1434    let expected_id = format!(
1435        "promotion-approval:{}",
1436        digest_json(&(
1437            PROMOTION_APPROVAL_PROTOCOL,
1438            approval.plan_digest.as_str(),
1439            approval.actor.as_str(),
1440            approval.authority.as_str(),
1441            Some(approval.authority_proof.as_str()),
1442            approval.issued_for_target_revision,
1443            approval.approved,
1444        ))
1445    );
1446    approval.protocol == PROMOTION_APPROVAL_PROTOCOL
1447        && approval.approval_id == expected_id
1448        && approval.authority == authority.authority()
1449        && approval.actor.starts_with("user:")
1450        && authority.verify_proof(
1451            approval.actor.as_str(),
1452            approval.plan_digest.as_str(),
1453            approval.issued_for_target_revision,
1454            approval.authority_proof.as_str(),
1455        )
1456}
1457
1458#[must_use]
1459pub fn promotion_plan_integrity_is_valid(plan: &PromotionPlan) -> bool {
1460    let target_workload_digests = plan
1461        .target_deployment
1462        .workloads
1463        .iter()
1464        .map(|workload| {
1465            (
1466                workload.workload_id.clone(),
1467                workload.artifact_digest.clone(),
1468            )
1469        })
1470        .collect::<BTreeMap<_, _>>();
1471    plan.protocol == PROMOTION_PLAN_PROTOCOL
1472        && deployment_plan_integrity_is_valid(&plan.target_deployment)
1473        && gateway_plan_integrity_is_valid(&plan.target_gateway)
1474        && plan.release_id == plan.target_deployment.release_id
1475        && plan.release_digest == plan.target_deployment.release_digest
1476        && plan.workload_digests == target_workload_digests
1477        && plan.target_environment == plan.target_deployment.environment
1478        && plan.target_environment_revision == plan.target_deployment.expected_environment_revision
1479        && plan.config_revision_id == plan.target_deployment.config_revision_id
1480        && plan.target_deployment.gateway_plan_digest == plan.target_gateway.plan_digest
1481        && plan.release_id == plan.target_gateway.edge_release_id
1482        && plan.release_digest == plan.target_gateway.edge_release_digest
1483        && plan.target_environment == plan.target_gateway.environment
1484        && plan.plan_id == format!("promotion-plan:{}", plan.plan_digest)
1485        && digest_json(&PromotionPlanDigestInput {
1486            protocol: plan.protocol.as_str(),
1487            release_id: plan.release_id.as_str(),
1488            release_digest: plan.release_digest.as_str(),
1489            workload_digests: &plan.workload_digests,
1490            source_environment: plan.source_environment.as_str(),
1491            target_environment: plan.target_environment.as_str(),
1492            source_environment_revision: plan.source_environment_revision,
1493            target_environment_revision: plan.target_environment_revision,
1494            source_verification_id: plan.source_verification_id.as_str(),
1495            source_verification_digest: plan.source_verification_digest.as_str(),
1496            policy_evidence_id: plan.policy_evidence_id.as_str(),
1497            policy_evidence_digest: plan.policy_evidence_digest.as_str(),
1498            config_revision_id: plan.config_revision_id.as_str(),
1499            secret_reference_ids: plan.secret_reference_ids.as_slice(),
1500            target_deployment_digest: plan.target_deployment.plan_digest.as_str(),
1501            target_gateway_digest: plan.target_gateway.plan_digest.as_str(),
1502            source_topology_digest: plan.source_topology_digest.as_str(),
1503            target_topology_digest: plan.target_topology_digest.as_str(),
1504            evidence_references: plan.evidence_references.as_slice(),
1505            freshness_horizon_revision: plan.freshness_horizon_revision,
1506            effects: &plan.effects,
1507        }) == plan.plan_digest
1508}
1509
1510fn deployment_rejection(error: DeploymentApplyRejection) -> PromotionApplyRejection {
1511    PromotionApplyRejection {
1512        issues: error.issues,
1513        effects: DeliveryEffects::default(),
1514    }
1515}
1516
1517fn rejection(
1518    code: DeliveryIssueCode,
1519    message: &str,
1520    remediation: &str,
1521    next_action: &str,
1522) -> PromotionApplyRejection {
1523    PromotionApplyRejection {
1524        issues: vec![issue(code, message, remediation, next_action)],
1525        effects: DeliveryEffects::default(),
1526    }
1527}
1528
1529fn digest_json(value: &impl Serialize) -> String {
1530    extraction_input_digest(serde_json::to_vec(value).expect("Promotion values must serialize"))
1531}