Skip to main content

lenso_service/production_delivery/
config.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use utoipa::ToSchema;
7
8use crate::extraction_input_digest;
9
10use super::{DeliveryEffects, DeliveryIssue, DeliveryIssueCode, issue};
11
12pub const CONFIG_CONTRACT_PROTOCOL: &str = "lenso.config-contract.v1";
13pub const CONFIG_REVISION_PROTOCOL: &str = "lenso.config-revision.v1";
14pub const CONFIG_ACTIVATION_PLAN_PROTOCOL: &str = "lenso.config-activation-plan.v1";
15pub const CONFIG_ACTIVATION_RECEIPT_PROTOCOL: &str = "lenso.config-activation-receipt.v1";
16
17#[derive(
18    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
19)]
20#[serde(rename_all = "snake_case")]
21pub enum ConfigValueType {
22    String,
23    Integer,
24    Number,
25    Boolean,
26    Object,
27    Array,
28}
29
30#[derive(
31    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
32)]
33#[serde(rename_all = "snake_case")]
34pub enum ConfigFieldSensitivity {
35    Public,
36    Sensitive,
37}
38
39#[derive(
40    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
41)]
42#[serde(rename_all = "snake_case")]
43pub enum ConfigFieldScope {
44    Service,
45    Workload,
46}
47
48#[derive(
49    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
50)]
51#[serde(rename_all = "snake_case")]
52pub enum ConfigFieldActivation {
53    Hot,
54    Restart,
55}
56
57#[derive(
58    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
59)]
60#[serde(rename_all = "camelCase")]
61pub struct ConfigField {
62    pub path: String,
63    pub value_type: ConfigValueType,
64    pub required: bool,
65    pub sensitivity: ConfigFieldSensitivity,
66    pub scope: ConfigFieldScope,
67    pub activation: ConfigFieldActivation,
68    pub mutable: bool,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
72#[serde(rename_all = "camelCase")]
73pub struct ConfigContractDefinition {
74    pub protocol: String,
75    pub reference: String,
76    pub digest: String,
77    pub fields: Vec<ConfigField>,
78}
79
80#[derive(
81    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
82)]
83#[serde(rename_all = "snake_case")]
84pub enum SecretReferenceStatus {
85    Resolved,
86    Unresolved,
87    Expired,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
91#[serde(rename_all = "camelCase")]
92pub struct SecretReference {
93    pub reference_id: String,
94    pub provider: String,
95    pub purpose: String,
96    pub scope: String,
97    pub status: SecretReferenceStatus,
98    #[serde(default)]
99    pub metadata: BTreeMap<String, String>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
103#[serde(rename_all = "camelCase")]
104pub struct SecretReferenceObservation {
105    pub status: SecretReferenceStatus,
106    #[serde(default)]
107    pub metadata: BTreeMap<String, String>,
108}
109
110/// Replaceable boundary for observing an opaque reference without reading its value.
111pub trait SecretProvider: std::fmt::Debug + Send + Sync {
112    fn provider_name(&self) -> &str;
113
114    fn observe_reference(&self, reference_id: &str) -> Option<SecretReferenceObservation>;
115}
116
117#[derive(Debug, Clone, Default)]
118pub struct DeterministicSecretProvider {
119    provider_name: String,
120    observations: BTreeMap<String, SecretReferenceObservation>,
121}
122
123impl DeterministicSecretProvider {
124    #[must_use]
125    pub fn new(
126        provider_name: impl Into<String>,
127        observations: impl IntoIterator<Item = (String, SecretReferenceObservation)>,
128    ) -> Self {
129        Self {
130            provider_name: provider_name.into(),
131            observations: observations.into_iter().collect(),
132        }
133    }
134}
135
136impl SecretProvider for DeterministicSecretProvider {
137    fn provider_name(&self) -> &str {
138        &self.provider_name
139    }
140
141    fn observe_reference(&self, reference_id: &str) -> Option<SecretReferenceObservation> {
142        self.observations.get(reference_id).cloned()
143    }
144}
145
146#[must_use]
147pub fn observe_secret_reference(
148    provider: &dyn SecretProvider,
149    reference_id: impl Into<String>,
150    purpose: impl Into<String>,
151    scope: impl Into<String>,
152) -> SecretReference {
153    let reference_id = reference_id.into();
154    let observation = provider
155        .observe_reference(&reference_id)
156        .filter(|observation| secret_metadata_is_safe(&observation.metadata))
157        .unwrap_or(SecretReferenceObservation {
158            status: SecretReferenceStatus::Unresolved,
159            metadata: BTreeMap::new(),
160        });
161    SecretReference {
162        reference_id,
163        provider: provider.provider_name().to_owned(),
164        purpose: purpose.into(),
165        scope: scope.into(),
166        status: observation.status,
167        metadata: observation.metadata,
168    }
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, ToSchema)]
172#[serde(rename_all = "camelCase")]
173pub struct ConfigImpact {
174    pub path: String,
175    pub scope: ConfigFieldScope,
176    pub activation: ConfigFieldActivation,
177    pub mutable: bool,
178}
179
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, ToSchema)]
181#[serde(rename_all = "camelCase")]
182pub struct ConfigRevision {
183    pub protocol: String,
184    pub revision_id: String,
185    pub revision_digest: String,
186    pub service_id: String,
187    pub contract_reference: String,
188    pub contract_digest: String,
189    pub values: BTreeMap<String, Value>,
190    pub secret_references: Vec<SecretReference>,
191    pub impacts: Vec<ConfigImpact>,
192}
193
194#[derive(
195    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
196)]
197#[serde(rename_all = "snake_case")]
198pub enum ConfigOperation {
199    Stage,
200    Activate,
201    Rollback,
202}
203
204#[derive(
205    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
206)]
207#[serde(rename_all = "snake_case")]
208pub enum ConfigRevisionActivation {
209    Staged,
210    Active,
211    RolledBack,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
215#[serde(rename_all = "camelCase")]
216pub struct ConfigActivationPlan {
217    pub protocol: String,
218    pub plan_id: String,
219    pub plan_digest: String,
220    pub environment: String,
221    pub expected_environment_revision: u64,
222    pub operation: ConfigOperation,
223    pub target_revision_id: String,
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub previous_revision_id: Option<String>,
226    pub contract_digest: String,
227    pub effects: DeliveryEffects,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
231#[serde(rename_all = "camelCase")]
232pub struct ConfigActivationReceipt {
233    pub protocol: String,
234    pub receipt_id: String,
235    pub plan_id: String,
236    pub environment: String,
237    pub environment_revision_before: u64,
238    pub environment_revision_after: u64,
239    pub target_revision_id: String,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub previous_revision_id: Option<String>,
242    pub activation: ConfigRevisionActivation,
243    pub effects: DeliveryEffects,
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
247#[serde(rename_all = "camelCase")]
248pub struct ConfigState {
249    pub environment: String,
250    pub environment_revision: u64,
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub staged_revision_id: Option<String>,
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub active_revision_id: Option<String>,
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub previous_revision_id: Option<String>,
257    #[serde(default)]
258    pub history: Vec<ConfigActivationReceipt>,
259}
260
261impl ConfigState {
262    #[must_use]
263    pub fn new(environment: impl Into<String>, environment_revision: u64) -> Self {
264        Self {
265            environment: environment.into(),
266            environment_revision,
267            staged_revision_id: None,
268            active_revision_id: None,
269            previous_revision_id: None,
270            history: Vec::new(),
271        }
272    }
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
276#[serde(rename_all = "camelCase")]
277pub struct ConfigApplyRejection {
278    pub issues: Vec<DeliveryIssue>,
279    pub effects: DeliveryEffects,
280}
281
282pub fn build_config_contract(
283    reference: impl Into<String>,
284    mut fields: Vec<ConfigField>,
285) -> Result<ConfigContractDefinition, Vec<DeliveryIssue>> {
286    let reference = reference.into();
287    fields.sort();
288    let mut issues = Vec::new();
289    if reference.trim().is_empty() || fields.is_empty() {
290        issues.push(issue(
291            DeliveryIssueCode::ConfigContractMismatch,
292            "A Config Contract requires a stable reference and at least one field.",
293            "Declare the exact configuration surface consumed by the Service Release.",
294            "Correct the Config Contract and build it again.",
295        ));
296    }
297    let unique_paths = fields
298        .iter()
299        .map(|field| field.path.as_str())
300        .collect::<BTreeSet<_>>();
301    if unique_paths.len() != fields.len() || unique_paths.contains("") {
302        issues.push(issue(
303            DeliveryIssueCode::ConfigContractMismatch,
304            "Config Contract field paths must be non-empty and unique.",
305            "Assign one stable path to every configuration field.",
306            "Correct duplicate or empty paths and build the contract again.",
307        ));
308    }
309    if !issues.is_empty() {
310        return Err(issues);
311    }
312    let digest = digest_json(&(
313        CONFIG_CONTRACT_PROTOCOL,
314        reference.as_str(),
315        fields.as_slice(),
316    ));
317    Ok(ConfigContractDefinition {
318        protocol: CONFIG_CONTRACT_PROTOCOL.to_owned(),
319        reference,
320        digest,
321        fields,
322    })
323}
324
325pub fn build_config_revision(
326    service_id: impl Into<String>,
327    contract: &ConfigContractDefinition,
328    values: BTreeMap<String, Value>,
329    mut secret_references: Vec<SecretReference>,
330    secret_provider: &dyn SecretProvider,
331) -> Result<ConfigRevision, Vec<DeliveryIssue>> {
332    let service_id = service_id.into();
333    let mut issues = Vec::new();
334    if !config_contract_integrity_is_valid(contract) {
335        issues.push(issue(
336            DeliveryIssueCode::ConfigContractMismatch,
337            "The Config Contract content does not match its digest.",
338            "Use the exact Config Contract bound to the Service Release.",
339            "Regenerate the Config Contract and Config Revision.",
340        ));
341    }
342
343    let fields = contract
344        .fields
345        .iter()
346        .map(|field| (field.path.as_str(), field))
347        .collect::<BTreeMap<_, _>>();
348    for (path, value) in &values {
349        let Some(field) = fields.get(path.as_str()) else {
350            issues.push(issue(
351                DeliveryIssueCode::ConfigContractMismatch,
352                format!("Configuration field `{path}` is not declared by the Config Contract."),
353                "Remove unknown values or update the Config Contract before building a revision.",
354                "Correct the values and build the Config Revision again.",
355            ));
356            continue;
357        };
358        if field.sensitivity == ConfigFieldSensitivity::Sensitive {
359            issues.push(issue(
360                DeliveryIssueCode::PlaintextSecretDetected,
361                format!("Sensitive field `{path}` was supplied as configuration data."),
362                "Replace the value with an opaque Secret Reference.",
363                "Remove the plaintext value and bind a Secret Reference.",
364            ));
365        } else if !value_matches(field.value_type, value) {
366            issues.push(issue(
367                DeliveryIssueCode::ConfigContractMismatch,
368                format!("Configuration field `{path}` has the wrong JSON value type."),
369                "Supply a value matching the Config Contract field type.",
370                "Correct the value and build the Config Revision again.",
371            ));
372        }
373    }
374
375    secret_references.sort_by(|left, right| {
376        (&left.purpose, &left.reference_id).cmp(&(&right.purpose, &right.reference_id))
377    });
378    let unique_reference_ids = secret_references
379        .iter()
380        .map(|reference| reference.reference_id.as_str())
381        .collect::<BTreeSet<_>>();
382    let unique_purposes = secret_references
383        .iter()
384        .map(|reference| reference.purpose.as_str())
385        .collect::<BTreeSet<_>>();
386    if unique_reference_ids.len() != secret_references.len()
387        || unique_purposes.len() != secret_references.len()
388    {
389        issues.push(issue(
390            DeliveryIssueCode::ConfigContractMismatch,
391            "Secret References must bind one unique opaque reference to each sensitive field.",
392            "Keep exactly one resolved Secret Reference per declared sensitive purpose.",
393            "Remove duplicate Secret References and build the Config Revision again.",
394        ));
395    }
396    for reference in &secret_references {
397        let observed = secret_provider.observe_reference(&reference.reference_id);
398        if reference.reference_id.trim().is_empty()
399            || reference.provider.trim().is_empty()
400            || reference.purpose.trim().is_empty()
401            || reference.scope.trim().is_empty()
402            || !secret_metadata_is_safe(&reference.metadata)
403        {
404            issues.push(issue(
405                DeliveryIssueCode::PlaintextSecretDetected,
406                "A Secret Reference contains unsafe or value-shaped metadata.",
407                "Keep only opaque provider identity, purpose, scope, and non-sensitive status metadata.",
408                "Remove sensitive metadata and build the Config Revision again.",
409            ));
410        }
411        if reference.provider != secret_provider.provider_name()
412            || observed.as_ref().is_none_or(|observed| {
413                observed.status != reference.status || observed.metadata != reference.metadata
414            })
415        {
416            issues.push(issue(
417                DeliveryIssueCode::SecretReferenceUnresolved,
418                format!(
419                    "Secret Reference `{}` is not attested by the selected Secret Provider.",
420                    reference.reference_id
421                ),
422                "Observe the opaque reference through the configured provider boundary.",
423                "Refresh the provider observation and build the Config Revision again.",
424            ));
425        }
426        if reference.status != SecretReferenceStatus::Resolved {
427            issues.push(issue(
428                DeliveryIssueCode::SecretReferenceUnresolved,
429                format!(
430                    "Secret Reference `{}` is not resolved.",
431                    reference.reference_id
432                ),
433                "Resolve the opaque reference through the selected Secret Provider.",
434                "Refresh Secret Reference status before activation.",
435            ));
436        }
437        if !fields
438            .get(reference.purpose.as_str())
439            .is_some_and(|field| field.sensitivity == ConfigFieldSensitivity::Sensitive)
440        {
441            issues.push(issue(
442                DeliveryIssueCode::ConfigContractMismatch,
443                format!(
444                    "Secret Reference `{}` does not bind a declared sensitive Config field.",
445                    reference.reference_id
446                ),
447                "Bind opaque references only to sensitive fields in the exact Config Contract.",
448                "Correct the Secret Reference purpose and build the Config Revision again.",
449            ));
450        }
451    }
452
453    for field in &contract.fields {
454        if !field.required {
455            continue;
456        }
457        let present = match field.sensitivity {
458            ConfigFieldSensitivity::Public => values.contains_key(&field.path),
459            ConfigFieldSensitivity::Sensitive => secret_references
460                .iter()
461                .any(|reference| reference.purpose == field.path),
462        };
463        if !present {
464            issues.push(issue(
465                if field.sensitivity == ConfigFieldSensitivity::Sensitive {
466                    DeliveryIssueCode::SecretReferenceUnresolved
467                } else {
468                    DeliveryIssueCode::ConfigContractMismatch
469                },
470                format!("Required configuration field `{}` is missing.", field.path),
471                "Supply the required non-secret value or opaque Secret Reference.",
472                "Complete the Config Revision and validate it again.",
473            ));
474        }
475    }
476    if !issues.is_empty() {
477        return Err(issues);
478    }
479
480    let impacts = contract
481        .fields
482        .iter()
483        .filter(|field| {
484            values.contains_key(&field.path)
485                || secret_references
486                    .iter()
487                    .any(|reference| reference.purpose == field.path)
488        })
489        .map(|field| ConfigImpact {
490            path: field.path.clone(),
491            scope: field.scope,
492            activation: field.activation,
493            mutable: field.mutable,
494        })
495        .collect::<Vec<_>>();
496    let revision_digest = digest_json(&(
497        CONFIG_REVISION_PROTOCOL,
498        service_id.as_str(),
499        contract.reference.as_str(),
500        contract.digest.as_str(),
501        &values,
502        &secret_references,
503        &impacts,
504    ));
505    Ok(ConfigRevision {
506        protocol: CONFIG_REVISION_PROTOCOL.to_owned(),
507        revision_id: format!("config-revision:{revision_digest}"),
508        revision_digest,
509        service_id,
510        contract_reference: contract.reference.clone(),
511        contract_digest: contract.digest.clone(),
512        values,
513        secret_references,
514        impacts,
515    })
516}
517
518fn secret_metadata_is_safe(metadata: &BTreeMap<String, String>) -> bool {
519    metadata.iter().all(|(key, value)| match key.as_str() {
520        "leaseExpiresAt" | "lastResolvedAt" => timestamp_metadata_is_safe(value),
521        "rotationRevision" | "providerRevision" => {
522            !value.is_empty()
523                && value.len() <= 20
524                && value.bytes().all(|byte| byte.is_ascii_digit())
525        }
526        "rotationStatus" => matches!(
527            value.as_str(),
528            "current" | "due" | "rotating" | "stale" | "revoked"
529        ),
530        _ => false,
531    })
532}
533
534/// Return whether a Secret Reference contains only the bounded, non-value metadata
535/// accepted by the production delivery contract.
536#[must_use]
537pub fn secret_reference_metadata_is_safe(reference: &SecretReference) -> bool {
538    secret_metadata_is_safe(&reference.metadata)
539}
540
541fn timestamp_metadata_is_safe(value: &str) -> bool {
542    value.len() >= 20
543        && value.len() <= 40
544        && value.as_bytes().get(4) == Some(&b'-')
545        && value.as_bytes().get(7) == Some(&b'-')
546        && value.as_bytes().get(10) == Some(&b'T')
547        && (value.ends_with('Z')
548            || value
549                .as_bytes()
550                .iter()
551                .rev()
552                .take(6)
553                .any(|byte| *byte == b'+'))
554        && value.bytes().all(|byte| {
555            byte.is_ascii_digit() || matches!(byte, b'-' | b':' | b'T' | b'.' | b'Z' | b'+')
556        })
557}
558
559pub fn plan_config_activation(
560    state: &ConfigState,
561    contract: &ConfigContractDefinition,
562    revision: &ConfigRevision,
563    secret_provider: &dyn SecretProvider,
564    operation: ConfigOperation,
565) -> Result<ConfigActivationPlan, Vec<DeliveryIssue>> {
566    if state.environment.trim().is_empty()
567        || !config_revision_matches_contract(revision, contract, secret_provider)
568    {
569        return Err(vec![issue(
570            DeliveryIssueCode::ConfigContractMismatch,
571            "Config activation requires an integrity-valid Config Revision and environment identity.",
572            "Regenerate the Config Revision from its exact Config Contract.",
573            "Correct the input and plan activation again.",
574        )]);
575    }
576    if operation == ConfigOperation::Activate
577        && state.staged_revision_id.as_deref() != Some(revision.revision_id.as_str())
578        && state.active_revision_id.as_deref() != Some(revision.revision_id.as_str())
579    {
580        return Err(vec![issue(
581            DeliveryIssueCode::ConfigContractMismatch,
582            "The Config Revision must be staged before activation.",
583            "Stage and validate the exact revision before making it active.",
584            "Plan and apply the stage operation first.",
585        )]);
586    }
587    if operation == ConfigOperation::Rollback
588        && state.previous_revision_id.as_deref() != Some(revision.revision_id.as_str())
589    {
590        return Err(vec![issue(
591            DeliveryIssueCode::ConfigContractMismatch,
592            "Config rollback must target the environment's explicit previous Config Revision.",
593            "Select the exact previous revision recorded by the active environment state.",
594            "Refresh Config state and plan rollback to its previous revision.",
595        )]);
596    }
597    let previous_revision_id = state.active_revision_id.clone();
598    let effects = DeliveryEffects::default();
599    let plan_digest = digest_json(&(
600        CONFIG_ACTIVATION_PLAN_PROTOCOL,
601        state.environment.as_str(),
602        state.environment_revision,
603        operation,
604        revision.revision_id.as_str(),
605        previous_revision_id.as_deref(),
606        revision.contract_digest.as_str(),
607        &effects,
608    ));
609    Ok(ConfigActivationPlan {
610        protocol: CONFIG_ACTIVATION_PLAN_PROTOCOL.to_owned(),
611        plan_id: format!("config-activation-plan:{plan_digest}"),
612        plan_digest,
613        environment: state.environment.clone(),
614        expected_environment_revision: state.environment_revision,
615        operation,
616        target_revision_id: revision.revision_id.clone(),
617        previous_revision_id,
618        contract_digest: revision.contract_digest.clone(),
619        effects,
620    })
621}
622
623pub fn apply_config_activation(
624    state: &mut ConfigState,
625    plan: &ConfigActivationPlan,
626) -> Result<ConfigActivationReceipt, ConfigApplyRejection> {
627    if !config_activation_plan_integrity_is_valid(plan) {
628        return Err(ConfigApplyRejection {
629            issues: vec![issue(
630                DeliveryIssueCode::StaleInput,
631                "Config activation inputs changed after the plan was generated.",
632                "Generate a new plan from the current environment and Config Revision state.",
633                "Refresh state and plan the configuration operation again.",
634            )],
635            effects: DeliveryEffects::default(),
636        });
637    }
638    if let Some(existing) = state
639        .history
640        .iter()
641        .find(|item| item.plan_id == plan.plan_id)
642    {
643        return config_activation_receipt_integrity_is_valid(existing, plan)
644            .then(|| existing.clone())
645            .ok_or_else(|| ConfigApplyRejection {
646                issues: vec![issue(
647                    DeliveryIssueCode::StaleInput,
648                    "The completed Config activation receipt no longer matches the exact plan.",
649                    "Preserve the immutable plan and append-only receipt together.",
650                    "Restore the original receipt or create a new Config activation plan.",
651                )],
652                effects: DeliveryEffects::default(),
653            });
654    }
655    if state.environment != plan.environment
656        || state.environment_revision != plan.expected_environment_revision
657    {
658        return Err(ConfigApplyRejection {
659            issues: vec![issue(
660                DeliveryIssueCode::StaleInput,
661                "Config activation inputs changed after the plan was generated.",
662                "Generate a new plan from the current environment and Config Revision state.",
663                "Refresh state and plan the configuration operation again.",
664            )],
665            effects: DeliveryEffects::default(),
666        });
667    }
668
669    let activation = match plan.operation {
670        ConfigOperation::Stage => {
671            state.staged_revision_id = Some(plan.target_revision_id.clone());
672            ConfigRevisionActivation::Staged
673        }
674        ConfigOperation::Activate => {
675            if state.staged_revision_id.as_deref() != Some(plan.target_revision_id.as_str())
676                && state.active_revision_id.as_deref() != Some(plan.target_revision_id.as_str())
677            {
678                return Err(ConfigApplyRejection {
679                    issues: vec![issue(
680                        DeliveryIssueCode::StaleInput,
681                        "The staged Config Revision changed before activation.",
682                        "Regenerate the plan from the current staged revision.",
683                        "Restage or replan activation.",
684                    )],
685                    effects: DeliveryEffects::default(),
686                });
687            }
688            state.previous_revision_id = state.active_revision_id.clone();
689            state.active_revision_id = Some(plan.target_revision_id.clone());
690            state.staged_revision_id = None;
691            ConfigRevisionActivation::Active
692        }
693        ConfigOperation::Rollback => {
694            if state.previous_revision_id.as_deref() != Some(plan.target_revision_id.as_str())
695                || state.active_revision_id != plan.previous_revision_id
696            {
697                return Err(ConfigApplyRejection {
698                    issues: vec![issue(
699                        DeliveryIssueCode::StaleInput,
700                        "The previous Config Revision changed before rollback.",
701                        "Regenerate rollback from the current active and previous revision pair.",
702                        "Refresh state and replan the rollback.",
703                    )],
704                    effects: DeliveryEffects::default(),
705                });
706            }
707            state.previous_revision_id = state.active_revision_id.clone();
708            state.active_revision_id = Some(plan.target_revision_id.clone());
709            state.staged_revision_id = None;
710            ConfigRevisionActivation::RolledBack
711        }
712    };
713    let revision_before = state.environment_revision;
714    state.environment_revision += 1;
715    let effects = DeliveryEffects {
716        mutates_configuration: true,
717        appends_ledger: true,
718        ..DeliveryEffects::default()
719    };
720    let receipt_id = format!(
721        "config-activation-receipt:{}",
722        digest_json(&(
723            plan.plan_id.as_str(),
724            revision_before,
725            state.environment_revision,
726            activation,
727        ))
728    );
729    let receipt = ConfigActivationReceipt {
730        protocol: CONFIG_ACTIVATION_RECEIPT_PROTOCOL.to_owned(),
731        receipt_id,
732        plan_id: plan.plan_id.clone(),
733        environment: state.environment.clone(),
734        environment_revision_before: revision_before,
735        environment_revision_after: state.environment_revision,
736        target_revision_id: plan.target_revision_id.clone(),
737        previous_revision_id: plan.previous_revision_id.clone(),
738        activation,
739        effects,
740    };
741    state.history.push(receipt.clone());
742    Ok(receipt)
743}
744
745#[must_use]
746pub fn config_contract_integrity_is_valid(contract: &ConfigContractDefinition) -> bool {
747    let mut canonical_fields = contract.fields.clone();
748    canonical_fields.sort();
749    let unique_paths = contract
750        .fields
751        .iter()
752        .map(|field| field.path.as_str())
753        .collect::<BTreeSet<_>>();
754    contract.protocol == CONFIG_CONTRACT_PROTOCOL
755        && !contract.reference.trim().is_empty()
756        && !contract.fields.is_empty()
757        && contract.fields == canonical_fields
758        && unique_paths.len() == contract.fields.len()
759        && !unique_paths.contains("")
760        && digest_json(&(
761            contract.protocol.as_str(),
762            contract.reference.as_str(),
763            contract.fields.as_slice(),
764        )) == contract.digest
765}
766
767#[must_use]
768pub fn config_revision_integrity_is_valid(revision: &ConfigRevision) -> bool {
769    revision.protocol == CONFIG_REVISION_PROTOCOL
770        && revision.revision_id == format!("config-revision:{}", revision.revision_digest)
771        && digest_json(&(
772            revision.protocol.as_str(),
773            revision.service_id.as_str(),
774            revision.contract_reference.as_str(),
775            revision.contract_digest.as_str(),
776            &revision.values,
777            &revision.secret_references,
778            &revision.impacts,
779        )) == revision.revision_digest
780}
781
782#[must_use]
783pub fn config_revision_matches_contract(
784    revision: &ConfigRevision,
785    contract: &ConfigContractDefinition,
786    secret_provider: &dyn SecretProvider,
787) -> bool {
788    if !config_contract_integrity_is_valid(contract)
789        || !config_revision_integrity_is_valid(revision)
790        || revision.contract_reference != contract.reference
791        || revision.contract_digest != contract.digest
792    {
793        return false;
794    }
795    build_config_revision(
796        revision.service_id.clone(),
797        contract,
798        revision.values.clone(),
799        revision.secret_references.clone(),
800        secret_provider,
801    )
802    .is_ok_and(|expected| expected == *revision)
803}
804
805#[must_use]
806pub fn config_activation_plan_integrity_is_valid(plan: &ConfigActivationPlan) -> bool {
807    plan.protocol == CONFIG_ACTIVATION_PLAN_PROTOCOL
808        && plan.plan_id == format!("config-activation-plan:{}", plan.plan_digest)
809        && digest_json(&(
810            plan.protocol.as_str(),
811            plan.environment.as_str(),
812            plan.expected_environment_revision,
813            plan.operation,
814            plan.target_revision_id.as_str(),
815            plan.previous_revision_id.as_deref(),
816            plan.contract_digest.as_str(),
817            &plan.effects,
818        )) == plan.plan_digest
819}
820
821#[must_use]
822pub fn config_activation_receipt_integrity_is_valid(
823    receipt: &ConfigActivationReceipt,
824    plan: &ConfigActivationPlan,
825) -> bool {
826    let activation = match plan.operation {
827        ConfigOperation::Stage => ConfigRevisionActivation::Staged,
828        ConfigOperation::Activate => ConfigRevisionActivation::Active,
829        ConfigOperation::Rollback => ConfigRevisionActivation::RolledBack,
830    };
831    let effects = DeliveryEffects {
832        mutates_configuration: true,
833        appends_ledger: true,
834        ..DeliveryEffects::default()
835    };
836    receipt.protocol == CONFIG_ACTIVATION_RECEIPT_PROTOCOL
837        && config_activation_plan_integrity_is_valid(plan)
838        && receipt.plan_id == plan.plan_id
839        && receipt.environment == plan.environment
840        && receipt.environment_revision_before == plan.expected_environment_revision
841        && receipt.environment_revision_after == receipt.environment_revision_before + 1
842        && receipt.target_revision_id == plan.target_revision_id
843        && receipt.previous_revision_id == plan.previous_revision_id
844        && receipt.activation == activation
845        && receipt.effects == effects
846        && receipt.receipt_id
847            == format!(
848                "config-activation-receipt:{}",
849                digest_json(&(
850                    plan.plan_id.as_str(),
851                    receipt.environment_revision_before,
852                    receipt.environment_revision_after,
853                    activation,
854                ))
855            )
856}
857
858fn value_matches(expected: ConfigValueType, value: &Value) -> bool {
859    match expected {
860        ConfigValueType::String => value.is_string(),
861        ConfigValueType::Integer => value.as_i64().is_some() || value.as_u64().is_some(),
862        ConfigValueType::Number => value.is_number(),
863        ConfigValueType::Boolean => value.is_boolean(),
864        ConfigValueType::Object => value.is_object(),
865        ConfigValueType::Array => value.is_array(),
866    }
867}
868
869fn digest_json(value: &impl Serialize) -> String {
870    extraction_input_digest(serde_json::to_vec(value).expect("delivery values must serialize"))
871}