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}