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}