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 SUPPORT_ENVELOPE_PROTOCOL: &str = "lenso.support-envelope.v1";
11
12#[derive(
13 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
14)]
15#[serde(rename_all = "snake_case")]
16pub enum SupportEnvelopeDecision {
17 Passed,
18 Blocked,
19}
20
21#[derive(
22 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
23)]
24#[serde(rename_all = "snake_case")]
25pub enum SupportEnvelopeIssueCode {
26 ScalePointMissing,
27 TopologyInvalid,
28 MeasurementIncomplete,
29 BudgetExceeded,
30 EnvironmentDrift,
31 SaturationUnknown,
32 HiddenCentralDependency,
33 CleanupIncomplete,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
37#[serde(rename_all = "camelCase")]
38pub struct SupportEnvelopeIssue {
39 pub code: SupportEnvelopeIssueCode,
40 pub message: String,
41 pub remediation: String,
42 pub next_actions: Vec<String>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
46#[serde(rename_all = "camelCase")]
47pub struct SupportScalePoint {
48 pub service_count: u32,
49 pub workload_count: u32,
50 pub store_count: u32,
51 pub contract_count: u32,
52 pub workflow_count: u32,
53 pub tenant_count: u32,
54 pub topology_digest: String,
55 pub environment_digest: String,
56 pub compatible_baseline_digest: String,
57 pub environment_verification: bool,
58 pub environment_drift_detected: bool,
59 pub measurement_digests: BTreeMap<String, String>,
60 pub budgets_passed: bool,
61 pub repeated_run_count: u32,
62 pub variance_basis_points: u32,
63 pub system_plane_data_plane_requests: u64,
64 pub runtime_console_data_plane_requests: u64,
65 pub telemetry_data_plane_requests: u64,
66 pub policy_data_plane_requests: u64,
67 pub registry_data_plane_requests: u64,
68 pub saturation_signal: String,
69 pub bottlenecks: Vec<String>,
70 pub cleanup_complete: bool,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
74#[serde(rename_all = "camelCase")]
75pub struct SupportEnvelopeInput {
76 pub support_manifest_digest: String,
77 pub adapter_versions: BTreeMap<String, String>,
78 pub points: Vec<SupportScalePoint>,
79 pub recommended_service_limit: u32,
80 pub variance_tolerance_basis_points: u32,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
84#[serde(rename_all = "camelCase")]
85pub struct SupportEnvelope {
86 pub protocol: String,
87 pub envelope_id: String,
88 pub envelope_digest: String,
89 pub support_manifest_digest: String,
90 pub adapter_versions: BTreeMap<String, String>,
91 pub points: Vec<SupportScalePoint>,
92 pub recommended_service_limit: u32,
93 pub decision: SupportEnvelopeDecision,
94 pub issues: Vec<SupportEnvelopeIssue>,
95 pub next_actions: Vec<String>,
96}
97
98#[must_use]
99pub fn evaluate_support_envelope(mut input: SupportEnvelopeInput) -> SupportEnvelope {
100 input.points.sort_by_key(|point| point.service_count);
101 let mut issues = Vec::new();
102 let counts = input
103 .points
104 .iter()
105 .map(|point| point.service_count)
106 .collect::<BTreeSet<_>>();
107 if counts != BTreeSet::from([3, 10, 20]) {
108 issues.push(issue(
109 SupportEnvelopeIssueCode::ScalePointMissing,
110 "Support evidence must include the three-, ten-, and twenty-Service scale points.",
111 "Run the same pinned profile at all declared support points.",
112 "Collect the missing scale point before publishing the envelope.",
113 ));
114 }
115 if input.recommended_service_limit != 20
116 || !valid_digest(&input.support_manifest_digest)
117 || input.adapter_versions.is_empty()
118 || input.variance_tolerance_basis_points == 0
119 {
120 issues.push(issue(
121 SupportEnvelopeIssueCode::TopologyInvalid,
122 "Support envelope metadata does not describe the bounded 3–20 Service product scope.",
123 "Bind the envelope to exact adapters and the reviewed M6 limit.",
124 "Correct the envelope metadata.",
125 ));
126 }
127 for point in &input.points {
128 if point.workload_count < point.service_count
129 || point.store_count != point.service_count
130 || point.contract_count < point.service_count
131 || point.workflow_count == 0
132 || point.tenant_count == 0
133 || !valid_digest(&point.topology_digest)
134 || !valid_digest(&point.environment_digest)
135 || !valid_digest(&point.compatible_baseline_digest)
136 || !point.environment_verification
137 {
138 issues.push(issue(
139 SupportEnvelopeIssueCode::TopologyInvalid,
140 format!(
141 "The {}-Service point does not represent distinct Service workloads and Stores.",
142 point.service_count
143 ),
144 "Scale logical Services, background work, tenants, Contracts, and Workflows together.",
145 "Correct the scale fixture and repeat the profile.",
146 ));
147 }
148 let required_measurements = [
149 "startup",
150 "rollout",
151 "direct_calls",
152 "events",
153 "inbox_outbox",
154 "workflows",
155 "timers",
156 "compensation",
157 "story_federation",
158 "policy",
159 "console",
160 "failure_recovery",
161 "connections",
162 "backlog",
163 "resources",
164 "evidence_freshness",
165 ];
166 if required_measurements
167 .iter()
168 .any(|name| !point.measurement_digests.contains_key(*name))
169 || point
170 .measurement_digests
171 .values()
172 .any(|digest| !valid_digest(digest))
173 || point.repeated_run_count < 3
174 || point.variance_basis_points > input.variance_tolerance_basis_points
175 {
176 issues.push(issue(
177 SupportEnvelopeIssueCode::MeasurementIncomplete,
178 format!(
179 "The {}-Service point lacks complete repeated evidence.",
180 point.service_count
181 ),
182 "Record load, resource, failure, recovery, and convergence evidence with variance.",
183 "Repeat the pinned environment runs.",
184 ));
185 }
186 if point.environment_drift_detected {
187 issues.push(issue(
188 SupportEnvelopeIssueCode::EnvironmentDrift,
189 format!(
190 "The {}-Service point drifted from its compatible pinned baseline.",
191 point.service_count
192 ),
193 "Report infrastructure drift separately from product budget regressions.",
194 "Restore the pinned environment and repeat the profile.",
195 ));
196 }
197 if !point.budgets_passed {
198 issues.push(issue(
199 SupportEnvelopeIssueCode::BudgetExceeded,
200 format!(
201 "The {}-Service point exceeded its reviewed budget.",
202 point.service_count
203 ),
204 "Keep the result environment-specific and diagnose the limiting resource.",
205 "Correct the bottleneck or lower the reviewed support limit.",
206 ));
207 }
208 if point.saturation_signal.trim().is_empty() || point.bottlenecks.is_empty() {
209 issues.push(issue(
210 SupportEnvelopeIssueCode::SaturationUnknown,
211 format!(
212 "The {}-Service point does not identify saturation or bottlenecks.",
213 point.service_count
214 ),
215 "State which resource limits the point and how it is observed.",
216 "Add the evidence-backed saturation signal.",
217 ));
218 }
219 if point.system_plane_data_plane_requests > 0
220 || point.runtime_console_data_plane_requests > 0
221 || point.telemetry_data_plane_requests > 0
222 || point.policy_data_plane_requests > 0
223 || point.registry_data_plane_requests > 0
224 {
225 issues.push(issue(
226 SupportEnvelopeIssueCode::HiddenCentralDependency,
227 "A scale point depends on a central coordination or observability service for established traffic.",
228 "Keep those surfaces outside the Data Plane after convergence.",
229 "Withhold the central surfaces and repeat the scale point.",
230 ));
231 }
232 if !point.cleanup_complete {
233 issues.push(issue(
234 SupportEnvelopeIssueCode::CleanupIncomplete,
235 "Scale-point cleanup is incomplete.",
236 "Remove or isolate disposable Stores, streams, identities, and Workloads.",
237 "Finish cleanup before accepting the envelope.",
238 ));
239 }
240 }
241 let decision = if issues.is_empty() {
242 SupportEnvelopeDecision::Passed
243 } else {
244 SupportEnvelopeDecision::Blocked
245 };
246 let next_actions = if issues.is_empty() {
247 vec!["Publish the observed 3–20 Service envelope as a bounded GA claim.".into()]
248 } else {
249 issues
250 .iter()
251 .flat_map(|issue| issue.next_actions.iter().cloned())
252 .collect()
253 };
254 let mut envelope = SupportEnvelope {
255 protocol: SUPPORT_ENVELOPE_PROTOCOL.to_owned(),
256 envelope_id: String::new(),
257 envelope_digest: String::new(),
258 support_manifest_digest: input.support_manifest_digest,
259 adapter_versions: input.adapter_versions,
260 points: input.points,
261 recommended_service_limit: input.recommended_service_limit,
262 decision,
263 issues,
264 next_actions,
265 };
266 envelope.envelope_digest = digest_without_identity(&envelope);
267 envelope.envelope_id = format!("support-envelope:{}", &envelope.envelope_digest[7..23]);
268 envelope
269}
270
271#[must_use]
272pub fn support_envelope_schema() -> Value {
273 let mut schema = serde_json::to_value(schemars::schema_for!(SupportEnvelope))
274 .expect("support envelope schema serializes");
275 schema["$id"] = Value::String(
276 "https://contracts.lenso.local/ga/lenso.support-envelope.v1.schema.json".to_owned(),
277 );
278 schema
279}
280
281fn issue(
282 code: SupportEnvelopeIssueCode,
283 message: impl Into<String>,
284 remediation: impl Into<String>,
285 next_action: impl Into<String>,
286) -> SupportEnvelopeIssue {
287 SupportEnvelopeIssue {
288 code,
289 message: message.into(),
290 remediation: remediation.into(),
291 next_actions: vec![next_action.into()],
292 }
293}
294
295fn valid_digest(value: &str) -> bool {
296 value.strip_prefix("sha256:").is_some_and(|digest| {
297 digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit())
298 })
299}
300
301fn digest_without_identity(envelope: &SupportEnvelope) -> String {
302 let mut canonical = envelope.clone();
303 canonical.envelope_id.clear();
304 canonical.envelope_digest.clear();
305 extraction_input_digest(&serde_json::to_vec(&canonical).expect("support envelope serializes"))
306}
307
308#[must_use]
309pub fn support_envelope_integrity_is_valid(envelope: &SupportEnvelope) -> bool {
310 valid_digest(&envelope.envelope_digest)
311 && envelope.envelope_digest == digest_without_identity(envelope)
312 && envelope.envelope_id == format!("support-envelope:{}", &envelope.envelope_digest[7..23])
313}