Skip to main content

lenso_service/
ga_support.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
10pub const GA_SUPPORT_MANIFEST_PROTOCOL: &str = "lenso.ga-support-manifest.v1";
11pub const GA_SUPPORT_EVALUATION_PROTOCOL: &str = "lenso.ga-support-evaluation.v1";
12pub const MANIFEST_MIGRATION_PLAN_PROTOCOL: &str = "lenso.manifest-migration-plan.v1";
13pub const MANIFEST_MIGRATION_RECEIPT_PROTOCOL: &str = "lenso.manifest-migration-receipt.v1";
14pub const SERVICE_UPGRADE_PLAN_PROTOCOL: &str = "lenso.service-upgrade-plan.v1";
15pub const CONTRACT_RETIREMENT_PLAN_PROTOCOL: &str = "lenso.contract-retirement-plan.v1";
16pub const CONTRACT_RETIREMENT_RECEIPT_PROTOCOL: &str = "lenso.contract-retirement-receipt.v2";
17pub const FAILURE_SCENARIO_EVIDENCE_PROTOCOL: &str = "lenso.failure-scenario-evidence.v1";
18
19#[derive(
20    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
21)]
22#[serde(rename_all = "snake_case")]
23pub enum SupportDecision {
24    Supported,
25    Unsupported,
26    Unknown,
27    Blocked,
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 SupportStatus {
35    Candidate,
36    GeneralAvailability,
37    Deprecated,
38    Unsupported,
39}
40
41#[derive(
42    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
43)]
44#[serde(rename_all = "snake_case")]
45pub enum ComponentKind {
46    Cli,
47    Runtime,
48    Contracts,
49    Provider,
50    Operator,
51    RuntimeConsole,
52    FirstPartyModule,
53    Skill,
54}
55
56impl ComponentKind {
57    const fn as_str(self) -> &'static str {
58        match self {
59            Self::Cli => "cli",
60            Self::Runtime => "runtime",
61            Self::Contracts => "contracts",
62            Self::Provider => "provider",
63            Self::Operator => "operator",
64            Self::RuntimeConsole => "runtime_console",
65            Self::FirstPartyModule => "first_party_module",
66            Self::Skill => "skill",
67        }
68    }
69}
70
71#[derive(
72    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
73)]
74#[serde(rename_all = "snake_case")]
75pub enum ManifestKind {
76    Provider,
77    Service,
78    System,
79    Module,
80    Backup,
81}
82
83#[derive(
84    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
85)]
86#[serde(rename_all = "camelCase")]
87pub struct GaComponent {
88    pub kind: ComponentKind,
89    pub component_id: String,
90    pub version: String,
91    pub digest: String,
92}
93
94impl GaComponent {
95    #[must_use]
96    pub fn reference(&self) -> String {
97        format!(
98            "{}:{}@{}",
99            self.kind.as_str(),
100            self.component_id,
101            self.version
102        )
103    }
104}
105
106#[derive(
107    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
108)]
109#[serde(rename_all = "camelCase")]
110pub struct ManifestFormat {
111    pub kind: ManifestKind,
112    pub version: String,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
116#[serde(rename_all = "camelCase")]
117pub struct DocumentationIdentity {
118    pub version: String,
119    pub digest: String,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
123#[serde(rename_all = "camelCase")]
124pub struct SupportCombinationInput {
125    pub combination_id: String,
126    pub component_references: Vec<String>,
127    pub state_version: String,
128    pub status: SupportStatus,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
132#[serde(rename_all = "camelCase")]
133pub struct UpgradeEdgeInput {
134    pub edge_id: String,
135    pub source_format: String,
136    pub target_format: String,
137    pub mixed_version_references: Vec<String>,
138    pub rollback_safe: bool,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "camelCase")]
143pub struct GaSupportManifestInput {
144    pub status: SupportStatus,
145    pub components: Vec<GaComponent>,
146    pub manifest_formats: Vec<ManifestFormat>,
147    pub state_versions: Vec<String>,
148    pub adapter_versions: BTreeMap<String, String>,
149    pub documentation: DocumentationIdentity,
150    pub combinations: Vec<SupportCombinationInput>,
151    pub upgrade_edges: Vec<UpgradeEdgeInput>,
152}
153
154#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
155#[serde(rename_all = "camelCase")]
156pub struct EvidenceReceiptTrust {
157    pub authorities: BTreeMap<String, String>,
158    pub public_keys: BTreeMap<String, String>,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
162#[serde(rename_all = "camelCase")]
163pub struct GaSupportManifest {
164    pub protocol: String,
165    pub manifest_id: String,
166    pub manifest_digest: String,
167    pub status: SupportStatus,
168    pub components: Vec<GaComponent>,
169    pub manifest_formats: Vec<ManifestFormat>,
170    pub state_versions: Vec<String>,
171    pub adapter_versions: BTreeMap<String, String>,
172    pub documentation: DocumentationIdentity,
173    pub combinations: Vec<SupportCombinationInput>,
174    pub upgrade_edges: Vec<UpgradeEdgeInput>,
175    #[serde(default)]
176    pub evidence_receipt_authorities: BTreeMap<String, String>,
177    #[serde(default)]
178    pub receipt_authority_public_keys: BTreeMap<String, String>,
179}
180
181impl GaSupportManifest {
182    #[must_use]
183    pub fn into_input(self) -> GaSupportManifestInput {
184        GaSupportManifestInput {
185            status: self.status,
186            components: self.components,
187            manifest_formats: self.manifest_formats,
188            state_versions: self.state_versions,
189            adapter_versions: self.adapter_versions,
190            documentation: self.documentation,
191            combinations: self.combinations,
192            upgrade_edges: self.upgrade_edges,
193        }
194    }
195}
196
197#[derive(
198    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
199)]
200#[serde(rename_all = "snake_case")]
201pub enum GaIssueCode {
202    ManifestInvalid,
203    CombinationUnknown,
204    CombinationUnsupported,
205    ManifestSourceStale,
206    ManifestFormatUnsupported,
207    ManifestTargetCollision,
208    ManifestIdentityChanged,
209    PlanIntegrityInvalid,
210    UpgradeUnsupported,
211    RetirementActiveConsumer,
212    RetirementEvidenceStale,
213    RetirementDeprecationIncomplete,
214    RetirementReplacementMissing,
215    RetirementApprovalInvalid,
216    RetirementInputStale,
217    FailureUnexpectedOutcome,
218    FailureCleanupIncomplete,
219}
220
221impl GaIssueCode {
222    #[must_use]
223    pub const fn as_str(self) -> &'static str {
224        match self {
225            Self::ManifestInvalid => "ga_manifest_invalid",
226            Self::CombinationUnknown => "ga_combination_unknown",
227            Self::CombinationUnsupported => "ga_combination_unsupported",
228            Self::ManifestSourceStale => "manifest_source_stale",
229            Self::ManifestFormatUnsupported => "manifest_format_unsupported",
230            Self::ManifestTargetCollision => "manifest_target_collision",
231            Self::ManifestIdentityChanged => "manifest_identity_changed",
232            Self::PlanIntegrityInvalid => "ga_plan_integrity_invalid",
233            Self::UpgradeUnsupported => "service_upgrade_unsupported",
234            Self::RetirementActiveConsumer => "retirement_active_consumer",
235            Self::RetirementEvidenceStale => "retirement_evidence_stale",
236            Self::RetirementDeprecationIncomplete => "retirement_deprecation_incomplete",
237            Self::RetirementReplacementMissing => "retirement_replacement_missing",
238            Self::RetirementApprovalInvalid => "retirement_approval_invalid",
239            Self::RetirementInputStale => "retirement_input_stale",
240            Self::FailureUnexpectedOutcome => "failure_unexpected_outcome",
241            Self::FailureCleanupIncomplete => "failure_cleanup_incomplete",
242        }
243    }
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
247#[serde(rename_all = "camelCase")]
248pub struct GaIssue {
249    pub code: GaIssueCode,
250    pub message: String,
251    pub remediation: String,
252    pub next_actions: Vec<String>,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
256#[serde(rename_all = "camelCase")]
257pub struct GaSupportEvaluation {
258    pub protocol: String,
259    pub manifest_id: String,
260    pub manifest_digest: String,
261    pub decision: SupportDecision,
262    pub combination_id: Option<String>,
263    pub issues: Vec<GaIssue>,
264    pub next_actions: Vec<String>,
265}
266
267pub fn assemble_ga_support_manifest(
268    input: GaSupportManifestInput,
269) -> Result<GaSupportManifest, Vec<GaIssue>> {
270    assemble_ga_support_manifest_with_trust(input, EvidenceReceiptTrust::default())
271}
272
273pub fn assemble_ga_support_manifest_with_trust(
274    mut input: GaSupportManifestInput,
275    trust: EvidenceReceiptTrust,
276) -> Result<GaSupportManifest, Vec<GaIssue>> {
277    input.components.sort();
278    input.manifest_formats.sort();
279    input.state_versions.sort();
280    input.state_versions.dedup();
281    input
282        .combinations
283        .sort_by(|left, right| left.combination_id.cmp(&right.combination_id));
284    for combination in &mut input.combinations {
285        combination.component_references.sort();
286        combination.component_references.dedup();
287    }
288    input
289        .upgrade_edges
290        .sort_by(|left, right| left.edge_id.cmp(&right.edge_id));
291    for edge in &mut input.upgrade_edges {
292        edge.mixed_version_references.sort();
293        edge.mixed_version_references.dedup();
294    }
295
296    let component_references = input
297        .components
298        .iter()
299        .map(GaComponent::reference)
300        .collect::<BTreeSet<_>>();
301    let invalid = input.components.is_empty()
302        || input.components.iter().any(|component| {
303            component.component_id.trim().is_empty()
304                || component.version.trim().is_empty()
305                || !valid_digest(&component.digest)
306        })
307        || !valid_digest(&input.documentation.digest)
308        || trust
309            .authorities
310            .values()
311            .any(|authority| !trust.public_keys.contains_key(authority))
312        || trust
313            .public_keys
314            .values()
315            .any(|key| !key.starts_with("-----BEGIN PUBLIC KEY-----"))
316        || input.combinations.iter().any(|combination| {
317            combination.component_references.is_empty()
318                || combination
319                    .component_references
320                    .iter()
321                    .any(|reference| !component_references.contains(reference))
322                || !input.state_versions.contains(&combination.state_version)
323        });
324    if invalid {
325        return Err(vec![issue(
326            GaIssueCode::ManifestInvalid,
327            "The GA Support Manifest contains an invalid or unbound subject.",
328            "Bind every combination to exact components, state formats, and immutable digests.",
329            "Correct the manifest source and regenerate its contracts.",
330        )]);
331    }
332
333    let mut manifest = GaSupportManifest {
334        protocol: GA_SUPPORT_MANIFEST_PROTOCOL.into(),
335        manifest_id: String::new(),
336        manifest_digest: String::new(),
337        status: input.status,
338        components: input.components,
339        manifest_formats: input.manifest_formats,
340        state_versions: input.state_versions,
341        adapter_versions: input.adapter_versions,
342        documentation: input.documentation,
343        combinations: input.combinations,
344        upgrade_edges: input.upgrade_edges,
345        evidence_receipt_authorities: trust.authorities,
346        receipt_authority_public_keys: trust.public_keys,
347    };
348    manifest.manifest_digest = ga_support_manifest_digest(&manifest);
349    manifest.manifest_id = format!("ga-support:{}", &manifest.manifest_digest[7..23]);
350    Ok(manifest)
351}
352
353#[must_use]
354pub fn evaluate_ga_support(
355    manifest: &GaSupportManifest,
356    component_references: &[&str],
357    state_version: &str,
358) -> GaSupportEvaluation {
359    if !ga_support_manifest_integrity_valid(manifest) {
360        return GaSupportEvaluation {
361            protocol: GA_SUPPORT_EVALUATION_PROTOCOL.into(),
362            manifest_id: manifest.manifest_id.clone(),
363            manifest_digest: manifest.manifest_digest.clone(),
364            decision: SupportDecision::Blocked,
365            combination_id: None,
366            issues: vec![issue(
367                GaIssueCode::ManifestInvalid,
368                "The GA Support Manifest content does not match its canonical identity.",
369                "Reject modified or unverified support manifests.",
370                "Regenerate the manifest from its reviewed source and verify its digest.",
371            )],
372            next_actions: vec!["Load an integrity-valid GA Support Manifest.".into()],
373        };
374    }
375    let requested = component_references
376        .iter()
377        .map(|reference| (*reference).to_owned())
378        .collect::<BTreeSet<_>>();
379    let combination = manifest.combinations.iter().find(|candidate| {
380        candidate
381            .component_references
382            .iter()
383            .cloned()
384            .collect::<BTreeSet<_>>()
385            == requested
386            && candidate.state_version == state_version
387    });
388    match combination {
389        Some(combination) if combination.status == SupportStatus::GeneralAvailability => {
390            GaSupportEvaluation {
391                protocol: GA_SUPPORT_EVALUATION_PROTOCOL.into(),
392                manifest_id: manifest.manifest_id.clone(),
393                manifest_digest: manifest.manifest_digest.clone(),
394                decision: SupportDecision::Supported,
395                combination_id: Some(combination.combination_id.clone()),
396                issues: Vec::new(),
397                next_actions: vec!["Proceed using the exact supported component set.".into()],
398            }
399        }
400        Some(combination) => GaSupportEvaluation {
401            protocol: GA_SUPPORT_EVALUATION_PROTOCOL.into(),
402            manifest_id: manifest.manifest_id.clone(),
403            manifest_digest: manifest.manifest_digest.clone(),
404            decision: SupportDecision::Unsupported,
405            combination_id: Some(combination.combination_id.clone()),
406            issues: vec![issue(
407                GaIssueCode::CombinationUnsupported,
408                "The exact combination is declared but is not supported for GA.",
409                "Select a General Availability combination from the manifest.",
410                "Inspect the declared support status and migration guidance.",
411            )],
412            next_actions: vec!["Select a GA combination from the support manifest.".into()],
413        },
414        None => GaSupportEvaluation {
415            protocol: GA_SUPPORT_EVALUATION_PROTOCOL.into(),
416            manifest_id: manifest.manifest_id.clone(),
417            manifest_digest: manifest.manifest_digest.clone(),
418            decision: SupportDecision::Unknown,
419            combination_id: None,
420            issues: vec![issue(
421                GaIssueCode::CombinationUnknown,
422                "The exact combination is absent from the GA Support Manifest.",
423                "Do not infer compatibility from adjacent semantic versions.",
424                "Choose an exact manifest combination or request compatibility evidence.",
425            )],
426            next_actions: vec!["Choose an exact combination from the support manifest.".into()],
427        },
428    }
429}
430
431pub fn ga_support_manifest_integrity_valid(manifest: &GaSupportManifest) -> bool {
432    if manifest.protocol != GA_SUPPORT_MANIFEST_PROTOCOL {
433        return false;
434    }
435    let digest = ga_support_manifest_digest(manifest);
436    manifest.manifest_digest == digest
437        && manifest.manifest_id == format!("ga-support:{}", &digest[7..23])
438}
439
440fn ga_support_manifest_digest(manifest: &GaSupportManifest) -> String {
441    if manifest.evidence_receipt_authorities.is_empty()
442        && manifest.receipt_authority_public_keys.is_empty()
443    {
444        return digest_json(&manifest.clone().into_input());
445    }
446    let mut canonical = manifest.clone();
447    canonical.protocol.clear();
448    canonical.manifest_id.clear();
449    canonical.manifest_digest.clear();
450    digest_json(&canonical)
451}
452
453#[must_use]
454pub fn contract_retirement_plan_integrity_is_valid(plan: &ContractRetirementPlan) -> bool {
455    valid_digest(&plan.plan_digest)
456        && plan.plan_digest
457            == plan_digest(plan, |value| {
458                value.plan_id.clear();
459                value.plan_digest.clear();
460            })
461        && plan.plan_id == format!("contract-retirement:{}", &plan.plan_digest[7..23])
462}
463
464#[must_use]
465pub fn contract_retirement_receipt_integrity_is_valid(receipt: &ContractRetirementReceipt) -> bool {
466    let mut canonical = receipt.clone();
467    canonical.receipt_id.clear();
468    canonical.receipt_digest.clear();
469    let digest = digest_json(&canonical);
470    receipt.protocol == CONTRACT_RETIREMENT_RECEIPT_PROTOCOL
471        && valid_digest(&receipt.plan_digest)
472        && receipt.receipt_digest == digest
473        && receipt.receipt_id == format!("contract-retirement-receipt:{}", &digest[7..23])
474        && !receipt.contract_id.trim().is_empty()
475        && !receipt.retired_version.trim().is_empty()
476        && !receipt.replacement_version.trim().is_empty()
477        && !receipt.approver.trim().is_empty()
478        && !receipt.approval_reason.trim().is_empty()
479        && receipt.retired
480}
481
482#[must_use]
483pub fn render_ga_support_manifest(manifest: &GaSupportManifest) -> String {
484    let mut output = format!(
485        "# Lenso GA Support Manifest\n\n- Protocol: `{}`\n- Manifest ID: `{}`\n- Manifest digest: `{}`\n- Status: `{:?}`\n- Documentation: `{}` (`{}`)\n\n## Components\n\n",
486        manifest.protocol,
487        manifest.manifest_id,
488        manifest.manifest_digest,
489        manifest.status,
490        manifest.documentation.version,
491        manifest.documentation.digest,
492    );
493    for component in &manifest.components {
494        output.push_str(&format!(
495            "- `{}` — `{}`\n",
496            component.reference(),
497            component.digest
498        ));
499    }
500    output.push_str("\n## Manifest and state formats\n\n");
501    for format in &manifest.manifest_formats {
502        output.push_str(&format!("- `{:?}`: `{}`\n", format.kind, format.version));
503    }
504    for state_version in &manifest.state_versions {
505        output.push_str(&format!("- State: `{state_version}`\n"));
506    }
507    output.push_str("\n## Supported combinations\n\n");
508    for combination in &manifest.combinations {
509        output.push_str(&format!(
510            "- `{}`: `{:?}`, state `{}`, components `{}`\n",
511            combination.combination_id,
512            combination.status,
513            combination.state_version,
514            combination.component_references.join("`, `")
515        ));
516    }
517    output.push_str("\n## Upgrade and skew edges\n\n");
518    for edge in &manifest.upgrade_edges {
519        output.push_str(&format!(
520            "- `{}`: `{}` -> `{}`; rollback safe `{}`; mixed versions `{}`\n",
521            edge.edge_id,
522            edge.source_format,
523            edge.target_format,
524            edge.rollback_safe,
525            edge.mixed_version_references.join("`, `"),
526        ));
527    }
528    output.push_str(
529        "\nUnknown combinations are not inferred compatible from semantic-version proximity.\n",
530    );
531    output
532}
533
534#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
535#[serde(rename_all = "camelCase")]
536pub struct ManifestMigrationInput {
537    pub kind: ManifestKind,
538    pub source_format: String,
539    pub target_format: String,
540    pub source: Value,
541    pub identity_pointers: Vec<String>,
542}
543
544#[derive(
545    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema,
546)]
547#[serde(rename_all = "camelCase")]
548pub struct ManifestMigrationEffects {
549    pub mutates_source: bool,
550    pub creates_target: bool,
551}
552
553#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, ToSchema)]
554#[serde(rename_all = "camelCase")]
555pub struct ManifestMigrationPlan {
556    pub protocol: String,
557    pub plan_id: String,
558    pub plan_digest: String,
559    pub kind: ManifestKind,
560    pub source_format: String,
561    pub target_format: String,
562    pub source_digest: String,
563    pub migrated_digest: String,
564    pub migrated: Value,
565    pub identity_pointers: Vec<String>,
566    pub effects: ManifestMigrationEffects,
567}
568
569#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, ToSchema)]
570#[serde(rename_all = "camelCase")]
571pub struct ManifestMigrationReceipt {
572    pub protocol: String,
573    pub receipt_id: String,
574    pub plan_digest: String,
575    pub source_digest: String,
576    pub migrated_digest: String,
577    pub migrated: Value,
578}
579
580pub fn plan_manifest_migration(
581    input: &ManifestMigrationInput,
582    manifest: &GaSupportManifest,
583) -> Result<ManifestMigrationPlan, GaIssue> {
584    if !ga_support_manifest_integrity_valid(manifest) {
585        return Err(issue(
586            GaIssueCode::ManifestInvalid,
587            "The GA Support Manifest content does not match its canonical identity.",
588            "Reject modified or unverified support manifests before planning migration.",
589            "Regenerate and verify the manifest before retrying dry-run.",
590        ));
591    }
592    let formats = manifest
593        .manifest_formats
594        .iter()
595        .map(|format| (format.kind, format.version.as_str()))
596        .collect::<BTreeSet<_>>();
597    if !formats.contains(&(input.kind, input.source_format.as_str()))
598        || !formats.contains(&(input.kind, input.target_format.as_str()))
599    {
600        return Err(issue(
601            GaIssueCode::ManifestFormatUnsupported,
602            "The requested manifest migration edge is not supported.",
603            "Select source and target formats declared by the GA Support Manifest.",
604            "Inspect the manifest format matrix before applying any change.",
605        ));
606    }
607    let mut migrated = input.source.clone();
608    let Some(object) = migrated.as_object_mut() else {
609        return Err(issue(
610            GaIssueCode::ManifestInvalid,
611            "The manifest must be a JSON object.",
612            "Provide a valid public manifest artifact.",
613            "Correct the source manifest and retry dry-run.",
614        ));
615    };
616    object.insert(
617        "protocol".into(),
618        Value::String(input.target_format.clone()),
619    );
620    for pointer in &input.identity_pointers {
621        if input.source.pointer(pointer) != migrated.pointer(pointer) {
622            return Err(issue(
623                GaIssueCode::ManifestIdentityChanged,
624                "Manifest migration changed a protected identity.",
625                "Preserve every declared Service, Module, Workload, Store, Contract, and authority identity.",
626                "Correct the migration adapter before apply.",
627            ));
628        }
629    }
630    let source_digest = digest_json(&input.source);
631    let migrated_digest = digest_json(&migrated);
632    let mut plan = ManifestMigrationPlan {
633        protocol: MANIFEST_MIGRATION_PLAN_PROTOCOL.into(),
634        plan_id: String::new(),
635        plan_digest: String::new(),
636        kind: input.kind,
637        source_format: input.source_format.clone(),
638        target_format: input.target_format.clone(),
639        source_digest,
640        migrated_digest,
641        migrated,
642        identity_pointers: input.identity_pointers.clone(),
643        effects: ManifestMigrationEffects::default(),
644    };
645    plan.plan_digest = plan_digest(&plan, |value| {
646        value.plan_id.clear();
647        value.plan_digest.clear();
648    });
649    plan.plan_id = format!("manifest-migration:{}", &plan.plan_digest[7..23]);
650    Ok(plan)
651}
652
653pub fn apply_manifest_migration(
654    plan: &ManifestMigrationPlan,
655    current_source: &Value,
656    target_exists: bool,
657) -> Result<ManifestMigrationReceipt, GaIssue> {
658    if plan_digest(plan, |value| {
659        value.plan_id.clear();
660        value.plan_digest.clear();
661    }) != plan.plan_digest
662    {
663        return Err(plan_integrity_issue());
664    }
665    if target_exists {
666        return Err(issue(
667            GaIssueCode::ManifestTargetCollision,
668            "The migration target already exists.",
669            "Choose an empty target or return the previously committed receipt.",
670            "Inspect the target and receipt store before retrying.",
671        ));
672    }
673    if digest_json(current_source) != plan.source_digest {
674        return Err(issue(
675            GaIssueCode::ManifestSourceStale,
676            "The source manifest changed after the plan was created.",
677            "Generate a new plan bound to the current source digest.",
678            "Repeat dry-run with the current manifest.",
679        ));
680    }
681    for pointer in &plan.identity_pointers {
682        if current_source.pointer(pointer) != plan.migrated.pointer(pointer) {
683            return Err(issue(
684                GaIssueCode::ManifestIdentityChanged,
685                "The planned migration does not preserve a protected identity.",
686                "Reject migration adapters that reinterpret business or authority identity.",
687                "Regenerate the plan with an identity-preserving adapter.",
688            ));
689        }
690    }
691    let receipt_id = format!("manifest-migration-receipt:{}", &plan.plan_digest[7..23]);
692    Ok(ManifestMigrationReceipt {
693        protocol: MANIFEST_MIGRATION_RECEIPT_PROTOCOL.into(),
694        receipt_id,
695        plan_digest: plan.plan_digest.clone(),
696        source_digest: plan.source_digest.clone(),
697        migrated_digest: plan.migrated_digest.clone(),
698        migrated: plan.migrated.clone(),
699    })
700}
701
702#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
703#[serde(rename_all = "camelCase")]
704pub struct ServiceUpgradeInput {
705    pub service_id: String,
706    pub from_release_id: String,
707    pub from_release_digest: String,
708    pub to_release_id: String,
709    pub to_release_digest: String,
710    pub config_revision_id: String,
711    pub config_revision_digest: String,
712    pub source_state_version: String,
713    pub target_state_version: String,
714    pub workflow_artifact_digests: Vec<String>,
715}
716
717#[derive(
718    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
719)]
720#[serde(rename_all = "snake_case")]
721pub enum UpgradeWorkload {
722    Migration,
723    Api,
724    Worker,
725}
726
727impl UpgradeWorkload {
728    #[must_use]
729    pub const fn as_str(self) -> &'static str {
730        match self {
731            Self::Migration => "migration",
732            Self::Api => "api",
733            Self::Worker => "worker",
734        }
735    }
736}
737
738#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
739#[serde(rename_all = "camelCase")]
740pub struct ServiceUpgradeStep {
741    pub sequence: u32,
742    pub workload: UpgradeWorkload,
743    pub precondition: String,
744    pub expected_state_version: String,
745}
746
747#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
748#[serde(rename_all = "camelCase")]
749pub struct UpgradeRollbackConstraint {
750    pub automatic_allowed: bool,
751    pub reason: String,
752    pub approval_boundary: Option<String>,
753}
754
755#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
756#[serde(rename_all = "camelCase")]
757pub struct ServiceUpgradePlan {
758    pub protocol: String,
759    pub plan_id: String,
760    pub plan_digest: String,
761    pub manifest_id: String,
762    pub manifest_digest: String,
763    pub input: ServiceUpgradeInput,
764    pub steps: Vec<ServiceUpgradeStep>,
765    pub mixed_version_references: Vec<String>,
766    pub preserved_identities: Vec<String>,
767    pub rollback: UpgradeRollbackConstraint,
768}
769
770#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
771#[serde(rename_all = "camelCase")]
772pub struct ServiceUpgradeRuntimeObservation {
773    pub workload: UpgradeWorkload,
774    pub current_release_id: String,
775    pub current_state_version: String,
776    pub migration_completed: bool,
777    pub workflow_artifact_digests: Vec<String>,
778}
779
780#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
781#[serde(rename_all = "camelCase")]
782pub struct ServiceUpgradeAdmission {
783    pub protocol: String,
784    pub plan_digest: String,
785    pub workload: UpgradeWorkload,
786    pub decision: SupportDecision,
787    pub claims_work: bool,
788    pub mutates_state: bool,
789    pub issues: Vec<GaIssue>,
790    pub next_actions: Vec<String>,
791}
792
793pub fn plan_service_upgrade(
794    manifest: &GaSupportManifest,
795    input: ServiceUpgradeInput,
796) -> Result<ServiceUpgradePlan, GaIssue> {
797    if !ga_support_manifest_integrity_valid(manifest) {
798        return Err(issue(
799            GaIssueCode::ManifestInvalid,
800            "The GA Support Manifest content does not match its canonical identity.",
801            "Reject modified or unverified support manifests before planning an upgrade.",
802            "Regenerate and verify the manifest before retrying.",
803        ));
804    }
805    let Some(edge) = manifest.upgrade_edges.iter().find(|edge| {
806        edge.source_format == input.source_state_version
807            && edge.target_format == input.target_state_version
808    }) else {
809        return Err(issue(
810            GaIssueCode::UpgradeUnsupported,
811            "The state upgrade edge is absent from the GA Support Manifest.",
812            "Use a declared edge; do not infer reader or writer compatibility.",
813            "Select a supported target or add reviewed compatibility evidence.",
814        ));
815    };
816    let rollback = if edge.rollback_safe {
817        UpgradeRollbackConstraint {
818            automatic_allowed: true,
819            reason: "The support edge declares state and workload rollback compatible.".into(),
820            approval_boundary: None,
821        }
822    } else {
823        UpgradeRollbackConstraint {
824            automatic_allowed: false,
825            reason: "The state edge is irreversible or not proven downgrade compatible.".into(),
826            approval_boundary: Some("service_state_upgrade_intervention".into()),
827        }
828    };
829    let mut plan = ServiceUpgradePlan {
830        protocol: SERVICE_UPGRADE_PLAN_PROTOCOL.into(),
831        plan_id: String::new(),
832        plan_digest: String::new(),
833        manifest_id: manifest.manifest_id.clone(),
834        manifest_digest: manifest.manifest_digest.clone(),
835        steps: vec![
836            ServiceUpgradeStep {
837                sequence: 1,
838                workload: UpgradeWorkload::Migration,
839                precondition: "exact source state and release digest remain current".into(),
840                expected_state_version: input.target_state_version.clone(),
841            },
842            ServiceUpgradeStep {
843                sequence: 2,
844                workload: UpgradeWorkload::Api,
845                precondition: "migration receipt is complete and target reader is compatible"
846                    .into(),
847                expected_state_version: input.target_state_version.clone(),
848            },
849            ServiceUpgradeStep {
850                sequence: 3,
851                workload: UpgradeWorkload::Worker,
852                precondition:
853                    "migration receipt is complete and pinned workflows are structurally compatible"
854                        .into(),
855                expected_state_version: input.target_state_version.clone(),
856            },
857        ],
858        mixed_version_references: edge.mixed_version_references.clone(),
859        preserved_identities: vec![
860            "service".into(),
861            "workflow_instance".into(),
862            "workflow_definition_artifact".into(),
863            "inbox".into(),
864            "outbox".into(),
865            "timer".into(),
866            "attempt".into(),
867            "compensation".into(),
868            "story_segment".into(),
869            "config_revision".into(),
870            "deployment_observation".into(),
871        ],
872        rollback,
873        input,
874    };
875    plan.plan_digest = plan_digest(&plan, |value| {
876        value.plan_id.clear();
877        value.plan_digest.clear();
878    });
879    plan.plan_id = format!("service-upgrade:{}", &plan.plan_digest[7..23]);
880    Ok(plan)
881}
882
883#[must_use]
884pub fn evaluate_service_upgrade_admission(
885    plan: &ServiceUpgradePlan,
886    observation: &ServiceUpgradeRuntimeObservation,
887) -> ServiceUpgradeAdmission {
888    let mut issues = Vec::new();
889    let integrity_valid = plan_digest(plan, |value| {
890        value.plan_id.clear();
891        value.plan_digest.clear();
892    }) == plan.plan_digest;
893    if !integrity_valid {
894        issues.push(plan_integrity_issue());
895    }
896    let expected_workflows = plan
897        .input
898        .workflow_artifact_digests
899        .iter()
900        .cloned()
901        .collect::<BTreeSet<_>>();
902    let observed_workflows = observation
903        .workflow_artifact_digests
904        .iter()
905        .cloned()
906        .collect::<BTreeSet<_>>();
907    let admitted = integrity_valid
908        && match observation.workload {
909            UpgradeWorkload::Migration => {
910                observation.current_release_id == plan.input.from_release_id
911                    && observation.current_state_version == plan.input.source_state_version
912            }
913            UpgradeWorkload::Api => {
914                observation.migration_completed
915                    && observation.current_release_id == plan.input.to_release_id
916                    && observation.current_state_version == plan.input.target_state_version
917            }
918            UpgradeWorkload::Worker => {
919                observation.migration_completed
920                    && observation.current_release_id == plan.input.to_release_id
921                    && observation.current_state_version == plan.input.target_state_version
922                    && expected_workflows == observed_workflows
923            }
924        };
925    if !admitted {
926        issues.push(issue(
927            GaIssueCode::UpgradeUnsupported,
928            "The Workload is not compatible with the current durable state and pinned artifacts.",
929            "Do not claim work or mutate state until the exact migration and compatibility preconditions pass.",
930            "Resume the migration-first plan or restore the last compatible release.",
931        ));
932    }
933    ServiceUpgradeAdmission {
934        protocol: "lenso.service-upgrade-admission.v1".into(),
935        plan_digest: plan.plan_digest.clone(),
936        workload: observation.workload,
937        decision: if admitted {
938            SupportDecision::Supported
939        } else {
940            SupportDecision::Blocked
941        },
942        claims_work: admitted && observation.workload == UpgradeWorkload::Worker,
943        mutates_state: admitted && observation.workload == UpgradeWorkload::Migration,
944        next_actions: if admitted {
945            vec!["Execute only the admitted Workload step.".into()]
946        } else {
947            vec!["Restore exact state, release, migration, and pinned Workflow evidence.".into()]
948        },
949        issues,
950    }
951}
952
953#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
954#[serde(rename_all = "camelCase")]
955pub struct ContractConsumerEvidence {
956    pub consumer_id: String,
957    pub active_version: Option<String>,
958    pub replacement_verified: bool,
959}
960
961#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
962#[serde(rename_all = "camelCase")]
963pub struct ContractRetirementInput {
964    pub system_graph_digest: String,
965    pub environment_evidence_digest: String,
966    pub evidence_fresh: bool,
967    pub contract_id: String,
968    pub retiring_version: String,
969    pub replacement_version: String,
970    pub deprecation_window_complete: bool,
971    pub consumers: Vec<ContractConsumerEvidence>,
972}
973
974#[derive(
975    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema,
976)]
977#[serde(rename_all = "camelCase")]
978pub struct ContractRetirementEffects {
979    pub retires_contract: bool,
980    pub mutates_consumers: bool,
981}
982
983#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
984#[serde(rename_all = "camelCase")]
985pub struct ContractRetirementPlan {
986    pub protocol: String,
987    pub plan_id: String,
988    pub plan_digest: String,
989    pub input_digest: String,
990    pub decision: SupportDecision,
991    pub contract_id: String,
992    pub retiring_version: String,
993    pub replacement_version: String,
994    pub affected_consumers: Vec<String>,
995    pub irreversible_effects: Vec<String>,
996    pub issues: Vec<GaIssue>,
997    pub effects: ContractRetirementEffects,
998    pub approval_boundary: String,
999}
1000
1001#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1002#[serde(rename_all = "camelCase")]
1003pub struct RetirementApproval {
1004    pub plan_digest: String,
1005    pub approver: String,
1006    pub reason: String,
1007}
1008
1009#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1010#[serde(rename_all = "camelCase")]
1011pub struct ContractRetirementReceipt {
1012    pub protocol: String,
1013    pub receipt_id: String,
1014    pub receipt_digest: String,
1015    pub plan_digest: String,
1016    pub contract_id: String,
1017    pub retired_version: String,
1018    pub replacement_version: String,
1019    pub approver: String,
1020    pub approval_reason: String,
1021    pub retired: bool,
1022}
1023
1024#[must_use]
1025pub fn plan_contract_retirement(input: &ContractRetirementInput) -> ContractRetirementPlan {
1026    let mut issues = Vec::new();
1027    if !input.evidence_fresh {
1028        issues.push(issue(
1029            GaIssueCode::RetirementEvidenceStale,
1030            "Consumer or Environment Verification evidence is stale.",
1031            "Refresh the System graph and Environment Verification.",
1032            "Regenerate the Retirement plan from fresh evidence.",
1033        ));
1034    }
1035    if !input.deprecation_window_complete {
1036        issues.push(issue(
1037            GaIssueCode::RetirementDeprecationIncomplete,
1038            "The declared deprecation window has not elapsed.",
1039            "Continue serving the old Contract Version.",
1040            "Retry after the declared window completes.",
1041        ));
1042    }
1043    let affected_consumers = input
1044        .consumers
1045        .iter()
1046        .filter(|consumer| consumer.active_version.as_deref() == Some(&input.retiring_version))
1047        .map(|consumer| consumer.consumer_id.clone())
1048        .collect::<Vec<_>>();
1049    if !affected_consumers.is_empty() {
1050        issues.push(issue(
1051            GaIssueCode::RetirementActiveConsumer,
1052            "At least one active Consumer still uses the retiring Contract Version.",
1053            "Move every Consumer to the compatible replacement before Retirement.",
1054            "Inspect affected Consumers and replacement verification.",
1055        ));
1056    }
1057    if input
1058        .consumers
1059        .iter()
1060        .any(|consumer| consumer.active_version.is_none() || !consumer.replacement_verified)
1061    {
1062        issues.push(issue(
1063            GaIssueCode::RetirementReplacementMissing,
1064            "Consumer inventory is unknown or replacement coverage is incomplete.",
1065            "Verify every active Consumer against the replacement Contract Version.",
1066            "Refresh Consumer compatibility evidence.",
1067        ));
1068    }
1069    let input_digest = digest_json(input);
1070    let mut plan = ContractRetirementPlan {
1071        protocol: CONTRACT_RETIREMENT_PLAN_PROTOCOL.into(),
1072        plan_id: String::new(),
1073        plan_digest: String::new(),
1074        input_digest,
1075        decision: if issues.is_empty() {
1076            SupportDecision::Supported
1077        } else {
1078            SupportDecision::Unsupported
1079        },
1080        contract_id: input.contract_id.clone(),
1081        retiring_version: input.retiring_version.clone(),
1082        replacement_version: input.replacement_version.clone(),
1083        affected_consumers,
1084        irreversible_effects: vec!["stop serving the retired Contract Version".into()],
1085        issues,
1086        effects: ContractRetirementEffects::default(),
1087        approval_boundary: "contract_retirement".into(),
1088    };
1089    plan.plan_digest = plan_digest(&plan, |value| {
1090        value.plan_id.clear();
1091        value.plan_digest.clear();
1092    });
1093    plan.plan_id = format!("contract-retirement:{}", &plan.plan_digest[7..23]);
1094    plan
1095}
1096
1097pub fn apply_contract_retirement(
1098    plan: &ContractRetirementPlan,
1099    current: &ContractRetirementInput,
1100    approval: &RetirementApproval,
1101) -> Result<ContractRetirementReceipt, GaIssue> {
1102    if plan_digest(plan, |value| {
1103        value.plan_id.clear();
1104        value.plan_digest.clear();
1105    }) != plan.plan_digest
1106    {
1107        return Err(plan_integrity_issue());
1108    }
1109    if digest_json(current) != plan.input_digest {
1110        return Err(issue(
1111            GaIssueCode::RetirementInputStale,
1112            "Retirement inputs changed after dry-run.",
1113            "Rebuild the plan from the current graph and Environment Verification.",
1114            "Repeat dry-run and request approval for the new digest.",
1115        ));
1116    }
1117    if plan.decision != SupportDecision::Supported
1118        || approval.plan_digest != plan.plan_digest
1119        || approval.approver.trim().is_empty()
1120        || approval.reason.trim().is_empty()
1121    {
1122        return Err(issue(
1123            GaIssueCode::RetirementApprovalInvalid,
1124            "Contract Retirement lacks exact human approval for this plan digest.",
1125            "Obtain approval bound to the current stale-safe plan.",
1126            "Stop before mutation and request the Contract Retirement Approval Boundary.",
1127        ));
1128    }
1129    let mut receipt = ContractRetirementReceipt {
1130        protocol: CONTRACT_RETIREMENT_RECEIPT_PROTOCOL.into(),
1131        receipt_id: String::new(),
1132        receipt_digest: String::new(),
1133        plan_digest: plan.plan_digest.clone(),
1134        contract_id: plan.contract_id.clone(),
1135        retired_version: plan.retiring_version.clone(),
1136        replacement_version: plan.replacement_version.clone(),
1137        approver: approval.approver.clone(),
1138        approval_reason: approval.reason.clone(),
1139        retired: true,
1140    };
1141    let digest = digest_json(&receipt);
1142    receipt.receipt_digest = digest.clone();
1143    receipt.receipt_id = format!("contract-retirement-receipt:{}", &digest[7..23]);
1144    Ok(receipt)
1145}
1146
1147#[derive(
1148    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
1149)]
1150#[serde(rename_all = "snake_case")]
1151pub enum FailureCondition {
1152    ApiCrash,
1153    WorkerCrash,
1154    NetworkSlow,
1155    NetworkPartitioned,
1156    PostgresStoreUnavailable,
1157    NatsDisconnected,
1158    NatsAcknowledgementLost,
1159    NatsRedelivery,
1160    NatsPoisonEvent,
1161    SpiffeWorkloadApiUnavailable,
1162    SpiffeCredentialExpired,
1163    SpiffeCredentialRotated,
1164    TelemetryUnavailable,
1165    StoryAggregationUnavailable,
1166    RuntimeConsoleUnavailable,
1167    SystemPlaneUnavailable,
1168}
1169
1170#[derive(
1171    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
1172)]
1173#[serde(rename_all = "snake_case")]
1174pub enum FailureOutcome {
1175    Continue,
1176    Degrade,
1177    PauseCoordinatedMutation,
1178    RejectWork,
1179    FailClosed,
1180}
1181
1182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1183#[serde(rename_all = "camelCase")]
1184pub struct FailureObservation {
1185    pub subject: String,
1186    #[serde(default, skip_serializing_if = "Option::is_none")]
1187    pub expected: Option<FailureOutcome>,
1188    pub outcome: FailureOutcome,
1189    pub evidence_digest: String,
1190}
1191
1192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1193#[serde(rename_all = "camelCase")]
1194pub struct FailureScenarioInput {
1195    pub scenario_id: String,
1196    pub condition: FailureCondition,
1197    pub expected: FailureOutcome,
1198    pub observations: Vec<FailureObservation>,
1199    pub effects: Vec<String>,
1200    pub cleanup_complete: bool,
1201    pub adapter_version: Option<String>,
1202    #[serde(default, skip_serializing_if = "Option::is_none")]
1203    pub controlled_time_unix_ms: Option<u64>,
1204}
1205
1206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1207#[serde(rename_all = "camelCase")]
1208pub struct FailureScenarioEvidence {
1209    pub protocol: String,
1210    pub evidence_id: String,
1211    pub evidence_digest: String,
1212    pub scenario_id: String,
1213    pub condition: FailureCondition,
1214    pub expected: FailureOutcome,
1215    pub observations: Vec<FailureObservation>,
1216    pub effects: Vec<String>,
1217    pub cleanup_complete: bool,
1218    pub adapter_version: Option<String>,
1219    #[serde(default, skip_serializing_if = "Option::is_none")]
1220    pub controlled_time_unix_ms: Option<u64>,
1221    pub decision: SupportDecision,
1222    pub issues: Vec<GaIssue>,
1223    pub remediation: Vec<String>,
1224}
1225
1226#[must_use]
1227pub fn evaluate_failure_scenario(input: FailureScenarioInput) -> FailureScenarioEvidence {
1228    let mut issues = Vec::new();
1229    if input.observations.is_empty()
1230        || input.observations.iter().any(|observation| {
1231            observation.outcome != observation.expected.unwrap_or(input.expected)
1232        })
1233    {
1234        issues.push(issue(
1235            GaIssueCode::FailureUnexpectedOutcome,
1236            "Observed Service behavior differs from the declared Failure Scenario outcome.",
1237            "Fail the scenario and preserve authoritative Service evidence for diagnosis.",
1238            "Inspect business effects, Inbox/Outbox, Workflow, and Story evidence.",
1239        ));
1240    }
1241    if !input.cleanup_complete {
1242        issues.push(issue(
1243            GaIssueCode::FailureCleanupIncomplete,
1244            "Failure Scenario cleanup is incomplete.",
1245            "Remove or isolate every disposable process, Store, stream, socket, and trust artifact.",
1246            "Finish cleanup before accepting the scenario evidence.",
1247        ));
1248    }
1249    let mut evidence = FailureScenarioEvidence {
1250        protocol: FAILURE_SCENARIO_EVIDENCE_PROTOCOL.into(),
1251        evidence_id: String::new(),
1252        evidence_digest: String::new(),
1253        scenario_id: input.scenario_id,
1254        condition: input.condition,
1255        expected: input.expected,
1256        observations: input.observations,
1257        effects: input.effects,
1258        cleanup_complete: input.cleanup_complete,
1259        adapter_version: input.adapter_version,
1260        controlled_time_unix_ms: input.controlled_time_unix_ms,
1261        decision: if issues.is_empty() {
1262            SupportDecision::Supported
1263        } else {
1264            SupportDecision::Unsupported
1265        },
1266        remediation: issues
1267            .iter()
1268            .map(|issue| issue.remediation.clone())
1269            .collect(),
1270        issues,
1271    };
1272    evidence.evidence_digest = digest_without(&evidence, |value| value.evidence_digest.clear());
1273    evidence.evidence_id = format!("failure-evidence:{}", &evidence.evidence_digest[7..23]);
1274    evidence
1275}
1276
1277fn issue(
1278    code: GaIssueCode,
1279    message: impl Into<String>,
1280    remediation: impl Into<String>,
1281    next_action: impl Into<String>,
1282) -> GaIssue {
1283    GaIssue {
1284        code,
1285        message: message.into(),
1286        remediation: remediation.into(),
1287        next_actions: vec![next_action.into()],
1288    }
1289}
1290
1291fn valid_digest(value: &str) -> bool {
1292    value.strip_prefix("sha256:").is_some_and(|digest| {
1293        digest.len() == 64
1294            && digest
1295                .bytes()
1296                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1297    })
1298}
1299
1300fn digest_json(value: &impl Serialize) -> String {
1301    extraction_input_digest(&serde_json::to_vec(value).expect("GA support values serialize"))
1302}
1303
1304fn digest_without<T: Clone + Serialize>(value: &T, clear: impl FnOnce(&mut T)) -> String {
1305    let mut canonical = value.clone();
1306    clear(&mut canonical);
1307    digest_json(&canonical)
1308}
1309
1310fn plan_digest<T: Clone + Serialize>(value: &T, clear_identity: impl FnOnce(&mut T)) -> String {
1311    digest_without(value, clear_identity)
1312}
1313
1314fn plan_integrity_issue() -> GaIssue {
1315    issue(
1316        GaIssueCode::PlanIntegrityInvalid,
1317        "The plan content does not match its immutable digest.",
1318        "Reject modified plans before any Workload claim, state mutation, or approval.",
1319        "Regenerate the plan from authoritative current inputs.",
1320    )
1321}
1322
1323pub fn ga_support_manifest_schema() -> Value {
1324    schema::<GaSupportManifest>(GA_SUPPORT_MANIFEST_PROTOCOL)
1325}
1326
1327pub fn manifest_migration_plan_schema() -> Value {
1328    schema::<ManifestMigrationPlan>(MANIFEST_MIGRATION_PLAN_PROTOCOL)
1329}
1330
1331pub fn service_upgrade_plan_schema() -> Value {
1332    schema::<ServiceUpgradePlan>(SERVICE_UPGRADE_PLAN_PROTOCOL)
1333}
1334
1335pub fn contract_retirement_plan_schema() -> Value {
1336    schema::<ContractRetirementPlan>(CONTRACT_RETIREMENT_PLAN_PROTOCOL)
1337}
1338
1339pub fn failure_scenario_evidence_schema() -> Value {
1340    schema::<FailureScenarioEvidence>(FAILURE_SCENARIO_EVIDENCE_PROTOCOL)
1341}
1342
1343fn schema<T: JsonSchema>(protocol: &str) -> Value {
1344    let mut schema = serde_json::to_value(schemars::schema_for!(T)).expect("schema serializes");
1345    let name = protocol.strip_prefix("lenso.").unwrap_or(protocol);
1346    schema["$id"] = Value::String(format!(
1347        "https://contracts.lenso.local/ga/lenso.{name}.schema.json"
1348    ));
1349    schema
1350}