Skip to main content

traverse_runtime/
artifact_router.rs

1use crate::executor::ExecutorError;
2#[cfg(feature = "wasmtime-executor")]
3use crate::executor::{ArtifactType, CapabilityExecutor, ExecutorCapability, WasmExecutor};
4use crate::{
5    LocalExecutionFailure, LocalExecutionFailureCode, LocalExecutionOutput, LocalExecutor,
6};
7use serde_json::Value;
8use std::collections::BTreeMap;
9use std::sync::Arc;
10use traverse_registry::ResolvedCapability;
11
12type NativeHandler =
13    dyn Fn(&Value) -> Result<LocalExecutionOutput, LocalExecutionFailure> + Send + Sync;
14
15/// Production local-execution boundary for registered artifacts.
16///
17/// WASM executes only from the resolved registered artifact. Native execution
18/// is limited to explicitly registered host handlers and never loads a binary
19/// or command from artifact metadata.
20#[derive(Clone)]
21pub struct ArtifactRouter {
22    #[cfg(feature = "wasmtime-executor")]
23    wasm: Arc<WasmExecutor>,
24    native_handlers: BTreeMap<String, Arc<NativeHandler>>,
25}
26
27impl ArtifactRouter {
28    /// Creates a router using the default bounded Wasmtime configuration.
29    ///
30    /// # Errors
31    ///
32    /// Returns an execution failure when the Wasmtime runtime cannot initialize.
33    pub fn new() -> Result<Self, LocalExecutionFailure> {
34        #[cfg(feature = "wasmtime-executor")]
35        {
36            WasmExecutor::new()
37                .map(|wasm| Self {
38                    wasm: Arc::new(wasm),
39                    native_handlers: BTreeMap::new(),
40                })
41                .map_err(|error| map_executor_error(&error))
42        }
43        #[cfg(not(feature = "wasmtime-executor"))]
44        {
45            Ok(Self {
46                native_handlers: BTreeMap::new(),
47            })
48        }
49    }
50
51    /// Registers one host-provided native handler for an exact capability id.
52    pub fn register_native_handler<F>(&mut self, capability_id: impl Into<String>, handler: F)
53    where
54        F: Fn(&Value) -> Result<LocalExecutionOutput, LocalExecutionFailure>
55            + Send
56            + Sync
57            + 'static,
58    {
59        self.native_handlers
60            .insert(capability_id.into(), Arc::new(handler));
61    }
62}
63
64impl LocalExecutor for ArtifactRouter {
65    fn execute(
66        &self,
67        capability: &ResolvedCapability,
68        input: &Value,
69    ) -> Result<LocalExecutionOutput, LocalExecutionFailure> {
70        if let Some(binary) = &capability.artifact.binary {
71            #[cfg(feature = "wasmtime-executor")]
72            {
73                let executor_capability = ExecutorCapability {
74                    capability_id: capability.contract.id.clone(),
75                    artifact_type: ArtifactType::Wasm,
76                    wasm_binary_path: Some(binary.location.clone()),
77                    wasm_checksum: capability
78                        .artifact
79                        .digests
80                        .binary_digest
81                        .as_deref()
82                        .and_then(|digest| digest.strip_prefix("sha256:"))
83                        .map(str::to_string),
84                    host_abi_version: None,
85                    emits: capability.contract.emits.clone(),
86                    service_type: capability.contract.service_type.clone(),
87                };
88                // Events emitted via `traverse_host::emit_event` during this
89                // call are returned as real `LocalExecutionOutput.emitted_events`
90                // (spec 101-local-executor-event-emission FR-003) — already
91                // ABI-validated by `WasmExecutor`. `ArtifactRouter` itself
92                // does not publish them (FR-004): it is used both directly
93                // by `workflows.rs` and, via `BoundLocalExecutor`, by
94                // `PlacementRouter` Step 5, so publishing here would
95                // double-publish on the live `Runtime::execute()` path.
96                return self
97                    .wasm
98                    .execute(&executor_capability, input)
99                    .map(|output| LocalExecutionOutput {
100                        value: output.value,
101                        emitted_events: output.emitted_events,
102                    })
103                    .map_err(|error| map_executor_error(&error));
104            }
105            #[cfg(not(feature = "wasmtime-executor"))]
106            {
107                let _ = binary;
108                return Err(constraint_failure(
109                    "WASM execution is unavailable in this runtime build",
110                ));
111            }
112        }
113        self.native_handlers
114            .get(&capability.contract.id)
115            .ok_or_else(|| constraint_failure("native capability has no explicit host handler"))?(
116            input,
117        )
118    }
119}
120
121fn map_executor_error(error: &ExecutorError) -> LocalExecutionFailure {
122    let code = match error {
123        ExecutorError::Timeout(_) => LocalExecutionFailureCode::Timeout,
124        ExecutorError::ResourceExhausted(_) => LocalExecutionFailureCode::ResourceExhausted,
125        ExecutorError::ChecksumMismatch { .. }
126        | ExecutorError::MalformedWasmArtifact { .. }
127        | ExecutorError::UnsupportedAbiVersion { .. }
128        | ExecutorError::UnauthorizedHostImport { .. }
129        | ExecutorError::BinaryLoadFailed(_)
130        | ExecutorError::RuntimeSetupFailed(_) => LocalExecutionFailureCode::ConstraintViolated,
131        ExecutorError::OutputDeserializationFailed(_) => LocalExecutionFailureCode::InvalidInput,
132        ExecutorError::ExecutionFailed(_) | ExecutorError::UnsupportedArtifactType => {
133            LocalExecutionFailureCode::ExecutionFailed
134        }
135    };
136    LocalExecutionFailure {
137        code,
138        message: "registered artifact execution failed".to_string(),
139    }
140}
141
142fn constraint_failure(message: &str) -> LocalExecutionFailure {
143    LocalExecutionFailure {
144        code: LocalExecutionFailureCode::ConstraintViolated,
145        message: message.to_string(),
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    #![allow(clippy::expect_used)]
152
153    use super::*;
154    use traverse_registry::{
155        ArtifactDigests, BinaryFormat, BinaryReference, CapabilityArtifactRecord,
156        CapabilityRegistration, CapabilityRegistry, ComposabilityMetadata, CompositionKind,
157        CompositionPattern, ImplementationKind, LookupScope, RegistryProvenance, RegistryScope,
158        SourceKind, SourceReference,
159    };
160
161    fn resolved_capability(binary: Option<BinaryReference>) -> ResolvedCapability {
162        let contract = serde_json::from_str(include_str!(
163            "../../../contracts/examples/hello-world/capabilities/say-hello/contract.json"
164        ))
165        .expect("checked-in capability contract should parse");
166        let mut registry = CapabilityRegistry::new();
167        registry
168            .register(CapabilityRegistration {
169                scope: RegistryScope::Public,
170                contract,
171                contract_path:
172                    "contracts/examples/hello-world/capabilities/say-hello/contract.json"
173                        .to_string(),
174                artifact: CapabilityArtifactRecord {
175                    artifact_ref: "artifact:hello.world.say-hello:1.0.0".to_string(),
176                    implementation_kind: ImplementationKind::Executable,
177                    source: SourceReference {
178                        kind: SourceKind::Local,
179                        location: "examples".to_string(),
180                    },
181                    binary: Some(binary.unwrap_or(BinaryReference {
182                        format: BinaryFormat::Wasm,
183                        location: "registered-test-module.wasm".to_string(),
184                        signature: None,
185                    })),
186                    workflow_ref: None,
187                    digests: ArtifactDigests {
188                        source_digest: "source-digest".to_string(),
189                        binary_digest: Some("sha256:checksum".to_string()),
190                    },
191                    provenance: RegistryProvenance {
192                        source: "test".to_string(),
193                        author: "test".to_string(),
194                        created_at: "2026-07-13T00:00:00Z".to_string(),
195                    },
196                },
197                registered_at: "2026-07-13T00:00:00Z".to_string(),
198                tags: Vec::new(),
199                composability: ComposabilityMetadata {
200                    kind: CompositionKind::Atomic,
201                    patterns: vec![CompositionPattern::Sequential],
202                    provides: Vec::new(),
203                    requires: Vec::new(),
204                },
205                governing_spec: "064-production-artifact-execution".to_string(),
206                validator_version: "test".to_string(),
207            })
208            .expect("test capability should register");
209        registry
210            .find_exact(LookupScope::PublicOnly, "hello.world.say-hello", "1.0.0")
211            .expect("registered capability should resolve")
212    }
213
214    #[test]
215    fn native_execution_requires_an_explicit_handler() {
216        let mut capability = resolved_capability(None);
217        capability.artifact.binary = None;
218        let mut router = ArtifactRouter::new().expect("router should initialize");
219        let failure = router
220            .execute(&capability, &serde_json::json!({}))
221            .expect_err("unregistered native handler should fail closed");
222        assert_eq!(failure.code, LocalExecutionFailureCode::ConstraintViolated);
223
224        router.register_native_handler("hello.world.say-hello", |_| {
225            Ok(LocalExecutionOutput {
226                value: serde_json::json!({"ok": true}),
227                emitted_events: Vec::new(),
228            })
229        });
230        assert_eq!(
231            router.execute(&capability, &serde_json::json!({})),
232            Ok(LocalExecutionOutput {
233                value: serde_json::json!({"ok": true}),
234                emitted_events: Vec::new(),
235            })
236        );
237    }
238
239    #[cfg(feature = "wasmtime-executor")]
240    #[test]
241    fn wasm_artifacts_are_executed_only_from_registered_binary_metadata() {
242        let capability = resolved_capability(Some(BinaryReference {
243            format: BinaryFormat::Wasm,
244            location: "missing-test-module.wasm".to_string(),
245            signature: None,
246        }));
247        let failure = ArtifactRouter::new()
248            .expect("router should initialize")
249            .execute(&capability, &serde_json::json!({}))
250            .expect_err("missing registered binary should fail");
251        assert_eq!(failure.code, LocalExecutionFailureCode::ConstraintViolated);
252        assert_eq!(failure.message, "registered artifact execution failed");
253    }
254
255    #[cfg(feature = "wasmtime-executor")]
256    #[test]
257    fn wasm_execution_success_returns_real_value_and_emitted_events() {
258        use sha2::{Digest, Sha256};
259        use std::fmt::Write as _;
260
261        // Spec 101-local-executor-event-emission FR-003: on a successful
262        // WASM execution, `ArtifactRouter` must return the executor's real
263        // `value`/`emitted_events`, not discard or reshape them.
264        let wat_src = r#"
265            (module
266                (import "wasi_snapshot_preview1" "fd_write"
267                    (func $fd_write (param i32 i32 i32 i32) (result i32)))
268                (memory (export "memory") 1)
269                (data (i32.const 8) "{}")
270                (func $_start (export "_start")
271                    (i32.store (i32.const 0) (i32.const 8))
272                    (i32.store (i32.const 4) (i32.const 2))
273                    (drop (call $fd_write (i32.const 1) (i32.const 0) (i32.const 1) (i32.const 4)))
274                )
275            )
276        "#;
277        let wasm_bytes = wat::parse_str(wat_src).expect("WAT source should parse");
278
279        let mut hasher = Sha256::new();
280        hasher.update(&wasm_bytes);
281        let checksum = hasher
282            .finalize()
283            .iter()
284            .fold(String::new(), |mut acc, byte| {
285                let _ = write!(acc, "{byte:02x}");
286                acc
287            });
288
289        let tmp = format!(
290            "/tmp/traverse-artifact-router-test-{}.wasm",
291            std::time::SystemTime::now()
292                .duration_since(std::time::UNIX_EPOCH)
293                .map_or(0, |d| d.as_nanos())
294        );
295        std::fs::write(&tmp, &wasm_bytes).expect("temp wasm module should write");
296
297        let mut capability = resolved_capability(Some(BinaryReference {
298            format: BinaryFormat::Wasm,
299            location: tmp.clone(),
300            signature: None,
301        }));
302        capability.artifact.digests.binary_digest = Some(format!("sha256:{checksum}"));
303
304        let result = ArtifactRouter::new()
305            .expect("router should initialize")
306            .execute(&capability, &serde_json::json!({}));
307        std::fs::remove_file(&tmp).ok();
308
309        assert_eq!(
310            result,
311            Ok(LocalExecutionOutput {
312                value: serde_json::json!({}),
313                emitted_events: Vec::new(),
314            })
315        );
316    }
317
318    #[test]
319    fn executor_errors_map_to_stable_local_failure_codes() {
320        let errors = [
321            (
322                ExecutorError::Timeout("x".to_string()),
323                LocalExecutionFailureCode::Timeout,
324            ),
325            (
326                ExecutorError::ResourceExhausted("x".to_string()),
327                LocalExecutionFailureCode::ResourceExhausted,
328            ),
329            (
330                ExecutorError::ChecksumMismatch {
331                    expected: "a".to_string(),
332                    actual: "b".to_string(),
333                },
334                LocalExecutionFailureCode::ConstraintViolated,
335            ),
336            (
337                ExecutorError::MalformedWasmArtifact {
338                    error_code: "x".to_string(),
339                    detail: "x".to_string(),
340                },
341                LocalExecutionFailureCode::ConstraintViolated,
342            ),
343            (
344                ExecutorError::UnsupportedAbiVersion {
345                    error_code: "x".to_string(),
346                    requested: "x".to_string(),
347                    supported: "x".to_string(),
348                },
349                LocalExecutionFailureCode::ConstraintViolated,
350            ),
351            (
352                ExecutorError::UnauthorizedHostImport {
353                    error_code: "x".to_string(),
354                    abi_version: "x".to_string(),
355                    module: "x".to_string(),
356                    name: "x".to_string(),
357                },
358                LocalExecutionFailureCode::ConstraintViolated,
359            ),
360            (
361                ExecutorError::BinaryLoadFailed("x".to_string()),
362                LocalExecutionFailureCode::ConstraintViolated,
363            ),
364            (
365                ExecutorError::RuntimeSetupFailed("x".to_string()),
366                LocalExecutionFailureCode::ConstraintViolated,
367            ),
368            (
369                ExecutorError::OutputDeserializationFailed("x".to_string()),
370                LocalExecutionFailureCode::InvalidInput,
371            ),
372            (
373                ExecutorError::ExecutionFailed("x".to_string()),
374                LocalExecutionFailureCode::ExecutionFailed,
375            ),
376            (
377                ExecutorError::UnsupportedArtifactType,
378                LocalExecutionFailureCode::ExecutionFailed,
379            ),
380        ];
381        for (error, expected) in errors {
382            let failure = map_executor_error(&error);
383            assert_eq!(failure.code, expected);
384            assert_eq!(failure.message, "registered artifact execution failed");
385        }
386    }
387}