diff --git a/Cargo.lock b/Cargo.lock index 242c99f1..53a199de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2156,6 +2156,7 @@ name = "echo-client" version = "0.1.0" dependencies = [ "nexum-sdk", + "videre-sdk", "wit-bindgen 0.58.0", ] @@ -3667,7 +3668,6 @@ dependencies = [ "http", "nexum-module-macros", "nexum-sdk-test", - "nexum-status-body", "nexum-world", "proptest", "serde_json", @@ -3687,14 +3687,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "nexum-status-body" -version = "0.1.0" -dependencies = [ - "borsh", - "thiserror 2.0.18", -] - [[package]] name = "nexum-tasks" version = "0.1.0" @@ -6045,7 +6037,6 @@ dependencies = [ "async-trait", "futures", "nexum-runtime", - "nexum-status-body", "nexum-tasks", "serde", "tempfile", @@ -6053,6 +6044,7 @@ dependencies = [ "tokio", "toml 1.1.2+spec-1.1.0", "tracing", + "videre-status-body", "wasmtime", ] @@ -6076,9 +6068,18 @@ dependencies = [ "strum", "thiserror 2.0.18", "videre-macros", + "videre-status-body", "wit-bindgen 0.59.0", ] +[[package]] +name = "videre-status-body" +version = "0.1.0" +dependencies = [ + "borsh", + "thiserror 2.0.18", +] + [[package]] name = "videre-test" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 94377aa5..54cf5075 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,6 @@ members = [ "crates/nexum-runtime", "crates/nexum-sdk", "crates/nexum-sdk-test", - "crates/nexum-status-body", "crates/nexum-tasks", "crates/nexum-world", "crates/no-std-probe", @@ -19,6 +18,7 @@ members = [ "crates/videre-host", "crates/videre-macros", "crates/videre-sdk", + "crates/videre-status-body", "crates/videre-test", "modules/ethflow-watcher", "modules/example", diff --git a/crates/nexum-module-macros/src/lib.rs b/crates/nexum-module-macros/src/lib.rs index bb90eb72..ca2d73aa 100644 --- a/crates/nexum-module-macros/src/lib.rs +++ b/crates/nexum-module-macros/src/lib.rs @@ -28,14 +28,14 @@ const HANDLERS: [&str; 6] = [ "on_chain_logs", "on_tick", "on_message", - "on_intent_status", + "on_custom", ]; /// Generate the per-cdylib glue for a nexum module. /// /// Apply to an `impl` block whose associated functions are the event /// handlers (`init`, `on_block`, `on_chain_logs`, `on_tick`, -/// `on_message`, `on_intent_status`). Each handler takes the wit-bindgen +/// `on_message`, `on_custom`). Each handler takes the wit-bindgen /// payload for its event and returns `Result<(), Fault>`; `init` takes /// the config table. /// Handlers left undefined are ignored (their events become no-ops). The @@ -138,7 +138,7 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { return syn::Error::new_spanned( self_ty, "#[nexum_sdk::module] found no recognised handlers on this impl; define at least one \ - of `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_intent_status`", + of `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_custom`", ) .to_compile_error() .into(); @@ -201,7 +201,7 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { let logs_arm = arm("on_chain_logs", "ChainLogs"); let tick_arm = arm("on_tick", "Tick"); let message_arm = arm("on_message", "Message"); - let intent_status_arm = arm("on_intent_status", "IntentStatus"); + let custom_arm = arm("on_custom", "Custom"); quote! { // Anchor a rebuild on the manifest and the extension registry: @@ -232,7 +232,7 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { #logs_arm #tick_arm #message_arm - #intent_status_arm + #custom_arm } } } diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index 4118fb81..07260cfd 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -23,9 +23,6 @@ stderr-echo = [] # calls back into this crate (`bind_host_via_wit_bindgen!`, the host # trait seam, the tracing facade). nexum-module-macros = { path = "../nexum-module-macros" } -# Decoder for the opaque status body an `intent-status` event carries; -# re-exported as `nexum_sdk::status_body`. -nexum-status-body = { path = "../nexum-status-body" } alloy-primitives.workspace = true # Typed EIP-155 chain id; already in the guest graph via alloy-provider. alloy-chains.workspace = true diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index cce15425..201aa1fd 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -51,9 +51,6 @@ //! handle plus [`ChainLogParts`], the WIT-edge `From` input the bind //! macro fills to rebuild it from the wire record. //! -//! - [`status_body`] - decoder for the opaque versioned status body an -//! `intent-status` event carries ([`StatusBody`]). -//! //! - [`config`] - `(key, value)` config-table lookups and decimal //! scaling ([`get_required`], [`get_optional`], [`scale_decimal`]). //! @@ -110,7 +107,6 @@ //! [`read_latest_answer`]: chain::chainlink::read_latest_answer //! [`Log`]: events::Log //! [`ChainLogParts`]: events::ChainLogParts -//! [`StatusBody`]: status_body::StatusBody //! [`get_required`]: config::get_required //! [`get_optional`]: config::get_optional //! [`scale_decimal`]: config::scale_decimal @@ -141,10 +137,6 @@ pub mod prelude; pub mod tracing; pub mod wit_bindgen_macro; -/// The opaque status-body codec: decode an `intent-status` event's -/// `status` bytes into a typed [`StatusBody`](status_body::StatusBody). -pub use nexum_status_body as status_body; - /// The level vocabulary for every SDK log path: the host logging trait, /// the guest tracing facade sink, and the module mocks all speak /// `tracing_core::Level`. Its `Ord` is filter-oriented (`ERROR` is the diff --git a/crates/videre-host/Cargo.toml b/crates/videre-host/Cargo.toml index 8db90362..a8c6dd11 100644 --- a/crates/videre-host/Cargo.toml +++ b/crates/videre-host/Cargo.toml @@ -14,7 +14,7 @@ workspace = true nexum-runtime = { path = "../nexum-runtime" } # Encoder for the opaque status body the host event stream carries; the # registry lowers an adapter-reported status through it. -nexum-status-body = { path = "../nexum-status-body" } +videre-status-body = { path = "../videre-status-body" } wasmtime.workspace = true anyhow.workspace = true diff --git a/crates/videre-host/src/lib.rs b/crates/videre-host/src/lib.rs index d228e27a..34187ca9 100644 --- a/crates/videre-host/src/lib.rs +++ b/crates/videre-host/src/lib.rs @@ -14,7 +14,7 @@ mod registry; use std::sync::Arc; use std::time::Duration; -use nexum_runtime::bindings::nexum::host::types::Event; +use nexum_runtime::bindings::nexum::host::types::{CustomEvent, Event}; use nexum_runtime::engine_config::EngineConfig; use nexum_runtime::host::component::RuntimeTypes; use nexum_runtime::host::extension::{ @@ -24,6 +24,8 @@ use nexum_runtime::host::extension::{ use nexum_runtime::host::state::HostState; use nexum_runtime::manifest::{ExtensionSections, NamespaceCaps}; use tokio::sync::mpsc; +use tracing::warn; +use videre_status_body::INTENT_STATUS_KIND; use wasmtime::component::{HasSelf, Linker}; pub use registry::{ @@ -31,9 +33,6 @@ pub use registry::{ VenueAdapterKind, VenueId, VenueInvoker, VenueRegistry, VenueRegistryBuilder, }; -/// The subscription kind the platform's status poller emits. -const INTENT_STATUS_KIND: &str = "intent-status"; - /// Buffer for the status poll channel; small because the event loop /// drains in real time. const STATUS_CHANNEL_BUF: usize = 64; @@ -155,10 +154,27 @@ async fn status_poll_task( loop { tokio::time::sleep(cadence).await; for update in registry.poll_status_transitions().await { + let attrs = vec![("venue", update.venue.clone())]; + // The transition rides the generic `custom` channel: the + // envelope is borsh, the status body its inner encoding. A + // keeper recovers it through `videre_sdk::event`. + let payload = match update.encode() { + Ok(payload) => payload, + Err(err) => { + warn!( + error = %err, + "intent-status envelope failed to encode - dropping transition", + ); + continue; + } + }; let event = ExtensionEvent { kind: INTENT_STATUS_KIND, - attrs: vec![("venue", update.venue.clone())], - event: Event::IntentStatus(update), + attrs, + event: Event::Custom(CustomEvent { + kind: INTENT_STATUS_KIND.to_owned(), + payload, + }), }; if tx.send(event).await.is_err() { // Receiver dropped -> engine shutting down. diff --git a/crates/videre-host/src/registry.rs b/crates/videre-host/src/registry.rs index 0f1057e9..d872d804 100644 --- a/crates/videre-host/src/registry.rs +++ b/crates/videre-host/src/registry.rs @@ -39,15 +39,16 @@ use nexum_runtime::host::extension::{ HostService, Installed, ProviderInstance, ProviderKind, downcast_service, }; use nexum_runtime::host::state::HostState; -use nexum_status_body::StatusBody; use tokio::sync::Mutex as AsyncMutex; use tracing::{info, warn}; +use videre_status_body::StatusBody; use wasmtime::Store; use wasmtime::component::HasSelf; -/// The registry-observed status transition delivered through the host -/// `event` variant, re-exported at the spelling the registry names. -pub use nexum_runtime::bindings::nexum::host::types::IntentStatusUpdate; +/// The registry-observed status transition, carried in the `custom` +/// event's opaque payload, re-exported at the spelling the registry +/// names. +pub use videre_status_body::IntentStatusUpdate; use crate::bindings::{ IntentHeader, IntentStatus, Quotation, RateLimit, SubmitOutcome, VenueAdapter, VenueError, @@ -291,7 +292,7 @@ fn is_terminal(status: IntentStatus) -> bool { /// stream carries. The registry attests the lifecycle state alone; proof /// and failure reason ride the body only when the venue supplies them. fn status_body(status: IntentStatus) -> StatusBody { - use nexum_status_body::IntentStatus as Lifecycle; + use videre_status_body::IntentStatus as Lifecycle; let status = match status { IntentStatus::Pending => Lifecycle::Pending, @@ -857,7 +858,7 @@ pub struct DuplicateVenue { mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; - use nexum_status_body::IntentStatus as Lifecycle; + use videre_status_body::IntentStatus as Lifecycle; use crate::bindings::value_flow::{Asset, AssetAmount}; use crate::bindings::{AuthScheme, IntentHeader, Settlement, UnsignedTx}; diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index 9c44c425..c068fdca 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -99,12 +99,19 @@ fn block(chain_id: u64) -> nexum::host::types::Block { } } -/// Wrap a polled transition as the extension event the platform emits. +/// Wrap a polled transition as the extension event the platform emits: +/// the transition rides the generic `custom` channel, its borsh envelope +/// the opaque payload. fn status_event(update: videre_host::IntentStatusUpdate) -> ExtensionEvent { + let attrs = vec![("venue", update.venue.clone())]; + let payload = update.encode().expect("encode intent-status envelope"); ExtensionEvent { kind: INTENT_STATUS, - attrs: vec![("venue", update.venue.clone())], - event: nexum::host::types::Event::IntentStatus(update), + attrs, + event: nexum::host::types::Event::Custom(nexum::host::types::CustomEvent { + kind: INTENT_STATUS.to_owned(), + payload, + }), } } @@ -244,6 +251,26 @@ fn scripted_registry(adapter: ScriptedAdapter) -> VenueRegistry { /// Write a manifest subscribing the example module to intent-status /// events from the `cow` venue. +fn echo_client_status_manifest(dir: &Path) -> PathBuf { + let manifest = dir.join("module.toml"); + std::fs::write( + &manifest, + r#" +[module] +name = "echo-client" + +[capabilities] +required = ["client", "logging"] + +[[subscription]] +kind = "intent-status" +venue = "cow" +"#, + ) + .expect("write manifest"); + manifest +} + fn intent_status_manifest(dir: &Path) -> PathBuf { let manifest = dir.join("module.toml"); std::fs::write( @@ -331,8 +358,8 @@ async fn e2e_intent_status_subscription_receives_polled_transitions() { let foreign = videre_host::IntentStatusUpdate { venue: "other".to_owned(), receipt: b"receipt".to_vec(), - status: nexum_status_body::StatusBody { - status: nexum_status_body::IntentStatus::Open, + status: videre_status_body::StatusBody { + status: videre_status_body::IntentStatus::Open, proof: None, reason: None, } @@ -355,11 +382,11 @@ async fn e2e_intent_status_subscription_receives_polled_transitions() { async fn e2e_intent_status_flows_through_the_event_loop() { use nexum_tasks::{TaskManager, TaskSet}; - let Some(wasm) = module_wasm_or_skip("example") else { + let Some(wasm) = module_wasm_or_skip("echo-client") else { return; }; let dir = tempfile::tempdir().expect("tempdir"); - let manifest = intent_status_manifest(dir.path()); + let manifest = echo_client_status_manifest(dir.path()); let registry = scripted_registry(ScriptedAdapter::new([])); let videre = Arc::new(Videre::from_registry(registry.clone())); @@ -419,14 +446,14 @@ async fn e2e_intent_status_flows_through_the_event_loop() { .await; assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - let runs = logs.list_runs("example"); - assert_eq!(runs.len(), 1, "one run recorded for the example module"); + let runs = logs.list_runs("echo-client"); + assert_eq!(runs.len(), 1, "one run recorded for the echo-client module"); let page = logs.read(&runs[0].run, 0); assert!( page.records .iter() - .any(|r| r.message.contains("intent status update from venue cow")), - "the module's on_intent_status handler ran; records were: {:?}", + .any(|r| r.message.contains("intent status from venue cow")), + "the module's on_custom handler decoded the transition; records were: {:?}", page.records .iter() .map(|r| r.message.as_str()) @@ -536,11 +563,11 @@ async fn e2e_echo_module_registry_adapter_round_trip() { for _ in 0..2 { for update in registry.poll_status_transitions().await { assert_eq!(update.venue, "echo-venue"); - let body = - nexum_status_body::StatusBody::decode(&update.status).expect("status body decodes"); + let body = videre_status_body::StatusBody::decode(&update.status) + .expect("status body decodes"); assert_eq!( body.status, - nexum_status_body::IntentStatus::Fulfilled, + videre_status_body::IntentStatus::Fulfilled, "echo settles instantly", ); delivered += supervisor diff --git a/crates/videre-macros/src/keeper.rs b/crates/videre-macros/src/keeper.rs index 568796d8..9d21c79f 100644 --- a/crates/videre-macros/src/keeper.rs +++ b/crates/videre-macros/src/keeper.rs @@ -165,7 +165,36 @@ pub(crate) fn expand(input: &ItemImpl) -> syn::Result { let logs_arm = arm("on_chain_logs", "ChainLogs"); let tick_arm = arm("on_tick", "Tick"); let message_arm = arm("on_message", "Message"); - let intent_status_arm = arm("on_intent_status", "IntentStatus"); + // The intent-status transition rides the generic `custom` channel; + // recover it typed through `videre_sdk::event` and dispatch to the + // keeper's `on_intent_status` when the kind matches. A malformed + // payload is the caller's `invalid-input`; a foreign kind is another + // extension's event and no-ops. + let custom_arm = match handler("on_intent_status") { + Some((_, is_async)) => { + let call = quote! { <#self_ty>::on_intent_status(update) }; + let body = if is_async { drive(call) } else { call }; + quote! { + nexum::host::types::Event::Custom(payload) => { + match ::videre_sdk::event::intent_status_update( + &payload.kind, + &payload.payload, + ) { + ::core::option::Option::Some(::core::result::Result::Ok(update)) => #body, + ::core::option::Option::Some(::core::result::Result::Err(err)) => { + ::core::result::Result::Err(nexum::host::types::Fault::InvalidInput( + ::std::string::ToString::to_string(&err), + )) + } + ::core::option::Option::None => ::core::result::Result::Ok(()), + } + } + } + } + None => quote! { + nexum::host::types::Event::Custom(_) => ::core::result::Result::Ok(()), + }, + }; Ok(quote! { // Anchor a rebuild on the manifest and the extension registry: @@ -210,7 +239,7 @@ pub(crate) fn expand(input: &ItemImpl) -> syn::Result { #logs_arm #tick_arm #message_arm - #intent_status_arm + #custom_arm } } } diff --git a/crates/videre-sdk/Cargo.toml b/crates/videre-sdk/Cargo.toml index 54701fd4..41755813 100644 --- a/crates/videre-sdk/Cargo.toml +++ b/crates/videre-sdk/Cargo.toml @@ -28,6 +28,9 @@ videre-macros = { path = "../videre-macros" } # chain wrapper implements, the shared `Fault` vocabulary, and the # wasi:http `fetch` surface re-exported as `transport::http`. nexum-sdk = { path = "../nexum-sdk" } +# The venue status-body codec, re-exported as `videre_sdk::status_body`; +# venue guests reach the `intent-status` decode path through here. +videre-status-body = { path = "../videre-status-body" } strum.workspace = true thiserror.workspace = true wit-bindgen.workspace = true diff --git a/crates/videre-sdk/src/event.rs b/crates/videre-sdk/src/event.rs new file mode 100644 index 00000000..fe450ba6 --- /dev/null +++ b/crates/videre-sdk/src/event.rs @@ -0,0 +1,72 @@ +//! Typed recovery of videre events from the core `custom` channel. +//! +//! A venue transition reaches a module as a `custom` event; this module +//! decodes it back into the typed [`IntentStatusUpdate`] a keeper handler +//! works with. The `#[keeper]` macro calls it for `on_intent_status`. + +use crate::IntentStatusUpdate; + +/// The `custom-event.kind` an intent-status transition rides on. +pub use crate::status_body::INTENT_STATUS_KIND; + +/// Why an intent-status `custom` payload did not decode. +pub use crate::status_body::EnvelopeError; + +/// Recover an [`IntentStatusUpdate`] from a `custom` event, keyed by its +/// `kind` and `payload`. `None` when the kind is another extension's; +/// `Some(Err)` when the payload is malformed. +pub fn intent_status_update( + kind: &str, + payload: &[u8], +) -> Option> { + if kind != INTENT_STATUS_KIND { + return None; + } + Some(IntentStatusUpdate::decode(payload)) +} + +#[cfg(test)] +mod tests { + use crate::status_body::{IntentStatus, StatusBody}; + + use super::*; + + fn envelope() -> Vec { + IntentStatusUpdate { + venue: "cow".to_owned(), + receipt: b"receipt".to_vec(), + status: StatusBody { + status: IntentStatus::Fulfilled, + proof: None, + reason: None, + } + .encode() + .expect("encode body"), + } + .encode() + .expect("encode envelope") + } + + #[test] + fn recovers_a_matching_kind() { + let recovered = intent_status_update(INTENT_STATUS_KIND, &envelope()) + .expect("kind matches") + .expect("payload decodes"); + assert_eq!(recovered.venue, "cow"); + assert_eq!(recovered.receipt, b"receipt"); + } + + #[test] + fn ignores_a_foreign_kind() { + assert!(intent_status_update("other-kind", &envelope()).is_none()); + } + + #[test] + fn reports_a_malformed_payload() { + assert!( + intent_status_update(INTENT_STATUS_KIND, b"\xff") + .expect("kind matches") + .is_err() + ); + } +} diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs index 149f0a3e..c4be49d6 100644 --- a/crates/videre-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -45,6 +45,14 @@ //! fault, the SDK-neutral fault, and [`VenueError`]; plus //! [`VenueFault`], the owned client-side mirror. //! +//! - [`event`] - typed recovery of videre events from the core `custom` +//! escape hatch: [`event::intent_status_update`] decodes an +//! intent-status transition a keeper subscribes to. +//! +//! - [`status_body`] - the venue status-body codec: decode an +//! `intent-status` event's `status` bytes into a typed +//! [`StatusBody`](status_body::StatusBody). +//! //! ## Why the bindgen lives in this crate //! //! The shared interfaces generate once, in [`bindings`], from an @@ -67,6 +75,7 @@ pub mod bindings; pub mod adapter; pub mod body; pub mod client; +pub mod event; pub mod faults; pub mod keeper; pub mod rt; @@ -105,6 +114,15 @@ pub use bindings::videre::types::types::{ }; /// The value-flow vocabulary intent headers are expressed in. pub use bindings::videre::value_flow::types as value_flow; +/// The venue status-body codec: decode an `intent-status` event's +/// `status` bytes into a typed [`StatusBody`](status_body::StatusBody). +pub use videre_status_body as status_body; + +/// The intent-status transition a keeper recovers from a `custom` event +/// through [`event::intent_status_update`]. Its wire form is a borsh +/// envelope defined by this struct, not a WIT record: it crosses the +/// `custom` event as opaque bytes. The status body rides its inner codec. +pub use videre_status_body::IntentStatusUpdate; /// The wire config table (`nexum:host/types.config`) `init` receives. pub use bindings::nexum::host::types::Config; diff --git a/crates/nexum-status-body/Cargo.toml b/crates/videre-status-body/Cargo.toml similarity index 93% rename from crates/nexum-status-body/Cargo.toml rename to crates/videre-status-body/Cargo.toml index cd4361b3..67967b12 100644 --- a/crates/nexum-status-body/Cargo.toml +++ b/crates/videre-status-body/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "nexum-status-body" +name = "videre-status-body" version = "0.1.0" edition.workspace = true license.workspace = true diff --git a/crates/nexum-status-body/src/lib.rs b/crates/videre-status-body/src/lib.rs similarity index 82% rename from crates/nexum-status-body/src/lib.rs rename to crates/videre-status-body/src/lib.rs index 2bd9c009..271b6f22 100644 --- a/crates/nexum-status-body/src/lib.rs +++ b/crates/videre-status-body/src/lib.rs @@ -15,6 +15,51 @@ use borsh::{BorshDeserialize, BorshSerialize}; /// Wire tag of the v1 payload. pub const VERSION_V1: u8 = 1; +/// The extension event kind an intent-status transition rides on: the +/// `custom-event.kind` the venue platform stamps and a subscribing +/// module matches. Shared by the emit side and the decode side so the +/// one string is never spelt twice. +pub const INTENT_STATUS_KIND: &str = "intent-status"; + +/// The intent-status transition an intent-status `custom` event carries +/// in its opaque payload: borsh `{venue, receipt, status}`, where +/// `status` is a [`StatusBody`]-encoded body (the inner codec above). +#[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] +pub struct IntentStatusUpdate { + /// Venue id the receipt was issued by. + pub venue: String, + /// The venue-scoped intent identifier, opaque to the host. + pub receipt: Vec, + /// The [`StatusBody`]-encoded status body. + pub status: Vec, +} + +impl IntentStatusUpdate { + /// Borsh-encode the envelope. + pub fn encode(&self) -> Result, EncodeError> { + let mut out = Vec::new(); + borsh::to_writer(&mut out, self).map_err(|err| EncodeError { + detail: err.to_string(), + })?; + Ok(out) + } + + /// Decode a borsh envelope, failing typedly on malformed bytes. + pub fn decode(bytes: &[u8]) -> Result { + borsh::from_slice(bytes).map_err(|err| EnvelopeError { + detail: err.to_string(), + }) + } +} + +/// Why bytes failed to decode as an [`IntentStatusUpdate`] envelope. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[error("malformed intent-status envelope: {detail}")] +pub struct EnvelopeError { + /// Borsh's decode failure detail. + pub detail: String, +} + /// Where an intent is in its life at the venue. The borsh discriminant /// is the wire form: append new states, never reorder. #[derive(BorshDeserialize, BorshSerialize, Clone, Copy, Debug, Eq, PartialEq)] diff --git a/modules/example/src/lib.rs b/modules/example/src/lib.rs index da1ddd26..5597f442 100644 --- a/modules/example/src/lib.rs +++ b/modules/example/src/lib.rs @@ -67,16 +67,13 @@ impl ExampleModule { Ok(()) } - fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { - let body = nexum_sdk::status_body::StatusBody::decode(&update.status) - .map_err(|err| Fault::InvalidInput(err.to_string()))?; + fn on_custom(event: types::CustomEvent) -> Result<(), Fault> { logging::log( logging::Level::Info, &format!( - "intent status update from venue {}: {:?} ({} receipt bytes)", - update.venue, - body.status, - update.receipt.len(), + "custom event kind {} ({} payload bytes)", + event.kind, + event.payload.len(), ), ); Ok(()) diff --git a/modules/examples/echo-client/Cargo.toml b/modules/examples/echo-client/Cargo.toml index f4cf47a0..c5c20896 100644 --- a/modules/examples/echo-client/Cargo.toml +++ b/modules/examples/echo-client/Cargo.toml @@ -14,4 +14,7 @@ crate-type = ["cdylib"] [dependencies] nexum-sdk = { path = "../../../crates/nexum-sdk" } +# The venue status-body codec the `on_custom` handler decodes an +# intent-status transition through, reached via `videre_sdk::status_body`. +videre-sdk = { path = "../../../crates/videre-sdk" } wit-bindgen = { version = "0.58", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/examples/echo-client/src/lib.rs b/modules/examples/echo-client/src/lib.rs index 185e22da..33025943 100644 --- a/modules/examples/echo-client/src/lib.rs +++ b/modules/examples/echo-client/src/lib.rs @@ -68,8 +68,13 @@ impl EchoClient { Ok(()) } - fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { - let body = nexum_sdk::status_body::StatusBody::decode(&update.status) + fn on_custom(event: types::CustomEvent) -> Result<(), Fault> { + if event.kind != videre_sdk::status_body::INTENT_STATUS_KIND { + return Ok(()); + } + let update = videre_sdk::status_body::IntentStatusUpdate::decode(&event.payload) + .map_err(|err| Fault::InvalidInput(err.to_string()))?; + let body = videre_sdk::status_body::StatusBody::decode(&update.status) .map_err(|err| Fault::InvalidInput(err.to_string()))?; logging::log( logging::Level::Info, diff --git a/modules/examples/echo-keeper/src/lib.rs b/modules/examples/echo-keeper/src/lib.rs index 05e41619..b840f7c0 100644 --- a/modules/examples/echo-keeper/src/lib.rs +++ b/modules/examples/echo-keeper/src/lib.rs @@ -92,8 +92,8 @@ impl EchoKeeper { Ok(()) } - fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { - let body = nexum_sdk::status_body::StatusBody::decode(&update.status) + fn on_intent_status(update: videre_sdk::IntentStatusUpdate) -> Result<(), Fault> { + let body = videre_sdk::status_body::StatusBody::decode(&update.status) .map_err(|err| Fault::InvalidInput(err.to_string()))?; logging::log( logging::Level::Info, diff --git a/scripts/check-venue-agnostic.sh b/scripts/check-venue-agnostic.sh index cf1df1b7..fe52c029 100755 --- a/scripts/check-venue-agnostic.sh +++ b/scripts/check-venue-agnostic.sh @@ -5,9 +5,9 @@ # charter symbol # (videre:|videre_host|Venue[A-Z]|EgressGuard|synthesize_venue|value-flow) # and no privileged router field; and nexum:host names no foreign WIT -# package and resolves as a leaf. The opaque-status envelope -# (intent-status-update, its venue id string) is ratified host surface, -# not a leak. Blocking in CI; run locally via `just check-venue-agnostic`. +# package, carries no venue-domain vocabulary (venue|receipt|intent-status), +# and resolves as a leaf. Blocking in CI; run locally via +# `just check-venue-agnostic`. set -uo pipefail @@ -38,6 +38,33 @@ for crate in nexum-runtime nexum-launch nexum-cli; do fi done +# 1b. Guest SDK: the generic guest SDK stays venue-free too. Its crate +# graph must reach no videre/venue crate (normal + build edges), so +# the venue status-codec never re-enters the domain-free SDK through a +# dependency; and its sources must name none of the intent-status +# codec's own vocabulary. The symbol scan is curated (not a bare +# `venue|receipt` sweep): the keeper stores legitimately speak of +# receipt-keyed journals and generic venue prose, so only the codec's +# distinctive names flag. +if tree="$(cargo tree -p nexum-sdk -e normal,build --all-features --prefix none --locked)"; then + reached="$(printf '%s\n' "$tree" | + awk '{print $1}' | sort -u | rg -i 'videre|intent|venue|cow' || true)" + if [[ -n $reached ]]; then + fail "nexum-sdk crate graph reaches: $(tr '\n' ' ' <<<"$reached")" + else + pass "nexum-sdk crate graph clean" + fi +else + fail "cargo tree failed for nexum-sdk" +fi +sdk_charter='intent-status|IntentStatusUpdate|INTENT_STATUS_KIND|[Ss]tatus[Bb]ody|status[-_]body' +rg -n --no-heading -e "$sdk_charter" crates/nexum-sdk/src +case $? in + 0) fail "venue status-codec vocabulary leaks into nexum-sdk" ;; + 1) pass "nexum-sdk carries no venue status-codec vocabulary" ;; + *) fail "nexum-sdk scan errored (crates/nexum-sdk/src missing?)" ;; +esac + # 2. Symbol scan: the charter set (the current venue vocabulary that would # signal a leak - videre WIT/crate refs, the Venue* types, the egress # guard). Section 1 guards dependency edges; this scan stays curated to @@ -62,8 +89,8 @@ case $? in esac # 4. WIT surface: nexum:host is a leaf. No foreign package named -# anywhere in its sources, no cross-package use/import, and the -# package resolves standalone. The opaque-status envelope stays. +# anywhere in its sources, no cross-package use/import, no venue-domain +# vocabulary, and the package resolves standalone. wit_charter='nexum:intent|nexum:adapter|value-flow|videre:|shepherd:cow' rg -n --no-heading -e "$wit_charter" wit/nexum-host case $? in @@ -77,6 +104,16 @@ case $? in 1) pass "nexum:host has no cross-package reference" ;; *) fail "WIT scan errored (wit/nexum-host missing?)" ;; esac +# Venue-domain vocabulary must not appear in the core event surface: the +# intent-status envelope is a videre-side borsh struct crossing `custom` +# as opaque bytes, so a term leaking back into nexum:host is a regression +# of the extraction. +rg -n --no-heading -i -e 'venue|receipt|intent-status' wit/nexum-host +case $? in + 0) fail "venue-domain vocabulary leaks into wit/nexum-host" ;; + 1) pass "nexum:host carries no venue-domain vocabulary" ;; + *) fail "WIT vocabulary scan errored (wit/nexum-host missing?)" ;; +esac if command -v wasm-tools >/dev/null; then if wasm-tools component wit wit/nexum-host >/dev/null; then pass "nexum:host resolves standalone" diff --git a/wit/nexum-host/types.wit b/wit/nexum-host/types.wit index 8e834263..0142f119 100644 --- a/wit/nexum-host/types.wit +++ b/wit/nexum-host/types.wit @@ -58,20 +58,17 @@ interface types { fired-at: u64, } - /// A host-observed status transition for a previously submitted - /// intent. Transport-blind: the subscriber sees only where the - /// intent is in its life, never how the host learnt it. - record intent-status-update { - /// Venue id the receipt was issued by. - venue: string, - /// The venue-scoped intent identifier, opaque to the host. - receipt: list, - /// Opaque versioned status body: a leading u8 version tag, - /// then that version's borsh payload (v1: lifecycle status, - /// optional settlement proof, optional failure reason). An - /// unknown tag is a decode error and the body is never empty. - /// The host never inspects it. - status: list, + /// The generic extension event: a domain extension's own event kind + /// and its opaque payload. The core routes by `kind` and never reads + /// `payload`; the subscribing module decodes it against the extension + /// that emitted it. + record custom-event { + /// Extension-scoped event kind, matched against a module's + /// `[[subscription]]` kind. + kind: string, + /// Opaque bytes the emitting extension defines and the module + /// decodes. + payload: list, } variant event { @@ -79,7 +76,7 @@ interface types { chain-logs(chain-logs), tick(tick), message(message), - intent-status(intent-status-update), + custom(custom-event), } /// Opaque config from module.toml [config] section.