1use std::collections::{BTreeMap, HashSet};
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use sha2::{Digest, Sha256};
6
7use crate::{AgentDefinition, EndpointDefinition, RuntimeError};
8
9pub const RUNTIME_MANIFEST_SCHEMA_VERSION: u32 = 1;
10
11const MAX_PROVIDERS: usize = 64;
12const MAX_AGENTS: usize = 1_024;
13const MAX_ENDPOINTS: usize = 2_048;
14const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;
15const SENSITIVE_KEYS: &[&str] = &[
16 "apikey",
17 "authorization",
18 "credential",
19 "password",
20 "privatekey",
21 "secret",
22 "token",
23];
24
25#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase", deny_unknown_fields)]
31pub struct ProviderRequirement {
32 pub id: String,
33 pub provider_type: String,
34 #[serde(default)]
35 pub capabilities: Vec<String>,
36 #[serde(default)]
37 pub settings: Value,
38 #[serde(default)]
39 pub resources: BTreeMap<String, String>,
40}
41
42#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase", deny_unknown_fields)]
45pub struct RuntimeManifest {
46 pub schema_version: u32,
47 pub project_id: String,
48 #[serde(default)]
49 pub providers: Vec<ProviderRequirement>,
50 #[serde(default)]
51 pub agents: Vec<AgentDefinition>,
52 #[serde(default)]
53 pub endpoints: Vec<EndpointDefinition>,
54 #[serde(default)]
55 pub metadata: Value,
56}
57
58impl RuntimeManifest {
59 pub fn new(project_id: impl Into<String>) -> Self {
60 Self {
61 schema_version: RUNTIME_MANIFEST_SCHEMA_VERSION,
62 project_id: project_id.into(),
63 providers: Vec::new(),
64 agents: Vec::new(),
65 endpoints: Vec::new(),
66 metadata: Value::Object(Default::default()),
67 }
68 }
69
70 pub fn from_json(bytes: &[u8]) -> Result<Self, RuntimeError> {
71 if bytes.len() > MAX_MANIFEST_BYTES {
72 return Err(RuntimeError::InvalidDefinition(
73 "runtime manifest is too large".to_string(),
74 ));
75 }
76 let manifest = serde_json::from_slice::<Self>(bytes)
77 .map_err(|error| RuntimeError::InvalidDefinition(error.to_string()))?;
78 manifest.validate()?;
79 Ok(manifest)
80 }
81
82 pub fn to_json(&self) -> Result<Vec<u8>, RuntimeError> {
83 self.validate()?;
84 serde_json::to_vec(self).map_err(|error| RuntimeError::Snapshot(error.to_string()))
85 }
86
87 pub fn content_hash(&self) -> Result<String, RuntimeError> {
88 let bytes = self.to_json()?;
89 let digest = Sha256::digest(bytes);
90 let mut encoded = String::with_capacity(7 + digest.len() * 2);
91 encoded.push_str("sha256:");
92 for byte in digest {
93 use std::fmt::Write;
94 write!(&mut encoded, "{byte:02x}").map_err(|_error| RuntimeError::Internal)?;
95 }
96 Ok(encoded)
97 }
98
99 pub fn validate(&self) -> Result<(), RuntimeError> {
100 if self.schema_version != RUNTIME_MANIFEST_SCHEMA_VERSION {
101 return Err(RuntimeError::InvalidDefinition(format!(
102 "unsupported runtime manifest schema version {}",
103 self.schema_version
104 )));
105 }
106 validate_portable_identifier("project", &self.project_id)?;
107 validate_count("providers", self.providers.len(), MAX_PROVIDERS)?;
108 validate_count("agents", self.agents.len(), MAX_AGENTS)?;
109 validate_count("endpoints", self.endpoints.len(), MAX_ENDPOINTS)?;
110
111 let mut provider_ids = HashSet::with_capacity(self.providers.len());
112 for provider in &self.providers {
113 validate_portable_identifier("provider", &provider.id)?;
114 validate_portable_identifier("provider type", &provider.provider_type)?;
115 if !provider_ids.insert(provider.id.as_str()) {
116 return Err(duplicate("provider", &provider.id));
117 }
118 validate_capabilities(&provider.capabilities)?;
119 validate_portable_provider_value(&provider.settings, "settings")?;
120 for (name, reference) in &provider.resources {
121 validate_portable_identifier("resource", name)?;
122 validate_resource_reference(reference)?;
123 }
124 }
125
126 let mut agent_ids = HashSet::with_capacity(self.agents.len());
127 for agent in &self.agents {
128 validate_portable_identifier("agent", &agent.id)?;
129 validate_portable_identifier("provider", &agent.provider)?;
130 if agent.name.trim().is_empty() {
131 return Err(RuntimeError::InvalidDefinition(
132 "agent name is required".to_string(),
133 ));
134 }
135 if !provider_ids.contains(agent.provider.as_str()) {
136 return Err(RuntimeError::ProviderNotFound(agent.provider.clone()));
137 }
138 if !agent_ids.insert(agent.id.as_str()) {
139 return Err(duplicate("agent", &agent.id));
140 }
141 validate_capabilities(&agent.capabilities)?;
142 validate_portable_value(&agent.metadata, "agent metadata")?;
143 }
144
145 let mut endpoint_names = HashSet::with_capacity(self.endpoints.len());
146 for endpoint in &self.endpoints {
147 validate_portable_identifier("endpoint", &endpoint.name)?;
148 validate_portable_identifier("agent", &endpoint.agent)?;
149 validate_portable_identifier("capability", &endpoint.capability)?;
150 if !agent_ids.contains(endpoint.agent.as_str()) {
151 return Err(RuntimeError::AgentNotFound(endpoint.agent.clone()));
152 }
153 if !endpoint_names.insert(endpoint.name.as_str()) {
154 return Err(duplicate("endpoint", &endpoint.name));
155 }
156 if !(1..=120_000).contains(&endpoint.timeout_ms) {
157 return Err(RuntimeError::InvalidDefinition(
158 "endpoint timeout must be between 1 and 120000 milliseconds".to_string(),
159 ));
160 }
161 let agent = self
162 .agents
163 .iter()
164 .find(|agent| agent.id == endpoint.agent)
165 .ok_or_else(|| RuntimeError::AgentNotFound(endpoint.agent.clone()))?;
166 if !agent
167 .capabilities
168 .iter()
169 .any(|capability| capability == &endpoint.capability)
170 {
171 return Err(RuntimeError::CapabilityUnavailable {
172 provider: agent.provider.clone(),
173 capability: endpoint.capability.clone(),
174 });
175 }
176 }
177
178 validate_portable_value(&self.metadata, "manifest metadata")?;
179
180 if self.to_unchecked_json()?.len() > MAX_MANIFEST_BYTES {
181 return Err(RuntimeError::InvalidDefinition(
182 "runtime manifest is too large".to_string(),
183 ));
184 }
185 Ok(())
186 }
187
188 fn to_unchecked_json(&self) -> Result<Vec<u8>, RuntimeError> {
189 serde_json::to_vec(self).map_err(|error| RuntimeError::Snapshot(error.to_string()))
190 }
191}
192
193#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase", deny_unknown_fields)]
196pub struct RuntimeRelease {
197 pub version: u64,
198 pub content_hash: String,
199 pub manifest: RuntimeManifest,
200}
201
202impl RuntimeRelease {
203 pub fn new(version: u64, manifest: RuntimeManifest) -> Result<Self, RuntimeError> {
204 if version == 0 {
205 return Err(RuntimeError::InvalidDefinition(
206 "runtime release version must be greater than zero".to_string(),
207 ));
208 }
209 manifest.validate()?;
210 let content_hash = manifest.content_hash()?;
211 Ok(Self {
212 version,
213 content_hash,
214 manifest,
215 })
216 }
217
218 pub fn validate(&self) -> Result<(), RuntimeError> {
219 if self.version == 0 {
220 return Err(RuntimeError::InvalidDefinition(
221 "runtime release version must be greater than zero".to_string(),
222 ));
223 }
224 self.manifest.validate()?;
225 if self.content_hash != self.manifest.content_hash()? {
226 return Err(RuntimeError::InvalidDefinition(
227 "runtime release content hash does not match its manifest".to_string(),
228 ));
229 }
230 Ok(())
231 }
232}
233
234#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
236#[serde(rename_all = "camelCase", deny_unknown_fields)]
237pub struct LocalProviderBinding {
238 pub provider_id: String,
239 #[serde(default)]
240 pub configuration: Value,
241}
242
243#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
245#[serde(rename_all = "camelCase", deny_unknown_fields)]
246pub struct RuntimeTraceRecord {
247 pub id: String,
248 pub project_id: String,
249 pub invocation_id: String,
250 pub endpoint: String,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub agent: Option<String>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub provider: Option<String>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub capability: Option<String>,
257 pub status: String,
258 pub duration_ms: u64,
259 pub created_at_ms: u64,
260}
261
262impl RuntimeTraceRecord {
263 pub fn validate(&self) -> Result<(), RuntimeError> {
264 validate_trace_identifier("trace", &self.id)?;
265 validate_portable_identifier("project", &self.project_id)?;
266 validate_trace_identifier("invocation", &self.invocation_id)?;
267 validate_portable_identifier("endpoint", &self.endpoint)?;
268 for (kind, value) in [
269 ("agent", self.agent.as_deref()),
270 ("provider", self.provider.as_deref()),
271 ("capability", self.capability.as_deref()),
272 ] {
273 if let Some(value) = value {
274 validate_portable_identifier(kind, value)?;
275 }
276 }
277 if !matches!(self.status.as_str(), "completed" | "cancelled" | "error") {
278 return Err(RuntimeError::InvalidDefinition(
279 "runtime trace status is invalid".to_string(),
280 ));
281 }
282 if self.duration_ms > 24 * 60 * 60 * 1_000 {
283 return Err(RuntimeError::InvalidDefinition(
284 "runtime trace duration is invalid".to_string(),
285 ));
286 }
287 if self.created_at_ms == 0 {
288 return Err(RuntimeError::InvalidDefinition(
289 "runtime trace timestamp is invalid".to_string(),
290 ));
291 }
292 Ok(())
293 }
294}
295
296fn validate_count(kind: &str, count: usize, maximum: usize) -> Result<(), RuntimeError> {
297 if count > maximum {
298 return Err(RuntimeError::InvalidDefinition(format!(
299 "runtime manifest contains too many {kind}"
300 )));
301 }
302 Ok(())
303}
304
305fn validate_capabilities(capabilities: &[String]) -> Result<(), RuntimeError> {
306 if capabilities.is_empty() {
307 return Err(RuntimeError::InvalidDefinition(
308 "at least one capability is required".to_string(),
309 ));
310 }
311 for capability in capabilities {
312 validate_portable_identifier("capability", capability)?;
313 }
314 Ok(())
315}
316
317fn validate_resource_reference(reference: &str) -> Result<(), RuntimeError> {
318 if reference.is_empty() || reference.len() > 512 {
319 return Err(RuntimeError::InvalidDefinition(
320 "resource reference must be between 1 and 512 bytes".to_string(),
321 ));
322 }
323 if is_absolute_or_file_path(reference) {
324 return Err(RuntimeError::InvalidDefinition(
325 "runtime manifests cannot contain filesystem paths".to_string(),
326 ));
327 }
328 Ok(())
329}
330
331fn validate_portable_provider_value(value: &Value, path: &str) -> Result<(), RuntimeError> {
332 validate_portable_value(value, &format!("provider {path}"))
333}
334
335fn validate_portable_value(value: &Value, path: &str) -> Result<(), RuntimeError> {
336 match value {
337 Value::Object(object) => {
338 for (key, value) in object {
339 let normalized = key
340 .chars()
341 .filter(|character| character.is_ascii_alphanumeric())
342 .flat_map(char::to_lowercase)
343 .collect::<String>();
344 if SENSITIVE_KEYS
345 .iter()
346 .any(|sensitive| normalized == *sensitive)
347 {
348 return Err(RuntimeError::InvalidDefinition(format!(
349 "runtime manifest {path} contains a credential field"
350 )));
351 }
352 validate_portable_value(value, path)?;
353 }
354 }
355 Value::Array(values) => {
356 for value in values {
357 validate_portable_value(value, path)?;
358 }
359 }
360 Value::String(value) if is_absolute_or_file_path(value) => {
361 return Err(RuntimeError::InvalidDefinition(format!(
362 "runtime manifest {path} contains a filesystem path"
363 )));
364 }
365 _ => {}
366 }
367 Ok(())
368}
369
370fn is_absolute_or_file_path(value: &str) -> bool {
371 value.starts_with('/')
372 || value.starts_with("file://")
373 || (value.len() >= 3
374 && value.as_bytes()[0].is_ascii_alphabetic()
375 && value.as_bytes()[1] == b':'
376 && matches!(value.as_bytes()[2], b'/' | b'\\'))
377}
378
379fn validate_portable_identifier(kind: &str, value: &str) -> Result<(), RuntimeError> {
380 if value.is_empty()
381 || value.len() > 128
382 || !value
383 .bytes()
384 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
385 {
386 return Err(RuntimeError::InvalidDefinition(format!(
387 "{kind} must be a portable identifier"
388 )));
389 }
390 Ok(())
391}
392
393fn validate_trace_identifier(kind: &str, value: &str) -> Result<(), RuntimeError> {
394 if value.is_empty() || value.len() > 256 || value.chars().any(char::is_control) {
395 return Err(RuntimeError::InvalidDefinition(format!(
396 "{kind} must be a valid trace identifier"
397 )));
398 }
399 Ok(())
400}
401
402fn duplicate(kind: &str, id: &str) -> RuntimeError {
403 RuntimeError::InvalidDefinition(format!("duplicate {kind} {id}"))
404}
405
406#[cfg(test)]
407mod tests {
408 use serde_json::json;
409
410 use super::*;
411
412 fn manifest() -> RuntimeManifest {
413 RuntimeManifest {
414 schema_version: RUNTIME_MANIFEST_SCHEMA_VERSION,
415 project_id: "moon-train".to_string(),
416 providers: vec![ProviderRequirement {
417 id: "local-model".to_string(),
418 provider_type: "openai-compatible".to_string(),
419 capabilities: vec!["chat".to_string()],
420 settings: json!({ "model": "small-chat" }),
421 resources: BTreeMap::from([("model".to_string(), "model:small-chat".to_string())]),
422 }],
423 agents: vec![AgentDefinition {
424 id: "guide".to_string(),
425 name: "Guide".to_string(),
426 provider: "local-model".to_string(),
427 capabilities: vec!["chat".to_string()],
428 metadata: json!({ "persona": "A calm guide." }),
429 }],
430 endpoints: vec![EndpointDefinition {
431 name: "guide".to_string(),
432 agent: "guide".to_string(),
433 capability: "chat".to_string(),
434 timeout_ms: 30_000,
435 }],
436 metadata: json!({ "title": "Last Train to the Moon" }),
437 }
438 }
439
440 #[test]
441 fn content_hash_is_stable_for_the_same_manifest() {
442 let manifest = manifest();
443 assert_eq!(
444 manifest.content_hash().unwrap(),
445 manifest.content_hash().unwrap()
446 );
447 }
448
449 #[test]
450 fn provider_credentials_are_rejected() {
451 let mut manifest = manifest();
452 manifest.providers[0].settings = json!({ "apiKey": "not-portable" });
453 let error = manifest.validate().unwrap_err();
454 assert!(error.to_string().contains("credential field"));
455 }
456
457 #[test]
458 fn provider_filesystem_paths_are_rejected() {
459 let mut manifest = manifest();
460 manifest.providers[0]
461 .resources
462 .insert("model".to_string(), "/Users/example/model.gguf".to_string());
463 let error = manifest.validate().unwrap_err();
464 assert!(error.to_string().contains("filesystem paths"));
465 }
466
467 #[test]
468 fn metadata_credentials_are_rejected() {
469 let mut manifest = manifest();
470 manifest.agents[0].metadata = json!({ "credentials": { "token": "not-portable" } });
471 let error = manifest.validate().unwrap_err();
472 assert!(error.to_string().contains("credential field"));
473 }
474
475 #[test]
476 fn endpoint_timeouts_are_bounded() {
477 let mut manifest = manifest();
478 manifest.endpoints[0].timeout_ms = 0;
479 let error = manifest.validate().unwrap_err();
480 assert!(error.to_string().contains("endpoint timeout"));
481 }
482
483 #[test]
484 fn trace_records_reject_unknown_statuses() {
485 let trace = RuntimeTraceRecord {
486 id: "trace-1".to_string(),
487 project_id: "moon-train".to_string(),
488 invocation_id: "invocation-1".to_string(),
489 endpoint: "guide".to_string(),
490 agent: Some("guide".to_string()),
491 provider: Some("local-model".to_string()),
492 capability: Some("chat".to_string()),
493 status: "pending".to_string(),
494 duration_ms: 1,
495 created_at_ms: 1,
496 };
497 let error = trace.validate().unwrap_err();
498 assert!(error.to_string().contains("trace status"));
499 }
500}