Skip to main content

greentic_runner_host/runner/
operator.rs

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/// Operator-facing invocation payload (CBOR envelope).
29#[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/// Operator response envelope serialized back to CBOR.
80#[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
427/// Invoke an operator request without assuming HTTP transport.
428pub 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
1026/// Convenience helper that takes CBOR bytes and reuses `invoke_operator`.
1027pub 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
1036/// Axum handler stub for `/operator/op/invoke`.
1037pub 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}