Skip to main content

lenso_service/production_delivery/
eligibility.rs

1use std::collections::BTreeMap;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use utoipa::ToSchema;
6
7use crate::extraction_input_digest;
8
9use super::{
10    DeliveryDecision, DeliveryEffects, DeliveryIssue, DeliveryIssueCode, ReleaseSignerStatus,
11    ReleaseTrustProvider, ServiceRelease, issue, service_release_integrity_is_valid,
12};
13
14pub const PRODUCTION_ELIGIBILITY_PROTOCOL: &str = "lenso.production-eligibility.v1";
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
17#[serde(rename_all = "camelCase")]
18pub struct ContractCompatibilityInput {
19    pub contract_id: String,
20    pub current_major: u32,
21    pub candidate_major: u32,
22    pub compatible: Option<bool>,
23    #[serde(default)]
24    pub active_consumers: Vec<String>,
25    pub consumer_migration_evidence: bool,
26    pub retiring: bool,
27    pub deprecation_window_complete: bool,
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 MigrationPhase {
35    Expand,
36    Backfill,
37    Verify,
38    Contract,
39    Irreversible,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
43#[serde(rename_all = "camelCase")]
44pub struct MigrationCompatibilityInput {
45    pub migration_id: String,
46    pub lineage_id: String,
47    pub sequence: u32,
48    pub phase: MigrationPhase,
49    pub verified: bool,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
53#[serde(rename_all = "camelCase")]
54pub struct WorkflowCompatibilityInput {
55    pub new_starts_compatible: Option<bool>,
56    pub in_flight_compatible: Option<bool>,
57    pub downgrade_safe: Option<bool>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
61#[serde(rename_all = "camelCase")]
62pub struct RollbackCompatibilityInput {
63    pub prior_release_compatible: Option<bool>,
64    pub schema_compatible: Option<bool>,
65    pub workflow_compatible: Option<bool>,
66    pub config_compatible: Option<bool>,
67    pub secret_references_compatible: Option<bool>,
68    pub edge_compatible: Option<bool>,
69    pub adapter_capable: Option<bool>,
70    pub previous_release_id: String,
71    pub previous_release_digest: String,
72    pub previous_deployment_plan_id: String,
73    pub previous_deployment_plan_digest: String,
74    pub previous_config_revision_id: String,
75    pub previous_config_revision_digest: String,
76    #[serde(default)]
77    pub previous_secret_reference_ids: Vec<String>,
78    pub previous_gateway_plan_id: String,
79    pub previous_gateway_plan_digest: String,
80    pub previous_gateway_configuration_identity: String,
81    pub previous_adapter: String,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
85#[serde(rename_all = "camelCase")]
86pub struct ProductionEligibilityInput {
87    pub release_id: String,
88    pub release_digest: String,
89    pub provider_id: String,
90    pub provider_proof: String,
91    pub system_graph_digest: String,
92    #[serde(default)]
93    pub contracts: Vec<ContractCompatibilityInput>,
94    #[serde(default)]
95    pub migrations: Vec<MigrationCompatibilityInput>,
96    pub workflows: WorkflowCompatibilityInput,
97    pub rollback: RollbackCompatibilityInput,
98    pub provider_compatibility_verified: Option<bool>,
99    pub workload_identity_production: Option<bool>,
100    pub tenancy_mode_production: Option<bool>,
101    pub tenant_context_enforced: Option<bool>,
102    pub call_policies_declared: Option<bool>,
103    pub dependencies_ready: Option<bool>,
104    pub resilience_declared: Option<bool>,
105    pub reliability_contract_complete: Option<bool>,
106    pub edge_contract_valid: Option<bool>,
107    pub environment_verification_fresh: Option<bool>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
111#[serde(rename_all = "camelCase")]
112pub struct ContractRetirementEvidence {
113    pub contract_id: String,
114    pub ready: bool,
115    pub active_consumers: Vec<String>,
116    pub deprecation_window_complete: bool,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
120#[serde(rename_all = "camelCase")]
121pub struct ProductionEligibilityEvidence {
122    pub protocol: String,
123    pub evidence_id: String,
124    pub evidence_digest: String,
125    pub release_id: String,
126    pub release_digest: String,
127    pub provider_id: String,
128    pub input_digest: String,
129    pub system_graph_digest: String,
130    pub decision: DeliveryDecision,
131    pub facts: BTreeMap<String, Option<bool>>,
132    pub contract_retirement: Vec<ContractRetirementEvidence>,
133    pub issues: Vec<DeliveryIssue>,
134    pub effects: DeliveryEffects,
135}
136
137#[must_use]
138pub fn evaluate_production_eligibility(
139    input: &ProductionEligibilityInput,
140    release: &ServiceRelease,
141    provider: &dyn ReleaseTrustProvider,
142) -> ProductionEligibilityEvidence {
143    let mut issues = Vec::new();
144    let mut retirement = Vec::new();
145    let input_digest = production_eligibility_input_digest(input);
146    let authority_valid = service_release_integrity_is_valid(release)
147        && input.release_id == release.release_id
148        && input.release_digest == release.release_digest
149        && !input.system_graph_digest.trim().is_empty()
150        && provider.verify(
151            input.provider_id.as_str(),
152            input_digest.as_str(),
153            input.provider_proof.as_str(),
154        ) == ReleaseSignerStatus::Trusted;
155    if !authority_valid {
156        issues.push(issue(
157            DeliveryIssueCode::PolicyEvidenceMissing,
158            "Production Eligibility is not attested for the exact Service Release and System graph.",
159            "Collect release-bound compatibility facts through a trusted eligibility provider.",
160            "Refresh and attest the exact eligibility input before policy evaluation.",
161        ));
162    }
163    let release_contracts = release
164        .contract_versions
165        .iter()
166        .map(|contract| contract.contract_id.as_str())
167        .collect::<std::collections::BTreeSet<_>>();
168    let input_contracts = input
169        .contracts
170        .iter()
171        .map(|contract| contract.contract_id.as_str())
172        .collect::<std::collections::BTreeSet<_>>();
173    let contracts_bind_release = release_contracts.len() == release.contract_versions.len()
174        && input_contracts.len() == input.contracts.len()
175        && release_contracts == input_contracts
176        && input.contracts.iter().all(|candidate| {
177            release.contract_versions.iter().any(|declared| {
178                declared.contract_id == candidate.contract_id
179                    && major_version(&declared.version) == Some(candidate.candidate_major)
180            })
181        });
182    if !contracts_bind_release {
183        issues.push(issue(
184            DeliveryIssueCode::ContractIncompatible,
185            "Eligibility Contract evidence does not cover the exact candidate Contract Versions.",
186            "Bind every candidate Contract identity and major version from the Service Release.",
187            "Refresh Contract compatibility evidence for the exact release.",
188        ));
189    }
190    let release_migrations = release
191        .migrations
192        .iter()
193        .map(|migration| migration.migration_id.as_str())
194        .collect::<std::collections::BTreeSet<_>>();
195    let input_migrations = input
196        .migrations
197        .iter()
198        .map(|migration| migration.migration_id.as_str())
199        .collect::<std::collections::BTreeSet<_>>();
200    let migrations_bind_release = release_migrations.len() == release.migrations.len()
201        && input_migrations.len() == input.migrations.len()
202        && release_migrations == input_migrations
203        && input.migrations.iter().all(|candidate| {
204            release.migrations.iter().any(|declared| {
205                declared.migration_id == candidate.migration_id
206                    && migration_phase(&declared.phase) == Some(candidate.phase)
207                    && (declared.reversible || candidate.phase != MigrationPhase::Irreversible)
208            })
209        });
210    if !migrations_bind_release {
211        issues.push(issue(
212            DeliveryIssueCode::MigrationUnsafe,
213            "Eligibility Migration evidence does not cover the exact candidate migration set.",
214            "Bind every migration identity, phase, and reversibility boundary from the Service Release.",
215            "Refresh Migration compatibility evidence for the exact release.",
216        ));
217    }
218    let contracts_safe = contracts_bind_release && input.contracts.iter().all(|contract| {
219        let compatible = match contract.compatible {
220            Some(true) => true,
221            Some(false) => {
222                contract.candidate_major > contract.current_major
223                    && contract.consumer_migration_evidence
224            }
225            None => false,
226        };
227        if !compatible {
228            issues.push(DeliveryIssue {
229                code: DeliveryIssueCode::ContractIncompatible,
230                message: format!(
231                    "Contract `{}` is incompatible or has unknown compatibility evidence.",
232                    contract.contract_id
233                ),
234                evidence_references: vec![format!("contract:{}", contract.contract_id)],
235                remediation: "Keep compatible additions on the current major version or publish a parallel major with explicit Consumer migration evidence.".to_owned(),
236                next_actions: vec!["Correct the Contract Version and rerun can-I-deploy.".to_owned()],
237            });
238        }
239        if contract.retiring {
240            let ready = contract.active_consumers.is_empty()
241                && contract.deprecation_window_complete;
242            retirement.push(ContractRetirementEvidence {
243                contract_id: contract.contract_id.clone(),
244                ready,
245                active_consumers: contract.active_consumers.clone(),
246                deprecation_window_complete: contract.deprecation_window_complete,
247            });
248            if !ready {
249                issues.push(DeliveryIssue {
250                    code: DeliveryIssueCode::ContractIncompatible,
251                    message: format!(
252                        "Contract `{}` cannot retire while Consumers or its deprecation window remain active.",
253                        contract.contract_id
254                    ),
255                    evidence_references: contract.active_consumers.clone(),
256                    remediation: "Migrate every active Consumer and satisfy the declared deprecation window.".to_owned(),
257                    next_actions: vec!["Report retirement readiness without retiring the Contract during Promotion.".to_owned()],
258                });
259            }
260        }
261        compatible
262    });
263
264    let mut migrations_safe = migrations_bind_release;
265    let mut irreversible = false;
266    let mut lineages = BTreeMap::<&str, Vec<&MigrationCompatibilityInput>>::new();
267    for migration in &input.migrations {
268        lineages
269            .entry(migration.lineage_id.as_str())
270            .or_default()
271            .push(migration);
272    }
273    for (lineage_id, mut migrations) in lineages {
274        migrations.sort_by_key(|migration| {
275            (migration.sequence, migration.phase, &migration.migration_id)
276        });
277        let lineage_identity_valid = !lineage_id.trim().is_empty()
278            && migrations.iter().all(|migration| {
279                !migration.migration_id.trim().is_empty() && migration.sequence > 0
280            })
281            && migrations
282                .windows(2)
283                .all(|pair| pair[0].sequence != pair[1].sequence);
284        if !lineage_identity_valid {
285            migrations_safe = false;
286            issues.push(issue(
287                DeliveryIssueCode::MigrationUnsafe,
288                format!(
289                    "Migration lineage `{lineage_id}` has missing or duplicate sequence identity."
290                ),
291                "Declare one non-zero sequence position for every migration step in the lineage.",
292                "Correct the migration lineage and rerun can-I-deploy.",
293            ));
294        }
295        for migration in &migrations {
296            if !migration.verified {
297                migrations_safe = false;
298                issues.push(issue(
299                    DeliveryIssueCode::MigrationUnsafe,
300                    format!("Migration `{}` is not verified.", migration.migration_id),
301                    "Provide verified migration evidence before production eligibility.",
302                    "Verify the migration step and rerun can-I-deploy.",
303                ));
304            }
305            if migration.phase == MigrationPhase::Irreversible {
306                irreversible = true;
307            }
308            if migration.phase != MigrationPhase::Contract {
309                continue;
310            }
311            let expand_verified = migrations.iter().any(|candidate| {
312                candidate.phase == MigrationPhase::Expand
313                    && candidate.verified
314                    && candidate.sequence < migration.sequence
315            });
316            let verify_verified = migrations.iter().any(|candidate| {
317                candidate.phase == MigrationPhase::Verify
318                    && candidate.verified
319                    && candidate.sequence < migration.sequence
320            });
321            if !migration.verified || !expand_verified || !verify_verified {
322                migrations_safe = false;
323                issues.push(issue(
324                    DeliveryIssueCode::MigrationUnsafe,
325                    format!(
326                        "Contract migration `{}` lacks verified expand-before-contract evidence in lineage `{lineage_id}`.",
327                        migration.migration_id
328                    ),
329                    "Complete verified expand, backfill where needed, and verification in the same lineage before contract.",
330                    "Correct the migration sequence and rerun can-I-deploy.",
331                ));
332            }
333        }
334    }
335
336    let workflows_safe = option_true(input.workflows.new_starts_compatible)
337        && option_true(input.workflows.in_flight_compatible)
338        && option_true(input.workflows.downgrade_safe);
339    if !workflows_safe {
340        issues.push(issue(
341            DeliveryIssueCode::WorkflowIncompatible,
342            "Durable Workflow compatibility for new or in-flight instances is unsafe or unknown.",
343            "Provide version-pinned compatibility and downgrade evidence for every active Workflow.",
344            "Correct Workflow compatibility before production Promotion.",
345        ));
346    }
347
348    let previous_secret_reference_ids = input
349        .rollback
350        .previous_secret_reference_ids
351        .iter()
352        .map(String::as_str)
353        .collect::<std::collections::BTreeSet<_>>();
354    let rollback_target_identified = input
355        .rollback
356        .previous_release_id
357        .starts_with("service-release:")
358        && !input.rollback.previous_release_digest.trim().is_empty()
359        && input.rollback.previous_release_id != release.release_id
360        && input.rollback.previous_release_digest != release.release_digest
361        && input
362            .rollback
363            .previous_deployment_plan_id
364            .starts_with("deployment-plan:")
365        && !input
366            .rollback
367            .previous_deployment_plan_digest
368            .trim()
369            .is_empty()
370        && input
371            .rollback
372            .previous_config_revision_id
373            .starts_with("config-revision:")
374        && !input
375            .rollback
376            .previous_config_revision_digest
377            .trim()
378            .is_empty()
379        && !input.rollback.previous_secret_reference_ids.is_empty()
380        && previous_secret_reference_ids.len()
381            == input.rollback.previous_secret_reference_ids.len()
382        && input
383            .rollback
384            .previous_gateway_plan_id
385            .starts_with("gateway-plan:")
386        && !input
387            .rollback
388            .previous_gateway_plan_digest
389            .trim()
390            .is_empty()
391        && !input
392            .rollback
393            .previous_gateway_configuration_identity
394            .trim()
395            .is_empty()
396        && !input.rollback.previous_adapter.trim().is_empty();
397    let rollback_safe = !irreversible
398        && rollback_target_identified
399        && (!release.rollback.previous_release_required
400            || option_true(input.rollback.prior_release_compatible))
401        && (!release.rollback.automatic_allowed
402            || !release
403                .migrations
404                .iter()
405                .any(|migration| !migration.reversible))
406        && option_true(input.rollback.prior_release_compatible)
407        && option_true(input.rollback.schema_compatible)
408        && option_true(input.rollback.workflow_compatible)
409        && option_true(input.rollback.config_compatible)
410        && option_true(input.rollback.secret_references_compatible)
411        && option_true(input.rollback.edge_compatible)
412        && option_true(input.rollback.adapter_capable);
413    if !rollback_safe {
414        issues.push(issue(
415            DeliveryIssueCode::RollbackUnsafe,
416            "Automatic rollback is unsafe or lacks required prior release, schema, Workflow, configuration, Secret Reference, edge, or adapter evidence.",
417            "Declare an honest rollback boundary and remove irreversible or destructive automatic recovery claims.",
418            "Provide a safe rollback target or require explicit intervention.",
419        ));
420    }
421
422    let mut facts = BTreeMap::from([
423        ("contracts.compatible".to_owned(), Some(contracts_safe)),
424        ("migrations.safe".to_owned(), Some(migrations_safe)),
425        ("workflows.compatible".to_owned(), Some(workflows_safe)),
426        ("rollback.safe".to_owned(), Some(rollback_safe)),
427        (
428            "providers.compatible".to_owned(),
429            input.provider_compatibility_verified,
430        ),
431        (
432            "identity.production".to_owned(),
433            input.workload_identity_production,
434        ),
435        (
436            "tenancy.mode.production".to_owned(),
437            input.tenancy_mode_production,
438        ),
439        ("tenancy.enforced".to_owned(), input.tenant_context_enforced),
440        (
441            "call_policies.declared".to_owned(),
442            input.call_policies_declared,
443        ),
444        ("dependencies.ready".to_owned(), input.dependencies_ready),
445        ("resilience.declared".to_owned(), input.resilience_declared),
446        (
447            "reliability.complete".to_owned(),
448            input.reliability_contract_complete,
449        ),
450        ("edge.valid".to_owned(), input.edge_contract_valid),
451        (
452            "environment.verification_fresh".to_owned(),
453            input.environment_verification_fresh,
454        ),
455    ]);
456    for (key, code, subject) in [
457        (
458            "providers.compatible",
459            DeliveryIssueCode::PolicyRuleBlocked,
460            "Provider compatibility evidence",
461        ),
462        (
463            "identity.production",
464            DeliveryIssueCode::PolicyRuleBlocked,
465            "production Workload Identity",
466        ),
467        (
468            "tenancy.mode.production",
469            DeliveryIssueCode::PolicyRuleBlocked,
470            "production Tenancy Mode",
471        ),
472        (
473            "tenancy.enforced",
474            DeliveryIssueCode::PolicyRuleBlocked,
475            "Tenant Context enforcement",
476        ),
477        (
478            "call_policies.declared",
479            DeliveryIssueCode::PolicyRuleBlocked,
480            "Call Policy declarations",
481        ),
482        (
483            "dependencies.ready",
484            DeliveryIssueCode::PolicyRuleBlocked,
485            "dependency readiness",
486        ),
487        (
488            "resilience.declared",
489            DeliveryIssueCode::PolicyRuleBlocked,
490            "resilience declarations",
491        ),
492        (
493            "reliability.complete",
494            DeliveryIssueCode::ReliabilityEvidenceMissing,
495            "Reliability Contract evidence",
496        ),
497        (
498            "edge.valid",
499            DeliveryIssueCode::EdgeExposureUnsafe,
500            "Edge Contract evidence",
501        ),
502        (
503            "environment.verification_fresh",
504            DeliveryIssueCode::ObservationStale,
505            "Environment Verification",
506        ),
507    ] {
508        if !facts.get(key).copied().flatten().unwrap_or(false) {
509            issues.push(issue(
510                code,
511                format!("Required production {subject} is false or unknown."),
512                format!("Provide current {subject} before production eligibility."),
513                "Refresh the missing evidence and rerun can-I-deploy.",
514            ));
515        }
516    }
517    if !authority_valid {
518        for value in facts.values_mut() {
519            *value = Some(false);
520        }
521    }
522    facts.insert("production.eligible".to_owned(), Some(issues.is_empty()));
523
524    #[derive(Serialize)]
525    struct EvidenceContent<'a> {
526        protocol: &'a str,
527        release_id: &'a str,
528        release_digest: &'a str,
529        provider_id: &'a str,
530        input_digest: &'a str,
531        system_graph_digest: &'a str,
532        facts: &'a BTreeMap<String, Option<bool>>,
533        contract_retirement: &'a [ContractRetirementEvidence],
534        issues: &'a [DeliveryIssue],
535    }
536    let content = EvidenceContent {
537        protocol: PRODUCTION_ELIGIBILITY_PROTOCOL,
538        release_id: &input.release_id,
539        release_digest: &input.release_digest,
540        provider_id: &input.provider_id,
541        input_digest: &input_digest,
542        system_graph_digest: &input.system_graph_digest,
543        facts: &facts,
544        contract_retirement: &retirement,
545        issues: &issues,
546    };
547    let evidence_digest = extraction_input_digest(
548        serde_json::to_vec(&content).expect("eligibility evidence must serialize"),
549    );
550    ProductionEligibilityEvidence {
551        protocol: PRODUCTION_ELIGIBILITY_PROTOCOL.to_owned(),
552        evidence_id: format!("production-eligibility:{evidence_digest}"),
553        evidence_digest,
554        release_id: input.release_id.clone(),
555        release_digest: input.release_digest.clone(),
556        provider_id: input.provider_id.clone(),
557        input_digest,
558        system_graph_digest: input.system_graph_digest.clone(),
559        decision: if issues.is_empty() {
560            DeliveryDecision::Passed
561        } else {
562            DeliveryDecision::Blocked
563        },
564        facts,
565        contract_retirement: retirement,
566        issues,
567        effects: DeliveryEffects::default(),
568    }
569}
570
571const fn option_true(value: Option<bool>) -> bool {
572    matches!(value, Some(true))
573}
574
575#[must_use]
576pub fn production_eligibility_evidence_integrity_is_valid(
577    evidence: &ProductionEligibilityEvidence,
578    input: &ProductionEligibilityInput,
579    release: &ServiceRelease,
580    provider: &dyn ReleaseTrustProvider,
581) -> bool {
582    evidence == &evaluate_production_eligibility(input, release, provider)
583}
584
585pub fn attest_production_eligibility_input(
586    release: &ServiceRelease,
587    provider: &dyn ReleaseTrustProvider,
588    provider_id: impl Into<String>,
589    mut input: ProductionEligibilityInput,
590) -> Result<ProductionEligibilityInput, DeliveryIssue> {
591    if !service_release_integrity_is_valid(release) {
592        return Err(issue(
593            DeliveryIssueCode::ReleaseTampered,
594            "Production Eligibility cannot attest an invalid Service Release.",
595            "Use the exact canonical Service Release as the eligibility subject.",
596            "Reassemble the release and collect eligibility evidence again.",
597        ));
598    }
599    input.release_id = release.release_id.clone();
600    input.release_digest = release.release_digest.clone();
601    input.provider_id = provider_id.into();
602    input.provider_proof.clear();
603    let subject = production_eligibility_input_digest(&input);
604    input.provider_proof = provider
605        .sign(input.provider_id.as_str(), subject.as_str())
606        .ok_or_else(|| {
607            issue(
608                DeliveryIssueCode::PolicyEvidenceMissing,
609                "The selected Production Eligibility provider is not trusted.",
610                "Use a configured evidence provider without exposing signing material.",
611                "Configure the provider and attest the eligibility input again.",
612            )
613        })?;
614    Ok(input)
615}
616
617fn production_eligibility_input_digest(input: &ProductionEligibilityInput) -> String {
618    #[derive(Serialize)]
619    #[serde(rename_all = "camelCase")]
620    struct Content<'a> {
621        release_id: &'a str,
622        release_digest: &'a str,
623        provider_id: &'a str,
624        system_graph_digest: &'a str,
625        contracts: &'a [ContractCompatibilityInput],
626        migrations: &'a [MigrationCompatibilityInput],
627        workflows: &'a WorkflowCompatibilityInput,
628        rollback: &'a RollbackCompatibilityInput,
629        provider_compatibility_verified: Option<bool>,
630        workload_identity_production: Option<bool>,
631        tenancy_mode_production: Option<bool>,
632        tenant_context_enforced: Option<bool>,
633        call_policies_declared: Option<bool>,
634        dependencies_ready: Option<bool>,
635        resilience_declared: Option<bool>,
636        reliability_contract_complete: Option<bool>,
637        edge_contract_valid: Option<bool>,
638        environment_verification_fresh: Option<bool>,
639    }
640    let content = Content {
641        release_id: &input.release_id,
642        release_digest: &input.release_digest,
643        provider_id: &input.provider_id,
644        system_graph_digest: &input.system_graph_digest,
645        contracts: &input.contracts,
646        migrations: &input.migrations,
647        workflows: &input.workflows,
648        rollback: &input.rollback,
649        provider_compatibility_verified: input.provider_compatibility_verified,
650        workload_identity_production: input.workload_identity_production,
651        tenancy_mode_production: input.tenancy_mode_production,
652        tenant_context_enforced: input.tenant_context_enforced,
653        call_policies_declared: input.call_policies_declared,
654        dependencies_ready: input.dependencies_ready,
655        resilience_declared: input.resilience_declared,
656        reliability_contract_complete: input.reliability_contract_complete,
657        edge_contract_valid: input.edge_contract_valid,
658        environment_verification_fresh: input.environment_verification_fresh,
659    };
660    extraction_input_digest(serde_json::to_vec(&content).expect("eligibility input must serialize"))
661}
662
663fn major_version(version: &str) -> Option<u32> {
664    version
665        .trim_start_matches('v')
666        .split('.')
667        .next()?
668        .parse()
669        .ok()
670}
671
672fn migration_phase(phase: &str) -> Option<MigrationPhase> {
673    match phase {
674        "expand" => Some(MigrationPhase::Expand),
675        "backfill" => Some(MigrationPhase::Backfill),
676        "verify" => Some(MigrationPhase::Verify),
677        "contract" => Some(MigrationPhase::Contract),
678        "irreversible" => Some(MigrationPhase::Irreversible),
679        _ => None,
680    }
681}