1use axum::{
2 body::{Body, to_bytes},
3 http::{HeaderMap, Response, StatusCode},
4};
5use serde::{Deserialize, Serialize};
6use serde_cbor;
7use serde_json::{Map, Value, json};
8use sha2::{Digest, Sha256};
9use std::sync::Arc;
10use std::sync::atomic::Ordering;
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
13use tracing::{Level, span};
14
15use crate::component_api::node::{ExecCtx as ComponentExecCtx, TenantCtx as ComponentTenantCtx};
16use crate::operator_registry::OperatorResolveError;
17use crate::routing::TenantRuntimeHandle;
18use crate::runner::contract_cache::ContractSnapshot;
19use crate::runner::contract_introspection::introspect_component_contract;
20use crate::runner::i18n::{I18nText, resolve_text, select_locale};
21use crate::runner::schema_validator::validate_json_instance;
22use crate::runtime::TenantRuntime;
23
24const CONTENT_TYPE_CBOR: &str = "application/cbor";
25const FLAG_SKIP_OUTPUT_VALIDATE: &str = "skip-output-validate";
26const FLAG_PERMISSIVE_SCHEMA: &str = "permissive-schema";
27
28#[derive(Debug, Deserialize)]
30pub struct OperatorRequest {
31 #[serde(default)]
32 pub tenant_id: Option<String>,
33 #[serde(default)]
34 pub provider_id: Option<String>,
35 #[serde(default)]
36 pub provider_type: Option<String>,
37 #[serde(default)]
38 pub pack_id: Option<String>,
39 pub op_id: String,
40 #[serde(default)]
41 pub trace_id: Option<String>,
42 #[serde(default)]
43 pub correlation_id: Option<String>,
44 #[serde(default)]
45 pub timeout: Option<u64>,
46 #[serde(default)]
47 pub flags: Vec<String>,
48 #[serde(default)]
49 pub op_version: Option<String>,
50 #[serde(default)]
51 pub schema_hash: Option<String>,
52 #[serde(default)]
53 pub locale: Option<String>,
54 pub payload: OperatorPayload,
55}
56
57impl OperatorRequest {
58 pub fn from_cbor(bytes: &[u8]) -> Result<Self, serde_cbor::Error> {
59 serde_cbor::from_slice(bytes)
60 }
61}
62
63#[derive(Debug, Deserialize)]
64pub struct OperatorPayload {
65 #[serde(default)]
66 #[serde(rename = "cbor_input")]
67 pub cbor_input: Vec<u8>,
68 #[serde(default)]
69 pub attachments: Vec<AttachmentRef>,
70}
71
72#[derive(Debug, Deserialize)]
73pub struct AttachmentRef {
74 pub id: String,
75 #[serde(default)]
76 pub metadata: Option<Value>,
77}
78
79#[derive(Debug, Serialize)]
81pub struct OperatorResponse {
82 pub status: OperatorStatus,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub cbor_output: Option<Vec<u8>>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub error: Option<OperatorError>,
87}
88
89impl OperatorResponse {
90 pub fn ok(output: Vec<u8>) -> Self {
91 Self {
92 status: OperatorStatus::Ok,
93 cbor_output: Some(output),
94 error: None,
95 }
96 }
97
98 pub fn error(code: OperatorErrorCode, message: impl Into<String>) -> Self {
99 Self {
100 status: OperatorStatus::Error,
101 cbor_output: None,
102 error: Some(OperatorError {
103 code,
104 message: message.into(),
105 details_cbor: None,
106 }),
107 }
108 }
109
110 pub fn error_with_diagnostics(
111 code: OperatorErrorCode,
112 message: impl Into<String>,
113 diagnostics: Vec<Diagnostic>,
114 ) -> Self {
115 let details_cbor = serde_cbor::to_vec(&diagnostics).ok();
116 Self {
117 status: OperatorStatus::Error,
118 cbor_output: None,
119 error: Some(OperatorError {
120 code,
121 message: message.into(),
122 details_cbor,
123 }),
124 }
125 }
126
127 pub fn to_cbor(&self) -> Result<Vec<u8>, serde_cbor::Error> {
128 serde_cbor::ser::to_vec_packed(self)
129 }
130}
131
132#[derive(Debug, Serialize)]
133pub struct OperatorError {
134 pub code: OperatorErrorCode,
135 pub message: String,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub details_cbor: Option<Vec<u8>>,
138}
139
140#[derive(Debug, Serialize)]
141pub enum OperatorStatus {
142 Ok,
143 Error,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
147#[serde(rename_all = "snake_case")]
148pub enum DiagnosticSeverity {
149 Error,
150 Warning,
151 Info,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
155pub struct Diagnostic {
156 pub code: String,
157 pub path: String,
158 pub severity: DiagnosticSeverity,
159 pub message_key: String,
160 pub fallback: String,
161 pub message: String,
162 #[serde(skip_serializing_if = "Option::is_none")]
163 pub hint: Option<String>,
164 #[serde(skip_serializing_if = "Option::is_none")]
165 pub component_id: Option<String>,
166 #[serde(skip_serializing_if = "Option::is_none")]
167 pub digest: Option<String>,
168 #[serde(skip_serializing_if = "Option::is_none")]
169 pub operation_id: Option<String>,
170}
171
172#[derive(Debug, Clone, Copy, Serialize)]
173#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
174pub enum OperatorErrorCode {
175 OpNotFound,
176 ProviderNotFound,
177 TenantNotAllowed,
178 InvalidRequest,
179 CborDecode,
180 TypeMismatch,
181 ComponentLoad,
182 InvokeTrap,
183 Timeout,
184 PolicyDenied,
185 HostFailure,
186}
187
188#[derive(Debug, Copy, Clone, PartialEq, Eq)]
189struct ExecutionValidationOptions {
190 validate_output: bool,
191 strict: bool,
192}
193
194impl Default for ExecutionValidationOptions {
195 fn default() -> Self {
196 Self {
197 validate_output: true,
198 strict: true,
199 }
200 }
201}
202
203fn validation_options_from_flags(flags: &[String]) -> ExecutionValidationOptions {
204 let mut options = ExecutionValidationOptions::default();
205 for flag in flags {
206 match flag.trim().to_ascii_lowercase().as_str() {
207 FLAG_SKIP_OUTPUT_VALIDATE => options.validate_output = false,
208 FLAG_PERMISSIVE_SCHEMA => options.strict = false,
209 _ => {}
210 }
211 }
212 options
213}
214
215fn normalize_operation_id(op_id: &str) -> String {
216 let normalized = op_id.trim();
217 if normalized.is_empty() {
218 "run".to_string()
219 } else {
220 normalized.to_string()
221 }
222}
223
224fn normalize_sha256_hash(value: &str) -> String {
225 let trimmed = value.trim();
226 if trimmed.starts_with("sha256:") {
227 trimmed.to_string()
228 } else {
229 format!("sha256:{trimmed}")
230 }
231}
232
233fn sha256_prefixed(bytes: &[u8]) -> String {
234 let mut hasher = Sha256::new();
235 hasher.update(bytes);
236 let digest = hasher.finalize();
237 format!("sha256:{}", to_hex(&digest))
238}
239
240fn to_hex(digest: &[u8]) -> String {
241 digest.iter().map(|byte| format!("{byte:02x}")).collect()
242}
243
244#[derive(Serialize)]
245struct SchemaHashMaterial<'a> {
246 resolved_digest: &'a str,
247 component_ref: &'a str,
248 operation_id: &'a str,
249 world: &'a str,
250 export: &'a str,
251 input_schema: &'a Value,
252 output_schema: &'a Value,
253 config_schema: &'a Value,
254 state_schema_ref: Option<&'a str>,
255}
256
257#[derive(Serialize)]
258struct DescribeHashMaterial<'a> {
259 resolved_digest: &'a str,
260 component_ref: &'a str,
261 world: &'a str,
262 export: &'a str,
263 pack_ref: &'a str,
264 input_schema: &'a Value,
265 output_schema: &'a Value,
266}
267
268#[allow(clippy::too_many_arguments)]
269fn compute_contract_hashes(
270 resolved_digest: &str,
271 component_ref: &str,
272 operation_id: &str,
273 world: &str,
274 export: &str,
275 input_schema: &Value,
276 output_schema: &Value,
277 config_schema: &Value,
278 state_schema_ref: Option<&str>,
279 pack_ref: &str,
280) -> (String, String) {
281 let input_schema = canonicalize_json_value(input_schema.clone());
282 let output_schema = canonicalize_json_value(output_schema.clone());
283 let config_schema = canonicalize_json_value(config_schema.clone());
284 let describe_material = DescribeHashMaterial {
285 resolved_digest,
286 component_ref,
287 world,
288 export,
289 pack_ref,
290 input_schema: &input_schema,
291 output_schema: &output_schema,
292 };
293 let describe_bytes =
294 serde_cbor::to_vec(&describe_material).expect("describe hash material serialization");
295 let describe_hash = sha256_prefixed(&describe_bytes);
296
297 let schema_material = SchemaHashMaterial {
298 resolved_digest,
299 component_ref,
300 operation_id,
301 world,
302 export,
303 input_schema: &input_schema,
304 output_schema: &output_schema,
305 config_schema: &config_schema,
306 state_schema_ref,
307 };
308 let schema_bytes =
309 serde_cbor::to_vec(&schema_material).expect("schema hash material serialization");
310 let schema_hash = sha256_prefixed(&schema_bytes);
311 (describe_hash, schema_hash)
312}
313
314fn canonicalize_json_value(value: Value) -> Value {
315 match value {
316 Value::Object(map) => {
317 let mut ordered = serde_json::Map::new();
318 let mut keys = map.keys().cloned().collect::<Vec<_>>();
319 keys.sort();
320 for key in keys {
321 let normalized = map
322 .get(&key)
323 .cloned()
324 .map(canonicalize_json_value)
325 .unwrap_or(Value::Null);
326 ordered.insert(key, normalized);
327 }
328 Value::Object(ordered)
329 }
330 Value::Array(items) => {
331 Value::Array(items.into_iter().map(canonicalize_json_value).collect())
332 }
333 other => other,
334 }
335}
336
337fn derive_output_schema_ref(config_schema_ref: Option<&str>) -> Option<String> {
338 let config_ref = config_schema_ref?;
339 let candidate = config_ref.replace("config", "output");
340 if candidate == config_ref {
341 None
342 } else {
343 Some(candidate)
344 }
345}
346
347fn schema_issues_to_diagnostics(
348 issues: Vec<crate::runner::schema_validator::SchemaValidationIssue>,
349 path_prefix: &str,
350 component_ref: &str,
351 resolved_digest: &str,
352 op_id: &str,
353 locale: &str,
354) -> Vec<Diagnostic> {
355 issues
356 .into_iter()
357 .map(|issue| {
358 let text = I18nText::new(issue.message_key, issue.fallback);
359 let path = if path_prefix.is_empty() {
360 issue.path
361 } else if issue.path == "/" {
362 path_prefix.to_string()
363 } else {
364 format!("{path_prefix}{}", issue.path)
365 };
366 Diagnostic {
367 code: issue.code,
368 path,
369 severity: DiagnosticSeverity::Error,
370 message_key: text.message_key.clone(),
371 fallback: text.fallback.clone(),
372 message: resolve_text(&text, locale),
373 hint: None,
374 component_id: Some(component_ref.to_string()),
375 digest: Some(resolved_digest.to_string()),
376 operation_id: Some(op_id.to_string()),
377 }
378 })
379 .collect()
380}
381
382#[allow(clippy::too_many_arguments)]
383fn diagnostic_error(
384 code: &str,
385 path: &str,
386 message_key: &str,
387 fallback: String,
388 operation_id: Option<&str>,
389 component_id: Option<&str>,
390 digest: Option<&str>,
391 locale: &str,
392) -> Diagnostic {
393 let text = I18nText::new(message_key, fallback);
394 let message = resolve_text(&text, locale);
395 Diagnostic {
396 code: code.to_string(),
397 path: path.to_string(),
398 severity: DiagnosticSeverity::Error,
399 message_key: text.message_key,
400 message,
401 fallback: text.fallback,
402 hint: None,
403 component_id: component_id.map(ToString::to_string),
404 digest: digest.map(ToString::to_string),
405 operation_id: operation_id.map(ToString::to_string),
406 }
407}
408
409impl OperatorErrorCode {
410 pub fn reason(&self) -> &'static str {
411 match self {
412 OperatorErrorCode::OpNotFound => "op not found",
413 OperatorErrorCode::ProviderNotFound => "provider not found",
414 OperatorErrorCode::TenantNotAllowed => "tenant not allowed",
415 OperatorErrorCode::InvalidRequest => "invalid operator request",
416 OperatorErrorCode::CborDecode => "failed to decode CBOR payload",
417 OperatorErrorCode::TypeMismatch => "type mismatch between CBOR and operation",
418 OperatorErrorCode::ComponentLoad => "failed to load component",
419 OperatorErrorCode::InvokeTrap => "component trapped during invoke",
420 OperatorErrorCode::Timeout => "invocation timed out",
421 OperatorErrorCode::PolicyDenied => "policy denied the operation",
422 OperatorErrorCode::HostFailure => "internal host failure",
423 }
424 }
425}
426
427pub async fn invoke_operator(
429 runtime: &TenantRuntime,
430 request: OperatorRequest,
431) -> OperatorResponse {
432 let op_id = normalize_operation_id(&request.op_id);
433 let validation_options = validation_options_from_flags(&request.flags);
434 let locale = select_locale(request.locale.as_deref());
435 if let Some(request_tenant) = request.tenant_id.as_deref()
436 && request_tenant != runtime.tenant()
437 {
438 let message = format!(
439 "tenant mismatch: routing resolved `{}` but request wants `{request_tenant}`",
440 runtime.tenant(),
441 );
442 return OperatorResponse::error_with_diagnostics(
443 OperatorErrorCode::TenantNotAllowed,
444 message.clone(),
445 vec![diagnostic_error(
446 "tenant_mismatch",
447 "/tenant_id",
448 "runner.operator.tenant_mismatch",
449 message,
450 Some(op_id.as_str()),
451 None,
452 runtime.digest(),
453 &locale,
454 )],
455 );
456 }
457
458 if request.provider_id.is_none() && request.provider_type.is_none() {
459 let message = "operator invoke requires provider_id or provider_type".to_string();
460 return OperatorResponse::error_with_diagnostics(
461 OperatorErrorCode::InvalidRequest,
462 message.clone(),
463 vec![diagnostic_error(
464 "missing_provider_selector",
465 "/provider_id",
466 "runner.operator.missing_provider_selector",
467 message,
468 Some(op_id.as_str()),
469 None,
470 runtime.digest(),
471 &locale,
472 )],
473 );
474 }
475
476 let tenant = runtime.tenant();
477 let root_span = span!(
478 Level::INFO,
479 "operator.invoke",
480 tenant = %tenant,
481 op_id = %op_id,
482 provider_id = ?request.provider_id,
483 provider_type = ?request.provider_type
484 );
485 let _root_guard = root_span.enter();
486
487 let provider_id = request.provider_id.as_deref();
488 let provider_type = request.provider_type.as_deref();
489 runtime
490 .operator_metrics()
491 .resolve_attempts
492 .fetch_add(1, Ordering::Relaxed);
493 let resolve_span = span!(Level::DEBUG, "resolve_op");
494 let _resolve_guard = resolve_span.enter();
495 let binding = match runtime
496 .operator_registry()
497 .resolve(provider_id, provider_type, &op_id)
498 {
499 Ok(binding) => binding,
500 Err(err) => {
501 let (code, message) = match err {
502 OperatorResolveError::ProviderNotFound => {
503 let label = provider_id.or(provider_type).unwrap_or("unknown");
504 (
505 OperatorErrorCode::ProviderNotFound,
506 format!("provider `{label}` not registered"),
507 )
508 }
509 OperatorResolveError::OpNotFound => {
510 let label = provider_id.or(provider_type).unwrap_or("unknown provider");
511 (
512 OperatorErrorCode::OpNotFound,
513 format!("op `{}` not found for provider `{label}`", &op_id),
514 )
515 }
516 };
517 runtime
518 .operator_metrics()
519 .resolve_errors
520 .fetch_add(1, Ordering::Relaxed);
521 let response = OperatorResponse::error(code, message);
522 let diagnostic = diagnostic_error(
523 match code {
524 OperatorErrorCode::ProviderNotFound => "provider_not_found",
525 OperatorErrorCode::OpNotFound => "op_not_found",
526 _ => "resolve_error",
527 },
528 "/op_id",
529 match code {
530 OperatorErrorCode::ProviderNotFound => "runner.operator.provider_not_found",
531 OperatorErrorCode::OpNotFound => "runner.operator.op_not_found",
532 _ => "runner.operator.resolve_error",
533 },
534 response
535 .error
536 .as_ref()
537 .map(|e| e.message.clone())
538 .unwrap_or_else(|| "operator resolve failed".to_string()),
539 Some(op_id.as_str()),
540 binding_component_ref_hint(provider_id, provider_type),
541 runtime.digest(),
542 &locale,
543 );
544 let response = OperatorResponse::error_with_diagnostics(
545 code,
546 response
547 .error
548 .as_ref()
549 .map(|e| e.message.clone())
550 .unwrap_or_else(|| "operator resolve failed".to_string()),
551 vec![diagnostic],
552 );
553 return response;
554 }
555 };
556 drop(_resolve_guard);
557
558 let policy = &runtime.config().operator_policy;
559 if !policy.allows_provider(provider_id, binding.provider_type.as_str()) {
560 return OperatorResponse::error(
561 OperatorErrorCode::PolicyDenied,
562 format!(
563 "provider `{}` not allowed for tenant {}",
564 binding
565 .provider_id
566 .as_deref()
567 .unwrap_or(&binding.provider_type),
568 runtime.config().tenant
569 ),
570 );
571 }
572 if !policy.allows_op(provider_id, binding.provider_type.as_str(), &binding.op_id) {
573 return OperatorResponse::error(
574 OperatorErrorCode::PolicyDenied,
575 format!(
576 "op `{}` is not permitted for provider `{}` on tenant {}",
577 binding.op_id,
578 binding
579 .provider_id
580 .as_deref()
581 .unwrap_or(&binding.provider_type),
582 runtime.config().tenant
583 ),
584 );
585 }
586
587 if let Some(req_pack) = request.pack_id.as_deref() {
588 let binding_pack = binding
589 .pack_ref
590 .split('@')
591 .next()
592 .unwrap_or(&binding.pack_ref);
593 if binding_pack != req_pack {
594 return OperatorResponse::error(
595 OperatorErrorCode::PolicyDenied,
596 format!(
597 "request bound to pack `{req_pack}`, but op lives in `{}`",
598 binding.pack_ref
599 ),
600 );
601 }
602 }
603
604 let attachments = match resolve_attachments(&request.payload, runtime) {
605 Ok(map) => map,
606 Err(response) => return response,
607 };
608
609 let decode_span = span!(Level::DEBUG, "decode_cbor");
610 let _decode_guard = decode_span.enter();
611 let input_value = match decode_request_payload(&request.payload.cbor_input) {
612 Ok(value) => value,
613 Err(err) => {
614 runtime
615 .operator_metrics()
616 .cbor_decode_errors
617 .fetch_add(1, Ordering::Relaxed);
618 return OperatorResponse::error(OperatorErrorCode::CborDecode, format!("{err}"));
619 }
620 };
621 drop(_decode_guard);
622
623 let input_value = merge_input_with_attachments(input_value, attachments);
624
625 let registry_component_ref = &binding.runtime.component_ref;
626 let resolved = match runtime.resolve_component(registry_component_ref) {
627 Some(resolved) => resolved,
628 None => {
629 return OperatorResponse::error(
630 OperatorErrorCode::ComponentLoad,
631 format!(
632 "component `{}` not found in tenant packs",
633 registry_component_ref
634 ),
635 );
636 }
637 };
638 let pack = resolved.pack;
639 let provider_binding = match pack.resolve_provider(provider_id, provider_type) {
640 Ok(binding) => binding,
641 Err(err) => {
642 return OperatorResponse::error(
643 OperatorErrorCode::HostFailure,
644 format!("failed to resolve provider runtime: {err}"),
645 );
646 }
647 };
648
649 let component_ref = &provider_binding.component_ref;
650 let resolved = match runtime.resolve_component(component_ref) {
651 Some(resolved) => resolved,
652 None => {
653 return OperatorResponse::error(
654 OperatorErrorCode::ComponentLoad,
655 format!("component `{}` not found in tenant packs", component_ref),
656 );
657 }
658 };
659 let pack = resolved.pack;
660 let resolved_digest = if resolved.digest == "unknown" {
661 binding
662 .pack_digest
663 .clone()
664 .unwrap_or_else(|| resolved.digest.clone())
665 } else {
666 resolved.digest.clone()
667 };
668 let introspected_contract =
669 match introspect_component_contract(pack.as_ref(), component_ref.as_str(), &op_id) {
670 Ok(value) => value,
671 Err(err) => {
672 let message = format!("failed to introspect component contract: {err}");
673 return OperatorResponse::error_with_diagnostics(
674 OperatorErrorCode::TypeMismatch,
675 message.clone(),
676 vec![diagnostic_error(
677 "contract_introspection_failed",
678 "/operation",
679 "runner.operator.contract_introspection_failed",
680 message,
681 Some(op_id.as_str()),
682 Some(component_ref.as_str()),
683 Some(resolved_digest.as_str()),
684 &locale,
685 )],
686 );
687 }
688 };
689 let invoke_op_id = introspected_contract
690 .as_ref()
691 .map(|contract| contract.selected_operation.clone())
692 .unwrap_or_else(|| op_id.clone());
693 let loaded_config_schema = introspected_contract
694 .as_ref()
695 .map(|contract| contract.config_schema.clone())
696 .filter(|value| !value.is_null())
697 .or_else(|| {
698 binding
699 .config_schema_ref
700 .as_deref()
701 .and_then(|schema_ref| pack.load_schema_json(schema_ref).ok().flatten())
702 })
703 .unwrap_or(Value::Null);
704 let loaded_output_schema = introspected_contract
705 .as_ref()
706 .map(|contract| contract.output_schema.clone())
707 .filter(|value| !value.is_null())
708 .or_else(|| {
709 derive_output_schema_ref(binding.config_schema_ref.as_deref())
710 .and_then(|schema_ref| pack.load_schema_json(&schema_ref).ok().flatten())
711 })
712 .unwrap_or(Value::Null);
713 let loaded_input_schema = introspected_contract
714 .as_ref()
715 .map(|contract| contract.input_schema.clone())
716 .filter(|value| !value.is_null())
717 .or_else(|| loaded_config_schema.is_null().then_some(Value::Null))
718 .or_else(|| Some(loaded_config_schema.clone()))
719 .unwrap_or(Value::Null);
720 let loaded_config_schema = binding
721 .config_schema_ref
722 .as_deref()
723 .and_then(|schema_ref| pack.load_schema_json(schema_ref).ok().flatten())
724 .unwrap_or_else(|| loaded_config_schema.clone());
725 let contract_key = format!(
726 "{}::{component_ref}::{op_id}::validate_output={}::strict={}",
727 resolved_digest, validation_options.validate_output, validation_options.strict
728 );
729 let _contract_snapshot = if let Some(snapshot) = runtime.contract_cache().get(&contract_key) {
730 snapshot
731 } else {
732 let (describe_hash, schema_hash) = introspected_contract
733 .as_ref()
734 .map(|contract| (contract.describe_hash.clone(), contract.schema_hash.clone()))
735 .unwrap_or_else(|| {
736 compute_contract_hashes(
737 &resolved_digest,
738 component_ref,
739 &invoke_op_id,
740 &provider_binding.world,
741 &provider_binding.export,
742 &loaded_input_schema,
743 &loaded_output_schema,
744 &loaded_config_schema,
745 binding.state_schema_ref.as_deref(),
746 &binding.pack_ref,
747 )
748 });
749 let mut snapshot = ContractSnapshot::new(
750 resolved_digest.clone(),
751 component_ref.clone(),
752 invoke_op_id.clone(),
753 validation_options.validate_output,
754 validation_options.strict,
755 );
756 snapshot.describe_hash = Some(describe_hash);
757 snapshot.schema_hash = Some(schema_hash);
758 let snapshot = Arc::new(snapshot);
759 runtime
760 .contract_cache()
761 .insert(contract_key, Arc::clone(&snapshot));
762 snapshot
763 };
764 if !loaded_input_schema.is_null() {
765 let issues = validate_json_instance(
766 &loaded_input_schema,
767 &input_value,
768 validation_options.strict,
769 );
770 if !issues.is_empty() {
771 let diagnostics = schema_issues_to_diagnostics(
772 issues,
773 "/input",
774 component_ref,
775 &resolved_digest,
776 &op_id,
777 &locale,
778 );
779 return OperatorResponse::error_with_diagnostics(
780 OperatorErrorCode::TypeMismatch,
781 "input failed schema validation".to_string(),
782 diagnostics,
783 );
784 }
785 } else if validation_options.strict && binding.config_schema_ref.is_some() {
786 let message = format!(
787 "schema `{}` referenced by op `{}` was not found in pack",
788 binding.config_schema_ref.as_deref().unwrap_or("unknown"),
789 op_id
790 );
791 return OperatorResponse::error_with_diagnostics(
792 OperatorErrorCode::TypeMismatch,
793 message.clone(),
794 vec![diagnostic_error(
795 "schema_ref_not_found",
796 "/schema_hash",
797 "runner.operator.schema_ref_not_found",
798 message,
799 Some(op_id.as_str()),
800 Some(component_ref.as_str()),
801 Some(resolved_digest.as_str()),
802 &locale,
803 )],
804 );
805 }
806
807 if let Some(request_schema_hash) = request.schema_hash.as_deref()
808 && let Some(expected_schema_hash) = _contract_snapshot.schema_hash.as_deref()
809 {
810 let expected = normalize_sha256_hash(expected_schema_hash);
811 let provided = normalize_sha256_hash(request_schema_hash);
812 if expected != provided {
813 let message = format!(
814 "schema_hash mismatch for op `{}`: expected `{}`, got `{}`",
815 op_id, expected, provided
816 );
817 return OperatorResponse::error_with_diagnostics(
818 OperatorErrorCode::TypeMismatch,
819 message.clone(),
820 vec![diagnostic_error(
821 "schema_hash_mismatch",
822 "/schema_hash",
823 "runner.operator.schema_hash_mismatch",
824 message,
825 Some(op_id.as_str()),
826 Some(component_ref.as_str()),
827 Some(resolved_digest.as_str()),
828 &locale,
829 )],
830 );
831 }
832 }
833
834 let input_json = match serde_json::to_string(&input_value) {
835 Ok(json) => json,
836 Err(err) => {
837 return OperatorResponse::error(
838 OperatorErrorCode::TypeMismatch,
839 format!("failed to serialise input JSON: {err}"),
840 );
841 }
842 };
843
844 let exec_ctx = build_exec_ctx(&request, runtime, &op_id);
845 runtime
846 .operator_metrics()
847 .invoke_attempts
848 .fetch_add(1, Ordering::Relaxed);
849 let invoke_span = span!(Level::INFO, "invoke_component", component = %component_ref);
850 let _invoke_guard = invoke_span.enter();
851 let result = if provider_binding.world.starts_with("greentic:provider-core") {
852 let input_bytes = input_json.clone().into_bytes();
853 match pack
854 .invoke_provider(&provider_binding, exec_ctx, &invoke_op_id, input_bytes)
855 .await
856 {
857 Ok(value) => value,
858 Err(err) => {
859 runtime
860 .operator_metrics()
861 .invoke_errors
862 .fetch_add(1, Ordering::Relaxed);
863 return OperatorResponse::error(
864 OperatorErrorCode::HostFailure,
865 format!("provider invoke failed: {err}"),
866 );
867 }
868 }
869 } else {
870 match pack
871 .invoke_component(
872 component_ref,
873 exec_ctx,
874 &invoke_op_id,
875 provider_binding.config_json.clone(),
876 input_json.clone(),
877 )
878 .await
879 {
880 Ok(value) => value,
881 Err(err) => {
882 runtime
883 .operator_metrics()
884 .invoke_errors
885 .fetch_add(1, Ordering::Relaxed);
886 return OperatorResponse::error(
887 OperatorErrorCode::HostFailure,
888 format!("component invoke failed: {err}"),
889 );
890 }
891 }
892 };
893 drop(_invoke_guard);
894
895 if validation_options.validate_output
896 && let Some(output_ref) = derive_output_schema_ref(binding.config_schema_ref.as_deref())
897 && let Ok(Some(output_schema)) = pack.load_schema_json(&output_ref)
898 {
899 let output_value = result
900 .as_object()
901 .and_then(|obj| obj.get("output"))
902 .unwrap_or(&result);
903 let issues =
904 validate_json_instance(&output_schema, output_value, validation_options.strict);
905 if !issues.is_empty() {
906 let diagnostics = schema_issues_to_diagnostics(
907 issues,
908 "/output",
909 component_ref,
910 &resolved_digest,
911 &op_id,
912 &locale,
913 );
914 return OperatorResponse::error_with_diagnostics(
915 OperatorErrorCode::TypeMismatch,
916 "output failed schema validation".to_string(),
917 diagnostics,
918 );
919 }
920 }
921
922 if let Some(new_state) = result.as_object().and_then(|obj| obj.get("new_state")) {
923 if let Some(config_ref) = binding.config_schema_ref.as_deref() {
924 let config_schema = match pack.load_schema_json(config_ref) {
925 Ok(Some(schema)) => schema,
926 Ok(None) => {
927 let message = format!(
928 "config schema `{}` required for new_state validation was not found",
929 config_ref
930 );
931 return OperatorResponse::error_with_diagnostics(
932 OperatorErrorCode::TypeMismatch,
933 message.clone(),
934 vec![diagnostic_error(
935 "new_state_schema_missing",
936 "/new_state",
937 "runner.operator.new_state_schema_missing",
938 message,
939 Some(op_id.as_str()),
940 Some(component_ref.as_str()),
941 Some(resolved_digest.as_str()),
942 &locale,
943 )],
944 );
945 }
946 Err(err) => {
947 let message = format!(
948 "failed to load config schema `{}` for new_state validation: {}",
949 config_ref, err
950 );
951 return OperatorResponse::error_with_diagnostics(
952 OperatorErrorCode::TypeMismatch,
953 message.clone(),
954 vec![diagnostic_error(
955 "new_state_schema_load_failed",
956 "/new_state",
957 "runner.operator.new_state_schema_load_failed",
958 message,
959 Some(op_id.as_str()),
960 Some(component_ref.as_str()),
961 Some(resolved_digest.as_str()),
962 &locale,
963 )],
964 );
965 }
966 };
967 let issues =
968 validate_json_instance(&config_schema, new_state, validation_options.strict);
969 if !issues.is_empty() {
970 let diagnostics = schema_issues_to_diagnostics(
971 issues,
972 "/new_state",
973 component_ref,
974 &resolved_digest,
975 &op_id,
976 &locale,
977 );
978 return OperatorResponse::error_with_diagnostics(
979 OperatorErrorCode::TypeMismatch,
980 "new_state failed schema validation".to_string(),
981 diagnostics,
982 );
983 }
984 } else if validation_options.strict {
985 let message = "new_state returned but no config_schema_ref is available".to_string();
986 return OperatorResponse::error_with_diagnostics(
987 OperatorErrorCode::TypeMismatch,
988 message.clone(),
989 vec![diagnostic_error(
990 "new_state_schema_unavailable",
991 "/new_state",
992 "runner.operator.new_state_schema_unavailable",
993 message,
994 Some(op_id.as_str()),
995 Some(component_ref.as_str()),
996 Some(resolved_digest.as_str()),
997 &locale,
998 )],
999 );
1000 }
1001 }
1002
1003 let encode_span = span!(Level::DEBUG, "encode_cbor");
1004 let _encode_guard = encode_span.enter();
1005 let output_bytes = match serde_cbor::to_vec(&result) {
1006 Ok(bytes) => bytes,
1007 Err(err) => {
1008 return OperatorResponse::error(
1009 OperatorErrorCode::HostFailure,
1010 format!("failed to encode CBOR output: {err}"),
1011 );
1012 }
1013 };
1014 drop(_encode_guard);
1015
1016 OperatorResponse::ok(output_bytes)
1017}
1018
1019fn binding_component_ref_hint<'a>(
1020 provider_id: Option<&'a str>,
1021 provider_type: Option<&'a str>,
1022) -> Option<&'a str> {
1023 provider_id.or(provider_type)
1024}
1025
1026pub async fn invoke_operator_cbor(
1028 runtime: &TenantRuntime,
1029 req_cbor: &[u8],
1030) -> Result<Vec<u8>, serde_cbor::Error> {
1031 let request = OperatorRequest::from_cbor(req_cbor)?;
1032 let response = invoke_operator(runtime, request).await;
1033 response.to_cbor()
1034}
1035
1036pub async fn invoke(
1038 TenantRuntimeHandle { runtime, .. }: TenantRuntimeHandle,
1039 _headers: HeaderMap,
1040 body: Body,
1041) -> Result<Response<Body>, Response<Body>> {
1042 let bytes = match to_bytes(body, usize::MAX).await {
1043 Ok(bytes) => bytes,
1044 Err(err) => {
1045 return Err(bad_request(format!("failed to read body: {err}")));
1046 }
1047 };
1048
1049 let request = match OperatorRequest::from_cbor(&bytes) {
1050 Ok(request) => request,
1051 Err(err) => {
1052 return Err(bad_request(format!("failed to decode request CBOR: {err}")));
1053 }
1054 };
1055
1056 let response = invoke_operator(&runtime, request).await;
1057 build_cbor_response(response)
1058}
1059
1060fn bad_request(message: String) -> Response<Body> {
1061 let payload = json!({ "error": message });
1062 Response::builder()
1063 .status(StatusCode::BAD_REQUEST)
1064 .header("content-type", "application/json")
1065 .body(Body::from(payload.to_string()))
1066 .expect("building JSON error response must succeed")
1067}
1068
1069#[allow(clippy::result_large_err)]
1070fn build_cbor_response(response: OperatorResponse) -> Result<Response<Body>, Response<Body>> {
1071 match response.to_cbor() {
1072 Ok(bytes) => Ok(Response::builder()
1073 .status(StatusCode::OK)
1074 .header("content-type", CONTENT_TYPE_CBOR)
1075 .body(Body::from(bytes))
1076 .expect("building CBOR response must succeed")),
1077 Err(err) => Err(bad_request(format!(
1078 "failed to serialize response CBOR: {err}"
1079 ))),
1080 }
1081}
1082
1083fn decode_request_payload(bytes: &[u8]) -> Result<Value, serde_cbor::Error> {
1084 if bytes.is_empty() {
1085 return Ok(Value::Null);
1086 }
1087 serde_cbor::from_slice(bytes)
1088}
1089
1090fn build_exec_ctx(
1091 request: &OperatorRequest,
1092 runtime: &TenantRuntime,
1093 operation_id: &str,
1094) -> ComponentExecCtx {
1095 let deadline_unix_ms = request.timeout.and_then(|timeout_ms| {
1096 SystemTime::now()
1097 .checked_add(Duration::from_millis(timeout_ms))
1098 .and_then(|deadline| deadline.duration_since(UNIX_EPOCH).ok())
1099 .map(|duration| duration.as_millis() as u64)
1100 });
1101
1102 let tenant_ctx = ComponentTenantCtx {
1103 tenant: runtime.config().tenant.clone(),
1104 team: None,
1105 user: None,
1106 trace_id: request.trace_id.clone(),
1107 i18n_id: None,
1108 correlation_id: request.correlation_id.clone(),
1109 deadline_unix_ms,
1110 attempt: 1,
1111 idempotency_key: request.correlation_id.clone(),
1112 };
1113
1114 ComponentExecCtx {
1115 tenant: tenant_ctx,
1116 i18n_id: None,
1117 flow_id: format!("operator/{operation_id}"),
1118 node_id: None,
1119 }
1120}
1121
1122fn resolve_attachments(
1123 payload: &OperatorPayload,
1124 runtime: &TenantRuntime,
1125) -> Result<Map<String, Value>, OperatorResponse> {
1126 let mut attachments = Map::new();
1127 for attachment in &payload.attachments {
1128 if let Some(kind) = AttachmentKind::from_metadata(attachment.metadata.as_ref()) {
1129 match kind {
1130 AttachmentKind::Secret { key, alias } => {
1131 let secret = runtime.get_secret(&key).map_err(|err| {
1132 OperatorResponse::error(
1133 OperatorErrorCode::PolicyDenied,
1134 format!("secret `{key}` access denied: {err}"),
1135 )
1136 })?;
1137 attachments.insert(alias, Value::String(secret));
1138 }
1139 }
1140 }
1141 }
1142 Ok(attachments)
1143}
1144
1145fn merge_input_with_attachments(input: Value, attachments: Map<String, Value>) -> Value {
1146 if attachments.is_empty() {
1147 return input;
1148 }
1149 match input {
1150 Value::Object(mut map) => {
1151 map.insert("_attachments".into(), Value::Object(attachments));
1152 Value::Object(map)
1153 }
1154 other => {
1155 let mut map = Map::new();
1156 map.insert("input".into(), other);
1157 map.insert("_attachments".into(), Value::Object(attachments));
1158 Value::Object(map)
1159 }
1160 }
1161}
1162
1163enum AttachmentKind {
1164 Secret { key: String, alias: String },
1165}
1166
1167impl AttachmentKind {
1168 fn from_metadata(metadata: Option<&Value>) -> Option<Self> {
1169 let metadata = metadata?.as_object()?;
1170 let attachment_type = metadata.get("type")?.as_str()?;
1171 match attachment_type {
1172 "secret" => {
1173 let key = metadata.get("key")?.as_str()?.to_string();
1174 let alias = metadata
1175 .get("alias")
1176 .and_then(Value::as_str)
1177 .map(|value| value.to_string())
1178 .unwrap_or_else(|| key.clone());
1179 Some(AttachmentKind::Secret { key, alias })
1180 }
1181 _ => None,
1182 }
1183 }
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188 use super::*;
1189 use serde_json::{Map, Value, json};
1190
1191 #[test]
1192 fn merge_input_with_attachments_preserves_map_fields() {
1193 let mut attachments = Map::new();
1194 attachments.insert("secret".into(), json!("value"));
1195 let mut input_map = Map::new();
1196 input_map.insert("foo".into(), json!("bar"));
1197 let merged = merge_input_with_attachments(Value::Object(input_map), attachments.clone());
1198 let obj = merged.as_object().expect("should be object");
1199 assert_eq!(obj.get("foo"), Some(&json!("bar")));
1200 assert_eq!(obj.get("_attachments"), Some(&Value::Object(attachments)));
1201 }
1202
1203 #[test]
1204 fn merge_input_with_attachments_wraps_scalar() {
1205 let mut attachments = Map::new();
1206 attachments.insert("secret".into(), json!("value"));
1207 let merged =
1208 merge_input_with_attachments(Value::String("text".into()), attachments.clone());
1209 let obj = merged.as_object().expect("should be object");
1210 assert_eq!(obj.get("input"), Some(&Value::String("text".into())));
1211 assert_eq!(obj.get("_attachments"), Some(&Value::Object(attachments)));
1212 }
1213
1214 #[test]
1215 fn attachment_kind_secret_requires_type_lock() {
1216 let metadata = json!({
1217 "type": "secret",
1218 "key": "TOKEN"
1219 });
1220 if let Some(AttachmentKind::Secret { key, alias }) =
1221 AttachmentKind::from_metadata(Some(&metadata))
1222 {
1223 assert_eq!(key, "TOKEN");
1224 assert_eq!(alias, "TOKEN");
1225 } else {
1226 panic!("expected secret attachment");
1227 }
1228 }
1229
1230 #[test]
1231 fn attachment_kind_secret_with_alias() {
1232 let metadata = json!({
1233 "type": "secret",
1234 "key": "TOKEN",
1235 "alias": "api_token"
1236 });
1237 if let Some(AttachmentKind::Secret { key, alias }) =
1238 AttachmentKind::from_metadata(Some(&metadata))
1239 {
1240 assert_eq!(key, "TOKEN");
1241 assert_eq!(alias, "api_token");
1242 } else {
1243 panic!("expected secret attachment");
1244 }
1245 }
1246
1247 #[test]
1248 fn error_with_diagnostics_encodes_details_cbor() {
1249 let diagnostics = vec![Diagnostic {
1250 code: "op_not_found".to_string(),
1251 path: "/op_id".to_string(),
1252 severity: DiagnosticSeverity::Error,
1253 message_key: "runner.operator.op_not_found".to_string(),
1254 fallback: "op `echo` not found".to_string(),
1255 message: "op `echo` not found".to_string(),
1256 hint: None,
1257 component_id: Some("provider.demo".to_string()),
1258 digest: Some("sha256:abc123".to_string()),
1259 operation_id: Some("echo".to_string()),
1260 }];
1261
1262 let response = OperatorResponse::error_with_diagnostics(
1263 OperatorErrorCode::OpNotFound,
1264 "op not found",
1265 diagnostics.clone(),
1266 );
1267 let details = response
1268 .error
1269 .as_ref()
1270 .and_then(|err| err.details_cbor.as_ref())
1271 .expect("details_cbor must exist");
1272 let decoded: Vec<Diagnostic> =
1273 serde_cbor::from_slice(details).expect("diagnostics should decode");
1274 assert_eq!(decoded, diagnostics);
1275 }
1276
1277 #[test]
1278 fn validation_options_default_to_strict_with_output_validation() {
1279 let options = validation_options_from_flags(&[]);
1280 assert!(options.validate_output);
1281 assert!(options.strict);
1282 }
1283
1284 #[test]
1285 fn validation_options_apply_known_flags() {
1286 let options = validation_options_from_flags(&[
1287 FLAG_SKIP_OUTPUT_VALIDATE.to_string(),
1288 FLAG_PERMISSIVE_SCHEMA.to_string(),
1289 ]);
1290 assert!(!options.validate_output);
1291 assert!(!options.strict);
1292 }
1293
1294 #[test]
1295 fn normalize_operation_defaults_to_run_when_blank() {
1296 assert_eq!(normalize_operation_id(""), "run");
1297 assert_eq!(normalize_operation_id(" "), "run");
1298 assert_eq!(normalize_operation_id("render"), "render");
1299 }
1300
1301 #[test]
1302 fn compute_contract_hashes_is_deterministic() {
1303 let input_schema = json!({
1304 "type": "object",
1305 "properties": {
1306 "message": { "type": "string" }
1307 }
1308 });
1309 let output_schema = json!({
1310 "type": "object",
1311 "properties": {
1312 "result": { "type": "string" }
1313 }
1314 });
1315 let one = compute_contract_hashes(
1316 "sha256:abc",
1317 "provider.dummy",
1318 "echo",
1319 "greentic:provider-core@1.0.0",
1320 "provider-core",
1321 &input_schema,
1322 &output_schema,
1323 &input_schema,
1324 Some("schemas/state.schema.json"),
1325 "operator.provider@0.1.0",
1326 );
1327 let two = compute_contract_hashes(
1328 "sha256:abc",
1329 "provider.dummy",
1330 "echo",
1331 "greentic:provider-core@1.0.0",
1332 "provider-core",
1333 &input_schema,
1334 &output_schema,
1335 &input_schema,
1336 Some("schemas/state.schema.json"),
1337 "operator.provider@0.1.0",
1338 );
1339 assert_eq!(one, two);
1340 }
1341}