1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use serde_json::{Value, json};
4use std::collections::{BTreeSet, HashSet};
5use utoipa::ToSchema;
6
7pub mod enrollment;
8pub mod runtime_observability;
9pub mod runtime_operations;
10
11pub use enrollment::*;
12pub use runtime_observability::*;
13pub use runtime_operations::*;
14
15pub const CORE_PROTOCOL: &str = "lenso.system-plane.v1";
16pub const CORE_PATH: &str = "/system-plane/v1";
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
19#[serde(rename_all = "camelCase", deny_unknown_fields)]
20pub struct CoreDocument {
21 pub protocol: String,
22 #[schema(min_length = 1)]
23 pub service_id: String,
24 #[schema(min_length = 1)]
25 pub service_principal: String,
26 #[schema(min_length = 1)]
27 pub service_revision: String,
28 #[serde(default, skip_serializing_if = "Vec::is_empty")]
29 pub capabilities: Vec<CapabilityAdvertisement>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
33#[serde(rename_all = "camelCase", deny_unknown_fields)]
34pub struct CapabilityAdvertisement {
35 #[schema(pattern = r"^lenso\.system-plane\.[a-z0-9]+(?:[.-][a-z0-9]+)*\.v[1-9][0-9]*$")]
36 pub contract_id: String,
37 #[schema(minimum = 1)]
38 pub major_version: u32,
39 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
40 pub feature_ids: BTreeSet<String>,
41 #[schema(pattern = r"^sha256:[0-9a-f]{64}$")]
42 pub schema_digest: String,
43 #[schema(pattern = r"^/system-plane/v1/[a-z0-9]+(?:[/-][a-z0-9]+)*$")]
44 pub endpoint: String,
45}
46
47#[derive(
48 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
49)]
50#[serde(rename_all = "snake_case")]
51pub enum CoreIssueCode {
52 InvalidProtocol,
53 MissingServiceIdentity,
54 MissingServicePrincipal,
55 MissingServiceRevision,
56 InvalidCapabilityContractId,
57 InvalidCapabilityMajorVersion,
58 CapabilityMajorVersionMismatch,
59 InvalidFeatureId,
60 InvalidSchemaDigest,
61 InvalidEndpointReference,
62 DuplicateCapability,
63}
64
65#[derive(
66 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
67)]
68#[serde(rename_all = "camelCase", deny_unknown_fields)]
69pub struct CoreIssue {
70 pub code: CoreIssueCode,
71 pub path: String,
72 pub message: String,
73 pub next_action: String,
74}
75
76#[must_use]
77pub fn validate_core_document(document: &CoreDocument) -> Vec<CoreIssue> {
78 let mut issues = Vec::new();
79
80 if document.protocol != CORE_PROTOCOL {
81 push_issue(
82 &mut issues,
83 CoreIssueCode::InvalidProtocol,
84 "$.protocol",
85 format!("protocol must be `{CORE_PROTOCOL}`"),
86 "Use the supported System Plane Core Protocol identifier.",
87 );
88 }
89 validate_present(
90 &document.service_id,
91 CoreIssueCode::MissingServiceIdentity,
92 "$.serviceId",
93 "serviceId must identify the managed Service",
94 "Publish the stable logical Service identity.",
95 &mut issues,
96 );
97 validate_present(
98 &document.service_principal,
99 CoreIssueCode::MissingServicePrincipal,
100 "$.servicePrincipal",
101 "servicePrincipal must identify the managed Service authority",
102 "Publish the stable Service Principal independently from endpoint and Workload identity.",
103 &mut issues,
104 );
105 validate_present(
106 &document.service_revision,
107 CoreIssueCode::MissingServiceRevision,
108 "$.serviceRevision",
109 "serviceRevision must identify the advertised Service state",
110 "Publish a stable revision that changes when the Core advertisement changes.",
111 &mut issues,
112 );
113
114 let mut advertised_contracts = HashSet::new();
115 for (index, capability) in document.capabilities.iter().enumerate() {
116 let base = format!("$.capabilities[{index}]");
117 let contract_major = capability_major_version(&capability.contract_id);
118
119 if contract_major.is_none() {
120 push_issue(
121 &mut issues,
122 CoreIssueCode::InvalidCapabilityContractId,
123 format!("{base}.contractId"),
124 "contractId must match `lenso.system-plane.<capability>.v<major>`",
125 "Publish a stable capability-specific System Plane Contract identifier.",
126 );
127 }
128 if capability.major_version == 0 {
129 push_issue(
130 &mut issues,
131 CoreIssueCode::InvalidCapabilityMajorVersion,
132 format!("{base}.majorVersion"),
133 "majorVersion must be greater than zero",
134 "Publish the supported major version for this Capability Contract.",
135 );
136 } else if contract_major.is_some_and(|major| major != capability.major_version) {
137 push_issue(
138 &mut issues,
139 CoreIssueCode::CapabilityMajorVersionMismatch,
140 format!("{base}.majorVersion"),
141 "majorVersion must match the version suffix in contractId",
142 "Make the advertised major version and Contract identifier agree.",
143 );
144 }
145
146 for feature_id in &capability.feature_ids {
147 if !valid_dotted_id(feature_id) {
148 push_issue(
149 &mut issues,
150 CoreIssueCode::InvalidFeatureId,
151 format!("{base}.featureIds"),
152 format!("feature identifier `{feature_id}` is not canonical"),
153 "Use lowercase dot-separated identifiers with alphanumeric or hyphenated segments.",
154 );
155 }
156 }
157 if !valid_sha256_digest(&capability.schema_digest) {
158 push_issue(
159 &mut issues,
160 CoreIssueCode::InvalidSchemaDigest,
161 format!("{base}.schemaDigest"),
162 "schemaDigest must be a lowercase `sha256:<64 hex>` digest",
163 "Publish the digest of the exact Capability Contract schema.",
164 );
165 }
166 if !valid_capability_endpoint(&capability.endpoint) {
167 push_issue(
168 &mut issues,
169 CoreIssueCode::InvalidEndpointReference,
170 format!("{base}.endpoint"),
171 "endpoint must be a relative subpath below `/system-plane/v1/`",
172 "Publish a capability endpoint inside the managed Service System Plane namespace.",
173 );
174 }
175 if !advertised_contracts.insert(capability.contract_id.as_str()) {
176 push_issue(
177 &mut issues,
178 CoreIssueCode::DuplicateCapability,
179 format!("{base}.contractId"),
180 format!(
181 "Capability Contract `{}` is advertised more than once",
182 capability.contract_id
183 ),
184 "Publish each exact Capability Contract identity once.",
185 );
186 }
187 }
188
189 issues
190}
191
192#[must_use]
193pub fn core_document_schema() -> Value {
194 let mut schema = serde_json::to_value(schemars::schema_for!(CoreDocument))
195 .expect("System Plane Core schema serializes");
196 schema["$id"] = Value::String(
197 "https://contracts.lenso.local/system-plane/lenso.system-plane.v1.schema.json".to_owned(),
198 );
199 schema["title"] = Value::String("Lenso System Plane Core Document".to_owned());
200 schema["properties"]["protocol"] = json!({ "const": CORE_PROTOCOL });
201 for field in ["serviceId", "servicePrincipal", "serviceRevision"] {
202 schema["properties"][field]["minLength"] = json!(1);
203 }
204 schema["$defs"]["CapabilityAdvertisement"]["properties"]["contractId"]["pattern"] =
205 json!(r"^lenso\.system-plane\.[a-z0-9]+(?:[.-][a-z0-9]+)*\.v[1-9][0-9]*$");
206 schema["$defs"]["CapabilityAdvertisement"]["properties"]["majorVersion"]["minimum"] = json!(1);
207 schema["$defs"]["CapabilityAdvertisement"]["properties"]["featureIds"]["items"] = json!({
208 "type": "string",
209 "pattern": r"^[a-z0-9]+(?:[.-][a-z0-9]+)*$"
210 });
211 schema["$defs"]["CapabilityAdvertisement"]["properties"]["schemaDigest"]["pattern"] =
212 json!(r"^sha256:[0-9a-f]{64}$");
213 schema["$defs"]["CapabilityAdvertisement"]["properties"]["endpoint"]["pattern"] =
214 json!(r"^/system-plane/v1/[a-z0-9]+(?:[/-][a-z0-9]+)*$");
215 schema
216}
217
218fn validate_present(
219 value: &str,
220 code: CoreIssueCode,
221 path: &str,
222 message: &str,
223 next_action: &str,
224 issues: &mut Vec<CoreIssue>,
225) {
226 if value.trim().is_empty() {
227 push_issue(issues, code, path, message, next_action);
228 }
229}
230
231fn capability_major_version(contract_id: &str) -> Option<u32> {
232 let remainder = contract_id.strip_prefix("lenso.system-plane.")?;
233 let (capability, version) = remainder.rsplit_once(".v")?;
234 if !valid_dotted_id(capability) {
235 return None;
236 }
237 let major = version.parse::<u32>().ok()?;
238 (major > 0 && version == major.to_string()).then_some(major)
239}
240
241fn valid_dotted_id(value: &str) -> bool {
242 !value.is_empty() && value.split('.').all(valid_id_segment)
243}
244
245fn valid_id_segment(segment: &str) -> bool {
246 !segment.is_empty()
247 && !segment.starts_with('-')
248 && !segment.ends_with('-')
249 && !segment.contains("--")
250 && segment
251 .bytes()
252 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
253}
254
255fn valid_sha256_digest(value: &str) -> bool {
256 value.strip_prefix("sha256:").is_some_and(|digest| {
257 digest.len() == 64
258 && digest
259 .bytes()
260 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
261 })
262}
263
264fn valid_capability_endpoint(value: &str) -> bool {
265 value
266 .strip_prefix("/system-plane/v1/")
267 .is_some_and(|suffix| {
268 !suffix.is_empty()
269 && !suffix.starts_with('/')
270 && !suffix.contains("//")
271 && !suffix.contains(['?', '#'])
272 && suffix.split('/').all(valid_id_segment)
273 })
274}
275
276fn push_issue(
277 issues: &mut Vec<CoreIssue>,
278 code: CoreIssueCode,
279 path: impl Into<String>,
280 message: impl Into<String>,
281 next_action: impl Into<String>,
282) {
283 issues.push(CoreIssue {
284 code,
285 path: path.into(),
286 message: message.into(),
287 next_action: next_action.into(),
288 });
289}