Skip to main content

lenso_service/
support_envelope.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 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}