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